links.handler.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. const { differenceInSeconds } = require("date-fns");
  2. const promisify = require("util").promisify;
  3. const bcrypt = require("bcryptjs");
  4. const { isbot } = require("isbot");
  5. const URL = require("url");
  6. const dns = require("dns");
  7. const validators = require("./validators.handler");
  8. const map = require("../utils/map.json");
  9. const transporter = require("../mail");
  10. const query = require("../queries");
  11. const queue = require("../queues");
  12. const utils = require("../utils");
  13. const env = require("../env");
  14. const CustomError = utils.CustomError;
  15. const dnsLookup = promisify(dns.lookup);
  16. async function get(req, res) {
  17. const { limit, skip } = req.context;
  18. const search = req.query.search;
  19. const userId = req.user.id;
  20. const match = {
  21. user_id: userId
  22. };
  23. const [data, total] = await Promise.all([
  24. query.link.get(match, { limit, search, skip }),
  25. query.link.total(match, { search })
  26. ]);
  27. const links = data.map(utils.sanitize.link);
  28. if (req.isHTML) {
  29. res.render("partials/links/table", {
  30. total,
  31. limit,
  32. skip,
  33. links,
  34. })
  35. return;
  36. }
  37. return res.send({
  38. total,
  39. limit,
  40. skip,
  41. data: links,
  42. });
  43. };
  44. async function getAdmin(req, res) {
  45. const { limit, skip } = req.context;
  46. const search = req.query.search;
  47. const user = req.query.user;
  48. let domain = req.query.domain;
  49. const banned = utils.parseBooleanQuery(req.query.banned);
  50. const anonymous = utils.parseBooleanQuery(req.query.anonymous);
  51. const has_domain = utils.parseBooleanQuery(req.query.has_domain);
  52. const match = {
  53. ...(banned !== undefined && { banned }),
  54. ...(anonymous !== undefined && { user_id: [anonymous ? "is" : "is not", null] }),
  55. ...(has_domain !== undefined && { domain_id: [has_domain ? "is not" : "is", null] }),
  56. };
  57. // if domain is equal to the defualt domain,
  58. // it means admins is looking for links with the defualt domain (no custom user domain)
  59. if (domain === env.DEFAULT_DOMAIN) {
  60. domain = undefined;
  61. match.domain_id = null;
  62. }
  63. const [data, total] = await Promise.all([
  64. query.link.getAdmin(match, { limit, search, user, domain, skip }),
  65. query.link.totalAdmin(match, { search, user, domain })
  66. ]);
  67. const links = data.map(utils.sanitize.link_admin);
  68. if (req.isHTML) {
  69. res.render("partials/admin/links/table", {
  70. total,
  71. total_formatted: total.toLocaleString("en-US"),
  72. limit,
  73. skip,
  74. links,
  75. })
  76. return;
  77. }
  78. return res.send({
  79. total,
  80. limit,
  81. skip,
  82. data: links,
  83. });
  84. };
  85. async function create(req, res) {
  86. const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
  87. const domain_id = fetched_domain ? fetched_domain.id : null;
  88. const targetDomain = utils.removeWww(URL.parse(target).hostname);
  89. const tasks = await Promise.all([
  90. validators.cooldown(req.user),
  91. validators.malware(req.user, target),
  92. validators.linksCount(req.user),
  93. reuse &&
  94. query.link.find({
  95. target,
  96. user_id: req.user.id,
  97. domain_id
  98. }),
  99. customurl &&
  100. query.link.find({
  101. address: customurl,
  102. domain_id
  103. }),
  104. !customurl && utils.generateId(query, domain_id),
  105. validators.bannedDomain(targetDomain),
  106. validators.bannedHost(targetDomain)
  107. ]);
  108. // if "reuse" is true, try to return
  109. // the existent URL without creating one
  110. if (tasks[3]) {
  111. return res.json(utils.sanitize.link(tasks[3]));
  112. }
  113. // Check if custom link already exists
  114. if (tasks[4]) {
  115. const error = "Custom URL is already in use.";
  116. res.locals.errors = { customurl: error };
  117. throw new CustomError(error);
  118. }
  119. // Create new link
  120. const address = customurl || tasks[5];
  121. const link = await query.link.create({
  122. password,
  123. address,
  124. domain_id,
  125. description,
  126. target,
  127. expire_in,
  128. user_id: req.user && req.user.id
  129. });
  130. if (!req.user && env.NON_USER_COOLDOWN) {
  131. query.ip.add(req.realIP);
  132. }
  133. link.domain = fetched_domain?.address;
  134. if (req.isHTML) {
  135. res.setHeader("HX-Trigger", "reloadMainTable");
  136. const shortURL = utils.getShortURL(link.address, link.domain);
  137. return res.render("partials/shortener", {
  138. link: shortURL.link,
  139. url: shortURL.url,
  140. });
  141. }
  142. return res
  143. .status(201)
  144. .send(utils.sanitize.link({ ...link }));
  145. }
  146. async function edit(req, res) {
  147. const link = await query.link.find({
  148. uuid: req.params.id,
  149. ...(!req.user.admin && { user_id: req.user.id })
  150. });
  151. if (!link) {
  152. throw new CustomError("Link was not found.");
  153. }
  154. let isChanged = false;
  155. [
  156. [req.body.address, "address"],
  157. [req.body.target, "target"],
  158. [req.body.description, "description"],
  159. [req.body.expire_in, "expire_in"],
  160. [req.body.password, "password"]
  161. ].forEach(([value, name]) => {
  162. if (!value) {
  163. if (name === "password" && link.password)
  164. req.body.password = null;
  165. else {
  166. delete req.body[name];
  167. return;
  168. }
  169. }
  170. if (value === link[name] && name !== "password") {
  171. delete req.body[name];
  172. return;
  173. }
  174. if (name === "expire_in" && link.expire_in)
  175. if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60)
  176. return;
  177. if (name === "password")
  178. if (value && value.replace(/•/ig, "").length === 0) {
  179. delete req.body.password;
  180. return;
  181. }
  182. isChanged = true;
  183. });
  184. if (!isChanged) {
  185. throw new CustomError("Should at least update one field.");
  186. }
  187. const { address, target, description, expire_in, password } = req.body;
  188. const targetDomain = target && utils.removeWww(URL.parse(target).hostname);
  189. const domain_id = link.domain_id || null;
  190. const tasks = await Promise.all([
  191. validators.cooldown(req.user),
  192. target && validators.malware(req.user, target),
  193. address &&
  194. query.link.find({
  195. address,
  196. domain_id
  197. }),
  198. target && validators.bannedDomain(targetDomain),
  199. target && validators.bannedHost(targetDomain)
  200. ]);
  201. // Check if custom link already exists
  202. if (tasks[2]) {
  203. const error = "Custom URL is already in use.";
  204. res.locals.errors = { address: error };
  205. throw new CustomError("Custom URL is already in use.");
  206. }
  207. // Update link
  208. const [updatedLink] = await query.link.update(
  209. {
  210. id: link.id
  211. },
  212. {
  213. ...(address && { address }),
  214. ...(description && { description }),
  215. ...(target && { target }),
  216. ...(expire_in && { expire_in }),
  217. ...((password || password === null) && { password })
  218. }
  219. );
  220. if (req.isHTML) {
  221. res.render("partials/links/edit", {
  222. swap_oob: true,
  223. success: "Link has been updated.",
  224. ...utils.sanitize.link({ ...link, ...updatedLink }),
  225. });
  226. return;
  227. }
  228. return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
  229. };
  230. async function editAdmin(req, res) {
  231. const link = await query.link.find({
  232. uuid: req.params.id,
  233. ...(!req.user.admin && { user_id: req.user.id })
  234. });
  235. if (!link) {
  236. throw new CustomError("Link was not found.");
  237. }
  238. let isChanged = false;
  239. [
  240. [req.body.address, "address"],
  241. [req.body.target, "target"],
  242. [req.body.description, "description"],
  243. [req.body.expire_in, "expire_in"],
  244. [req.body.password, "password"]
  245. ].forEach(([value, name]) => {
  246. if (!value) {
  247. if (name === "password" && link.password)
  248. req.body.password = null;
  249. else {
  250. delete req.body[name];
  251. return;
  252. }
  253. }
  254. if (value === link[name] && name !== "password") {
  255. delete req.body[name];
  256. return;
  257. }
  258. if (name === "expire_in" && link.expire_in)
  259. if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60)
  260. return;
  261. if (name === "password")
  262. if (value && value.replace(/•/ig, "").length === 0) {
  263. delete req.body.password;
  264. return;
  265. }
  266. isChanged = true;
  267. });
  268. if (!isChanged) {
  269. throw new CustomError("Should at least update one field.");
  270. }
  271. const { address, target, description, expire_in, password } = req.body;
  272. const targetDomain = target && utils.removeWww(URL.parse(target).hostname);
  273. const domain_id = link.domain_id || null;
  274. const tasks = await Promise.all([
  275. validators.cooldown(req.user),
  276. target && validators.malware(req.user, target),
  277. address &&
  278. query.link.find({
  279. address,
  280. domain_id
  281. }),
  282. target && validators.bannedDomain(targetDomain),
  283. target && validators.bannedHost(targetDomain)
  284. ]);
  285. // Check if custom link already exists
  286. if (tasks[2]) {
  287. const error = "Custom URL is already in use.";
  288. res.locals.errors = { address: error };
  289. throw new CustomError("Custom URL is already in use.");
  290. }
  291. // Update link
  292. const [updatedLink] = await query.link.update(
  293. {
  294. id: link.id
  295. },
  296. {
  297. ...(address && { address }),
  298. ...(description && { description }),
  299. ...(target && { target }),
  300. ...(expire_in && { expire_in }),
  301. ...((password || password === null) && { password })
  302. }
  303. );
  304. if (req.isHTML) {
  305. res.render("partials/admin/links/edit", {
  306. swap_oob: true,
  307. success: "Link has been updated.",
  308. ...utils.sanitize.linkAdmin({ ...link, ...updatedLink }),
  309. });
  310. return;
  311. }
  312. return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
  313. };
  314. async function remove(req, res) {
  315. const { error, isRemoved, link } = await query.link.remove({
  316. uuid: req.params.id,
  317. ...(!req.user.admin && { user_id: req.user.id })
  318. });
  319. if (!isRemoved) {
  320. const messsage = error || "Could not delete the link.";
  321. throw new CustomError(messsage);
  322. }
  323. if (req.isHTML) {
  324. res.setHeader("HX-Reswap", "outerHTML");
  325. res.setHeader("HX-Trigger", "reloadMainTable");
  326. res.render("partials/links/dialog/delete_success", {
  327. link: utils.getShortURL(link.address, link.domain).link,
  328. });
  329. return;
  330. }
  331. return res
  332. .status(200)
  333. .send({ message: "Link has been deleted successfully." });
  334. };
  335. async function report(req, res) {
  336. const { link } = req.body;
  337. await transporter.sendReportEmail(link);
  338. if (req.isHTML) {
  339. res.render("partials/report/form", {
  340. message: "Report was received. We'll take actions shortly."
  341. });
  342. return;
  343. }
  344. return res
  345. .status(200)
  346. .send({ message: "Thanks for the report, we'll take actions shortly." });
  347. };
  348. async function ban(req, res) {
  349. const { id } = req.params;
  350. const update = {
  351. banned_by_id: req.user.id,
  352. banned: true
  353. };
  354. // 1. check if link exists
  355. const link = await query.link.find({ uuid: id });
  356. if (!link) {
  357. throw new CustomError("No link has been found.", 400);
  358. }
  359. if (link.banned) {
  360. throw new CustomError("Link has been banned already.", 400);
  361. }
  362. const tasks = [];
  363. // 2. ban link
  364. tasks.push(query.link.update({ uuid: id }, update));
  365. const domain = utils.removeWww(URL.parse(link.target).hostname);
  366. // 3. ban target's domain
  367. if (req.body.domain) {
  368. tasks.push(query.domain.add({ ...update, address: domain }));
  369. }
  370. // 4. ban target's host
  371. if (req.body.host) {
  372. const dnsRes = await dnsLookup(domain).catch(() => {
  373. throw new CustomError("Couldn't fetch DNS info.");
  374. });
  375. const host = dnsRes?.address;
  376. tasks.push(query.host.add({ ...update, address: host }));
  377. }
  378. // 5. ban link owner
  379. if (req.body.user && link.user_id) {
  380. tasks.push(query.user.update({ id: link.user_id }, update));
  381. }
  382. // 6. ban all of owner's links
  383. if (req.body.userLinks && link.user_id) {
  384. tasks.push(query.link.update({ user_id: link.user_id }, update));
  385. }
  386. // 7. wait for all tasks to finish
  387. await Promise.all(tasks).catch((err) => {
  388. throw new CustomError("Couldn't ban entries.");
  389. });
  390. // 8. send response
  391. if (req.isHTML) {
  392. res.setHeader("HX-Reswap", "outerHTML");
  393. res.setHeader("HX-Trigger", "reloadMainTable");
  394. res.render("partials/links/dialog/ban_success", {
  395. link: utils.getShortURL(link.address, link.domain).link,
  396. });
  397. return;
  398. }
  399. return res.status(200).send({ message: "Banned link successfully." });
  400. };
  401. async function redirect(req, res, next) {
  402. const isPreservedUrl = utils.preservedURLs.some(
  403. item => item === req.path.replace("/", "")
  404. );
  405. if (isPreservedUrl) return next();
  406. // 1. If custom domain, get domain info
  407. const host = utils.removeWww(req.headers.host);
  408. const domain =
  409. host !== env.DEFAULT_DOMAIN
  410. ? await query.domain.find({ address: host })
  411. : null;
  412. // 2. Get link
  413. const address = req.params.id.replace("+", "");
  414. const link = await query.link.find({
  415. address,
  416. domain_id: domain ? domain.id : null
  417. });
  418. // 3. When no link, if has domain redirect to domain's homepage
  419. // otherwise redirect to 404
  420. if (!link) {
  421. return res.redirect(domain?.homepage || "/404");
  422. }
  423. // 4. If link is banned, redirect to banned page.
  424. if (link.banned) {
  425. return res.redirect("/banned");
  426. }
  427. // 5. If wants to see link info, then redirect
  428. const isRequestingInfo = /.*\+$/gi.test(req.params.id);
  429. if (isRequestingInfo && !link.password) {
  430. if (req.isHTML) {
  431. res.render("url_info", {
  432. title: "Short link information",
  433. target: link.target,
  434. link: utils.getShortURL(link.address, link.domain).link
  435. });
  436. return;
  437. }
  438. return res.send({ target: link.target });
  439. }
  440. // 6. If link is protected, redirect to password page
  441. if (link.password) {
  442. res.render("protected", {
  443. title: "Protected short link",
  444. id: link.uuid
  445. });
  446. return;
  447. }
  448. // 7. Create link visit
  449. const isBot = isbot(req.headers["user-agent"]);
  450. if (link.user_id && !isBot) {
  451. queue.visit.add({
  452. userAgent: req.headers["user-agent"],
  453. realIP: req.realIP,
  454. referrer: req.get("Referrer"),
  455. link
  456. });
  457. }
  458. // 8. Redirect to target
  459. return res.redirect(link.target);
  460. };
  461. async function redirectProtected(req, res) {
  462. // 1. Get link
  463. const uuid = req.params.id;
  464. const link = await query.link.find({ uuid });
  465. // 2. Throw error if no link
  466. if (!link || !link.password) {
  467. throw new CustomError("Couldn't find the link.", 400);
  468. }
  469. // 3. Check if password matches
  470. const matches = await bcrypt.compare(req.body.password, link.password);
  471. if (!matches) {
  472. throw new CustomError("Password is not correct.", 401);
  473. }
  474. // 4. Create visit
  475. if (link.user_id) {
  476. queue.visit.add({
  477. userAgent: req.headers["user-agent"],
  478. realIP: req.realIP,
  479. referrer: req.get("Referrer"),
  480. link
  481. });
  482. }
  483. // 5. Send target
  484. if (req.isHTML) {
  485. res.setHeader("HX-Redirect", link.target);
  486. res.render("partials/protected/form", {
  487. id: link.uuid,
  488. message: "Redirecting...",
  489. });
  490. return;
  491. }
  492. return res.status(200).send({ target: link.target });
  493. };
  494. async function redirectCustomDomainHomepage(req, res, next) {
  495. const path = req.path;
  496. const host = utils.removeWww(req.headers.host);
  497. if (host === env.DEFAULT_DOMAIN) {
  498. return next();
  499. }
  500. if (
  501. path === "/" ||
  502. utils.preservedURLs
  503. .filter(l => l !== "url-password")
  504. .some(item => item === path.replace("/", ""))
  505. ) {
  506. const domain = await query.domain.find({ address: host });
  507. const redirectURL = domain
  508. ? domain.homepage
  509. : `https://${env.DEFAULT_DOMAIN + path}`;
  510. return res.redirect(302, redirectURL);
  511. }
  512. return next();
  513. };
  514. async function stats(req, res) {
  515. const { user } = req;
  516. const uuid = req.params.id;
  517. const link = await query.link.find({
  518. ...(!user.admin && { user_id: user.id }),
  519. uuid
  520. });
  521. if (!link) {
  522. if (req.isHTML) {
  523. res.setHeader("HX-Redirect", "/404");
  524. res.status(200).send("");
  525. return;
  526. }
  527. throw new CustomError("Link could not be found.");
  528. }
  529. const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
  530. if (!stats) {
  531. throw new CustomError("Could not get the short link stats. Try again later.");
  532. }
  533. if (req.isHTML) {
  534. res.render("partials/stats", {
  535. link: utils.sanitize.link(link),
  536. stats,
  537. map,
  538. });
  539. return;
  540. }
  541. return res.status(200).send({
  542. ...stats,
  543. ...utils.sanitize.link(link)
  544. });
  545. };
  546. module.exports = {
  547. ban,
  548. create,
  549. edit,
  550. editAdmin,
  551. get,
  552. getAdmin,
  553. remove,
  554. report,
  555. stats,
  556. redirect,
  557. redirectProtected,
  558. redirectCustomDomainHomepage,
  559. }