validators.ts 11 KB

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