kutt/server/handlers/validators.handler.js

475 lines
14 KiB
JavaScript
Raw Normal View History

2024-08-11 18:41:03 +03:30
const { body, param } = require("express-validator");
const { isAfter, subDays, subHours, addMilliseconds } = require("date-fns");
const urlRegex = require("url-regex-safe");
const { promisify } = require("util");
const bcrypt = require("bcryptjs");
const axios = require("axios");
const dns = require("dns");
const URL = require("url");
const ms = require("ms");
const { CustomError, addProtocol, preservedURLs, removeWww } = require("../utils");
const query = require("../queries");
const knex = require("../knex");
const env = require("../env");
const dnsLookup = promisify(dns.lookup);
const checkUser = (value, { req }) => !!req.user;
2024-08-21 21:22:59 +03:30
let body1;
let body2;
2024-08-11 18:41:03 +03:30
const createLink = [
body("target")
.exists({ checkNull: true, checkFalsy: true })
.withMessage("Target is missing.")
.isString()
.trim()
.isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.")
.customSanitizer(addProtocol)
.custom(
value =>
urlRegex({ exact: true, strict: false }).test(value) ||
/^(?!https?)(\w+):\/\//.test(value)
)
.withMessage("URL is not valid.")
.custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
.withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
body("password")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
body("customurl")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.trim()
.isLength({ min: 1, max: 64 })
.withMessage("Custom URL length must be between 1 and 64.")
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
2024-08-21 21:22:59 +03:30
.withMessage("Custom URL is not valid.")
2024-08-11 18:41:03 +03:30
.custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
.withMessage("You can't use this custom URL."),
body("reuse")
.optional({ nullable: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isBoolean()
.withMessage("Reuse must be boolean."),
body("description")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
2024-08-21 21:22:59 +03:30
.isLength({ min: 1, max: 2040 })
.withMessage("Description length must be between 1 and 2040."),
2024-08-11 18:41:03 +03:30
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
2024-08-21 21:22:59 +03:30
.withMessage("Expire time should be more than 1 minute.")
2024-08-11 18:41:03 +03:30
.customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
body("domain")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.withMessage("Domain should be string.")
.customSanitizer(value => value.toLowerCase())
.custom(async (address, { req }) => {
if (address === env.DEFAULT_DOMAIN) {
req.body.domain = null;
return;
}
const domain = await query.domain.find({
address,
user_id: req.user.id
});
2024-08-21 21:22:59 +03:30
req.body.fetched_domain = domain || null;
2024-08-11 18:41:03 +03:30
if (!domain) return Promise.reject();
})
.withMessage("You can't use this domain.")
];
2024-08-21 21:22:59 +03:30
const editLink = [
body("target")
.optional({ checkFalsy: true, nullable: true })
.isString()
.trim()
.isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.")
.customSanitizer(addProtocol)
.custom(
value =>
urlRegex({ exact: true, strict: false }).test(value) ||
/^(?!https?)(\w+):\/\//.test(value)
)
.withMessage("URL is not valid.")
.custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
.withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
body("password")
.optional({ nullable: true, checkFalsy: true })
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
body("address")
.optional({ checkFalsy: true, nullable: true })
.isString()
.trim()
.isLength({ min: 1, max: 64 })
.withMessage("Custom URL length must be between 1 and 64.")
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
.withMessage("Custom URL is not valid")
.custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
.withMessage("You can't use this custom URL."),
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
.withMessage("Expire time should be more than 1 minute.")
.customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
body("description")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 36, max: 36 })
];
2024-08-11 18:41:03 +03:30
// export const redirectProtected = [
// body("password", "Password is invalid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isString()
// .isLength({ min: 3, max: 64 })
// .withMessage("Password length must be between 3 and 64."),
// param("id", "ID is invalid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 36, max: 36 })
// ];
// export const addDomain = [
// body("address", "Domain is not valid")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 3, max: 64 })
// .withMessage("Domain length must be between 3 and 64.")
// .trim()
// .customSanitizer(value => {
// const parsed = URL.parse(value);
// return removeWww(parsed.hostname || parsed.href);
// })
// .custom(value => urlRegex({ exact: true, strict: false }).test(value))
// .custom(value => value !== env.DEFAULT_DOMAIN)
// .withMessage("You can't use the default domain.")
// .custom(async value => {
// const domain = await query.domain.find({ address: value });
// if (domain?.user_id || domain?.banned) return Promise.reject();
// })
// .withMessage("You can't add this domain."),
// body("homepage")
// .optional({ checkFalsy: true, nullable: true })
// .customSanitizer(addProtocol)
// .custom(value => urlRegex({ exact: true, strict: false }).test(value))
// .withMessage("Homepage is not valid.")
// ];
// export const removeDomain = [
// param("id", "ID is invalid.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .isLength({ min: 36, max: 36 })
// ];
2024-08-21 21:22:59 +03:30
const deleteLink = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 })
];
2024-08-11 18:41:03 +03:30
// export const reportLink = [
// body("link", "No link has been provided.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .customSanitizer(addProtocol)
// .custom(
// value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN
// )
// .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
// ];
// export const banLink = [
// param("id", "ID is invalid.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .isLength({ min: 36, max: 36 }),
// body("host", '"host" should be a boolean.')
// .optional({
// nullable: true
// })
// .isBoolean(),
// body("user", '"user" should be a boolean.')
// .optional({
// nullable: true
// })
// .isBoolean(),
// body("userlinks", '"userlinks" should be a boolean.')
// .optional({
// nullable: true
// })
// .isBoolean(),
// body("domain", '"domain" should be a boolean.')
// .optional({
// nullable: true
// })
// .isBoolean()
// ];
// export const getStats = [
// param("id", "ID is invalid.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .isLength({ min: 36, max: 36 })
// ];
const signup = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
.custom(async (value, { req }) => {
const user = await query.user.find({ email: value });
if (user) {
req.user = user;
}
if (user?.verified) return Promise.reject();
})
.withMessage("You can't use this email address.")
];
const login = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
];
// export const changePassword = [
// body("password", "Password is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 8, max: 64 })
// .withMessage("Password length must be between 8 and 64.")
// ];
// export const resetPasswordRequest = [
// body("email", "Email is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .trim()
// .isEmail()
// .isLength({ min: 0, max: 255 })
// .withMessage("Email length must be max 255."),
// body("password", "Password is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 8, max: 64 })
// .withMessage("Password length must be between 8 and 64.")
// ];
// export const resetEmailRequest = [
// body("email", "Email is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .trim()
// .isEmail()
// .isLength({ min: 0, max: 255 })
// .withMessage("Email length must be max 255.")
// ];
// export const deleteUser = [
// body("password", "Password is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 8, max: 64 })
// .custom(async (password, { req }) => {
// const isMatch = await bcrypt.compare(password, req.user.password);
// if (!isMatch) return Promise.reject();
// })
// ];
// TODO: if user has posted malware should do something better
function cooldown(user) {
if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldowns) return;
// If has active cooldown then throw error
const hasCooldownNow = user.cooldowns.some(cooldown =>
isAfter(subHours(new Date(), 12), new Date(cooldown))
);
if (hasCooldownNow) {
throw new CustomError("Cooldown because of a malware URL. Wait 12h");
}
}
// TODO: if user or non-user has posted malware should do something better
async function malware(user, target) {
if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
const isMalware = await axios.post(
`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
{
client: {
clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
clientVersion: "1.0.0"
},
threatInfo: {
threatTypes: [
"THREAT_TYPE_UNSPECIFIED",
"MALWARE",
"SOCIAL_ENGINEERING",
"UNWANTED_SOFTWARE",
"POTENTIALLY_HARMFUL_APPLICATION"
],
platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
threatEntryTypes: [
"EXECUTABLE",
"URL",
"THREAT_ENTRY_TYPE_UNSPECIFIED"
],
threatEntries: [{ url: target }]
}
}
);
if (!isMalware.data || !isMalware.data.matches) return;
if (user) {
const [updatedUser] = await query.user.update(
{ id: user.id },
{
cooldowns: knex.raw("array_append(cooldowns, ?)", [
new Date().toISOString()
])
}
);
// Ban if too many cooldowns
if (updatedUser.cooldowns.length > 2) {
await query.user.update({ id: user.id }, { banned: true });
throw new CustomError("Too much malware requests. You are now banned.");
}
}
throw new CustomError(
user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
);
};
async function linksCount(user) {
if (!user) return;
const count = await query.link.total({
user_id: user.id,
2024-08-21 21:22:59 +03:30
"links.created_at": [">", subDays(new Date(), 1).toISOString()]
2024-08-11 18:41:03 +03:30
});
if (count > env.USER_LIMIT_PER_DAY) {
throw new CustomError(
`You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
);
}
};
async function bannedDomain(domain) {
const isBanned = await query.domain.find({
address: domain,
banned: true
});
if (isBanned) {
throw new CustomError("URL is containing malware/scam.", 400);
}
};
async function bannedHost(domain) {
let isBanned;
try {
const dnsRes = await dnsLookup(domain);
if (!dnsRes || !dnsRes.address) return;
isBanned = await query.host.find({
address: dnsRes.address,
banned: true
});
} catch (error) {
isBanned = null;
}
if (isBanned) {
throw new CustomError("URL is containing malware/scam.", 400);
}
};
module.exports = {
bannedDomain,
bannedHost,
checkUser,
cooldown,
createLink,
2024-08-21 21:22:59 +03:30
deleteLink,
editLink,
2024-08-11 18:41:03 +03:30
linksCount,
login,
malware,
signup,
}