link.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. import bcrypt from "bcryptjs";
  2. import { isAfter, subDays, set } from "date-fns";
  3. import knex from "../knex";
  4. import * as redis from "../redis";
  5. import {
  6. generateShortLink,
  7. getRedisKey,
  8. getUTCDate,
  9. getDifferenceFunction,
  10. statsObjectToArray
  11. } from "../utils";
  12. import { banDomain } from "./domain";
  13. import { banHost } from "./host";
  14. import { banUser } from "./user";
  15. interface CreateLink extends Link {
  16. reuse?: boolean;
  17. domainName?: string;
  18. }
  19. export const createShortLink = async (data: CreateLink, user: UserJoined) => {
  20. const { id: user_id = null, domain, domain_id = null } =
  21. user || ({} as UserJoined);
  22. let password;
  23. if (data.password) {
  24. const salt = await bcrypt.genSalt(12);
  25. password = await bcrypt.hash(data.password, salt);
  26. }
  27. const [link]: Link[] = await knex<Link>("links").insert(
  28. {
  29. domain_id,
  30. address: data.address,
  31. password,
  32. target: data.target,
  33. user_id
  34. },
  35. "*"
  36. );
  37. return {
  38. ...link,
  39. password: !!data.password,
  40. reuse: !!data.reuse,
  41. shortLink: generateShortLink(data.address, domain),
  42. shortUrl: generateShortLink(data.address, domain)
  43. };
  44. };
  45. export const addLinkCount = async (id: number) => {
  46. return knex<Link>("links")
  47. .where({ id })
  48. .increment("visit_count", 1);
  49. };
  50. interface ICreateVisit {
  51. browser: string;
  52. country: string;
  53. domain?: string;
  54. id: number;
  55. os: string;
  56. referrer: string;
  57. }
  58. export const createVisit = async (params: ICreateVisit) => {
  59. const data = {
  60. ...params,
  61. country: params.country.toLowerCase(),
  62. referrer: params.referrer.toLowerCase()
  63. };
  64. const visit = await knex<Visit>("visits")
  65. .where({ link_id: params.id })
  66. .andWhere(
  67. knex.raw("date_trunc('hour', created_at) = date_trunc('hour', ?)", [
  68. knex.fn.now()
  69. ])
  70. )
  71. .first();
  72. if (visit) {
  73. await knex("visits")
  74. .where({ id: visit.id })
  75. .increment(`br_${data.browser}`, 1)
  76. .increment(`os_${data.os}`, 1)
  77. .increment("total", 1)
  78. .update({
  79. updated_at: new Date().toISOString(),
  80. countries: knex.raw(
  81. "jsonb_set(countries, '{??}', (COALESCE(countries->>?,'0')::int + 1)::text::jsonb)",
  82. [data.country, data.country]
  83. ),
  84. referrers: knex.raw(
  85. "jsonb_set(referrers, '{??}', (COALESCE(referrers->>?,'0')::int + 1)::text::jsonb)",
  86. [data.referrer, data.referrer]
  87. )
  88. });
  89. } else {
  90. await knex<Visit>("visits").insert({
  91. [`br_${data.browser}`]: 1,
  92. countries: { [data.country]: 1 },
  93. referrers: { [data.referrer]: 1 },
  94. [`os_${data.os}`]: 1,
  95. total: 1,
  96. link_id: data.id
  97. });
  98. }
  99. return visit;
  100. };
  101. interface IFindLink {
  102. address?: string;
  103. domain_id?: number | null;
  104. user_id?: number | null;
  105. target?: string;
  106. }
  107. export const findLink = async ({
  108. address,
  109. domain_id,
  110. user_id,
  111. target
  112. }: IFindLink): Promise<Link> => {
  113. const redisKey = getRedisKey.link(address, domain_id, user_id);
  114. const cachedLink = await redis.get(redisKey);
  115. if (cachedLink) return JSON.parse(cachedLink);
  116. const link = await knex<Link>("links")
  117. .where({
  118. ...(address && { address }),
  119. ...(domain_id && { domain_id }),
  120. ...(user_id && { user_id }),
  121. ...(target && { target })
  122. })
  123. .first();
  124. if (link) {
  125. redis.set(redisKey, JSON.stringify(link), "EX", 60 * 60 * 2);
  126. }
  127. return link;
  128. };
  129. export const getUserLinksCount = async (params: {
  130. user_id: number;
  131. date?: Date;
  132. }) => {
  133. const model = knex<Link>("links").where({ user_id: params.user_id });
  134. // TODO: Test counts;
  135. let res;
  136. if (params.date) {
  137. res = await model
  138. .andWhere("created_at", ">", params.date.toISOString())
  139. .count("id");
  140. } else {
  141. res = await model.count("id");
  142. }
  143. return res[0] && res[0].count;
  144. };
  145. interface IGetLinksOptions {
  146. count?: string;
  147. page?: string;
  148. search?: string;
  149. }
  150. export const getLinks = async (
  151. user_id: number,
  152. options: IGetLinksOptions = {}
  153. ) => {
  154. const { count = "5", page = "1", search = "" } = options;
  155. const limit = parseInt(count) < 50 ? parseInt(count) : 50;
  156. const offset = (parseInt(page) - 1) * limit;
  157. const model = knex<LinkJoinedDomain>("links")
  158. .select(
  159. "links.id",
  160. "links.address",
  161. "links.banned",
  162. "links.created_at",
  163. "links.domain_id",
  164. "links.updated_at",
  165. "links.password",
  166. "links.target",
  167. "links.visit_count",
  168. "links.user_id",
  169. "links.uuid",
  170. "domains.address as domain"
  171. )
  172. .offset(offset)
  173. .limit(limit)
  174. .orderBy("created_at", "desc")
  175. .where("links.user_id", user_id);
  176. if (search) {
  177. model.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
  178. search
  179. ]);
  180. }
  181. const matchedLinks = await model.leftJoin(
  182. "domains",
  183. "links.domain_id",
  184. "domains.id"
  185. );
  186. const links = matchedLinks.map(link => ({
  187. ...link,
  188. id: link.address,
  189. password: !!link.password,
  190. shortLink: generateShortLink(link.address, link.domain),
  191. shortUrl: generateShortLink(link.address, link.domain)
  192. }));
  193. return links;
  194. };
  195. interface IDeleteLink {
  196. address: string;
  197. user_id: number;
  198. domain?: string;
  199. }
  200. export const deleteLink = async (data: IDeleteLink) => {
  201. const link: LinkJoinedDomain = await knex<LinkJoinedDomain>("links")
  202. .select("links.id", "domains.address as domain")
  203. .where({
  204. "links.address": data.address,
  205. "links.user_id": data.user_id,
  206. ...(!data.domain && { domain_id: null })
  207. })
  208. .leftJoin("domains", "links.domain_id", "domains.id")
  209. .first();
  210. if (!link) return;
  211. if (link.domain !== data.domain) {
  212. return;
  213. }
  214. await knex<Visit>("visits")
  215. .where("link_id", link.id)
  216. .delete();
  217. const deletedLink = await knex<Link>("links")
  218. .where("id", link.id)
  219. .delete();
  220. redis.del(getRedisKey.link(link.address, link.domain_id, link.user_id));
  221. return !!deletedLink;
  222. };
  223. /*
  224. ** Collecting stats
  225. */
  226. interface StatsResult {
  227. stats: {
  228. browser: { name: string; value: number }[];
  229. os: { name: string; value: number }[];
  230. country: { name: string; value: number }[];
  231. referrer: { name: string; value: number }[];
  232. };
  233. views: number[];
  234. }
  235. const getInitStats = (): Stats =>
  236. Object.create({
  237. browser: {
  238. chrome: 0,
  239. edge: 0,
  240. firefox: 0,
  241. ie: 0,
  242. opera: 0,
  243. other: 0,
  244. safari: 0
  245. },
  246. os: {
  247. android: 0,
  248. ios: 0,
  249. linux: 0,
  250. macos: 0,
  251. other: 0,
  252. windows: 0
  253. },
  254. country: {},
  255. referrer: {}
  256. });
  257. const STATS_PERIODS: [number, "lastDay" | "lastWeek" | "lastMonth"][] = [
  258. [1, "lastDay"],
  259. [7, "lastWeek"],
  260. [30, "lastMonth"]
  261. ];
  262. interface IGetStatsResponse {
  263. allTime: StatsResult;
  264. id: string;
  265. lastDay: StatsResult;
  266. lastMonth: StatsResult;
  267. lastWeek: StatsResult;
  268. shortLink: string;
  269. shortUrl: string;
  270. target: string;
  271. total: number;
  272. updatedAt: string;
  273. }
  274. export const getStats = async (link: Link, domain: Domain) => {
  275. const stats = {
  276. lastDay: {
  277. stats: getInitStats(),
  278. views: new Array(24).fill(0)
  279. },
  280. lastWeek: {
  281. stats: getInitStats(),
  282. views: new Array(7).fill(0)
  283. },
  284. lastMonth: {
  285. stats: getInitStats(),
  286. views: new Array(30).fill(0)
  287. },
  288. allTime: {
  289. stats: getInitStats(),
  290. views: new Array(18).fill(0)
  291. }
  292. };
  293. const visitsStream: any = knex<Visit>("visits")
  294. .where("link_id", link.id)
  295. .stream();
  296. const nowUTC = getUTCDate();
  297. const now = new Date();
  298. for await (const visit of visitsStream as Visit[]) {
  299. STATS_PERIODS.forEach(([days, type]) => {
  300. const isIncluded = isAfter(
  301. new Date(visit.created_at),
  302. subDays(nowUTC, days)
  303. );
  304. if (isIncluded) {
  305. const diffFunction = getDifferenceFunction(type);
  306. const diff = diffFunction(now, visit.created_at);
  307. const index = stats[type].views.length - diff - 1;
  308. const view = stats[type].views[index];
  309. const period = stats[type].stats;
  310. stats[type].stats = {
  311. browser: {
  312. chrome: period.browser.chrome + visit.br_chrome,
  313. edge: period.browser.edge + visit.br_edge,
  314. firefox: period.browser.firefox + visit.br_firefox,
  315. ie: period.browser.ie + visit.br_ie,
  316. opera: period.browser.opera + visit.br_opera,
  317. other: period.browser.other + visit.br_other,
  318. safari: period.browser.safari + visit.br_safari
  319. },
  320. os: {
  321. android: period.os.android + visit.os_android,
  322. ios: period.os.ios + visit.os_ios,
  323. linux: period.os.linux + visit.os_linux,
  324. macos: period.os.macos + visit.os_macos,
  325. other: period.os.other + visit.os_other,
  326. windows: period.os.windows + visit.os_windows
  327. },
  328. country: {
  329. ...period.country,
  330. ...Object.entries(visit.countries).reduce(
  331. (obj, [country, count]) => ({
  332. ...obj,
  333. [country]: (period.country[country] || 0) + count
  334. }),
  335. {}
  336. )
  337. },
  338. referrer: {
  339. ...period.referrer,
  340. ...Object.entries(visit.referrers).reduce(
  341. (obj, [referrer, count]) => ({
  342. ...obj,
  343. [referrer]: (period.referrer[referrer] || 0) + count
  344. }),
  345. {}
  346. )
  347. }
  348. };
  349. stats[type].views[index] = view + visit.total;
  350. }
  351. });
  352. const allTime = stats.allTime.stats;
  353. const diffFunction = getDifferenceFunction("allTime");
  354. const diff = diffFunction(
  355. set(new Date(), { date: 1 }),
  356. set(new Date(visit.created_at), { date: 1 })
  357. );
  358. const index = stats.allTime.views.length - diff - 1;
  359. const view = stats.allTime.views[index];
  360. stats.allTime.stats = {
  361. browser: {
  362. chrome: allTime.browser.chrome + visit.br_chrome,
  363. edge: allTime.browser.edge + visit.br_edge,
  364. firefox: allTime.browser.firefox + visit.br_firefox,
  365. ie: allTime.browser.ie + visit.br_ie,
  366. opera: allTime.browser.opera + visit.br_opera,
  367. other: allTime.browser.other + visit.br_other,
  368. safari: allTime.browser.safari + visit.br_safari
  369. },
  370. os: {
  371. android: allTime.os.android + visit.os_android,
  372. ios: allTime.os.ios + visit.os_ios,
  373. linux: allTime.os.linux + visit.os_linux,
  374. macos: allTime.os.macos + visit.os_macos,
  375. other: allTime.os.other + visit.os_other,
  376. windows: allTime.os.windows + visit.os_windows
  377. },
  378. country: {
  379. ...allTime.country,
  380. ...Object.entries(visit.countries).reduce(
  381. (obj, [country, count]) => ({
  382. ...obj,
  383. [country]: (allTime.country[country] || 0) + count
  384. }),
  385. {}
  386. )
  387. },
  388. referrer: {
  389. ...allTime.referrer,
  390. ...Object.entries(visit.referrers).reduce(
  391. (obj, [referrer, count]) => ({
  392. ...obj,
  393. [referrer]: (allTime.referrer[referrer] || 0) + count
  394. }),
  395. {}
  396. )
  397. }
  398. };
  399. stats.allTime.views[index] = view + visit.total;
  400. }
  401. const response: IGetStatsResponse = {
  402. allTime: {
  403. stats: statsObjectToArray(stats.allTime.stats),
  404. views: stats.allTime.views
  405. },
  406. id: link.address,
  407. lastDay: {
  408. stats: statsObjectToArray(stats.lastDay.stats),
  409. views: stats.lastDay.views
  410. },
  411. lastMonth: {
  412. stats: statsObjectToArray(stats.lastMonth.stats),
  413. views: stats.lastMonth.views
  414. },
  415. lastWeek: {
  416. stats: statsObjectToArray(stats.lastWeek.stats),
  417. views: stats.lastWeek.views
  418. },
  419. shortLink: generateShortLink(link.address, domain.address),
  420. shortUrl: generateShortLink(link.address, domain.address),
  421. target: link.target,
  422. total: link.visit_count,
  423. updatedAt: new Date().toISOString()
  424. };
  425. return response;
  426. };
  427. interface IBanLink {
  428. adminId?: number;
  429. banUser?: boolean;
  430. domain?: string;
  431. host?: string;
  432. address: string;
  433. }
  434. export const banLink = async (data: IBanLink) => {
  435. const tasks = [];
  436. const banned_by_id = data.adminId;
  437. // Ban link
  438. const [link]: Link[] = await knex<Link>("links")
  439. .where({ address: data.address, domain_id: null })
  440. .update(
  441. { banned: true, banned_by_id, updated_at: new Date().toISOString() },
  442. "*"
  443. );
  444. if (!link) throw new Error("No link has been found.");
  445. // If user, ban user and all of their links.
  446. if (data.banUser && link.user_id) {
  447. tasks.push(banUser(link.user_id, banned_by_id));
  448. tasks.push(
  449. knex<Link>("links")
  450. .where({ user_id: link.user_id })
  451. .update(
  452. { banned: true, banned_by_id, updated_at: new Date().toISOString() },
  453. "*"
  454. )
  455. );
  456. }
  457. // Ban host
  458. if (data.host) tasks.push(banHost(data.host, banned_by_id));
  459. // Ban domain
  460. if (data.domain) tasks.push(banDomain(data.domain, banned_by_id));
  461. redis.del(getRedisKey.link(link.address, link.domain_id, link.user_id));
  462. redis.del(getRedisKey.link(link.address, link.domain_id));
  463. redis.del(getRedisKey.link(link.address));
  464. return Promise.all(tasks);
  465. };