url.js 14 KB

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