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", "license": "MIT",
"dependencies": { "dependencies": {
"app-root-path": "3.1.0", "app-root-path": "3.1.0",
"axios": "1.7.7",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bull": "4.16.2", "bull": "4.16.2",
"cookie-parser": "1.4.6", "cookie-parser": "1.4.6",
@ -41,7 +40,7 @@
"pg-query-stream": "4.6.0", "pg-query-stream": "4.6.0",
"signale": "1.4.0", "signale": "1.4.0",
"useragent": "2.3.0", "useragent": "2.3.0",
"uuid": "^10.0.0", "uuid": "10.0.0",
"winston": "3.3.3", "winston": "3.3.3",
"winston-daily-rotate-file": "4.7.1" "winston-daily-rotate-file": "4.7.1"
}, },
@ -1478,23 +1477,6 @@
"lodash": "^4.17.14" "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": { "node_modules/babel-plugin-styled-components": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz", "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" "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": { "node_modules/commander": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
@ -2146,15 +2116,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@ -2595,26 +2556,6 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT" "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": { "node_modules/foreach": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz",
@ -2628,20 +2569,6 @@
"integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==", "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==",
"license": "Apache2" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -4600,12 +4527,6 @@
"node": ">= 0.10" "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": { "node_modules/pseudomap": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "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", "homepage": "https://github.com/thedevs-network/kutt#readme",
"dependencies": { "dependencies": {
"app-root-path": "3.1.0", "app-root-path": "3.1.0",
"axios": "1.7.7",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bull": "4.16.2", "bull": "4.16.2",
"cookie-parser": "1.4.6", "cookie-parser": "1.4.6",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
const query = require("../queries");
const utils = require("../utils"); const utils = require("../utils");
const query = require("../queries")
const env = require("../env"); const env = require("../env");
async function homepage(req, res) { async function homepage(req, res) {
@ -10,7 +10,8 @@ async function homepage(req, res) {
function login(req, res) { function login(req, res) {
if (req.user) { if (req.user) {
return res.redirect("/"); res.redirect("/");
return;
} }
res.render("login", { res.render("login", {
title: "Log in or sign up" title: "Log in or sign up"
@ -18,7 +19,7 @@ function login(req, res) {
} }
function logout(req, res) { function logout(req, res) {
res.clearCookie("token", { httpOnly: true, secure: env.isProd }); utils.deleteCurrentToken(res);
res.render("logout", { res.render("logout", {
title: "Logging out.." title: "Logging out.."
}); });
@ -31,21 +32,12 @@ function notFound(req, res) {
} }
function settings(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", { res.render("settings", {
title: "Settings" title: "Settings"
}); });
} }
function stats(req, res) { 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", { res.render("stats", {
title: "Stats" title: "Stats"
}); });
@ -154,21 +146,13 @@ async function getReportEmail(req, res) {
}); });
} }
async function linkEdit(req, res) { async function linkEdit(req, res) {
const link = await query.link.find({ const link = await query.link.find({
uuid: req.params.id, uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.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", { 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); await query.user.remove(req.user);
if (req.isHTML) { if (req.isHTML) {
res.clearCookie("token", { httpOnly: true, secure: env.isProd }); utils.deleteCurrentToken(res);
res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage"); res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");
res.render("partials/settings/delete_account", { res.render("partials/settings/delete_account", {
success: "Account has been deleted. Logging out..." 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 { 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 bcrypt = require("bcryptjs");
const axios = require("axios");
const dns = require("dns"); const dns = require("dns");
const URL = require("url"); const URL = require("url");
const ms = require("ms"); const ms = require("ms");
@ -109,11 +108,7 @@ const editLink = [
.isLength({ min: 1, max: 2040 }) .isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.") .withMessage("Maximum URL length is 2040.")
.customSanitizer(utils.addProtocol) .customSanitizer(utils.addProtocol)
.custom( .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
value =>
urlRegex({ exact: true, strict: false }).test(value) ||
/^(?!https?)(\w+):\/\//.test(value)
)
.withMessage("URL is not valid.") .withMessage("URL is not valid.")
.custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN) .custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
.withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`), .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
@ -176,11 +171,12 @@ const addDomain = [
.isLength({ min: 3, max: 64 }) .isLength({ min: 3, max: 64 })
.withMessage("Domain length must be between 3 and 64.") .withMessage("Domain length must be between 3 and 64.")
.trim() .trim()
.customSanitizer(utils.addProtocol)
.custom(value => utils.urlRegex.test(value))
.customSanitizer(value => { .customSanitizer(value => {
const parsed = URL.parse(value); const parsed = URL.parse(value);
return utils.removeWww(parsed.hostname || parsed.href); return utils.removeWww(parsed.hostname || parsed.href);
}) })
.custom(value => urlRegex({ exact: true, strict: false }).test(value))
.custom(value => value !== env.DEFAULT_DOMAIN) .custom(value => value !== env.DEFAULT_DOMAIN)
.withMessage("You can't use the default domain.") .withMessage("You can't use the default domain.")
.custom(async value => { .custom(async value => {
@ -191,7 +187,7 @@ const addDomain = [
body("homepage") body("homepage")
.optional({ checkFalsy: true, nullable: true }) .optional({ checkFalsy: true, nullable: true })
.customSanitizer(utils.addProtocol) .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.") .withMessage("Homepage is not valid.")
]; ];
@ -282,11 +278,11 @@ const signup = [
.custom(async (value, { req }) => { .custom(async (value, { req }) => {
const user = await query.user.find({ email: value }); const user = await query.user.find({ email: value });
if (user) { if (user)
req.user = user; req.user = user;
}
if (user?.verified) return Promise.reject(); if (user?.verified)
return Promise.reject();
}) })
.withMessage("You can't use this email address.") .withMessage("You can't use this email address.")
]; ];
@ -337,15 +333,6 @@ const resetPassword = [
.withMessage("Email length must be max 255.") .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 = [ const deleteUser = [
body("password", "Password is not valid.") body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true }) .exists({ checkFalsy: true, checkNull: true })
@ -375,31 +362,34 @@ function cooldown(user) {
async function malware(user, target) { async function malware(user, target) {
if (!env.GOOGLE_SAFE_BROWSING_KEY) return; 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}`, `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
{ {
client: { method: "post",
clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""), body: JSON.stringify({
clientVersion: "1.0.0" client: {
}, clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
threatInfo: { clientVersion: "1.0.0"
threatTypes: [ },
"THREAT_TYPE_UNSPECIFIED", threatInfo: {
"MALWARE", threatTypes: [
"SOCIAL_ENGINEERING", "THREAT_TYPE_UNSPECIFIED",
"UNWANTED_SOFTWARE", "MALWARE",
"POTENTIALLY_HARMFUL_APPLICATION" "SOCIAL_ENGINEERING",
], "UNWANTED_SOFTWARE",
platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"], "POTENTIALLY_HARMFUL_APPLICATION"
threatEntryTypes: [ ],
"EXECUTABLE", platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
"URL", threatEntryTypes: [
"THREAT_ENTRY_TYPE_UNSPECIFIED" "EXECUTABLE",
], "URL",
threatEntries: [{ url: target }] "THREAT_ENTRY_TYPE_UNSPECIFIED"
} ],
threatEntries: [{ url: target }]
}
})
} }
); ).then(res => res.json());
if (!isMalware.data || !isMalware.data.matches) return; if (!isMalware.data || !isMalware.data.matches) return;
if (user) { if (user) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
const ms = require("ms"); const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears } = require("date-fns");
const path = require("path");
const nanoid = require("nanoid/generate"); const nanoid = require("nanoid/generate");
const JWT = require("jsonwebtoken"); 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 hbs = require("hbs");
const ms = require("ms");
const env = require("../env"); 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) { async function generateId(query, domain_id) {
const address = nanoid( const address = nanoid(
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789", "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
@ -262,6 +274,7 @@ function registerHandlebarsHelpers() {
module.exports = { module.exports = {
addProtocol, addProtocol,
CustomError, CustomError,
deleteCurrentToken,
generateId, generateId,
getDifferenceFunction, getDifferenceFunction,
getInitStats, getInitStats,
@ -276,6 +289,7 @@ module.exports = {
registerHandlebarsHelpers, registerHandlebarsHelpers,
removeWww, removeWww,
sanitize, sanitize,
setToken,
signToken, signToken,
sleep, sleep,
statsObjectToArray, statsObjectToArray,

View File

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

View File

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

View File

@ -6,10 +6,17 @@
<b>{{default_domain}}/shorturl</b> you can have <b>{{default_domain}}/shorturl</b> you can have
<b>yoursite.com/shorturl.</b> <b>yoursite.com/shorturl.</b>
</p> </p>
<p> <p>
Point your domain's A record to <b>192.64.116.170</b> then add the domain Point your domain's A record to
via the form below: {{#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> </p>
{{> settings/domain/table}} {{> settings/domain/table}}
<div class="add-domain-wrapper"> <div class="add-domain-wrapper">
<button <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 form .response p { margin: 2rem 0 0; }
#links-table-wrapper table tr.edit p.no-links {
width: 100%;
text-align: center;
}
.dialog .ban-checklist { .dialog .ban-checklist {
display: flex; display: flex;
align-items: center; align-items: center;

View File

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