url.js 15 KB

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