links.handler.js 16 KB

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