url.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  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('MATCH (d:DOMAIN { name: $customDomain })<-[:OWNS]-(u) RETURN u', {
  192. customDomain,
  193. })
  194. )
  195. .then(({ records }) => {
  196. session.close();
  197. const data = records.length && records[0].get('u').properties;
  198. resolve(data);
  199. })
  200. .catch(err => session.close() || reject(err));
  201. });
  202. exports.setCustomDomain = ({ user, customDomain }) =>
  203. new Promise((resolve, reject) => {
  204. const session = driver.session();
  205. session
  206. .writeTransaction(tx =>
  207. tx.run(
  208. 'MATCH (u:USER { email: $email }) ' +
  209. 'OPTIONAL MATCH (u)-[r:OWNS]->() DELETE r ' +
  210. 'MERGE (d:DOMAIN { name: $customDomain }) ' +
  211. 'MERGE (u)-[:OWNS]->(d) RETURN u, d',
  212. {
  213. customDomain,
  214. email: user.email,
  215. }
  216. )
  217. )
  218. .then(({ records }) => {
  219. session.close();
  220. const data = records.length && records[0].get('d').properties;
  221. resolve(data);
  222. })
  223. .catch(err => session.close() || reject(err));
  224. });
  225. exports.deleteCustomDomain = ({ user }) =>
  226. new Promise((resolve, reject) => {
  227. const session = driver.session();
  228. session
  229. .writeTransaction(tx =>
  230. tx.run('MATCH (u:USER { email: $email }) MATCH (u)-[r:OWNS]->() DELETE r RETURN u', {
  231. email: user.email,
  232. })
  233. )
  234. .then(({ records }) => {
  235. session.close();
  236. const data = records.length && records[0].get('u').properties;
  237. resolve(data);
  238. })
  239. .catch(err => session.close() || reject(err));
  240. });
  241. exports.deleteUrl = ({ id, domain, user }) =>
  242. new Promise((resolve, reject) => {
  243. const session = driver.session();
  244. session
  245. .writeTransaction(tx =>
  246. tx.run(
  247. 'MATCH (u:USER { email: $email }) ' +
  248. 'MATCH (u)-[:CREATED]->(l { id: $id }) ' +
  249. `${
  250. domain
  251. ? 'MATCH (l)-[:USES]->(:DOMAIN { name: $domain })'
  252. : 'MATCH (l) WHERE NOT (l)-[:USES]->()'
  253. }` +
  254. 'OPTIONAL MATCH (l)-[:MATCHES]->(v) ' +
  255. 'DETACH DELETE l, v RETURN u',
  256. {
  257. email: user.email,
  258. domain,
  259. id,
  260. }
  261. )
  262. )
  263. .then(({ records }) => {
  264. session.close();
  265. const data = records.length && records[0].get('u').properties;
  266. resolve(data);
  267. })
  268. .catch(err => session.close() || reject(err));
  269. });
  270. /* Collecting stats */
  271. const initialStats = {
  272. browser: {
  273. IE: 0,
  274. Firefox: 0,
  275. Chrome: 0,
  276. Opera: 0,
  277. Safari: 0,
  278. Edge: 0,
  279. Other: 0,
  280. },
  281. os: {
  282. Windows: 0,
  283. 'Mac Os X': 0,
  284. Linux: 0,
  285. 'Chrome OS': 0,
  286. Android: 0,
  287. iOS: 0,
  288. Other: 0,
  289. },
  290. country: {},
  291. referrer: {},
  292. dates: [],
  293. };
  294. const filterByDate = days => record => isAfter(record.date, subDays(getUTCDate(), days));
  295. /* eslint-disable no-param-reassign */
  296. const calcStats = (obj, record) => {
  297. obj.browser[record.browser] += 1;
  298. obj.os[record.os] += 1;
  299. obj.country[record.country] = obj.country[record.country] + 1 || 1;
  300. obj.referrer[record.referrer] = obj.referrer[record.referrer] + 1 || 1;
  301. obj.dates = [...obj.dates, record.date];
  302. return obj;
  303. };
  304. /* eslint-enable no-param-reassign */
  305. const objectToArray = item => {
  306. const objToArr = key =>
  307. Array.from(Object.keys(item[key]))
  308. .map(name => ({
  309. name,
  310. value: item[key][name],
  311. }))
  312. .sort((a, b) => b.value - a.value);
  313. return {
  314. browser: objToArr('browser'),
  315. os: objToArr('os'),
  316. country: objToArr('country'),
  317. referrer: objToArr('referrer'),
  318. };
  319. };
  320. const calcViewPerDate = (views, period, sub, compare, lastDate = getUTCDate(), arr = []) => {
  321. if (arr.length === period) return arr;
  322. const matchedStats = views.filter(date => compare(date, lastDate));
  323. const viewsPerDate = [matchedStats.length, ...arr];
  324. return calcViewPerDate(views, period, sub, compare, sub(lastDate, 1), viewsPerDate);
  325. };
  326. const calcViews = {
  327. 0: views => calcViewPerDate(views, 24, subHours, isSameHour),
  328. 1: views => calcViewPerDate(views, 7, subDays, isSameDay),
  329. 2: views => calcViewPerDate(views, 30, subDays, isSameDay),
  330. 3: views => calcViewPerDate(views, 18, subMonths, isSameMonth),
  331. };
  332. exports.getStats = ({ id, domain, user }) =>
  333. new Promise((resolve, reject) => {
  334. const session = driver.session();
  335. session
  336. .readTransaction(tx =>
  337. tx.run(
  338. 'MATCH (l:URL { id: $id })<-[:CREATED]-(u:USER { email: $email }) ' +
  339. `${domain ? 'MATCH (l)-[:USES]->(domain { name: $domain })' : ''}` +
  340. 'MATCH (v)-[:VISITED]->(l) ' +
  341. 'MATCH (v)-[:BROWSED_BY]->(b) ' +
  342. 'MATCH (v)-[:LOCATED_IN]->(c) ' +
  343. 'MATCH (v)-[:OS]->(o) ' +
  344. 'MATCH (v)-[:REFERRED_BY]->(r) ' +
  345. 'MATCH (v)-[:VISITED_IN]->(d) ' +
  346. 'RETURN l, b.browser AS browser, c.country AS country,' +
  347. `${domain ? 'domain.name AS domain, ' : ''}` +
  348. 'o.os AS os, r.referrer AS referrer, d.date AS date ' +
  349. 'ORDER BY d.date DESC',
  350. {
  351. email: user.email,
  352. domain,
  353. id,
  354. }
  355. )
  356. )
  357. .then(({ records }) => {
  358. session.close();
  359. if (!records.length) resolve([]);
  360. const allStats = records.map(record => ({
  361. browser: record.get('browser'),
  362. os: record.get('os'),
  363. country: record.get('country'),
  364. referrer: record.get('referrer'),
  365. date: record.get('date'),
  366. }));
  367. const statsPeriods = [1, 7, 30, 550];
  368. const stats = statsPeriods
  369. .map(statsPeriod => allStats.filter(filterByDate(statsPeriod)))
  370. .map(statsPeriod => statsPeriod.reduce(calcStats, _.cloneDeep(initialStats)))
  371. .map((statsPeriod, index) => ({
  372. stats: objectToArray(statsPeriod),
  373. views: calcViews[index](statsPeriod.dates),
  374. }));
  375. const response = {
  376. total: records.length,
  377. id,
  378. shortUrl: `http${!domain ? 's' : ''}://${
  379. domain ? records[0].get('domain') : config.DEFAULT_DOMAIN
  380. }/${id}`,
  381. target: records[0].get('l').properties.target,
  382. updatedAt: new Date().toISOString(),
  383. lastDay: stats[0],
  384. lastWeek: stats[1],
  385. lastMonth: stats[2],
  386. allTime: stats[3],
  387. };
  388. return resolve(response);
  389. })
  390. .catch(err => session.close() || reject(err));
  391. });
  392. exports.urlCountFromDate = ({ date, email }) =>
  393. new Promise((resolve, reject) => {
  394. const session = driver.session();
  395. session
  396. .readTransaction(tx =>
  397. tx.run(
  398. 'MATCH (u:USER { email: $email })-[:CREATED]->(l) WHERE l.createdAt > $date ' +
  399. 'RETURN COUNT(l) as count',
  400. {
  401. date,
  402. email,
  403. }
  404. )
  405. )
  406. .then(({ records }) => {
  407. session.close();
  408. const count = records.length && records[0].get('count').toNumber();
  409. return resolve({ count });
  410. })
  411. .catch(err => reject(err));
  412. });
  413. exports.banUrl = async ({ id, domain, host, user }) => {
  414. const session = driver.session();
  415. const userQuery = user
  416. ? 'OPTIONAL MATCH (u:USER)-[:CREATED]->(l) SET u.banned = true WITH u ' +
  417. 'OPTIONAL MATCH (u)-[:CREATED]->(ls:URL) SET ls.banned = true'
  418. : '';
  419. const domainQuery = domain
  420. ? 'MERGE (d:DOMAIN { name: $domain }) ON CREATE SET d.banned = true'
  421. : '';
  422. const hostQuery = host ? 'MERGE (h:HOST { name: $host }) ON CREATE SET h.banned = true' : '';
  423. const withL = user || domain || host ? 'WITH l' : '';
  424. await session.writeTransaction(tx =>
  425. tx.run(
  426. 'MATCH (l:URL { id: $id }) WHERE NOT (l)-[:USES]->(:DOMAIN) ' +
  427. `SET l.banned = true ${withL} ${userQuery} ${domainQuery} ${hostQuery}`,
  428. {
  429. id,
  430. domain,
  431. host,
  432. }
  433. )
  434. );
  435. session.close();
  436. };
  437. exports.getBannedDomain = async (domain = '') => {
  438. const session = driver.session();
  439. const { records } = await session.readTransaction(tx =>
  440. tx.run('MATCH (d:DOMAIN { name: $domain, banned: true }) RETURN d', {
  441. domain,
  442. })
  443. );
  444. session.close();
  445. return records.length > 0;
  446. };
  447. exports.getBannedHost = async (host = '') => {
  448. const session = driver.session();
  449. const { records } = await session.readTransaction(tx =>
  450. tx.run('MATCH (h:HOST { name: $host, banned: true }) RETURN h', {
  451. host,
  452. })
  453. );
  454. session.close();
  455. return records.length > 0;
  456. };