link.ts 12 KB

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