links.handler.js 16 KB

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