url.js 14 KB

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