more cleanups

This commit is contained in:
Pouria Ezzati 2024-09-12 14:26:39 +03:30
parent cd0ed07687
commit 5ea233f06b
No known key found for this signature in database
24 changed files with 319 additions and 374 deletions

10
jsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "CommonJS",
"allowImportingTsExtensions": false
},
"exclude": [
"node_modules",
"**/node_modules/*"
]
}

81
package-lock.json generated
View File

@ -10,7 +10,6 @@
"license": "MIT",
"dependencies": {
"app-root-path": "3.1.0",
"axios": "1.7.7",
"bcryptjs": "2.4.3",
"bull": "4.16.2",
"cookie-parser": "1.4.6",
@ -41,7 +40,7 @@
"pg-query-stream": "4.6.0",
"signale": "1.4.0",
"useragent": "2.3.0",
"uuid": "^10.0.0",
"uuid": "10.0.0",
"winston": "3.3.3",
"winston-daily-rotate-file": "4.7.1"
},
@ -1478,23 +1477,6 @@
"lodash": "^4.17.14"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-styled-components": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz",
@ -1907,18 +1889,6 @@
"text-hex": "1.0.x"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
@ -2146,15 +2116,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@ -2595,26 +2556,6 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreach": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz",
@ -2628,20 +2569,6 @@
"integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==",
"license": "Apache2"
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -4600,12 +4527,6 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",

View File

