url.js 15 KB

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