url.js 15 KB

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