url.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. const generate = require('nanoid/generate');
  2. const bcrypt = require('bcryptjs');
  3. const _ = require('lodash/');
  4. const {
  5. isAfter,
  6. isSameHour,
  7. isSameDay,
  8. isSameMonth,
  9. subDays,
  10. subHours,
  11. subMonths,
  12. } = require('date-fns');
  13. const driver = require('./neo4j');
  14. const config = require('../config');
  15. const { generateShortUrl } = require('../utils');
  16. const getUTCDate = (dateString = Date.now()) => {
  17. const date = new Date(dateString);
  18. return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
  19. };
  20. const generateId = () =>
  21. generate('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', 6);
  22. const queryNewUrl = 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt }) RETURN l';
  23. const queryNewUserUrl = (domain, password) =>
  24. 'MATCH (u:USER { email: $email })' +
  25. 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt' +
  26. `${password ? ', password: $password' : ''} })` +
  27. 'CREATE (u)-[:CREATED]->(l)' +
  28. `${domain ? 'MERGE (l)-[:USES]->(:DOMAIN { name: $domain })' : ''}` +
  29. 'RETURN l';
  30. exports.createShortUrl = params =>
  31. new Promise(async (resolve, reject) => {
  32. const query = params.user ? queryNewUserUrl(params.user.domain, params.password) : queryNewUrl;
  33. const session = driver.session();
  34. const salt = params.password && (await bcrypt.genSalt(12));
  35. const hash = params.password && (await bcrypt.hash(params.password, salt));
  36. session
  37. .writeTransaction(tx =>
  38. tx.run(query, {
  39. createdAt: new Date().toJSON(),
  40. domain: params.user && params.user.domain,
  41. email: params.user && params.user.email,
  42. id: params.id,
  43. password: hash || '',
  44. target: params.target,
  45. })
  46. )
  47. .then(({ records }) => {
  48. session.close();
  49. const data = records[0].get('l').properties;
  50. resolve({
  51. ...data,
  52. password: !!data.password,
  53. reuse: !!params.reuse,
  54. shortUrl: generateShortUrl(data.id, params.user.domain),
  55. });
  56. })
  57. .catch(reject);
  58. });
  59. exports.createVisit = params =>
  60. new Promise((resolve, reject) => {
  61. const session = driver.session();
  62. session
  63. .writeTransaction(tx =>
  64. tx.run(
  65. 'MATCH (l:URL { id: $id })' +
  66. `${params.domain ? 'MATCH (l)-[:USES]->({ name: $domain })' : ''}` +
  67. 'CREATE (v:VISIT)' +
  68. 'MERGE (b:BROWSER { browser: $browser })' +
  69. 'MERGE (c:COUNTRY { country: $country })' +
  70. 'MERGE (o:OS { os: $os })' +
  71. 'MERGE (r:REFERRER { referrer: $referrer })' +
  72. 'MERGE (d:DATE { date: $date })' +
  73. 'MERGE (v)-[:VISITED]->(l)' +
  74. 'MERGE (v)-[:BROWSED_BY]->(b)' +
  75. 'MERGE (v)-[:LOCATED_IN]->(c)' +
  76. 'MERGE (v)-[:OS]->(o)' +
  77. 'MERGE (v)-[:REFERRED_BY]->(r)' +
  78. 'MERGE (v)-[:VISITED_IN]->(d)' +
  79. 'RETURN l',
  80. {
  81. id: params.id,
  82. browser: params.browser,
  83. domain: params.domain,
  84. country: params.country,
  85. os: params.os,
  86. referrer: params.referrer,
  87. date: getUTCDate().toJSON(),
  88. }
  89. )
  90. )
  91. .then(({ records }) => {
  92. session.close();
  93. const url = records.length && records[0].get('l').properties;
  94. resolve(url);
  95. })
  96. .catch(reject);
  97. });
  98. exports.findUrl = ({ id, domain, target }) =>
  99. new Promise((resolve, reject) => {
  100. const session = driver.session();
  101. session
  102. .readTransaction(tx =>
  103. tx.run(
  104. `MATCH (l:URL { ${id ? 'id: $id' : 'target: $target'} })` +
  105. `${
  106. domain
  107. ? 'MATCH (l)-[:USES]->(d:DOMAIN { name: $domain })'
  108. : 'OPTIONAL MATCH (l)-[:USES]->(d)'
  109. }` +
  110. 'OPTIONAL MATCH (u)-[:CREATED]->(l)' +
  111. 'RETURN l, d.name AS domain, u.email AS user',
  112. {
  113. id,
  114. domain,
  115. target,
  116. }
  117. )
  118. )
  119. .then(({ records }) => {
  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'),
  127. }));
  128. resolve(url);
  129. })
  130. .catch(reject);
  131. });
  132. exports.getUrls = ({ user, options }) =>
  133. new Promise((resolve, reject) => {
  134. const session = driver.session();
  135. const { count = 5, page = 1, search = '' } = options;
  136. const searchQuery = search ? 'WHERE l.id =~ $search OR l.target =~ $search' : '';
  137. session
  138. .readTransaction(tx =>
  139. tx.run(
  140. `MATCH (u:USER { email: $email })-[:CREATED]->(l) ${searchQuery} ` +
  141. 'OPTIONAL MATCH (l)-[:USES]->(d)' +
  142. 'OPTIONAL MATCH (l)<-[:VISITED]-(v:VISIT)' +
  143. 'RETURN l, d.name AS domain, COUNT(v) AS count ORDER BY l.createdAt DESC',
  144. {
  145. email: user.email,
  146. search: `(?i).*${search}.*`,
  147. }
  148. )
  149. )
  150. .then(({ records }) => {
  151. session.close();
  152. const countAll = records.length;
  153. const first = (page - 1) * count;
  154. const last = page * count;
  155. const urls = records.slice(first, last).map(record => ({
  156. ...record.get('l').properties,
  157. password: !!record.get('l').properties.password,
  158. count: record.get('count').toNumber(),
  159. shortUrl: `http${!record.get('domain') ? 's' : ''}://${record.get('domain') ||
  160. config.DEFAULT_DOMAIN}/${record.get('l').properties.id}`,
  161. }));
  162. resolve({ list: urls, countAll });
  163. })
  164. .catch(reject);
  165. });
  166. exports.getCustomDomain = ({ customDomain }) =>
  167. new Promise((resolve, reject) => {
  168. const session = driver.session();
  169. session
  170. .readTransaction(tx =>
  171. tx.run('MATCH (d:DOMAIN { name: $customDomain })<-[:OWNS]-(u) RETURN u', {
  172. customDomain,
  173. })
  174. )
  175. .then(({ records }) => {
  176. session.close();
  177. const data = records.length && records[0].get('u').properties;
  178. resolve(data);
  179. })
  180. .catch(reject);
  181. });
  182. exports.setCustomDomain = ({ user, customDomain }) =>
  183. new Promise((resolve, reject) => {
  184. const session = driver.session();
  185. session
  186. .writeTransaction(tx =>
  187. tx.run(
  188. 'MATCH (u:USER { email: $email }) ' +
  189. 'OPTIONAL MATCH (u)-[r:OWNS]->() DELETE r ' +
  190. 'MERGE (d:DOMAIN { name: $customDomain }) ' +
  191. 'MERGE (u)-[:OWNS]->(d) RETURN u, d',
  192. {
  193. customDomain,
  194. email: user.email,
  195. }
  196. )
  197. )
  198. .then(({ records }) => {
  199. session.close();
  200. const data = records.length && records[0].get('d').properties;
  201. resolve(data);
  202. })
  203. .catch(reject);
  204. });
  205. exports.deleteCustomDomain = ({ user }) =>
  206. new Promise((resolve, reject) => {
  207. const session = driver.session();
  208. session
  209. .writeTransaction(tx =>
  210. tx.run('MATCH (u:USER { email: $email }) MATCH (u)-[r:OWNS]->() DELETE r RETURN u', {
  211. email: user.email,
  212. })
  213. )
  214. .then(({ records }) => {
  215. session.close();
  216. const data = records.length && records[0].get('u').properties;
  217. resolve(data);
  218. })
  219. .catch(reject);
  220. });
  221. exports.deleteUrl = ({ id, domain, user }) =>
  222. new Promise((resolve, reject) => {
  223. const session = driver.session();
  224. session
  225. .writeTransaction(tx =>
  226. tx.run(
  227. 'MATCH (u:USER { email: $email }) ' +
  228. 'MATCH (u)-[:CREATED]->(l { id: $id }) ' +
  229. `${
  230. domain
  231. ? 'MATCH (l)-[:USES]->(:DOMAIN { name: $domain })'
  232. : 'MATCH (l) WHERE NOT (l)-[:USES]->()'
  233. }` +
  234. 'OPTIONAL MATCH (l)-[:MATCHES]->(v) ' +
  235. 'DETACH DELETE l, v RETURN u',
  236. {
  237. email: user.email,
  238. domain,
  239. id,
  240. }
  241. )
  242. )
  243. .then(({ records }) => {
  244. session.close();
  245. const data = records.length && records[0].get('u').properties;
  246. resolve(data);
  247. })
  248. .catch(reject);
  249. });
  250. /* Collecting stats */
  251. const initialStats = {
  252. browser: {
  253. IE: 0,
  254. Firefox: 0,
  255. Chrome: 0,
  256. Opera: 0,
  257. Safari: 0,
  258. Edge: 0,
  259. Other: 0,
  260. },
  261. os: {
  262. Windows: 0,
  263. 'Mac Os X': 0,
  264. Linux: 0,
  265. 'Chrome OS': 0,
  266. Android: 0,
  267. iOS: 0,
  268. Other: 0,
  269. },
  270. country: {},
  271. referrer: {},
  272. dates: [],
  273. };
  274. const filterByDate = days => record => isAfter(record.date, subDays(getUTCDate(), days));
  275. /* eslint-disable no-param-reassign */
  276. const calcStats = (obj, record) => {
  277. obj.browser[record.browser] += 1;
  278. obj.os[record.os] += 1;
  279. obj.country[record.country] = obj.country[record.country] + 1 || 1;
  280. obj.referrer[record.referrer] = obj.referrer[record.referrer] + 1 || 1;
  281. obj.dates = [...obj.dates, record.date];
  282. return obj;
  283. };
  284. /* eslint-enable no-param-reassign */
  285. const objectToArray = item => {
  286. const objToArr = key =>
  287. Array.from(Object.keys(item[key]))
  288. .map(name => ({
  289. name,
  290. value: item[key][name],
  291. }))
  292. .sort((a, b) => b.value - a.value);
  293. return {
  294. browser: objToArr('browser'),
  295. os: objToArr('os'),
  296. country: objToArr('country'),
  297. referrer: objToArr('referrer'),
  298. };
  299. };
  300. const calcViewPerDate = (views, period, sub, compare, lastDate = getUTCDate(), arr = []) => {
  301. if (arr.length === period) return arr;
  302. const matchedStats = views.filter(date => compare(date, lastDate));
  303. const viewsPerDate = [matchedStats.length, ...arr];
  304. return calcViewPerDate(views, period, sub, compare, sub(lastDate, 1), viewsPerDate);
  305. };
  306. const calcViews = {
  307. 0: views => calcViewPerDate(views, 24, subHours, isSameHour),
  308. 1: views => calcViewPerDate(views, 7, subDays, isSameDay),
  309. 2: views => calcViewPerDate(views, 30, subDays, isSameDay),
  310. 3: views => calcViewPerDate(views, 18, subMonths, isSameMonth),
  311. };
  312. exports.getStats = ({ id, domain, user }) =>
  313. new Promise((resolve, reject) => {
  314. const session = driver.session();
  315. session
  316. .readTransaction(tx =>
  317. tx.run(
  318. 'MATCH (l:URL { id: $id })<-[:CREATED]-(u:USER { email: $email }) ' +
  319. `${domain ? 'MATCH (l)-[:USES]->(domain { name: $domain })' : ''}` +
  320. 'MATCH (v)-[:VISITED]->(l) ' +
  321. 'MATCH (v)-[:BROWSED_BY]->(b) ' +
  322. 'MATCH (v)-[:LOCATED_IN]->(c) ' +
  323. 'MATCH (v)-[:OS]->(o) ' +
  324. 'MATCH (v)-[:REFERRED_BY]->(r) ' +
  325. 'MATCH (v)-[:VISITED_IN]->(d) ' +
  326. 'RETURN l, b.browser AS browser, c.country AS country,' +
  327. `${domain ? 'domain.name AS domain, ' : ''}` +
  328. 'o.os AS os, r.referrer AS referrer, d.date AS date ' +
  329. 'ORDER BY d.date DESC',
  330. {
  331. email: user.email,
  332. domain,
  333. id,
  334. }
  335. )
  336. )
  337. .then(({ records }) => {
  338. session.close();
  339. if (!records.length) resolve([]);
  340. const allStats = records.map(record => ({
  341. browser: record.get('browser'),
  342. os: record.get('os'),
  343. country: record.get('country'),
  344. referrer: record.get('referrer'),
  345. date: record.get('date'),
  346. }));
  347. const statsPeriods = [1, 7, 30, 550];
  348. const stats = statsPeriods
  349. .map(statsPeriod => allStats.filter(filterByDate(statsPeriod)))
  350. .map(statsPeriod => statsPeriod.reduce(calcStats, _.cloneDeep(initialStats)))
  351. .map((statsPeriod, index) => ({
  352. stats: objectToArray(statsPeriod),
  353. views: calcViews[index](statsPeriod.dates),
  354. }));
  355. const response = {
  356. total: records.length,
  357. id,
  358. shortUrl: `http${!domain ? 's' : ''}://${
  359. domain ? records[0].get('domain') : config.DEFAULT_DOMAIN
  360. }/${id}`,
  361. target: records[0].get('l').properties.target,
  362. lastDay: stats[0],
  363. lastWeek: stats[1],
  364. lastMonth: stats[2],
  365. allTime: stats[3],
  366. };
  367. return resolve(response);
  368. })
  369. .catch(reject);
  370. });