validators.ts 14 KB

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