validators.handler.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. const { isAfter, subDays, subHours, addMilliseconds } = require("date-fns");
  2. const { body, param } = require("express-validator");
  3. const promisify = require("util").promisify;
  4. const bcrypt = require("bcryptjs");
  5. const dns = require("dns");
  6. const URL = require("url");
  7. const ms = require("ms");
  8. const query = require("../queries");
  9. const utils = require("../utils");
  10. const knex = require("../knex");
  11. const env = require("../env");
  12. const dnsLookup = promisify(dns.lookup);
  13. const checkUser = (value, { req }) => !!req.user;
  14. const sanitizeCheckbox = value => value === true || value === "on" || value;
  15. const createLink = [
  16. body("target")
  17. .exists({ checkNull: true, checkFalsy: true })
  18. .withMessage("Target is missing.")
  19. .isString()
  20. .trim()
  21. .isLength({ min: 1, max: 2040 })
  22. .withMessage("Maximum URL length is 2040.")
  23. .customSanitizer(utils.addProtocol)
  24. .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
  25. .withMessage("URL is not valid.")
  26. .custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
  27. .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
  28. body("password")
  29. .optional({ nullable: true, checkFalsy: true })
  30. .custom(checkUser)
  31. .withMessage("Only users can use this field.")
  32. .isString()
  33. .isLength({ min: 3, max: 64 })
  34. .withMessage("Password length must be between 3 and 64."),
  35. body("customurl")
  36. .optional({ nullable: true, checkFalsy: true })
  37. .custom(checkUser)
  38. .withMessage("Only users can use this field.")
  39. .isString()
  40. .trim()
  41. .isLength({ min: 1, max: 64 })
  42. .withMessage("Custom URL length must be between 1 and 64.")
  43. .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
  44. .withMessage("Custom URL is not valid.")
  45. .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value))
  46. .withMessage("You can't use this custom URL."),
  47. body("reuse")
  48. .optional({ nullable: true })
  49. .custom(checkUser)
  50. .withMessage("Only users can use this field.")
  51. .isBoolean()
  52. .withMessage("Reuse must be boolean."),
  53. body("description")
  54. .optional({ nullable: true, checkFalsy: true })
  55. .isString()
  56. .trim()
  57. .isLength({ min: 1, max: 2040 })
  58. .withMessage("Description length must be between 1 and 2040."),
  59. body("expire_in")
  60. .optional({ nullable: true, checkFalsy: true })
  61. .isString()
  62. .trim()
  63. .custom(value => {
  64. try {
  65. return !!ms(value);
  66. } catch {
  67. return false;
  68. }
  69. })
  70. .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
  71. .customSanitizer(ms)
  72. .custom(value => value >= ms("1m"))
  73. .withMessage("Expire time should be more than 1 minute.")
  74. .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
  75. body("domain")
  76. .optional({ nullable: true, checkFalsy: true })
  77. .customSanitizer(value => value === env.DEFAULT_DOMAIN ? null : value)
  78. .custom(checkUser)
  79. .withMessage("Only users can use this field.")
  80. .isString()
  81. .withMessage("Domain should be string.")
  82. .customSanitizer(value => value.toLowerCase())
  83. .custom(async (address, { req }) => {
  84. const domain = await query.domain.find({
  85. address,
  86. user_id: req.user.id
  87. });
  88. req.body.fetched_domain = domain || null;
  89. if (!domain) return Promise.reject();
  90. })
  91. .withMessage("You can't use this domain.")
  92. ];
  93. const editLink = [
  94. body("target")
  95. .optional({ checkFalsy: true, nullable: true })
  96. .isString()
  97. .trim()
  98. .isLength({ min: 1, max: 2040 })
  99. .withMessage("Maximum URL length is 2040.")
  100. .customSanitizer(utils.addProtocol)
  101. .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
  102. .withMessage("URL is not valid.")
  103. .custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
  104. .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
  105. body("password")
  106. .optional({ nullable: true, checkFalsy: true })
  107. .isString()
  108. .isLength({ min: 3, max: 64 })
  109. .withMessage("Password length must be between 3 and 64."),
  110. body("address")
  111. .optional({ checkFalsy: true, nullable: true })
  112. .isString()
  113. .trim()
  114. .isLength({ min: 1, max: 64 })
  115. .withMessage("Custom URL length must be between 1 and 64.")
  116. .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
  117. .withMessage("Custom URL is not valid")
  118. .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value))
  119. .withMessage("You can't use this custom URL."),
  120. body("expire_in")
  121. .optional({ nullable: true, checkFalsy: true })
  122. .isString()
  123. .trim()
  124. .custom(value => {
  125. try {
  126. return !!ms(value);
  127. } catch {
  128. return false;
  129. }
  130. })
  131. .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
  132. .customSanitizer(ms)
  133. .custom(value => value >= ms("1m"))
  134. .withMessage("Expire time should be more than 1 minute.")
  135. .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
  136. body("description")
  137. .optional({ nullable: true, checkFalsy: true })
  138. .isString()
  139. .trim()
  140. .isLength({ min: 0, max: 2040 })
  141. .withMessage("Description length must be between 0 and 2040."),
  142. param("id", "ID is invalid.")
  143. .exists({ checkFalsy: true, checkNull: true })
  144. .isLength({ min: 36, max: 36 })
  145. ];
  146. const redirectProtected = [
  147. body("password", "Password is invalid.")
  148. .exists({ checkFalsy: true, checkNull: true })
  149. .isString()
  150. .isLength({ min: 3, max: 64 })
  151. .withMessage("Password length must be between 3 and 64."),
  152. param("id", "ID is invalid.")
  153. .exists({ checkFalsy: true, checkNull: true })
  154. .isLength({ min: 36, max: 36 })
  155. ];
  156. const addDomain = [
  157. body("address", "Domain is not valid.")
  158. .exists({ checkFalsy: true, checkNull: true })
  159. .isLength({ min: 3, max: 64 })
  160. .withMessage("Domain length must be between 3 and 64.")
  161. .trim()
  162. .customSanitizer(utils.addProtocol)
  163. .custom(value => utils.urlRegex.test(value))
  164. .customSanitizer(value => {
  165. const parsed = URL.parse(value);
  166. return utils.removeWww(parsed.hostname || parsed.href);
  167. })
  168. .custom(value => value !== env.DEFAULT_DOMAIN)
  169. .withMessage("You can't use the default domain.")
  170. .custom(async value => {
  171. const domain = await query.domain.find({ address: value });
  172. if (domain?.user_id || domain?.banned) return Promise.reject();
  173. })
  174. .withMessage("You can't add this domain."),
  175. body("homepage")
  176. .optional({ checkFalsy: true, nullable: true })
  177. .customSanitizer(utils.addProtocol)
  178. .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
  179. .withMessage("Homepage is not valid.")
  180. ];
  181. const removeDomain = [
  182. param("id", "ID is invalid.")
  183. .exists({
  184. checkFalsy: true,
  185. checkNull: true
  186. })
  187. .isLength({ min: 36, max: 36 })
  188. ];
  189. const deleteLink = [
  190. param("id", "ID is invalid.")
  191. .exists({
  192. checkFalsy: true,
  193. checkNull: true
  194. })
  195. .isLength({ min: 36, max: 36 })
  196. ];
  197. const reportLink = [
  198. body("link", "No link has been provided.")
  199. .exists({
  200. checkFalsy: true,
  201. checkNull: true
  202. })
  203. .customSanitizer(utils.addProtocol)
  204. .custom(
  205. value => utils.removeWww(URL.parse(value).host) === env.DEFAULT_DOMAIN
  206. )
  207. .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
  208. ];
  209. const banLink = [
  210. param("id", "ID is invalid.")
  211. .exists({
  212. checkFalsy: true,
  213. checkNull: true
  214. })
  215. .isLength({ min: 36, max: 36 }),
  216. body("host", '"host" should be a boolean.')
  217. .optional({
  218. nullable: true
  219. })
  220. .customSanitizer(sanitizeCheckbox)
  221. .isBoolean(),
  222. body("user", '"user" should be a boolean.')
  223. .optional({
  224. nullable: true
  225. })
  226. .customSanitizer(sanitizeCheckbox)
  227. .isBoolean(),
  228. body("userLinks", '"userLinks" should be a boolean.')
  229. .optional({
  230. nullable: true
  231. })
  232. .customSanitizer(sanitizeCheckbox)
  233. .isBoolean(),
  234. body("domain", '"domain" should be a boolean.')
  235. .optional({
  236. nullable: true
  237. })
  238. .customSanitizer(sanitizeCheckbox)
  239. .isBoolean()
  240. ];
  241. const getStats = [
  242. param("id", "ID is invalid.")
  243. .exists({
  244. checkFalsy: true,
  245. checkNull: true
  246. })
  247. .isLength({ min: 36, max: 36 })
  248. ];
  249. const signup = [
  250. body("password", "Password is not valid.")
  251. .exists({ checkFalsy: true, checkNull: true })
  252. .isLength({ min: 8, max: 64 })
  253. .withMessage("Password length must be between 8 and 64."),
  254. body("email", "Email is not valid.")
  255. .exists({ checkFalsy: true, checkNull: true })
  256. .trim()
  257. .isEmail()
  258. .isLength({ min: 0, max: 255 })
  259. .withMessage("Email length must be max 255.")
  260. .custom(async (value, { req }) => {
  261. const user = await query.user.find({ email: value });
  262. if (user)
  263. req.user = user;
  264. if (user?.verified)
  265. return Promise.reject();
  266. })
  267. .withMessage("You can't use this email address.")
  268. ];
  269. const login = [
  270. body("password", "Password is not valid.")
  271. .exists({ checkFalsy: true, checkNull: true })
  272. .isLength({ min: 8, max: 64 })
  273. .withMessage("Password length must be between 8 and 64."),
  274. body("email", "Email is not valid.")
  275. .exists({ checkFalsy: true, checkNull: true })
  276. .trim()
  277. .isEmail()
  278. .isLength({ min: 1, max: 255 })
  279. .withMessage("Email length must be max 255.")
  280. ];
  281. const changePassword = [
  282. body("currentpassword", "Password is not valid.")
  283. .exists({ checkFalsy: true, checkNull: true })
  284. .isLength({ min: 8, max: 64 })
  285. .withMessage("Password length must be between 8 and 64."),
  286. body("newpassword", "Password is not valid.")
  287. .exists({ checkFalsy: true, checkNull: true })
  288. .isLength({ min: 8, max: 64 })
  289. .withMessage("Password length must be between 8 and 64.")
  290. ];
  291. const changeEmail = [
  292. body("password", "Password is not valid.")
  293. .exists({ checkFalsy: true, checkNull: true })
  294. .isLength({ min: 8, max: 64 })
  295. .withMessage("Password length must be between 8 and 64."),
  296. body("email", "Email address is not valid.")
  297. .exists({ checkFalsy: true, checkNull: true })
  298. .trim()
  299. .isEmail()
  300. .isLength({ min: 1, max: 255 })
  301. .withMessage("Email length must be max 255.")
  302. ];
  303. const resetPassword = [
  304. body("email", "Email is not valid.")
  305. .exists({ checkFalsy: true, checkNull: true })
  306. .trim()
  307. .isEmail()
  308. .isLength({ min: 0, max: 255 })
  309. .withMessage("Email length must be max 255.")
  310. ];
  311. const deleteUser = [
  312. body("password", "Password is not valid.")
  313. .exists({ checkFalsy: true, checkNull: true })
  314. .isLength({ min: 8, max: 64 })
  315. .custom(async (password, { req }) => {
  316. const isMatch = await bcrypt.compare(password, req.user.password);
  317. if (!isMatch) return Promise.reject();
  318. })
  319. .withMessage("Password is not correct.")
  320. ];
  321. // TODO: if user has posted malware should do something better
  322. function cooldown(user) {
  323. if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldowns) return;
  324. // If has active cooldown then throw error
  325. const hasCooldownNow = user.cooldowns.some(cooldown =>
  326. isAfter(subHours(new Date(), 12), new Date(cooldown))
  327. );
  328. if (hasCooldownNow) {
  329. throw new utils.CustomError("Cooldown because of a malware URL. Wait 12h");
  330. }
  331. }
  332. // TODO: if user or non-user has posted malware should do something better
  333. async function malware(user, target) {
  334. if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
  335. const isMalware = await fetch(
  336. `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
  337. {
  338. method: "post",
  339. body: JSON.stringify({
  340. client: {
  341. clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
  342. clientVersion: "1.0.0"
  343. },
  344. threatInfo: {
  345. threatTypes: [
  346. "THREAT_TYPE_UNSPECIFIED",
  347. "MALWARE",
  348. "SOCIAL_ENGINEERING",
  349. "UNWANTED_SOFTWARE",
  350. "POTENTIALLY_HARMFUL_APPLICATION"
  351. ],
  352. platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
  353. threatEntryTypes: [
  354. "EXECUTABLE",
  355. "URL",
  356. "THREAT_ENTRY_TYPE_UNSPECIFIED"
  357. ],
  358. threatEntries: [{ url: target }]
  359. }
  360. })
  361. }
  362. ).then(res => res.json());
  363. if (!isMalware.data || !isMalware.data.matches) return;
  364. if (user) {
  365. const [updatedUser] = await query.user.update(
  366. { id: user.id },
  367. {
  368. cooldowns: knex.raw("array_append(cooldowns, ?)", [
  369. new Date().toISOString()
  370. ])
  371. }
  372. );
  373. // Ban if too many cooldowns
  374. if (updatedUser.cooldowns.length > 2) {
  375. await query.user.update({ id: user.id }, { banned: true });
  376. throw new utils.CustomError("Too much malware requests. You are now banned.");
  377. }
  378. }
  379. throw new utils.CustomError(
  380. user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
  381. );
  382. };
  383. async function linksCount(user) {
  384. if (!user) return;
  385. const count = await query.link.total({
  386. user_id: user.id,
  387. "links.created_at": [">", subDays(new Date(), 1).toISOString()]
  388. });
  389. if (count > env.USER_LIMIT_PER_DAY) {
  390. throw new utils.CustomError(
  391. `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
  392. );
  393. }
  394. };
  395. async function bannedDomain(domain) {
  396. const isBanned = await query.domain.find({
  397. address: domain,
  398. banned: true
  399. });
  400. if (isBanned) {
  401. throw new utils.CustomError("URL is containing malware/scam.", 400);
  402. }
  403. };
  404. async function bannedHost(domain) {
  405. let isBanned;
  406. try {
  407. const dnsRes = await dnsLookup(domain);
  408. if (!dnsRes || !dnsRes.address) return;
  409. isBanned = await query.host.find({
  410. address: dnsRes.address,
  411. banned: true
  412. });
  413. } catch (error) {
  414. isBanned = null;
  415. }
  416. if (isBanned) {
  417. throw new utils.CustomError("URL is containing malware/scam.", 400);
  418. }
  419. };
  420. module.exports = {
  421. addDomain,
  422. banLink,
  423. bannedDomain,
  424. bannedHost,
  425. changeEmail,
  426. changePassword,
  427. checkUser,
  428. cooldown,
  429. createLink,
  430. deleteLink,
  431. deleteUser,
  432. editLink,
  433. getStats,
  434. linksCount,
  435. login,
  436. malware,
  437. redirectProtected,
  438. removeDomain,
  439. reportLink,
  440. resetPassword,
  441. signup,
  442. }