validators.handler.js 14 KB

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