|
@@ -1,18 +1,17 @@
|
|
|
-import { body, validationResult } from "express-validator";
|
|
|
|
|
|
|
+import { body, param } from "express-validator";
|
|
|
|
|
+import { isAfter, subDays, subHours } from "date-fns";
|
|
|
import urlRegex from "url-regex";
|
|
import urlRegex from "url-regex";
|
|
|
|
|
+import { promisify } from "util";
|
|
|
|
|
+import axios from "axios";
|
|
|
|
|
+import dns from "dns";
|
|
|
import URL from "url";
|
|
import URL from "url";
|
|
|
|
|
|
|
|
-import { findDomain } from "../queries/domain";
|
|
|
|
|
-import { CustomError } from "../utils";
|
|
|
|
|
|
|
+import { CustomError, addProtocol } from "../utils";
|
|
|
|
|
+import query from "../queries";
|
|
|
|
|
+import knex from "../knex";
|
|
|
|
|
+import env from "../env";
|
|
|
|
|
|
|
|
-export const verify = (req, res, next) => {
|
|
|
|
|
- const errors = validationResult(req);
|
|
|
|
|
- if (!errors.isEmpty()) {
|
|
|
|
|
- const message = errors.array()[0].msg;
|
|
|
|
|
- throw new CustomError(message, 400);
|
|
|
|
|
- }
|
|
|
|
|
- return next();
|
|
|
|
|
-};
|
|
|
|
|
|
|
+const dnsLookup = promisify(dns.lookup);
|
|
|
|
|
|
|
|
export const preservedUrls = [
|
|
export const preservedUrls = [
|
|
|
"login",
|
|
"login",
|
|
@@ -32,53 +31,341 @@ export const preservedUrls = [
|
|
|
"banned",
|
|
"banned",
|
|
|
"terms",
|
|
"terms",
|
|
|
"privacy",
|
|
"privacy",
|
|
|
|
|
+ "protected",
|
|
|
"report",
|
|
"report",
|
|
|
"pricing"
|
|
"pricing"
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
|
|
+export const checkUser = (value, { req }) => !!req.user;
|
|
|
|
|
+
|
|
|
export const createLink = [
|
|
export const createLink = [
|
|
|
body("target")
|
|
body("target")
|
|
|
.exists({ checkNull: true, checkFalsy: true })
|
|
.exists({ checkNull: true, checkFalsy: true })
|
|
|
.withMessage("Target is missing.")
|
|
.withMessage("Target is missing.")
|
|
|
|
|
+ .isString()
|
|
|
|
|
+ .trim()
|
|
|
.isLength({ min: 1, max: 2040 })
|
|
.isLength({ min: 1, max: 2040 })
|
|
|
.withMessage("Maximum URL length is 2040.")
|
|
.withMessage("Maximum URL length is 2040.")
|
|
|
|
|
+ .customSanitizer(addProtocol)
|
|
|
.custom(
|
|
.custom(
|
|
|
value =>
|
|
value =>
|
|
|
urlRegex({ exact: true, strict: false }).test(value) ||
|
|
urlRegex({ exact: true, strict: false }).test(value) ||
|
|
|
/^(?!https?)(\w+):\/\//.test(value)
|
|
/^(?!https?)(\w+):\/\//.test(value)
|
|
|
)
|
|
)
|
|
|
.withMessage("URL is not valid.")
|
|
.withMessage("URL is not valid.")
|
|
|
- .custom(value => URL.parse(value).host !== process.env.DEFAULT_DOMAIN)
|
|
|
|
|
- .withMessage(`${process.env.DEFAULT_DOMAIN} URLs are not allowed.`),
|
|
|
|
|
|
|
+ .custom(value => URL.parse(value).host !== env.DEFAULT_DOMAIN)
|
|
|
|
|
+ .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
|
|
|
body("password")
|
|
body("password")
|
|
|
.optional()
|
|
.optional()
|
|
|
|
|
+ .custom(checkUser)
|
|
|
|
|
+ .withMessage("Only users can use this field.")
|
|
|
|
|
+ .isString()
|
|
|
.isLength({ min: 3, max: 64 })
|
|
.isLength({ min: 3, max: 64 })
|
|
|
.withMessage("Password length must be between 3 and 64."),
|
|
.withMessage("Password length must be between 3 and 64."),
|
|
|
body("customurl")
|
|
body("customurl")
|
|
|
.optional()
|
|
.optional()
|
|
|
|
|
+ .custom(checkUser)
|
|
|
|
|
+ .withMessage("Only users can use this field.")
|
|
|
|
|
+ .isString()
|
|
|
|
|
+ .trim()
|
|
|
.isLength({ min: 1, max: 64 })
|
|
.isLength({ min: 1, max: 64 })
|
|
|
.withMessage("Custom URL length must be between 1 and 64.")
|
|
.withMessage("Custom URL length must be between 1 and 64.")
|
|
|
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
|
|
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
|
|
|
.withMessage("Custom URL is not valid")
|
|
.withMessage("Custom URL is not valid")
|
|
|
- .custom(value => preservedUrls.some(url => url.toLowerCase() === value))
|
|
|
|
|
|
|
+ .custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
|
|
|
.withMessage("You can't use this custom URL."),
|
|
.withMessage("You can't use this custom URL."),
|
|
|
body("reuse")
|
|
body("reuse")
|
|
|
.optional()
|
|
.optional()
|
|
|
|
|
+ .custom(checkUser)
|
|
|
|
|
+ .withMessage("Only users can use this field.")
|
|
|
.isBoolean()
|
|
.isBoolean()
|
|
|
.withMessage("Reuse must be boolean."),
|
|
.withMessage("Reuse must be boolean."),
|
|
|
body("domain")
|
|
body("domain")
|
|
|
.optional()
|
|
.optional()
|
|
|
|
|
+ .custom(checkUser)
|
|
|
|
|
+ .withMessage("Only users can use this field.")
|
|
|
.isString()
|
|
.isString()
|
|
|
.withMessage("Domain should be string.")
|
|
.withMessage("Domain should be string.")
|
|
|
|
|
+ .customSanitizer(value => value.toLowerCase())
|
|
|
.custom(async (address, { req }) => {
|
|
.custom(async (address, { req }) => {
|
|
|
- const domain = await findDomain({
|
|
|
|
|
|
|
+ const domain = await query.domain.find({
|
|
|
address,
|
|
address,
|
|
|
- userId: req.user && req.user.id
|
|
|
|
|
|
|
+ user_id: req.user.id
|
|
|
});
|
|
});
|
|
|
req.body.domain = domain || null;
|
|
req.body.domain = domain || null;
|
|
|
|
|
|
|
|
- if (domain) return true;
|
|
|
|
|
|
|
+ return !!domain;
|
|
|
|
|
+ })
|
|
|
|
|
+ .withMessage("You can't use this domain.")
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+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 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, { req }) => {
|
|
|
|
|
+ const domains = await query.domain.get({ user_id: req.user.id });
|
|
|
|
|
+ return domains.length === 0;
|
|
|
|
|
+ })
|
|
|
|
|
+ .withMessage("You already own a domain. Contact support if you need more.")
|
|
|
|
|
+ .custom(async value => {
|
|
|
|
|
+ const domain = await query.domain.find({ address: value });
|
|
|
|
|
+ return !domain || !domain.user_id || !domain.banned;
|
|
|
|
|
+ })
|
|
|
|
|
+ .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 })
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+export const deleteLink = [
|
|
|
|
|
+ param("id", "ID is invalid.")
|
|
|
|
|
+ .exists({
|
|
|
|
|
+ checkFalsy: true,
|
|
|
|
|
+ checkNull: true
|
|
|
|
|
+ })
|
|
|
|
|
+ .isLength({ min: 36, max: 36 })
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+export const reportLink = [
|
|
|
|
|
+ body("link", "No link has been provided.")
|
|
|
|
|
+ .exists({
|
|
|
|
|
+ checkFalsy: true,
|
|
|
|
|
+ checkNull: true
|
|
|
|
|
+ })
|
|
|
|
|
+ .custom(value => 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 })
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+export 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;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- throw new CustomError("You can't use this domain.", 400);
|
|
|
|
|
|
|
+ return !user || !user.verified;
|
|
|
})
|
|
})
|
|
|
|
|
+ .withMessage("You can't use this email address.")
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+export 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.")
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+export const cooldown = (user: 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");
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export const malware = async (user: User, target: string) => {
|
|
|
|
|
+ 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()
|
|
|
|
|
+ ]) as any
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 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!"
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export const linksCount = async (user?: User) => {
|
|
|
|
|
+ if (!user) return;
|
|
|
|
|
+
|
|
|
|
|
+ const count = await query.link.total({
|
|
|
|
|
+ user_id: user.id,
|
|
|
|
|
+ created_at: [">", subDays(new Date(), 1).toISOString()]
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (count > env.USER_LIMIT_PER_DAY) {
|
|
|
|
|
+ throw new CustomError(
|
|
|
|
|
+ `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export const bannedDomain = async (domain: string) => {
|
|
|
|
|
+ const isBanned = await query.domain.find({
|
|
|
|
|
+ address: domain,
|
|
|
|
|
+ banned: true
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (isBanned) {
|
|
|
|
|
+ throw new CustomError("URL is containing malware/scam.", 400);
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export const bannedHost = async (domain: string) => {
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+};
|