linkController.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. import bcrypt from "bcryptjs";
  2. import dns from "dns";
  3. import { Handler } from "express";
  4. import geoip from "geoip-lite";
  5. import isbot from "isbot";
  6. import generate from "nanoid/generate";
  7. import ua from "universal-analytics";
  8. import URL from "url";
  9. import urlRegex from "url-regex";
  10. import useragent from "useragent";
  11. import { promisify } from "util";
  12. import { deleteDomain, getDomain, setDomain } from "../db/domain";
  13. import { addIP } from "../db/ip";
  14. import {
  15. addLinkCount,
  16. banLink,
  17. createShortLink,
  18. createVisit,
  19. deleteLink,
  20. findLink,
  21. getLinks,
  22. getStats,
  23. getUserLinksCount
  24. } from "../db/link";
  25. import transporter from "../mail/mail";
  26. import * as redis from "../redis";
  27. import {
  28. addProtocol,
  29. generateShortLink,
  30. getStatsCacheTime,
  31. getStatsLimit
  32. } from "../utils";
  33. import {
  34. checkBannedDomain,
  35. checkBannedHost,
  36. cooldownCheck,
  37. malwareCheck,
  38. preservedUrls,
  39. urlCountsCheck
  40. } from "./validateBodyController";
  41. const dnsLookup = promisify(dns.lookup);
  42. const generateId = async () => {
  43. const address = generate(
  44. "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
  45. 6
  46. );
  47. const link = await findLink({ address });
  48. if (!link) return address;
  49. return generateId();
  50. };
  51. export const shortener: Handler = async (req, res) => {
  52. try {
  53. const target = addProtocol(req.body.target);
  54. const targetDomain = URL.parse(target).hostname;
  55. const queries = await Promise.all([
  56. process.env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
  57. process.env.GOOGLE_SAFE_BROWSING_KEY &&
  58. malwareCheck(req.user, req.body.target),
  59. req.user && urlCountsCheck(req.user),
  60. req.user &&
  61. req.body.reuse &&
  62. findLink({
  63. target,
  64. user_id: req.user.id
  65. }),
  66. req.user &&
  67. req.body.customurl &&
  68. findLink({
  69. address: req.body.customurl,
  70. domain_id: req.user.domain_id || null
  71. }),
  72. (!req.user || !req.body.customurl) && generateId(),
  73. checkBannedDomain(targetDomain),
  74. checkBannedHost(targetDomain)
  75. ]);
  76. // if "reuse" is true, try to return
  77. // the existent URL without creating one
  78. if (queries[3]) {
  79. const { domain_id: d, user_id: u, ...link } = queries[3];
  80. const shortLink = generateShortLink(link.address, req.user.domain);
  81. const data = {
  82. ...link,
  83. id: link.address,
  84. password: !!link.password,
  85. reuse: true,
  86. shortLink,
  87. shortUrl: shortLink
  88. };
  89. return res.json(data);
  90. }
  91. // Check if custom link already exists
  92. if (queries[4]) {
  93. throw new Error("Custom URL is already in use.");
  94. }
  95. // Create new link
  96. const address = (req.user && req.body.customurl) || queries[5];
  97. const link = await createShortLink(
  98. {
  99. ...req.body,
  100. address,
  101. target
  102. },
  103. req.user
  104. );
  105. if (!req.user && Number(process.env.NON_USER_COOLDOWN)) {
  106. addIP(req.realIP);
  107. }
  108. return res.json({ ...link, id: link.address });
  109. } catch (error) {
  110. return res.status(400).json({ error: error.message });
  111. }
  112. };
  113. const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
  114. const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];
  115. const filterInBrowser = agent => item =>
  116. agent.family.toLowerCase().includes(item.toLocaleLowerCase());
  117. const filterInOs = agent => item =>
  118. agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
  119. export const goToLink: Handler = async (req, res, next) => {
  120. const { host } = req.headers;
  121. const reqestedId = req.params.id || req.body.id;
  122. const address = reqestedId.replace("+", "");
  123. const customDomain = host !== process.env.DEFAULT_DOMAIN && host;
  124. // TODO: Extract parsing into their own function
  125. const agent = useragent.parse(req.headers["user-agent"]);
  126. const [browser = "Other"] = browsersList.filter(filterInBrowser(agent));
  127. const [os = "Other"] = osList.filter(filterInOs(agent));
  128. const referrer =
  129. req.header("Referer") && URL.parse(req.header("Referer")).hostname;
  130. const location = geoip.lookup(req.realIP);
  131. const country = location && location.country;
  132. const isBot = isbot(req.headers["user-agent"]);
  133. const domain = await (customDomain && getDomain({ address: customDomain }));
  134. const link = await findLink({ address, domain_id: domain && domain.id });
  135. if (!link) {
  136. if (host !== process.env.DEFAULT_DOMAIN) {
  137. if (!domain || !domain.homepage) return next();
  138. return res.redirect(301, domain.homepage);
  139. }
  140. return next();
  141. }
  142. if (link.banned) {
  143. return res.redirect("/banned");
  144. }
  145. const doesRequestInfo = /.*\+$/gi.test(reqestedId);
  146. if (doesRequestInfo && !link.password) {
  147. req.linkTarget = link.target;
  148. req.pageType = "info";
  149. return next();
  150. }
  151. if (link.password && !req.body.password) {
  152. req.protectedLink = address;
  153. req.pageType = "password";
  154. return next();
  155. }
  156. if (link.password) {
  157. const isMatch = await bcrypt.compare(req.body.password, link.password);
  158. if (!isMatch) {
  159. return res.status(401).json({ error: "Password is not correct" });
  160. }
  161. if (link.user_id && !isBot) {
  162. addLinkCount(link.id);
  163. createVisit({
  164. browser: browser.toLowerCase(),
  165. country: country || "Unknown",
  166. domain: customDomain,
  167. id: link.id,
  168. os: os.toLowerCase().replace(/\s/gi, ""),
  169. referrer: referrer.replace(/\./gi, "[dot]") || "Direct",
  170. limit: getStatsLimit()
  171. });
  172. }
  173. return res.status(200).json({ target: link.target });
  174. }
  175. if (link.user_id && !isBot) {
  176. addLinkCount(link.id);
  177. createVisit({
  178. browser: browser.toLowerCase(),
  179. country: (country && country.toLocaleLowerCase()) || "unknown",
  180. domain: customDomain,
  181. id: link.id,
  182. os: os.toLowerCase().replace(/\s/gi, ""),
  183. referrer: (referrer && referrer.replace(/\./gi, "[dot]")) || "direct",
  184. limit: getStatsLimit()
  185. });
  186. }
  187. if (process.env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
  188. const visitor = ua(process.env.GOOGLE_ANALYTICS_UNIVERSAL);
  189. visitor
  190. .pageview({
  191. dp: `/${address}`,
  192. ua: req.headers["user-agent"],
  193. uip: req.realIP,
  194. aip: 1
  195. })
  196. .send();
  197. }
  198. return res.redirect(link.target);
  199. };
  200. export const getUserLinks: Handler = async (req, res) => {
  201. // TODO: Use aggregation
  202. const [countAll, list] = await Promise.all([
  203. getUserLinksCount({ user_id: req.user.id }),
  204. getLinks(req.user.id, req.query)
  205. ]);
  206. return res.json({ list, countAll });
  207. };
  208. export const setCustomDomain: Handler = async (req, res) => {
  209. const parsed = URL.parse(req.body.customDomain);
  210. const customDomain = parsed.hostname || parsed.href;
  211. if (!customDomain)
  212. return res.status(400).json({ error: "Domain is not valid." });
  213. if (customDomain.length > 40) {
  214. return res
  215. .status(400)
  216. .json({ error: "Maximum custom domain length is 40." });
  217. }
  218. if (customDomain === process.env.DEFAULT_DOMAIN) {
  219. return res.status(400).json({ error: "You can't use default domain." });
  220. }
  221. const isValidHomepage =
  222. !req.body.homepage ||
  223. urlRegex({ exact: true, strict: false }).test(req.body.homepage);
  224. if (!isValidHomepage)
  225. return res.status(400).json({ error: "Homepage is not valid." });
  226. const homepage =
  227. req.body.homepage &&
  228. (URL.parse(req.body.homepage).protocol
  229. ? req.body.homepage
  230. : `http://${req.body.homepage}`);
  231. const matchedDomain = await getDomain({ address: customDomain });
  232. if (
  233. matchedDomain &&
  234. matchedDomain.user_id &&
  235. matchedDomain.user_id !== req.user.id
  236. ) {
  237. return res.status(400).json({
  238. error: "Domain is already taken. Contact us for multiple users."
  239. });
  240. }
  241. const userCustomDomain = await setDomain(
  242. {
  243. address: customDomain,
  244. homepage
  245. },
  246. req.user,
  247. matchedDomain
  248. );
  249. if (userCustomDomain) {
  250. return res.status(201).json({
  251. customDomain: userCustomDomain.address,
  252. homepage: userCustomDomain.homepage
  253. });
  254. }
  255. return res.status(400).json({ error: "Couldn't set custom domain." });
  256. };
  257. export const deleteCustomDomain: Handler = async (req, res) => {
  258. const response = await deleteDomain(req.user);
  259. if (response)
  260. return res.status(200).json({ message: "Domain deleted successfully" });
  261. return res.status(400).json({ error: "Couldn't delete custom domain." });
  262. };
  263. export const customDomainRedirection: Handler = async (req, res, next) => {
  264. const { headers, path } = req;
  265. if (
  266. headers.host !== process.env.DEFAULT_DOMAIN &&
  267. (path === "/" ||
  268. preservedUrls
  269. .filter(l => l !== "url-password")
  270. .some(item => item === path.replace("/", "")))
  271. ) {
  272. const domain = await getDomain({ address: headers.host });
  273. return res.redirect(
  274. 301,
  275. (domain && domain.homepage) ||
  276. `https://${process.env.DEFAULT_DOMAIN + path}`
  277. );
  278. }
  279. return next();
  280. };
  281. export const deleteUserLink: Handler = async (req, res) => {
  282. const { id, domain } = req.body;
  283. if (!id) {
  284. return res.status(400).json({ error: "No id has been provided." });
  285. }
  286. const response = await deleteLink({
  287. address: id,
  288. domain: domain !== process.env.DEFAULT_DOMAIN && domain,
  289. user_id: req.user.id
  290. });
  291. if (response) {
  292. return res.status(200).json({ message: "Short link deleted successfully" });
  293. }
  294. return res.status(400).json({ error: "Couldn't delete the short link." });
  295. };
  296. export const getLinkStats: Handler = async (req, res) => {
  297. if (!req.query.id) {
  298. return res.status(400).json({ error: "No id has been provided." });
  299. }
  300. const { hostname } = URL.parse(req.query.domain);
  301. const hasCustomDomain =
  302. req.query.domain && hostname !== process.env.DEFAULT_DOMAIN;
  303. const customDomain = hasCustomDomain
  304. ? (await getDomain({ address: req.query.domain })) || ({ id: -1 } as Domain)
  305. : ({} as Domain);
  306. const redisKey = req.query.id + (customDomain.address || "") + req.user.email;
  307. const cached = await redis.get(redisKey);
  308. if (cached) return res.status(200).json(JSON.parse(cached));
  309. const link = await findLink({
  310. address: req.query.id,
  311. domain_id: hasCustomDomain ? customDomain.id : null,
  312. user_id: req.user && req.user.id
  313. });
  314. if (!link) {
  315. return res.status(400).json({ error: "Couldn't find the short link." });
  316. }
  317. const stats = await getStats(link, customDomain);
  318. if (!stats) {
  319. return res
  320. .status(400)
  321. .json({ error: "Could not get the short link stats." });
  322. }
  323. const cacheTime = getStatsCacheTime(0);
  324. redis.set(redisKey, JSON.stringify(stats), "EX", cacheTime);
  325. return res.status(200).json(stats);
  326. };
  327. export const reportLink: Handler = async (req, res) => {
  328. if (!req.body.link) {
  329. return res.status(400).json({ error: "No URL has been provided." });
  330. }
  331. const { hostname } = URL.parse(req.body.link);
  332. if (hostname !== process.env.DEFAULT_DOMAIN) {
  333. return res.status(400).json({
  334. error: `You can only report a ${process.env.DEFAULT_DOMAIN} link`
  335. });
  336. }
  337. const mail = await transporter.sendMail({
  338. from: process.env.MAIL_USER,
  339. to: process.env.REPORT_MAIL,
  340. subject: "[REPORT]",
  341. text: req.body.url,
  342. html: req.body.url
  343. });
  344. if (mail.accepted.length) {
  345. return res
  346. .status(200)
  347. .json({ message: "Thanks for the report, we'll take actions shortly." });
  348. }
  349. return res
  350. .status(400)
  351. .json({ error: "Couldn't submit the report. Try again later." });
  352. };
  353. export const ban: Handler = async (req, res) => {
  354. if (!req.body.id)
  355. return res.status(400).json({ error: "No id has been provided." });
  356. const link = await findLink({ address: req.body.id, domain_id: null });
  357. if (!link) return res.status(400).json({ error: "Couldn't find the link." });
  358. if (link.banned) {
  359. return res.status(200).json({ message: "Link was banned already." });
  360. }
  361. const domain = URL.parse(link.target).hostname;
  362. let host;
  363. if (req.body.host) {
  364. try {
  365. const dnsRes = await dnsLookup(domain);
  366. host = dnsRes && dnsRes.address;
  367. } catch (error) {
  368. host = null;
  369. }
  370. }
  371. await banLink({
  372. adminId: req.user.id,
  373. domain,
  374. host,
  375. address: req.body.id,
  376. banUser: !!req.body.user
  377. });
  378. return res.status(200).json({ message: "Link has been banned successfully" });
  379. };