url.js 13 KB

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