@ -27,7 +27,6 @@
"homepage": "https://github.com/thedevs-network/kutt#readme",
"dependencies": {
"app-root-path": "3.1.0",
"axios": "1.7.7",
"bcryptjs": "2.4.3",
"bull": "4.16.2",
"cookie-parser": "1.4.6",

View File

@ -23,6 +23,7 @@ const env = cleanEnv(process.env, {
DEFAULT_MAX_STATS_PER_LINK: num({ default: 5000 }),
DISALLOW_ANONYMOUS_LINKS: bool({ default: false }),
DISALLOW_REGISTRATION: bool({ default: false }),
SERVER_IP_ADDRESS: str({ default: "" }),
CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }),
JWT_SECRET: str(),
ADMIN_EMAILS: str({ default: "" }),

View File

@ -3,36 +3,49 @@ const passport = require("passport");
const { v4: uuid } = require("uuid");
const bcrypt = require("bcryptjs");
const nanoid = require("nanoid");
const axios = require("axios");
const { CustomError } = require("../utils");
const query = require("../queries");
const utils = require("../utils");
const redis = require("../redis");
const mail = require("../mail");
const env = require("../env");
function authenticate(type, error, isStrict) {
const CustomError = utils.CustomError;
function authenticate(type, error, isStrict, redirect) {
return function auth(req, res, next) {
if (req.user) return next();
passport.authenticate(type, (err, user) => {
if (err) return next(err);
const accepts = req.accepts(["json", "html"]);
if (
redirect &&
((!user && isStrict) ||
(user && isStrict && !user.verified) ||
(user && user.banned))
) {
if (redirect === "page") {
res.redirect("/login");
return;
}
if (redirect === "header") {
res.setHeader("HX-Redirect", "/login");
res.send("NOT_AUTHENTICATED");
return;
}
}
if (!user && isStrict) {
req.viewTemplate = "partials/auth/form";
throw new CustomError(error, 401);
}
if (user && isStrict && !user.verified) {
req.viewTemplate = "partials/auth/form";
throw new CustomError("Your email address is not verified. " +
"Sign up to get the verification link again.", 400);
}
if (user && user.banned) {
req.viewTemplate = "partials/auth/form";
throw new CustomError("You're banned from using this website.", 403);
}
@ -49,10 +62,11 @@ function authenticate(type, error, isStrict) {
}
}
const local = authenticate("local", "Login credentials are wrong.", true);
const jwt = authenticate("jwt", "Unauthorized.", true);
const jwtLoose = authenticate("jwt", "Unauthorized.", false);
const apikey = authenticate("localapikey", "API key is not correct.", false);
const local = authenticate("local", "Login credentials are wrong.", true, null);
const jwt = authenticate("jwt", "Unauthorized.", true, "header");
const jwtPage = authenticate("jwt", "Unauthorized.", true, "page");
const jwtLoose = authenticate("jwt", "Unauthorized.", false, null);
const apikey = authenticate("localapikey", "API key is not correct.", false, null);
async function cooldown(req, res, next) {
if (env.DISALLOW_ANONYMOUS_LINKS) return next();
@ -76,7 +90,6 @@ async function cooldown(req, res, next) {
}
function admin(req, res, next) {
// FIXME: attaching to req is risky, find another way
if (req.user.admin) return next();
throw new CustomError("Unauthorized", 401);
}
@ -104,11 +117,7 @@ function login(req, res) {
const token = utils.signToken(req.user);
if (req.isHTML) {
res.cookie("token", token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
httpOnly: true,
secure: env.isProd
});
utils.setToken(res, token);
res.render("partials/auth/welcome");
return;
}
@ -133,12 +142,8 @@ async function verify(req, res, next) {
if (user) {
const token = utils.signToken(user);
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
res.cookie("token", token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
httpOnly: true,
secure: env.isProd
});
utils.deleteCurrentToken(res);
utils.setToken(res, token);
res.locals.token_verified = true;
req.cookies.token = token;
}
@ -208,7 +213,7 @@ async function resetPasswordRequest(req, res) {
if (user) {
// TODO: handle error
await mail.resetPasswordToken(user).catch(() => null);
mail.resetPasswordToken(user).catch(() => null);
}
if (req.isHTML) {
@ -237,12 +242,8 @@ async function resetPassword(req, res, next) {
if (user) {
const token = utils.signToken(user);
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
res.cookie("token", token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
httpOnly: true,
secure: env.isProd
});
utils.deleteCurrentToken(res);
utils.setToken(res, token);
res.locals.token_verified = true;
req.cookies.token = token;
}
@ -306,8 +307,6 @@ async function changeEmailRequest(req, res) {
async function changeEmail(req, res, next) {
const changeEmailToken = req.params.changeEmailToken;
console.log("-", changeEmailToken, "-");
if (changeEmailToken) {
const foundUser = await query.user.find({
change_email_token: changeEmailToken
@ -332,12 +331,8 @@ async function changeEmail(req, res, next) {
if (user) {
const token = utils.signToken(user);
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
res.cookie("token", token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
httpOnly: true,
secure: env.isProd
});
utils.deleteCurrentToken(res);
utils.setToken(res, token);
res.locals.token_verified = true;
req.cookies.token = token;
}
@ -355,6 +350,7 @@ module.exports = {
generateApiKey,
jwt,
jwtLoose,
jwtPage,
local,
login,
resetPassword,

View File

@ -1,6 +1,6 @@
const { Handler } = require("express");
const { CustomError, sanitize, sleep } = require("../utils");
const { CustomError, sanitize } = require("../utils");
const query = require("../queries");
const redis = require("../redis");

View File

@ -1,16 +1,14 @@
const { validationResult } = require("express-validator");
const signale = require("signale");
const { CustomError, sanitize } = require("../utils");
const { logger } = require("../config/winston");
const query = require("../queries")
const { CustomError } = require("../utils");
const env = require("../env");
// export const ip: Handler = (req, res, next) => {
// req.realIP =
// (req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
// return next();
// };
function ip(req, res, next) {
req.realIP = req.headers["x-real-ip"] || req.connection.remoteAddress || "";
return next();
};
function error(error, req, res, _next) {
if (env.isDev) {
@ -42,7 +40,6 @@ function verify(req, res, next) {
res.locals.errors[e.param] = e.msg;
});
throw new CustomError(error, 400);
}
@ -71,11 +68,10 @@ function parseQuery(req, res, next) {
}
const limit = parseInt(req.query.limit) || 10;
const skip = parseInt(req.query.skip) || 0;
req.context = {
limit: limit > 50 ? 50 : limit,
skip,
skip: parseInt(req.query.skip) || 0,
all: admin ? req.query.all === "true" || req.query.all === "on" : false
};
@ -84,6 +80,7 @@ function parseQuery(req, res, next) {
module.exports = {
error,
ip,
parseQuery,
verify,
}

View File

@ -1,3 +1,4 @@
const { differenceInSeconds } = require("date-fns");
const promisify = require("util").promisify;
const bcrypt = require("bcryptjs");
const isbot = require("isbot");
@ -11,7 +12,6 @@ const query = require("../queries");
const queue = require("../queues");
const utils = require("../utils");
const env = require("../env");
const { differenceInSeconds } = require("date-fns");
const CustomError = utils.CustomError;
const dnsLookup = promisify(dns.lookup);
@ -56,7 +56,7 @@ async function create(req, res) {
const targetDomain = utils.removeWww(URL.parse(target).hostname);
const queries = await Promise.all([
const tasks = await Promise.all([
validators.cooldown(req.user),
validators.malware(req.user, target),
validators.linksCount(req.user),
@ -78,19 +78,19 @@ async function create(req, res) {
// if "reuse" is true, try to return
// the existent URL without creating one
if (queries[3]) {
return res.json(utils.sanitize.link(queries[3]));
if (tasks[3]) {
return res.json(utils.sanitize.link(tasks[3]));
}
// Check if custom link already exists
if (queries[4]) {
if (tasks[4]) {
const error = "Custom URL is already in use.";
res.locals.errors = { customurl: error };
throw new CustomError(error);
}
// Create new link
const address = customurl || queries[5];
const address = customurl || tasks[5];
const link = await query.link.create({
password,
address,
@ -122,7 +122,6 @@ async function create(req, res) {
}
async function edit(req, res) {
const { address, target, description, expire_in, password } = req.body;
const link = await query.link.find({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
@ -134,23 +133,32 @@ async function edit(req, res) {
let isChanged = false;
[
[address, "address"],
[target, "target"],
[description, "description"],
[expire_in, "expire_in"],
[password, "password"]
[req.body.address, "address"],
[req.body.target, "target"],
[req.body.description, "description"],
[req.body.expire_in, "expire_in"],
[req.body.password, "password"]
].forEach(([value, name]) => {
if (!value) {
delete req.body[name];
return;
if (name === "password" && link.password)
req.body.password = null;
else {
delete req.body[name];
return;
}
}
if (value === link[name]) {
if (value === link[name] && name !== "password") {
delete req.body[name];
return;
}
if (name === "expire_in")
if (differenceInSeconds(new Date(value), new Date(link.expire_in)) <= 60)
return;
if (name === "password")
if (value && value.replace(/•/ig, "").length === 0) {
delete req.body.password;
return;
}
isChanged = true;
});
@ -158,23 +166,25 @@ async function edit(req, res) {
throw new CustomError("Should at least update one field.");
}
const targetDomain = utils.removeWww(URL.parse(target).hostname);
const { address, target, description, expire_in, password } = req.body;
const targetDomain = target && utils.removeWww(URL.parse(target).hostname);
const domain_id = link.domain_id || null;
const queries = await Promise.all([
const tasks = await Promise.all([
validators.cooldown(req.user),
target && validators.malware(req.user, target),
address && address !== link.address &&
address &&
query.link.find({
address,
domain_id
}),
validators.bannedDomain(targetDomain),
validators.bannedHost(targetDomain)
target && validators.bannedDomain(targetDomain),
target && validators.bannedHost(targetDomain)
]);
// Check if custom link already exists
if (queries[2]) {
if (tasks[2]) {
const error = "Custom URL is already in use.";
res.locals.errors = { address: error };
throw new CustomError("Custom URL is already in use.");
@ -190,7 +200,7 @@ async function edit(req, res) {
...(description && { description }),
...(target && { target }),
...(expire_in && { expire_in }),
...(password && { password })
...((password || password === null) && { password })
}
);

View File

@ -8,7 +8,7 @@ function isHTML(req, res, next) {
next();
}
function addNoLayoutLocals(req, res, next) {
function noLayout(req, res, next) {
res.locals.layout = null;
next();
}
@ -20,13 +20,14 @@ function viewTemplate(template) {
}
}
function addConfigLocals(req, res, next) {
function config(req, res, next) {
res.locals.default_domain = env.DEFAULT_DOMAIN;
res.locals.site_name = env.SITE_NAME;
res.locals.server_ip_address = env.SERVER_IP_ADDRESS;
next();
}
async function addUserLocals(req, res, next) {
async function user(req, res, next) {
const user = req.user;
res.locals.user = user;
res.locals.domains = user && (await query.domain.get({ user_id: user.id })).map(utils.sanitize.domain);
@ -50,12 +51,12 @@ function protected(req, res, next) {
}
module.exports = {
addConfigLocals,
addNoLayoutLocals,
addUserLocals,
config,
createLink,
editLink,
isHTML,
noLayout,
protected,
user,
viewTemplate,
}

View File

@ -1,5 +1,5 @@
const query = require("../queries");
const utils = require("../utils");
const query = require("../queries")
const env = require("../env");
async function homepage(req, res) {
@ -10,7 +10,8 @@ async function homepage(req, res) {
function login(req, res) {
if (req.user) {
return res.redirect("/");
res.redirect("/");
return;
}
res.render("login", {
title: "Log in or sign up"
@ -18,7 +19,7 @@ function login(req, res) {
}
function logout(req, res) {
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
utils.deleteCurrentToken(res);
res.render("logout", {
title: "Logging out.."
});
@ -31,21 +32,12 @@ function notFound(req, res) {
}
function settings(req, res) {
// TODO: make this a middelware function, apply it to where it's necessary
if (!req.user) {
return res.redirect("/");
}
res.render("settings", {
title: "Settings"
});
}
function stats(req, res) {
// TODO: make this a middelware function, apply it to where it's necessary
if (!req.user) {
return res.redirect("/");
}
const id = req.query.id;
res.render("stats", {
title: "Stats"
});
@ -154,21 +146,13 @@ async function getReportEmail(req, res) {
});
}
async function linkEdit(req, res) {
const link = await query.link.find({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
});
// TODO: handle when no link
// if (!link) {
// return res.render("partials/links/dialog/message", {
// layout: false,
// message: "Could not find the link."
// });
// }
res.render("partials/links/edit", {
...utils.sanitize.link(link),
...(!link && utils.sanitize.link(link)),
});
}

View File

@ -18,7 +18,7 @@ async function remove(req, res) {
await query.user.remove(req.user);
if (req.isHTML) {
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
utils.deleteCurrentToken(res);
res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");
res.render("partials/settings/delete_account", {
success: "Account has been deleted. Logging out..."

View File

@ -1,8 +1,7 @@
const { body, param } = require("express-validator");
const { isAfter, subDays, subHours, addMilliseconds } = require("date-fns");
const { promisify } = require("util");
const { body, param } = require("express-validator");
const promisify = require("util").promisify;
const bcrypt = require("bcryptjs");
const axios = require("axios");
const dns = require("dns");
const URL = require("url");
const ms = require("ms");
@ -109,11 +108,7 @@ const editLink = [
.isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.")
.customSanitizer(utils.addProtocol)
.custom(
value =>
urlRegex({ exact: true, strict: false }).test(value) ||
/^(?!https?)(\w+):\/\//.test(value)
)
.custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
.withMessage("URL is not valid.")
.custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
.withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
@ -176,11 +171,12 @@ const addDomain = [
.isLength({ min: 3, max: 64 })
.withMessage("Domain length must be between 3 and 64.")
.trim()
.customSanitizer(utils.addProtocol)
.custom(value => utils.urlRegex.test(value))
.customSanitizer(value => {
const parsed = URL.parse(value);
return utils.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 => {
@ -191,7 +187,7 @@ const addDomain = [
body("homepage")
.optional({ checkFalsy: true, nullable: true })
.customSanitizer(utils.addProtocol)
.custom(value => urlRegex({ exact: true, strict: false }).test(value))
.custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
.withMessage("Homepage is not valid.")
];
@ -282,11 +278,11 @@ const signup = [
.custom(async (value, { req }) => {
const user = await query.user.find({ email: value });
if (user) {
if (user)
req.user = user;
}
if (user?.verified) return Promise.reject();
if (user?.verified)
return Promise.reject();
})
.withMessage("You can't use this email address.")
];
@ -337,15 +333,6 @@ const resetPassword = [
.withMessage("Email length must be max 255.")
];
// 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.")
// ];
const deleteUser = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
@ -375,31 +362,34 @@ function cooldown(user) {
async function malware(user, target) {
if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
const isMalware = await axios.post(
const isMalware = await fetch(
`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 }]
}
method: "post",
body: JSON.stringify({
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 }]
}
})
}
);
).then(res => res.json());
if (!isMalware.data || !isMalware.data.matches) return;
if (user) {

View File

@ -23,10 +23,7 @@ const transporter = nodemailer.createTransport(mailConfig);
// Read email templates
const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
const changeEmailTemplatePath = path.join(
__dirname,
"template-change-email.html"
);
const changeEmailTemplatePath = path.join(__dirname,"template-change-email.html");
const resetEmailTemplate = fs
.readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)

View File

@ -1,6 +1,5 @@
const bcrypt = require("bcryptjs");
// FIXME: circular dependency
const CustomError = require("../utils").CustomError;
const redis = require("../redis");
const knex = require("../knex");

View File

@ -52,11 +52,11 @@ async function add(params) {
};
async function find(match, total) {
// if (match.link_id) {
// const key = redis.key.stats(match.link_id);
// const cached = await redis.client.get(key);
// if (cached) return JSON.parse(cached);
// }
if (match.link_id) {
const key = redis.key.stats(match.link_id);
const cached = await redis.client.get(key);
if (cached) return JSON.parse(cached);
}
const stats = {
lastDay: {

View File

@ -12,7 +12,7 @@ const router = Router();
router.get(
"/",
asyncHandler(auth.jwtLoose),
asyncHandler(locals.addUserLocals),
asyncHandler(locals.user),
asyncHandler(renders.homepage)
);
@ -31,35 +31,42 @@ router.get(
router.get(
"/404",
asyncHandler(auth.jwtLoose),
asyncHandler(locals.user),
asyncHandler(renders.notFound)
);
router.get(
"/settings",
asyncHandler(auth.jwtLoose),
asyncHandler(locals.addUserLocals),
asyncHandler(auth.jwtPage),
asyncHandler(locals.user),
asyncHandler(renders.settings)
);
router.get(
"/stats",
asyncHandler(auth.jwtLoose),
asyncHandler(locals.addUserLocals),
asyncHandler(auth.jwtPage),
asyncHandler(locals.user),
asyncHandler(renders.stats)
);
router.get(
"/banned",
asyncHandler(auth.jwtLoose),
asyncHandler(locals.user),
asyncHandler(renders.banned)
);
router.get(
"/report",
asyncHandler(auth.jwtLoose),
asyncHandler(locals.user),
asyncHandler(renders.report)
);
router.get(
"/reset-password",
asyncHandler(auth.jwtLoose),
asyncHandler(locals.user),
asyncHandler(renders.resetPassword)
);
@ -67,7 +74,7 @@ router.get(
"/reset-password/:resetPasswordToken",
asyncHandler(auth.resetPassword),
asyncHandler(auth.jwtLoose),
asyncHandler(locals.addUserLocals),
asyncHandler(locals.user),
asyncHandler(renders.resetPasswordResult)
);
@ -75,7 +82,7 @@ router.get(
"/verify-email/:changeEmailToken",
asyncHandler(auth.changeEmail),
asyncHandler(auth.jwtLoose),
asyncHandler(locals.addUserLocals),
asyncHandler(locals.user),
asyncHandler(renders.verifyChangeEmail)
);
@ -83,26 +90,28 @@ router.get(
"/verify/:verificationToken",
asyncHandler(auth.verify),
asyncHandler(auth.jwtLoose),
asyncHandler(locals.addUserLocals),
asyncHandler(locals.user),
asyncHandler(renders.verify)
);
router.get(
"/terms",
asyncHandler(auth.jwtLoose),
asyncHandler(locals.user),
asyncHandler(renders.terms)
);
// partial renders
router.get(
"/confirm-link-delete",
locals.addNoLayoutLocals,
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(renders.confirmLinkDelete)
);
router.get(
"/confirm-link-ban",
locals.addNoLayoutLocals,
locals.noLayout,
locals.viewTemplate("partials/links/dialog/message"),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
@ -111,21 +120,21 @@ router.get(
router.get(
"/link/edit/:id",
locals.addNoLayoutLocals,
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(renders.linkEdit)
);
router.get(
"/add-domain-form",
locals.addNoLayoutLocals,
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(renders.addDomainForm)
);
router.get(
"/confirm-domain-delete",
locals.addNoLayoutLocals,
locals.noLayout,
locals.viewTemplate("partials/settings/domain/delete"),
asyncHandler(auth.jwt),
asyncHandler(renders.confirmDomainDelete)
@ -133,7 +142,7 @@ router.get(
router.get(
"/get-report-email",
locals.addNoLayoutLocals,
locals.noLayout,
locals.viewTemplate("partials/report/email"),
asyncHandler(renders.getReportEmail)
);

View File

@ -9,11 +9,11 @@ const link = require("./link.routes");
const user = require("./user.routes");
const auth = require("./auth.routes");
const apiRouter = Router();
const renderRouter = Router();
renderRouter.use(renders);
apiRouter.use(locals.addNoLayoutLocals);
const apiRouter = Router();
apiRouter.use(locals.noLayout);
apiRouter.use("/domains", domains);
apiRouter.use("/health", health);
apiRouter.use("/links", link);

View File

@ -22,7 +22,8 @@ require("./passport");
// create express app
const app = express();
// TODO: comments
// stating that this app is running behind a proxy
// and the express app should get the IP address from the proxy server
app.set("trust proxy", true);
if (env.isDev) {
@ -36,27 +37,29 @@ app.use(express.urlencoded({ extended: true }));
app.use(express.static("static"));
app.use(passport.initialize());
// app.use(helpers.ip);
app.use(helpers.ip);
app.use(locals.isHTML);
app.use(locals.addConfigLocals);
app.use(locals.config);
// template engine / serve html
app.set("view engine", "hbs");
app.set("views", path.join(__dirname, "views"));
utils.registerHandlebarsHelpers();
// render html pages
app.use("/", routes.render);
// if is custom domain, redirect to the set homepage
app.use(asyncHandler(links.redirectCustomDomainHomepage));
// handle api requests
app.use("/api/v2", routes.api);
app.use("/api", routes.api);
// finally, redirect the short link to the target
app.get("/:id", asyncHandler(links.redirect));
// Error handler
// handle errors coming from above routes
app.use(helpers.error);
app.listen(env.PORT, () => {

View File

@ -1,9 +1,9 @@
const ms = require("ms");
const path = require("path");
const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears } = require("date-fns");
const nanoid = require("nanoid/generate");
const JWT = require("jsonwebtoken");
const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears } = require("date-fns");
const path = require("path");
const hbs = require("hbs");
const ms = require("ms");
const env = require("../env");
@ -37,6 +37,18 @@ function signToken(user) {
)
}
function setToken(res, token) {
res.cookie("token", token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
httpOnly: true,
secure: env.isProd
});
}
function deleteCurrentToken(res) {
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
}
async function generateId(query, domain_id) {
const address = nanoid(
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
@ -262,6 +274,7 @@ function registerHandlebarsHelpers() {
module.exports = {
addProtocol,
CustomError,
deleteCurrentToken,
generateId,
getDifferenceFunction,
getInitStats,
@ -276,6 +289,7 @@ module.exports = {
registerHandlebarsHelpers,
removeWww,
sanitize,
setToken,
signToken,
sleep,
statsObjectToArray,

View File

@ -2,8 +2,7 @@
<div class="logo-wrapper">
<a class="logo nav" href="/" title="Kutt">
<img src="/images/logo.svg" alt="kutt" width="18" height="24" />
{{!-- TODO: configurable site name --}}
Kutt
{{site_name}}
</a>
<ul class="logo-links">
<li>

View File

@ -1,113 +1,117 @@
<td class="content">
<form
id="edit-form-{{id}}"
hx-patch="/api/links/{id}"
hx-ext="path-params"
hx-vals='{"id":"{{id}}"}'
hx-select="form"
hx-swap="outerHTML"
hx-sync="this:replace"
class="{{class}}"
>
<div>
<label class="{{#if errors.target}}error{{/if}}">
Target:
<input
id="edit-target-{{id}}"
name="target"
type="text"
placeholder="Target..."
required="true"
value="{{target}}"
hx-preserve="true"
/>
{{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
</label>
<label class="{{#if errors.address}}error{{/if}}">
localhost:3000/
<input
id="edit-address-{{id}}"
name="address"
type="text"
placeholder="Custom URL..."
required="true"
value="{{address}}"
hx-preserve="true"
/>
{{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
</label>
<label class="{{#if errors.password}}error{{/if}}">
Password:
<input
id="edit-password-{{id}}"
name="password"
type="password"
placeholder="Password..."
value="{{#if password}}••••••••{{/if}}"
hx-preserve="true"
/>
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
</label>
</div>
<div>
<label class="{{#if errors.description}}error{{/if}}">
Description:
<input
id="edit-description-{{id}}"
name="description"
type="text"
placeholder="Description..."
value="{{description}}"
hx-preserve="true"
/>
{{#if errors.description}}<p class="error">{{errors.description}}</p>{{/if}}
</label>
<label class="{{#if errors.expire_in}}error{{/if}}">
Expire in:
<input
id="edit-expire_in-{{id}}"
name="expire_in"
type="text"
placeholder="2 minutes/hours/days"
value="{{relative_expire_in}}"
hx-preserve="true"
/>
{{#if errors.expire_in}}<p class="error">{{errors.expire_in}}</p>{{/if}}
</label>
</div>
<div>
<button
type="button"
onclick="
const tr = closest('tr');
if (!tr) return;
tr.classList.remove('show');
tr.removeChild(tr.querySelector('.content'));
"
>
Close
</button>
<button type="submit" class="primary">
<span class="reload">
{{> icons/reload}}
</span>
<span class="loader">
{{> icons/spinner}}
</span>
Update
</button>
</div>
<div class="response">
{{#if error}}
{{#unless errors}}
<p class="error">{{error}}</p>
{{/unless}}
{{else if success}}
<p class="success">{{success}}</p>
{{/if}}
</div>
<template>
{{> links/tr}}
</template>
</form>
{{#if id}}
<form
id="edit-form-{{id}}"
hx-patch="/api/links/{id}"
hx-ext="path-params"
hx-vals='{"id":"{{id}}"}'
hx-select="form"
hx-swap="outerHTML"
hx-sync="this:replace"
class="{{class}}"
>
<div>
<label class="{{#if errors.target}}error{{/if}}">
Target:
<input
id="edit-target-{{id}}"
name="target"
type="text"
placeholder="Target..."
required="true"
value="{{target}}"
hx-preserve="true"
/>
{{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
</label>
<label class="{{#if errors.address}}error{{/if}}">
localhost:3000/
<input
id="edit-address-{{id}}"
name="address"
type="text"
placeholder="Custom URL..."
required="true"
value="{{address}}"
hx-preserve="true"
/>
{{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
</label>
<label class="{{#if errors.password}}error{{/if}}">
Password:
<input
id="edit-password-{{id}}"
name="password"
type="password"
placeholder="Password..."
value="{{#if password}}••••••••{{/if}}"
hx-preserve="true"
/>
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
</label>
</div>
<div>
<label class="{{#if errors.description}}error{{/if}}">
Description:
<input
id="edit-description-{{id}}"
name="description"
type="text"
placeholder="Description..."
value="{{description}}"
hx-preserve="true"
/>
{{#if errors.description}}<p class="error">{{errors.description}}</p>{{/if}}
</label>
<label class="{{#if errors.expire_in}}error{{/if}}">
Expire in:
<input
id="edit-expire_in-{{id}}"
name="expire_in"
type="text"
placeholder="2 minutes/hours/days"
value="{{relative_expire_in}}"
hx-preserve="true"
/>
{{#if errors.expire_in}}<p class="error">{{errors.expire_in}}</p>{{/if}}
</label>
</div>
<div>
<button
type="button"
onclick="
const tr = closest('tr');
if (!tr) return;
tr.classList.remove('show');
tr.removeChild(tr.querySelector('.content'));
"
>
Close
</button>
<button type="submit" class="primary">
<span class="reload">
{{> icons/reload}}
</span>
<span class="loader">
{{> icons/spinner}}
</span>
Update
</button>
</div>
<div class="response">
{{#if error}}
{{#unless errors}}
<p class="error">{{error}}</p>
{{/unless}}
{{else if success}}
<p class="success">{{success}}</p>
{{/if}}
</div>
<template>
{{> links/tr}}
</template>
</form>
{{else}}
<p class="no-links">No link was found.</p>
{{/if}}
</td>

View File

@ -6,10 +6,17 @@
<b>{{default_domain}}/shorturl</b> you can have
<b>yoursite.com/shorturl.</b>
</p>
<p>
Point your domain's A record to <b>192.64.116.170</b> then add the domain
via the form below:
Point your domain's A record to
{{#if server_ip_address}}
<b>{{server_ip_address}}</b>
{{else}}
our <b>IP address</b>
{{/if}}
then add the domain via the form below:
</p>
{{> settings/domain/table}}
<div class="add-domain-wrapper">
<button

View File

@ -1407,6 +1407,11 @@ main form label#advanced input {
#links-table-wrapper table tr.edit form .response p { margin: 2rem 0 0; }
#links-table-wrapper table tr.edit p.no-links {
width: 100%;
text-align: center;
}
.dialog .ban-checklist {
display: flex;
align-items: center;

View File

@ -117,7 +117,6 @@ function handleShortURLCopyLink(element) {
}, 1000);
}
// TODO: make it an extension
// open and close dialog
function openDialog(id, name) {
const dialog = document.getElementById(id);