validators.ts 12 KB

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