url.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. const bcrypt = require('bcryptjs');
  2. const _ = require('lodash/');
  3. const { isAfter, subDays } = require('date-fns');
  4. const driver = require('./neo4j');
  5. const {
  6. generateShortUrl,
  7. statsObjectToArray,
  8. getDifferenceFunction,
  9. getUTCDate,
  10. } = require('../utils');
  11. const queryNewUrl = 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt }) RETURN l';
  12. const queryNewUserUrl = (domain, password) =>
  13. 'MATCH (u:USER { email: $email })' +
  14. 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt' +
  15. `${password ? ', password: $password' : ''} })` +
  16. 'CREATE (u)-[:CREATED]->(l)' +
  17. `${domain ? 'MERGE (l)-[:USES]->(:DOMAIN { name: $domain })' : ''}` +
  18. 'RETURN l';
  19. exports.createShortUrl = async params => {
  20. const session = driver.session();
  21. const query = params.user ? queryNewUserUrl(params.user.domain, params.password) : queryNewUrl;
  22. const salt = params.password && (await bcrypt.genSalt(12));
  23. const hash = params.password && (await bcrypt.hash(params.password, salt));
  24. const { records = [] } = await session.writeTransaction(tx =>
  25. tx.run(query, {
  26. createdAt: new Date().toJSON(),
  27. domain: params.user && params.user.domain,
  28. email: params.user && params.user.email,
  29. id: params.id,
  30. password: hash || '',
  31. target: params.target,
  32. })
  33. );
  34. session.close();
  35. const data = records[0].get('l').properties;
  36. return {
  37. ...data,
  38. password: !!data.password,
  39. reuse: !!params.reuse,
  40. shortUrl: generateShortUrl(
  41. data.id,
  42. params.user && params.user.domain,
  43. params.user && params.user.useHttps
  44. ),
  45. };
  46. };
  47. exports.createVisit = async params => {
  48. const session = driver.session();
  49. const { records = [] } = await session.writeTransaction(tx =>
  50. tx.run(
  51. 'MATCH (l:URL { id: $id }) ' +
  52. `${params.domain ? 'MATCH (l)-[:USES]->({ name: $domain })' : ''} ` +
  53. 'SET l.count = l.count + 1 ' +
  54. 'CREATE (v:VISIT)' +
  55. 'MERGE (b:BROWSER { browser: $browser })' +
  56. 'MERGE (c:COUNTRY { country: $country })' +
  57. 'MERGE (o:OS { os: $os })' +
  58. 'MERGE (r:REFERRER { referrer: $referrer })' +
  59. 'MERGE (d:DATE { date: $date })' +
  60. 'MERGE (v)-[:VISITED]->(l)' +
  61. 'MERGE (v)-[:BROWSED_BY]->(b)' +
  62. 'MERGE (v)-[:LOCATED_IN]->(c)' +
  63. 'MERGE (v)-[:OS]->(o)' +
  64. 'MERGE (v)-[:REFERRED_BY]->(r)' +
  65. 'MERGE (v)-[:VISITED_IN]->(d)' +
  66. 'RETURN l',
  67. {
  68. id: params.id,
  69. browser: params.browser,
  70. domain: params.domain,
  71. country: params.country,
  72. os: params.os,
  73. referrer: params.referrer,
  74. date: getUTCDate().toJSON(),
  75. }
  76. )
  77. );
  78. session.close();
  79. const url = records.length && records[0].get('l').properties;
  80. return url;
  81. };
  82. exports.findUrl = async ({ id, domain, target }) => {
  83. const session = driver.session();
  84. const { records = [] } = await session.readTransaction(tx =>
  85. tx.run(
  86. `MATCH (l:URL { ${id ? 'id: $id' : 'target: $target'} })` +
  87. `${
  88. domain
  89. ? 'MATCH (l)-[:USES]->(d:DOMAIN { name: $domain })'
  90. : 'OPTIONAL MATCH (l)-[:USES]->(d)'
  91. }` +
  92. 'OPTIONAL MATCH (u)-[:CREATED]->(l)' +
  93. 'RETURN l, d.name AS domain, u.email AS user',
  94. {
  95. id,
  96. domain,
  97. target,
  98. }
  99. )
  100. );
  101. session.close();
  102. const url =
  103. records.length &&
  104. records.map(record => ({
  105. ...record.get('l').properties,
  106. domain: record.get('domain'),
  107. user: record.get('user'),
  108. }));
  109. return url;
  110. };
  111. exports.getCountUrls = async ({ user }) => {
  112. const session = driver.session();
  113. const { records = [] } = await session.readTransaction(tx =>
  114. tx.run('MATCH (u:USER {email: $email}) RETURN size((u)-[:CREATED]->()) as count', {
  115. email: user.email,
  116. })
  117. );
  118. session.close();
  119. const countAll = records.length ? records[0].get('count').toNumber() : 0;
  120. return { countAll };
  121. };
  122. exports.getUrls = async ({ user, options, setCount }) => {
  123. const session = driver.session();
  124. const { count = 5, page = 1, search = '' } = options;
  125. const limit = parseInt(count, 10);
  126. const skip = parseInt(page, 10);
  127. const searchQuery = search ? 'WHERE l.id =~ $search OR l.target =~ $search' : '';
  128. const setVisitsCount = setCount ? 'SET l.count = size((l)<-[:VISITED]-())' : '';
  129. const { records = [] } = await session.readTransaction(tx =>
  130. tx.run(
  131. `MATCH (u:USER { email: $email })-[:CREATED]->(l) ${searchQuery} ` +
  132. 'WITH l ORDER BY l.createdAt DESC ' +
  133. 'WITH l SKIP $skip LIMIT $limit ' +
  134. `OPTIONAL MATCH (l)-[:USES]->(d) ${setVisitsCount} ` +
  135. 'RETURN l, d.name AS domain, d.useHttps as useHttps',
  136. {
  137. email: user.email,
  138. limit,
  139. skip: limit * (skip - 1),
  140. search: `(?i).*${search}.*`,
  141. }
  142. )
  143. );
  144. session.close();
  145. const urls = records.map(record => {
  146. const visitCount = record.get('l').properties.count;
  147. const domain = record.get('domain');
  148. const protocol = record.get('useHttps') || !domain ? 'https://' : 'http://';
  149. return {
  150. ...record.get('l').properties,
  151. count: typeof visitCount === 'object' ? visitCount.toNumber() : visitCount,
  152. password: !!record.get('l').properties.password,
  153. shortUrl: `${protocol}${domain || process.env.DEFAULT_DOMAIN}/${
  154. record.get('l').properties.id
  155. }`,
  156. };
  157. });
  158. return { list: urls };
  159. };
  160. exports.getCustomDomain = async ({ customDomain }) => {
  161. const session = driver.session();
  162. const { records = [] } = await session.readTransaction(tx =>
  163. tx.run(
  164. 'MATCH (d:DOMAIN { name: $customDomain })<-[:OWNS]-(u) RETURN u.email as email, d.homepage as homepage',
  165. {
  166. customDomain,
  167. }
  168. )
  169. );
  170. session.close();
  171. const data = records.length
  172. ? {
  173. email: records[0].get('email'),
  174. homepage: records[0].get('homepage'),
  175. }
  176. : {};
  177. return data;
  178. };
  179. exports.setCustomDomain = async ({ user, customDomain, homepage, useHttps }) => {
  180. const session = driver.session();
  181. const { records = [] } = await session.writeTransaction(tx =>
  182. tx.run(
  183. 'MATCH (u:USER { email: $email }) ' +
  184. 'OPTIONAL MATCH (u)-[r:OWNS]->() DELETE r ' +
  185. `MERGE (d:DOMAIN { name: $customDomain, homepage: $homepage, useHttps: $useHttps }) ` +
  186. 'MERGE (u)-[:OWNS]->(d) RETURN u, d',
  187. {
  188. customDomain,
  189. homepage: homepage || '',
  190. email: user.email,
  191. useHttps: !!useHttps,
  192. }
  193. )
  194. );
  195. session.close();
  196. const data = records.length && records[0].get('d').properties;
  197. return data;
  198. };
  199. exports.deleteCustomDomain = async ({ user }) => {
  200. const session = driver.session();
  201. const { records = [] } = await session.writeTransaction(tx =>
  202. tx.run('MATCH (u:USER { email: $email }) MATCH (u)-[r:OWNS]->() DELETE r RETURN u', {
  203. email: user.email,
  204. })
  205. );
  206. session.close();
  207. const data = records.length && records[0].get('u').properties;
  208. return data;
  209. };
  210. exports.deleteUrl = async ({ id, domain, user }) => {
  211. const session = driver.session();
  212. const { records = [] } = await session.writeTransaction(tx =>
  213. tx.run(
  214. 'MATCH (u:USER { email: $email }) ' +
  215. 'MATCH (u)-[:CREATED]->(l { id: $id }) ' +
  216. `${
  217. domain
  218. ? 'MATCH (l)-[:USES]->(:DOMAIN { name: $domain })'
  219. : 'MATCH (l) WHERE NOT (l)-[:USES]->()'
  220. }` +
  221. 'OPTIONAL MATCH (l)-[:MATCHES]->(v) ' +
  222. 'DETACH DELETE l, v RETURN u',
  223. {
  224. email: user.email,
  225. domain,
  226. id,
  227. }
  228. )
  229. );
  230. session.close();
  231. const data = records.length && records[0].get('u').properties;
  232. return data;
  233. };
  234. /*
  235. ** Collecting stats
  236. */
  237. const initialStats = {
  238. browser: {
  239. IE: 0,
  240. Firefox: 0,
  241. Chrome: 0,
  242. Opera: 0,
  243. Safari: 0,
  244. Edge: 0,
  245. Other: 0,
  246. },
  247. os: {
  248. Windows: 0,
  249. 'Mac Os X': 0,
  250. Linux: 0,
  251. 'Chrome OS': 0,
  252. Android: 0,
  253. iOS: 0,
  254. Other: 0,
  255. },
  256. country: {},
  257. referrer: {},
  258. dates: [],
  259. };
  260. exports.getStats = ({ id, domain, user }) =>
  261. new Promise((resolve, reject) => {
  262. const session = driver.session();
  263. const stats = {
  264. lastDay: {
  265. stats: _.cloneDeep(initialStats),
  266. views: new Array(24).fill(0),
  267. },
  268. lastWeek: {
  269. stats: _.cloneDeep(initialStats),
  270. views: new Array(7).fill(0),
  271. },
  272. lastMonth: {
  273. stats: _.cloneDeep(initialStats),
  274. views: new Array(30).fill(0),
  275. },
  276. allTime: {
  277. stats: _.cloneDeep(initialStats),
  278. views: new Array(18).fill(0),
  279. },
  280. };
  281. let total = 0;
  282. const statsPeriods = [[1, 'lastDay'], [7, 'lastWeek'], [30, 'lastMonth']];
  283. session
  284. .run(
  285. 'MATCH (l:URL { id: $id })<-[:CREATED]-(u:USER { email: $email }) ' +
  286. `${domain ? 'MATCH (l)-[:USES]->(domain { name: $domain })' : ''}` +
  287. 'MATCH (v)-[:VISITED]->(l) ' +
  288. 'MATCH (v)-[:BROWSED_BY]->(b) ' +
  289. 'MATCH (v)-[:LOCATED_IN]->(c) ' +
  290. 'MATCH (v)-[:OS]->(o) ' +
  291. 'MATCH (v)-[:REFERRED_BY]->(r) ' +
  292. 'MATCH (v)-[:VISITED_IN]->(d) ' +
  293. 'WITH l, b.browser AS browser, c.country AS country, ' +
  294. 'o.os AS os, r.referrer AS referrer, d.date AS date ' +
  295. 'RETURN l, browser, country, os, referrer, date ' +
  296. 'ORDER BY date DESC',
  297. {
  298. email: user.email,
  299. domain,
  300. id,
  301. }
  302. )
  303. .subscribe({
  304. onNext(record) {
  305. total += 1;
  306. const browser = record.get('browser');
  307. const os = record.get('os');
  308. const country = record.get('country');
  309. const referrer = record.get('referrer');
  310. const date = record.get('date');
  311. statsPeriods.forEach(([days, type]) => {
  312. const isIncluded = isAfter(date, subDays(getUTCDate(), days));
  313. if (isIncluded) {
  314. const period = stats[type].stats;
  315. const diffFunction = getDifferenceFunction(type);
  316. const now = new Date();
  317. const diff = diffFunction(now, date);
  318. const index = stats[type].views.length - diff - 1;
  319. const view = stats[type].views[index];
  320. period.browser[browser] += 1;
  321. period.os[os] += 1;
  322. period.country[country] = period.country[country] + 1 || 1;
  323. period.referrer[referrer] = period.referrer[referrer] + 1 || 1;
  324. stats[type].views[index] = view + 1 || 1;
  325. }
  326. });
  327. const allTime = stats.allTime.stats;
  328. const diffFunction = getDifferenceFunction('allTime');
  329. const now = new Date();
  330. const diff = diffFunction(now, date);
  331. const index = stats.allTime.views.length - diff - 1;
  332. const view = stats.allTime.views[index];
  333. allTime.browser[browser] += 1;
  334. allTime.os[os] += 1;
  335. allTime.country[country] = allTime.country[country] + 1 || 1;
  336. allTime.referrer[referrer] = allTime.referrer[referrer] + 1 || 1;
  337. allTime.dates = [...allTime.dates, date];
  338. stats.allTime.views[index] = view + 1 || 1;
  339. },
  340. onCompleted() {
  341. stats.lastDay.stats = statsObjectToArray(stats.lastDay.stats);
  342. stats.lastWeek.stats = statsObjectToArray(stats.lastWeek.stats);
  343. stats.lastMonth.stats = statsObjectToArray(stats.lastMonth.stats);
  344. stats.allTime.stats = statsObjectToArray(stats.allTime.stats);
  345. const response = {
  346. total,
  347. id,
  348. updatedAt: new Date().toISOString(),
  349. lastDay: stats.lastDay,
  350. lastWeek: stats.lastWeek,
  351. lastMonth: stats.lastMonth,
  352. allTime: stats.allTime,
  353. };
  354. return resolve(response);
  355. },
  356. onError(error) {
  357. session.close();
  358. return reject(error);
  359. },
  360. });
  361. });
  362. exports.urlCountFromDate = async ({ date, email }) => {
  363. const session = driver.session();
  364. const { records = [] } = await session.readTransaction(tx =>
  365. tx.run(
  366. 'MATCH (u:USER { email: $email })-[:CREATED]->(l) WHERE l.createdAt > $date ' +
  367. 'WITH COUNT(l) as count RETURN count',
  368. {
  369. date,
  370. email,
  371. }
  372. )
  373. );
  374. session.close();
  375. const count = records.length && records[0].get('count').toNumber();
  376. return { count };
  377. };
  378. exports.banUrl = async ({ adminEmail, id, domain, host, user }) => {
  379. const session = driver.session();
  380. const userQuery = user
  381. ? 'OPTIONAL MATCH (u:USER)-[:CREATED]->(l) SET u.banned = true WITH a, u ' +
  382. 'OPTIONAL MATCH (u)-[:CREATED]->(ls:URL) SET ls.banned = true WITH a, u, ls ' +
  383. 'WHERE u.email IS NOT NULL MERGE (a)-[:BANNED]->(u) MERGE (a)-[:BANNED]->(ls) '
  384. : '';
  385. const domainQuery = domain
  386. ? 'MERGE (d:DOMAIN { name: $domain }) ON CREATE SET d.banned = true WITH a, d ' +
  387. 'WHERE d.name IS NOT NULL MERGE (a)-[:BANNED]->(d)'
  388. : '';
  389. const hostQuery = host
  390. ? 'MERGE (h:HOST { name: $host }) ON CREATE SET h.banned = true WITH a, h ' +
  391. 'WHERE h.name IS NOT NULL MERGE (a)-[:BANNED]->(h)'
  392. : '';
  393. await session.writeTransaction(tx =>
  394. tx.run(
  395. 'MATCH (l:URL { id: $id }) WHERE NOT (l)-[:USES]->(:DOMAIN) ' +
  396. 'MATCH (a:USER) WHERE a.email = $adminEmail ' +
  397. 'SET l.banned = true WITH l, a MERGE (a)-[:BANNED]->(l) WITH l, a ' +
  398. `${userQuery} ${domainQuery} ${hostQuery}`,
  399. {
  400. adminEmail,
  401. id,
  402. domain,
  403. host,
  404. }
  405. )
  406. );
  407. session.close();
  408. };
  409. exports.getBannedDomain = async (domain = '') => {
  410. const session = driver.session();
  411. const { records } = await session.readTransaction(tx =>
  412. tx.run('MATCH (d:DOMAIN { name: $domain, banned: true }) RETURN d', {
  413. domain,
  414. })
  415. );
  416. session.close();
  417. return records.length > 0;
  418. };
  419. exports.getBannedHost = async (host = '') => {
  420. const session = driver.session();
  421. const { records } = await session.readTransaction(tx =>
  422. tx.run('MATCH (h:HOST { name: $host, banned: true }) RETURN h', {
  423. host,
  424. })
  425. );
  426. session.close();
  427. return records.length > 0;
  428. };