link.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import bcrypt from 'bcryptjs';
  2. import _ from 'lodash';
  3. import { isAfter, subDays } from 'date-fns';
  4. import { Types } from 'mongoose';
  5. import Link, { ILink } from '../models/link';
  6. import Visit from '../models/visit';
  7. import Domain, { IDomain } from '../models/domain';
  8. import {
  9. generateShortLink,
  10. statsObjectToArray,
  11. getDifferenceFunction,
  12. getUTCDate,
  13. } from '../utils';
  14. import { getDomain, banDomain } from './domain';
  15. import * as redis from '../redis';
  16. import { banHost } from './host';
  17. import { banUser } from './user';
  18. interface ICreateLink extends ILink {
  19. reuse?: boolean;
  20. }
  21. export const createShortLink = async (data: ICreateLink) => {
  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 = await Link.create({
  28. id: data.id,
  29. password,
  30. target: data.target,
  31. user: data.user,
  32. domain: data.domain,
  33. });
  34. return {
  35. ...link,
  36. password: !!data.password,
  37. reuse: !!data.reuse,
  38. shortLink: generateShortLink(
  39. data.id,
  40. data.domain && (data.domain as IDomain).name
  41. ),
  42. };
  43. };
  44. export const addLinkCount = async (
  45. id: Types.ObjectId,
  46. customDomain?: string
  47. ) => {
  48. const domain = await (customDomain && getDomain({ name: customDomain }));
  49. const url = await Link.findOneAndUpdate(
  50. { id, domain: domain || { $exists: false } },
  51. { $inc: { count: 1 } }
  52. );
  53. return url;
  54. };
  55. interface ICreateVisit {
  56. browser: string;
  57. country: string;
  58. domain?: string;
  59. id: string;
  60. limit: number;
  61. os: string;
  62. referrer: string;
  63. }
  64. export const createVisit = async (params: ICreateVisit) => {
  65. const domain = await (params.domain && getDomain({ name: params.domain }));
  66. const link = await Link.findOne({
  67. id: params.id,
  68. domain: domain || { $exists: false },
  69. });
  70. if (link.count > params.limit) return null;
  71. const visit = await Visit.findOneAndUpdate(
  72. {
  73. data: getUTCDate().toJSON(),
  74. link,
  75. },
  76. {
  77. $inc: {
  78. [`browser.${params.browser}`]: 1,
  79. [`country.${params.country}`]: 1,
  80. [`os.${params.os}`]: 1,
  81. [`referrer.${params.referrer}`]: 1,
  82. total: 1,
  83. },
  84. },
  85. { upsert: true }
  86. );
  87. return visit;
  88. };
  89. interface IFindLink {
  90. id?: string;
  91. domain?: Types.ObjectId | string;
  92. user?: Types.ObjectId | string;
  93. target?: string;
  94. }
  95. export const findLink = async (
  96. { id = '', domain = '', user = '', target }: IFindLink,
  97. options?: { forceDomainCheck?: boolean }
  98. ) => {
  99. const redisKey = id + domain.toString() + user.toString();
  100. const cachedLink = await redis.get(redisKey);
  101. if (cachedLink) return JSON.parse(cachedLink);
  102. const link = await Link.findOne({
  103. ...(id && { id }),
  104. ...(domain && { domain }),
  105. ...(options.forceDomainCheck && { domain: domain || { $exists: false } }),
  106. ...(user && { user }),
  107. ...(target && { target }),
  108. }).populate('domain');
  109. redis.set(redisKey, JSON.stringify(link), 'EX', 60 * 60 * 2);
  110. // TODO: Get user?
  111. return link;
  112. };
  113. export const getUserLinksCount = async (params: {
  114. user: Types.ObjectId;
  115. date?: Date;
  116. }) => {
  117. const count = await Link.find({
  118. user: params.user,
  119. ...(params.date && { createdAt: { $gt: params.date } }),
  120. }).count();
  121. return count;
  122. };
  123. interface IGetLinksOptions {
  124. count?: string;
  125. page?: string;
  126. search?: string;
  127. }
  128. export const getLinks = async (
  129. user: Types.ObjectId,
  130. options: IGetLinksOptions = {}
  131. ) => {
  132. const { count = '5', page = '1', search = '' } = options;
  133. const limit = parseInt(count, 10);
  134. const skip = parseInt(page, 10);
  135. const $regex = new RegExp(`.*${search}.*`, 'i');
  136. const matchedLinks = await Link.find({
  137. user,
  138. $or: [{ id: { $regex } }, { target: { $regex } }],
  139. })
  140. .sort({ createdAt: -1 })
  141. .skip(skip)
  142. .limit(limit)
  143. .populate('domain');
  144. const links = matchedLinks.map(link => ({
  145. ...link,
  146. password: !!link.password,
  147. shortLink: generateShortLink(
  148. link.id,
  149. link.domain && (link.domain as IDomain).name
  150. ),
  151. }));
  152. return links;
  153. };
  154. interface IDeleteLink {
  155. id: string;
  156. user: Types.ObjectId;
  157. domain?: Types.ObjectId;
  158. }
  159. export const deleteLink = async (data: IDeleteLink) => {
  160. const link = await Link.findOneAndDelete({
  161. id: data.id,
  162. user: data.user,
  163. domain: data.domain || { $exists: false },
  164. });
  165. await Visit.deleteMany({ link });
  166. const domainKey = link.domain ? link.domain.toString() : '';
  167. const userKey = link.user ? link.user.toString() : '';
  168. redis.del(link.id + domainKey);
  169. redis.del(link.id + domainKey + userKey);
  170. return link;
  171. };
  172. /*
  173. ** Collecting stats
  174. */
  175. interface IStats {
  176. browser: Record<
  177. 'chrome' | 'edge' | 'firefox' | 'ie' | 'opera' | 'other' | 'safari',
  178. number
  179. >;
  180. os: Record<
  181. 'android' | 'ios' | 'linux' | 'macos' | 'other' | 'windows',
  182. number
  183. >;
  184. country: Record<string, number>;
  185. referrer: Record<string, number>;
  186. dates: Date[];
  187. }
  188. interface Stats {
  189. stats: IStats;
  190. views: number[];
  191. }
  192. const INIT_STATS: IStats = {
  193. browser: {
  194. chrome: 0,
  195. edge: 0,
  196. firefox: 0,
  197. ie: 0,
  198. opera: 0,
  199. other: 0,
  200. safari: 0,
  201. },
  202. os: {
  203. android: 0,
  204. ios: 0,
  205. linux: 0,
  206. macos: 0,
  207. other: 0,
  208. windows: 0,
  209. },
  210. country: {},
  211. referrer: {},
  212. dates: [],
  213. };
  214. const STATS_PERIODS: [number, 'lastDay' | 'lastWeek' | 'lastMonth'][] = [
  215. [1, 'lastDay'],
  216. [7, 'lastWeek'],
  217. [30, 'lastMonth'],
  218. ];
  219. interface IGetStats {
  220. domain: Types.ObjectId;
  221. id: string;
  222. user: Types.ObjectId;
  223. }
  224. interface IGetStatsResponse {
  225. allTime: Stats;
  226. id: string;
  227. lastDay: Stats;
  228. lastMonth: Stats;
  229. lastWeek: Stats;
  230. shortLink: string;
  231. target: string;
  232. total: number;
  233. updatedAt: string;
  234. }
  235. export const getStats = async (data: IGetStats) => {
  236. const stats = {
  237. lastDay: {
  238. stats: _.cloneDeep(INIT_STATS),
  239. views: new Array(24).fill(0),
  240. },
  241. lastWeek: {
  242. stats: _.cloneDeep(INIT_STATS),
  243. views: new Array(7).fill(0),
  244. },
  245. lastMonth: {
  246. stats: _.cloneDeep(INIT_STATS),
  247. views: new Array(30).fill(0),
  248. },
  249. allTime: {
  250. stats: _.cloneDeep(INIT_STATS),
  251. views: new Array(18).fill(0),
  252. },
  253. };
  254. const domain = await (data.domain && Domain.findOne({ name: data.domain }));
  255. const link = await Link.findOne({
  256. id: data.id,
  257. user: data.user,
  258. ...(domain && { domain }),
  259. });
  260. if (!link) throw new Error("Couldn't get stats for this link.");
  261. const visits = await Visit.find({
  262. link: link.id,
  263. });
  264. visits.forEach(visit => {
  265. STATS_PERIODS.forEach(([days, type]) => {
  266. const isIncluded = isAfter(visit.date, subDays(getUTCDate(), days));
  267. if (isIncluded) {
  268. const diffFunction = getDifferenceFunction(type);
  269. const now = new Date();
  270. const diff = diffFunction(now, visit.date);
  271. const index = stats[type].views.length - diff - 1;
  272. const view = stats[type].views[index];
  273. const period = stats[type].stats;
  274. stats[type].stats = {
  275. browser: {
  276. chrome: period.chrome + visit.browser.chrome,
  277. edge: period.edge + visit.browser.edge,
  278. firefox: period.firefox + visit.browser.firefox,
  279. ie: period.ie + visit.browser.ie,
  280. opera: period.opera + visit.browser.opera,
  281. other: period.other + visit.browser.other,
  282. safari: period.safari + visit.browser.safari,
  283. },
  284. os: {
  285. android: period.android + visit.os.android,
  286. ios: period.ios + visit.os.ios,
  287. linux: period.linux + visit.os.linux,
  288. macos: period.macos + visit.os.macos,
  289. other: period.other + visit.os.other,
  290. windows: period.windows + visit.os.windows,
  291. },
  292. country: {
  293. ...period.country,
  294. ...Object.keys(visit.country).reduce(
  295. (obj, key) => ({
  296. ...obj,
  297. [key]: period.country[key] + visit.country[key],
  298. }),
  299. {}
  300. ),
  301. },
  302. referrer: {
  303. ...period.referrer,
  304. ...Object.keys(visit.referrer).reduce(
  305. (obj, key) => ({
  306. ...obj,
  307. [key]: period.referrer[key] + visit.referrer[key],
  308. }),
  309. {}
  310. ),
  311. },
  312. };
  313. stats[type].views[index] = view + 1 || 1;
  314. }
  315. });
  316. const allTime = stats.allTime.stats;
  317. const diffFunction = getDifferenceFunction('allTime');
  318. const now = new Date();
  319. const diff = diffFunction(now, visit.date);
  320. const index = stats.allTime.views.length - diff - 1;
  321. const view = stats.allTime.views[index];
  322. stats.allTime.stats = {
  323. browser: {
  324. chrome: allTime.chrome + visit.browser.chrome,
  325. edge: allTime.edge + visit.browser.edge,
  326. firefox: allTime.firefox + visit.browser.firefox,
  327. ie: allTime.ie + visit.browser.ie,
  328. opera: allTime.opera + visit.browser.opera,
  329. other: allTime.other + visit.browser.other,
  330. safari: allTime.safari + visit.browser.safari,
  331. },
  332. os: {
  333. android: allTime.android + visit.os.android,
  334. ios: allTime.ios + visit.os.ios,
  335. linux: allTime.linux + visit.os.linux,
  336. macos: allTime.macos + visit.os.macos,
  337. other: allTime.other + visit.os.other,
  338. windows: allTime.windows + visit.os.windows,
  339. },
  340. country: {
  341. ...allTime.country,
  342. ...Object.keys(visit.country).reduce(
  343. (obj, key) => ({
  344. ...obj,
  345. [key]: allTime.country[key] + visit.country[key],
  346. }),
  347. {}
  348. ),
  349. },
  350. referrer: {
  351. ...allTime.referrer,
  352. ...Object.keys(visit.referrer).reduce(
  353. (obj, key) => ({
  354. ...obj,
  355. [key]: allTime.referrer[key] + visit.referrer[key],
  356. }),
  357. {}
  358. ),
  359. },
  360. };
  361. stats.allTime.views[index] = view + 1 || 1;
  362. });
  363. stats.lastDay.stats = statsObjectToArray(stats.lastDay.stats);
  364. stats.lastWeek.stats = statsObjectToArray(stats.lastWeek.stats);
  365. stats.lastMonth.stats = statsObjectToArray(stats.lastMonth.stats);
  366. stats.allTime.stats = statsObjectToArray(stats.allTime.stats);
  367. const response: IGetStatsResponse = {
  368. allTime: stats.allTime,
  369. id: link.id,
  370. lastDay: stats.lastDay,
  371. lastMonth: stats.lastMonth,
  372. lastWeek: stats.lastWeek,
  373. shortLink: generateShortLink(
  374. link.id,
  375. link.domain && (link.domain as IDomain).name
  376. ),
  377. target: link.target,
  378. total: link.count,
  379. updatedAt: new Date().toISOString(),
  380. };
  381. return response;
  382. };
  383. interface IBanLink {
  384. adminId?: Types.ObjectId;
  385. banUser?: boolean;
  386. domain?: string;
  387. host?: string;
  388. id: string;
  389. }
  390. export const banLink = async (data: IBanLink) => {
  391. const tasks = [];
  392. const bannedBy = data.adminId;
  393. // Ban link
  394. const link = await Link.findOneAndUpdate(
  395. { id: data.id },
  396. { banned: true, bannedBy },
  397. { new: true }
  398. );
  399. if (!link) throw new Error('No link has been found.');
  400. // If user, ban user and all of their links.
  401. if (data.banUser && link.user) {
  402. tasks.push(banUser(link.user, bannedBy));
  403. tasks.push(
  404. Link.updateMany({ user: link.user }, { banned: true, bannedBy })
  405. );
  406. }
  407. // Ban host
  408. if (data.host) tasks.push(banHost(data.host, bannedBy));
  409. // Ban domain
  410. if (data.domain) tasks.push(banDomain(data.domain, bannedBy));
  411. const domainKey = link.domain ? link.domain.toString() : '';
  412. const userKey = link.user ? link.user.toString() : '';
  413. redis.del(link.id + domainKey);
  414. redis.del(link.id + domainKey + userKey);
  415. return Promise.all(tasks);
  416. };