url.js 14 KB

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