links.handler.js 16 KB

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