url.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. const bcrypt = require('bcryptjs');
  2. const _ = require('lodash/');
  3. const {
  4. isAfter,
  5. isSameHour,
  6. isSameDay,
  7. isSameMonth,
  8. subDays,
  9. subHours,
  10. subMonths,
  11. } = require('date-fns');
  12. const driver = require('./neo4j');
  13. const config = require('../config');
  14. const { generateShortUrl } = require('../utils');
  15. const getUTCDate = (dateString = Date.now()) => {
  16. const date = new Date(dateString);
  17. return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
  18. };
  19. const queryNewUrl = 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt }) RETURN l';
  20. const queryNewUserUrl = (domain, password) =>
  21. 'MATCH (u:USER { email: $email })' +
  22. 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt' +
  23. `${password ? ', password: $password' : ''} })` +
  24. 'CREATE (u)-[:CREATED]->(l)' +
  25. `${domain ? 'MERGE (l)-[:USES]->(:DOMAIN { name: $domain })' : ''}` +
  26. 'RETURN l';
  27. exports.createShortUrl = params =>
  28. new Promise(async (resolve, reject) => {
  29. const query = params.user ? queryNewUserUrl(params.user.domain, params.password) : queryNewUrl;
  30. const session = driver.session();
  31. const salt = params.password && (await bcrypt.genSalt(12));
  32. const hash = params.password && (await bcrypt.hash(params.password, salt));
  33. session
  34. .writeTransaction(tx =>
  35. tx.run(query, {
  36. createdAt: new Date().toJSON(),
  37. domain: params.user && params.user.domain,
  38. email: params.user && params.user.email,
  39. id: params.id,
  40. password: hash || '',
  41. target: params.target,
  42. })
  43. )
  44. .then(({ records }) => {
  45. session.close();
  46. const data = records[0].get('l').properties;
  47. resolve({
  48. ...data,
  49. password: !!data.password,
  50. reuse: !!params.reuse,
  51. shortUrl: generateShortUrl(data.id, params.user && params.user.domain),
  52. });
  53. })
  54. .catch(err => session.close() || reject(err));
  55. });
  56. exports.createVisit = params =>
  57. new Promise((resolve, reject) => {
  58. const session = driver.session();
  59. session
  60. .writeTransaction(tx =>
  61. tx.run(
  62. 'MATCH (l:URL { id: $id }) ' +
  63. `${params.domain ? 'MATCH (l)-[:USES]->({ name: $domain })' : ''} ` +
  64. 'SET l.count = l.count + 1 ' +
  65. 'CREATE (v:VISIT)' +
  66. 'MERGE (b:BROWSER { browser: $browser })' +
  67. 'MERGE (c:COUNTRY { country: $country })' +
  68. 'MERGE (o:OS { os: $os })' +
  69. 'MERGE (r:REFERRER { referrer: $referrer })' +
  70. 'MERGE (d:DATE { date: $date })' +
  71. 'MERGE (v)-[:VISITED]->(l)' +
  72. 'MERGE (v)-[:BROWSED_BY]->(b)' +
  73. 'MERGE (v)-[:LOCATED_IN]->(c)' +
  74. 'MERGE (v)-[:OS]->(o)' +
  75. 'MERGE (v)-[:REFERRED_BY]->(r)' +
  76. 'MERGE (v)-[:VISITED_IN]->(d)' +
  77. 'RETURN l',
  78. {
  79. id: params.id,
  80. browser: params.browser,
  81. domain: params.domain,
  82. country: params.country,
  83. os: params.os,
  84. referrer: params.referrer,
  85. date: getUTCDate().toJSON(),
  86. }
  87. )
  88. )
  89. .then(({ records }) => {
  90. session.close();
  91. const url = records.length && records[0].get('l').properties;
  92. resolve(url);
  93. })
  94. .catch(err => session.close() || reject(err));
  95. });
  96. exports.findUrl = ({ id, domain, target }) =>
  97. new Promise((resolve, reject) => {
  98. const session = driver.session();
  99. session
  100. .readTransaction(tx =>
  101. tx.run(
  102. `MATCH (l:URL { ${id ? 'id: $id' : 'target: $target'} })` +
  103. `${
  104. domain
  105. ? 'MATCH (l)-[:USES]->(d:DOMAIN { name: $domain })'
  106. : 'OPTIONAL MATCH (l)-[:USES]->(d)'
  107. }` +
  108. 'OPTIONAL MATCH (u)-[:CREATED]->(l)' +
  109. 'RETURN l, d.name AS domain, u.email AS user',
  110. {
  111. id,
  112. domain,
  113. target,
  114. }
  115. )
  116. )
  117. .then(({ records }) => {
  118. session.close();
  119. const url =
  120. records.length &&
  121. records.map(record => ({
  122. ...record.get('l').properties,
  123. domain: record.get('domain'),
  124. user: record.get('user'),
  125. }));
  126. resolve(url);
  127. })
  128. .catch(err => session.close() || reject(err));
  129. });
  130. exports.getCountUrls = ({ user }) =>
  131. new Promise((resolve, reject) => {
  132. const session = driver.session();
  133. session
  134. .readTransaction(tx =>
  135. tx.run('MATCH (u:USER {email: $email}) RETURN size((u)-[:CREATED]->()) as count', {
  136. email: user.email,
  137. })
  138. )
  139. .then(({ records }) => {
  140. session.close();
  141. const countAll = records.length ? records[0].get('count').toNumber() : 0;
  142. resolve({ countAll });
  143. })
  144. .catch(err => session.close() || reject(err));
  145. });
  146. exports.getUrls = ({ user, options, setCount }) =>
  147. new Promise((resolve, reject) => {
  148. const session = driver.session();
  149. const { count = 5, page = 1, search = '' } = options;
  150. const limit = parseInt(count, 10);
  151. const skip = parseInt(page, 10);
  152. const searchQuery = search ? 'WHERE l.id =~ $search OR l.target =~ $search' : '';
  153. const setVisitsCount = setCount ? 'SET l.count = size((l)<-[:VISITED]-())' : '';
  154. session
  155. .readTransaction(tx =>
  156. tx.run(
  157. `MATCH (u:USER { email: $email })-[:CREATED]->(l) ${searchQuery} ` +
  158. 'WITH l ORDER BY l.createdAt DESC ' +
  159. 'WITH l SKIP $skip LIMIT $limit ' +
  160. `OPTIONAL MATCH (l)-[:USES]->(d) ${setVisitsCount} ` +
  161. 'RETURN l, d.name AS domain',
  162. {
  163. email: user.email,
  164. limit,
  165. skip: limit * (skip - 1),
  166. search: `(?i).*${search}.*`,
  167. }
  168. )
  169. )
  170. .then(({ records }) => {
  171. session.close();
  172. const urls = records.map(record => {
  173. const visitCount = record.get('l').properties.count;
  174. return {
  175. ...record.get('l').properties,
  176. count: typeof visitCount === 'object' ? visitCount.toNumber() : visitCount,
  177. password: !!record.get('l').properties.password,
  178. shortUrl: `http${!record.get('domain') ? 's' : ''}://${record.get('domain') ||
  179. config.DEFAULT_DOMAIN}/${record.get('l').properties.id}`,
  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 }) =>
  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 }) ` +
  219. 'MERGE (u)-[:OWNS]->(d) RETURN u, d',
  220. {
  221. customDomain,
  222. homepage: homepage || '',
  223. email: user.email,
  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. /* Collecting stats */
  280. const initialStats = {
  281. browser: {
  282. IE: 0,
  283. Firefox: 0,
  284. Chrome: 0,
  285. Opera: 0,
  286. Safari: 0,
  287. Edge: 0,
  288. Other: 0,
  289. },
  290. os: {
  291. Windows: 0,
  292. 'Mac Os X': 0,
  293. Linux: 0,
  294. 'Chrome OS': 0,
  295. Android: 0,
  296. iOS: 0,
  297. Other: 0,
  298. },
  299. country: {},
  300. referrer: {},
  301. dates: [],
  302. };
  303. const filterByDate = days => record => isAfter(record.date, subDays(getUTCDate(), days));
  304. /* eslint-disable no-param-reassign */
  305. const calcStats = (obj, record) => {
  306. obj.browser[record.browser] += 1;
  307. obj.os[record.os] += 1;
  308. obj.country[record.country] = obj.country[record.country] + 1 || 1;
  309. obj.referrer[record.referrer] = obj.referrer[record.referrer] + 1 || 1;
  310. obj.dates = [...obj.dates, record.date];
  311. return obj;
  312. };
  313. /* eslint-enable no-param-reassign */
  314. const objectToArray = item => {
  315. const objToArr = key =>
  316. Array.from(Object.keys(item[key]))
  317. .map(name => ({
  318. name,
  319. value: item[key][name],
  320. }))
  321. .sort((a, b) => b.value - a.value);
  322. return {
  323. browser: objToArr('browser'),
  324. os: objToArr('os'),
  325. country: objToArr('country'),
  326. referrer: objToArr('referrer'),
  327. };
  328. };
  329. const calcViewPerDate = (views, period, sub, compare, lastDate = getUTCDate(), arr = []) => {
  330. if (arr.length === period) return arr;
  331. const matchedStats = views.filter(date => compare(date, lastDate));
  332. const viewsPerDate = [matchedStats.length, ...arr];
  333. return calcViewPerDate(views, period, sub, compare, sub(lastDate, 1), viewsPerDate);
  334. };
  335. const calcViews = {
  336. 0: views => calcViewPerDate(views, 24, subHours, isSameHour),
  337. 1: views => calcViewPerDate(views, 7, subDays, isSameDay),
  338. 2: views => calcViewPerDate(views, 30, subDays, isSameDay),
  339. 3: views => calcViewPerDate(views, 18, subMonths, isSameMonth),
  340. };
  341. exports.getStats = ({ id, domain, user }) =>
  342. new Promise((resolve, reject) => {
  343. const session = driver.session();
  344. session
  345. .readTransaction(tx =>
  346. tx.run(
  347. 'MATCH (l:URL { id: $id })<-[:CREATED]-(u:USER { email: $email }) ' +
  348. `${domain ? 'MATCH (l)-[:USES]->(domain { name: $domain })' : ''}` +
  349. 'MATCH (v)-[:VISITED]->(l) ' +
  350. 'MATCH (v)-[:BROWSED_BY]->(b) ' +
  351. 'MATCH (v)-[:LOCATED_IN]->(c) ' +
  352. 'MATCH (v)-[:OS]->(o) ' +
  353. 'MATCH (v)-[:REFERRED_BY]->(r) ' +
  354. 'MATCH (v)-[:VISITED_IN]->(d) ' +
  355. 'RETURN l, b.browser AS browser, c.country AS country,' +
  356. `${domain ? 'domain.name AS domain, ' : ''}` +
  357. 'o.os AS os, r.referrer AS referrer, d.date AS date ' +
  358. 'ORDER BY d.date DESC',
  359. {
  360. email: user.email,
  361. domain,
  362. id,
  363. }
  364. )
  365. )
  366. .then(({ records }) => {
  367. session.close();
  368. if (!records.length) resolve([]);
  369. const allStats = records.map(record => ({
  370. browser: record.get('browser'),
  371. os: record.get('os'),
  372. country: record.get('country'),
  373. referrer: record.get('referrer'),
  374. date: record.get('date'),
  375. }));
  376. const statsPeriods = [1, 7, 30, 550];
  377. const stats = statsPeriods
  378. .map(statsPeriod => allStats.filter(filterByDate(statsPeriod)))
  379. .map(statsPeriod => statsPeriod.reduce(calcStats, _.cloneDeep(initialStats)))
  380. .map((statsPeriod, index) => ({
  381. stats: objectToArray(statsPeriod),
  382. views: calcViews[index](statsPeriod.dates),
  383. }));
  384. const response = {
  385. total: records.length,
  386. id,
  387. shortUrl: `http${!domain ? 's' : ''}://${
  388. domain ? records[0].get('domain') : config.DEFAULT_DOMAIN
  389. }/${id}`,
  390. target: records[0].get('l').properties.target,
  391. updatedAt: new Date().toISOString(),
  392. lastDay: stats[0],
  393. lastWeek: stats[1],
  394. lastMonth: stats[2],
  395. allTime: stats[3],
  396. };
  397. return resolve(response);
  398. })
  399. .catch(err => session.close() || reject(err));
  400. });
  401. exports.urlCountFromDate = ({ date, email }) =>
  402. new Promise((resolve, reject) => {
  403. const session = driver.session();
  404. session
  405. .readTransaction(tx =>
  406. tx.run(
  407. 'MATCH (u:USER { email: $email })-[:CREATED]->(l) WHERE l.createdAt > $date ' +
  408. 'RETURN COUNT(l) as count',
  409. {
  410. date,
  411. email,
  412. }
  413. )
  414. )
  415. .then(({ records }) => {
  416. session.close();
  417. const count = records.length && records[0].get('count').toNumber();
  418. return resolve({ count });
  419. })
  420. .catch(err => reject(err));
  421. });
  422. exports.banUrl = async ({ id, domain, host, user }) => {
  423. const session = driver.session();
  424. const userQuery = user
  425. ? 'OPTIONAL MATCH (u:USER)-[:CREATED]->(l) SET u.banned = true WITH u ' +
  426. 'OPTIONAL MATCH (u)-[:CREATED]->(ls:URL) SET ls.banned = true'
  427. : '';
  428. const domainQuery = domain
  429. ? 'MERGE (d:DOMAIN { name: $domain }) ON CREATE SET d.banned = true'
  430. : '';
  431. const hostQuery = host ? 'MERGE (h:HOST { name: $host }) ON CREATE SET h.banned = true' : '';
  432. const withL = user || domain || host ? 'WITH l' : '';
  433. await session.writeTransaction(tx =>
  434. tx.run(
  435. 'MATCH (l:URL { id: $id }) WHERE NOT (l)-[:USES]->(:DOMAIN) ' +
  436. `SET l.banned = true ${withL} ${userQuery} ${domainQuery} ${hostQuery}`,
  437. {
  438. id,
  439. domain,
  440. host,
  441. }
  442. )
  443. );
  444. session.close();
  445. };
  446. exports.getBannedDomain = async (domain = '') => {
  447. const session = driver.session();
  448. const { records } = await session.readTransaction(tx =>
  449. tx.run('MATCH (d:DOMAIN { name: $domain, banned: true }) RETURN d', {
  450. domain,
  451. })
  452. );
  453. session.close();
  454. return records.length > 0;
  455. };
  456. exports.getBannedHost = async (host = '') => {
  457. const session = driver.session();
  458. const { records } = await session.readTransaction(tx =>
  459. tx.run('MATCH (h:HOST { name: $host, banned: true }) RETURN h', {
  460. host,
  461. })
  462. );
  463. session.close();
  464. return records.length > 0;
  465. };