662 lines
16 KiB
JavaScript
662 lines
16 KiB
JavaScript
const { differenceInSeconds } = require("date-fns");
|
|
const promisify = require("node:util").promisify;
|
|
const bcrypt = require("bcryptjs");
|
|
const { isbot } = require("isbot");
|
|
const URL = require("node:url");
|
|
const dns = require("node:dns");
|
|
|
|
const validators = require("./validators.handler");
|
|
const map = require("../utils/map.json");
|
|
const transporter = require("../mail");
|
|
const query = require("../queries");
|
|
const queue = require("../queues");
|
|
const utils = require("../utils");
|
|
const env = require("../env");
|
|
|
|
const CustomError = utils.CustomError;
|
|
const dnsLookup = promisify(dns.lookup);
|
|
|
|
async function get(req, res) {
|
|
const { limit, skip } = req.context;
|
|
const search = req.query.search;
|
|
const userId = req.user.id;
|
|
|
|
const match = {
|
|
user_id: userId
|
|
};
|
|
|
|
const [data, total] = await Promise.all([
|
|
query.link.get(match, { limit, search, skip }),
|
|
query.link.total(match, { search })
|
|
]);
|
|
|
|
if (req.isHTML) {
|
|
res.render("partials/links/table", {
|
|
total,
|
|
limit,
|
|
skip,
|
|
links: data.map(utils.sanitize.link_html),
|
|
})
|
|
return;
|
|
}
|
|
|
|
return res.send({
|
|
total,
|
|
limit,
|
|
skip,
|
|
data: data.map(utils.sanitize.link),
|
|
});
|
|
};
|
|
|
|
async function getAdmin(req, res) {
|
|
const { limit, skip } = req.context;
|
|
const search = req.query.search;
|
|
const user = req.query.user;
|
|
let domain = req.query.domain;
|
|
const banned = utils.parseBooleanQuery(req.query.banned);
|
|
const anonymous = utils.parseBooleanQuery(req.query.anonymous);
|
|
const has_domain = utils.parseBooleanQuery(req.query.has_domain);
|
|
|
|
const match = {
|
|
...(banned !== undefined && { banned }),
|
|
...(anonymous !== undefined && { user_id: [anonymous ? "is" : "is not", null] }),
|
|
...(has_domain !== undefined && { domain_id: [has_domain ? "is not" : "is", null] }),
|
|
};
|
|
|
|
// if domain is equal to the defualt domain,
|
|
// it means admins is looking for links with the defualt domain (no custom user domain)
|
|
if (domain === env.DEFAULT_DOMAIN) {
|
|
domain = undefined;
|
|
match.domain_id = null;
|
|
}
|
|
|
|
const [data, total] = await Promise.all([
|
|
query.link.getAdmin(match, { limit, search, user, domain, skip }),
|
|
query.link.totalAdmin(match, { search, user, domain })
|
|
]);
|
|
|
|
const links = data.map(utils.sanitize.link_admin);
|
|
|
|
if (req.isHTML) {
|
|
res.render("partials/admin/links/table", {
|
|
total,
|
|
total_formatted: total.toLocaleString("en-US"),
|
|
limit,
|
|
skip,
|
|
links,
|
|
})
|
|
return;
|
|
}
|
|
|
|
return res.send({
|
|
total,
|
|
limit,
|
|
skip,
|
|
data: links,
|
|
});
|
|
};
|
|
|
|
async function create(req, res) {
|
|
const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
|
|
const domain_id = fetched_domain ? fetched_domain.id : null;
|
|
|
|
const targetDomain = utils.removeWww(URL.parse(target).hostname);
|
|
|
|
const tasks = await Promise.all([
|
|
reuse &&
|
|
query.link.find({
|
|
target,
|
|
user_id: req.user.id,
|
|
domain_id
|
|
}),
|
|
customurl &&
|
|
query.link.find({
|
|
address: customurl,
|
|
domain_id
|
|
}),
|
|
!customurl && utils.generateId(query, domain_id),
|
|
validators.bannedDomain(targetDomain),
|
|
validators.bannedHost(targetDomain)
|
|
]);
|
|
|
|
// if "reuse" is true, try to return
|
|
// the existent URL without creating one
|
|
if (tasks[0]) {
|
|
return res.json(utils.sanitize.link(tasks[0]));
|
|
}
|
|
|
|
// Check if custom link already exists
|
|
if (tasks[1]) {
|
|
const error = "Custom URL is already in use.";
|
|
res.locals.errors = { customurl: error };
|
|
throw new CustomError(error);
|
|
}
|
|
|
|
// Create new link
|
|
const address = customurl || tasks[2];
|
|
const link = await query.link.create({
|
|
password,
|
|
address,
|
|
domain_id,
|
|
description,
|
|
target,
|
|
expire_in,
|
|
user_id: req.user && req.user.id
|
|
});
|
|
|
|
link.domain = fetched_domain?.address;
|
|
|
|
if (req.isHTML) {
|
|
res.setHeader("HX-Trigger", "reloadMainTable");
|
|
const shortURL = utils.getShortURL(link.address, link.domain);
|
|
return res.render("partials/shortener", {
|
|
link: shortURL.link,
|
|
url: shortURL.url,
|
|
});
|
|
}
|
|
|
|
return res
|
|
.status(201)
|
|
.send(utils.sanitize.link({ ...link }));
|
|
}
|
|
|
|
async function edit(req, res) {
|
|
const link = await query.link.find({
|
|
uuid: req.params.id,
|
|
...(!req.user.admin && { user_id: req.user.id })
|
|
});
|
|
|
|
if (!link) {
|
|
throw new CustomError("Link was not found.");
|
|
}
|
|
|
|
let isChanged = false;
|
|
[
|
|
[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) {
|
|
if (name === "password" && link.password)
|
|
req.body.password = null;
|
|
else {
|
|
delete req.body[name];
|
|
return;
|
|
}
|
|
}
|
|
if (value === link[name] && name !== "password") {
|
|
delete req.body[name];
|
|
return;
|
|
}
|
|
if (name === "expire_in" && link.expire_in)
|
|
if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60)
|
|
return;
|
|
if (name === "password")
|
|
if (value && value.replace(/•/ig, "").length === 0) {
|
|
delete req.body.password;
|
|
return;
|
|
}
|
|
isChanged = true;
|
|
});
|
|
|
|
if (!isChanged) {
|
|
throw new CustomError("Should at least update one field.");
|
|
}
|
|
|
|
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 tasks = await Promise.all([
|
|
address &&
|
|
query.link.find({
|
|
address,
|
|
domain_id
|
|
}),
|
|
target && validators.bannedDomain(targetDomain),
|
|
target && validators.bannedHost(targetDomain)
|
|
]);
|
|
|
|
// Check if custom link already exists
|
|
if (tasks[0]) {
|
|
const error = "Custom URL is already in use.";
|
|
res.locals.errors = { address: error };
|
|
throw new CustomError("Custom URL is already in use.");
|
|
}
|
|
|
|
// Update link
|
|
const [updatedLink] = await query.link.update(
|
|
{
|
|
id: link.id
|
|
},
|
|
{
|
|
...(address && { address }),
|
|
...(description && { description }),
|
|
...(target && { target }),
|
|
...(expire_in && { expire_in }),
|
|
...((password || password === null) && { password })
|
|
}
|
|
);
|
|
|
|
if (req.isHTML) {
|
|
res.render("partials/links/edit", {
|
|
swap_oob: true,
|
|
success: "Link has been updated.",
|
|
...utils.sanitize.link_html({ ...updatedLink }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
return res.status(200).send(utils.sanitize.link({ ...updatedLink }));
|
|
};
|
|
|
|
async function editAdmin(req, res) {
|
|
const link = await query.link.find({
|
|
uuid: req.params.id,
|
|
...(!req.user.admin && { user_id: req.user.id })
|
|
});
|
|
|
|
if (!link) {
|
|
throw new CustomError("Link was not found.");
|
|
}
|
|
|
|
let isChanged = false;
|
|
[
|
|
[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) {
|
|
if (name === "password" && link.password)
|
|
req.body.password = null;
|
|
else {
|
|
delete req.body[name];
|
|
return;
|
|
}
|
|
}
|
|
if (value === link[name] && name !== "password") {
|
|
delete req.body[name];
|
|
return;
|
|
}
|
|
if (name === "expire_in" && link.expire_in)
|
|
if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60)
|
|
return;
|
|
if (name === "password")
|
|
if (value && value.replace(/•/ig, "").length === 0) {
|
|
delete req.body.password;
|
|
return;
|
|
}
|
|
isChanged = true;
|
|
});
|
|
|
|
if (!isChanged) {
|
|
throw new CustomError("Should at least update one field.");
|
|
}
|
|
|
|
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 tasks = await Promise.all([
|
|
address &&
|
|
query.link.find({
|
|
address,
|
|
domain_id
|
|
}),
|
|
target && validators.bannedDomain(targetDomain),
|
|
target && validators.bannedHost(targetDomain)
|
|
]);
|
|
|
|
// Check if custom link already exists
|
|
if (tasks[0]) {
|
|
const error = "Custom URL is already in use.";
|
|
res.locals.errors = { address: error };
|
|
throw new CustomError("Custom URL is already in use.");
|
|
}
|
|
|
|
// Update link
|
|
const [updatedLink] = await query.link.update(
|
|
{
|
|
id: link.id
|
|
},
|
|
{
|
|
...(address && { address }),
|
|
...(description && { description }),
|
|
...(target && { target }),
|
|
...(expire_in && { expire_in }),
|
|
...((password || password === null) && { password })
|
|
}
|
|
);
|
|
|
|
if (req.isHTML) {
|
|
res.render("partials/admin/links/edit", {
|
|
swap_oob: true,
|
|
success: "Link has been updated.",
|
|
...utils.sanitize.link_admin({ ...updatedLink }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
return res.status(200).send(utils.sanitize.link({ ...updatedLink }));
|
|
};
|
|
|
|
async function remove(req, res) {
|
|
const { error, isRemoved, link } = await query.link.remove({
|
|
uuid: req.params.id,
|
|
...(!req.user.admin && { user_id: req.user.id })
|
|
});
|
|
|
|
if (!isRemoved) {
|
|
const messsage = error || "Could not delete the link.";
|
|
throw new CustomError(messsage);
|
|
}
|
|
|
|
if (req.isHTML) {
|
|
res.setHeader("HX-Reswap", "outerHTML");
|
|
res.setHeader("HX-Trigger", "reloadMainTable");
|
|
res.render("partials/links/dialog/delete_success", {
|
|
link: utils.getShortURL(link.address, link.domain).link,
|
|
});
|
|
return;
|
|
}
|
|
|
|
return res
|
|
.status(200)
|
|
.send({ message: "Link has been deleted successfully." });
|
|
};
|
|
|
|
async function report(req, res) {
|
|
const { link } = req.body;
|
|
|
|
await transporter.sendReportEmail(link);
|
|
|
|
if (req.isHTML) {
|
|
res.render("partials/report/form", {
|
|
message: "Report was received. We'll take actions shortly."
|
|
});
|
|
return;
|
|
}
|
|
|
|
return res
|
|
.status(200)
|
|
.send({ message: "Thanks for the report, we'll take actions shortly." });
|
|
};
|
|
|
|
async function ban(req, res) {
|
|
const { id } = req.params;
|
|
|
|
const update = {
|
|
banned_by_id: req.user.id,
|
|
banned: true
|
|
};
|
|
|
|
// 1. check if link exists
|
|
const link = await query.link.find({ uuid: id });
|
|
|
|
if (!link) {
|
|
throw new CustomError("No link has been found.", 400);
|
|
}
|
|
|
|
if (link.banned) {
|
|
throw new CustomError("Link has been banned already.", 400);
|
|
}
|
|
|
|
const tasks = [];
|
|
|
|
// 2. ban link
|
|
tasks.push(query.link.update({ uuid: id }, update));
|
|
|
|
const domain = utils.removeWww(URL.parse(link.target).hostname);
|
|
|
|
// 3. ban target's domain
|
|
if (req.body.domain) {
|
|
tasks.push(query.domain.add({ ...update, address: domain }));
|
|
}
|
|
|
|
// 4. ban target's host
|
|
if (req.body.host) {
|
|
const dnsRes = await dnsLookup(domain).catch(() => {
|
|
throw new CustomError("Couldn't fetch DNS info.");
|
|
});
|
|
const host = dnsRes?.address;
|
|
tasks.push(query.host.add({ ...update, address: host }));
|
|
}
|
|
|
|
// 5. ban link owner
|
|
if (req.body.user && link.user_id) {
|
|
tasks.push(query.user.update({ id: link.user_id }, update));
|
|
}
|
|
|
|
// 6. ban all of owner's links
|
|
if (req.body.userLinks && link.user_id) {
|
|
tasks.push(query.link.update({ user_id: link.user_id }, update));
|
|
}
|
|
|
|
// 7. wait for all tasks to finish
|
|
await Promise.all(tasks).catch((err) => {
|
|
throw new CustomError("Couldn't ban entries.");
|
|
});
|
|
|
|
// 8. send response
|
|
if (req.isHTML) {
|
|
res.setHeader("HX-Reswap", "outerHTML");
|
|
res.setHeader("HX-Trigger", "reloadMainTable");
|
|
res.render("partials/links/dialog/ban_success", {
|
|
link: utils.getShortURL(link.address, link.domain).link,
|
|
});
|
|
return;
|
|
}
|
|
|
|
return res.status(200).send({ message: "Banned link successfully." });
|
|
};
|
|
|
|
async function redirect(req, res, next) {
|
|
const isPreservedUrl = utils.preservedURLs.some(
|
|
item => item === req.path.replace("/", "")
|
|
);
|
|
|
|
if (isPreservedUrl) return next();
|
|
|
|
// 1. If custom domain, get domain info
|
|
const host = utils.removeWww(req.headers.host);
|
|
const domain =
|
|
host !== env.DEFAULT_DOMAIN
|
|
? await query.domain.find({ address: host })
|
|
: null;
|
|
|
|
// 2. Get link
|
|
const address = req.params.id.replace("+", "");
|
|
const link = await query.link.find({
|
|
address,
|
|
domain_id: domain ? domain.id : null
|
|
});
|
|
|
|
// 3. When no link, if has domain redirect to domain's homepage
|
|
// otherwise redirect to 404
|
|
if (!link) {
|
|
return res.redirect(domain?.homepage || "/404");
|
|
}
|
|
|
|
// 4. If link is banned, redirect to banned page.
|
|
if (link.banned) {
|
|
return res.redirect("/banned");
|
|
}
|
|
|
|
// 5. If wants to see link info, then redirect
|
|
const isRequestingInfo = /.*\+$/gi.test(req.params.id);
|
|
if (isRequestingInfo && !link.password) {
|
|
if (req.isHTML) {
|
|
res.render("url_info", {
|
|
title: "Short link information",
|
|
target: link.target,
|
|
link: utils.getShortURL(link.address, link.domain).link
|
|
});
|
|
return;
|
|
}
|
|
return res.send({ target: link.target });
|
|
}
|
|
|
|
// 6. If link is protected, redirect to password page
|
|
if (link.password) {
|
|
if ("authorization" in req.headers) {
|
|
const auth = req.headers.authorization;
|
|
const firstSpace = auth.indexOf(" ");
|
|
if (firstSpace !== -1) {
|
|
const method = auth.slice(0, firstSpace);
|
|
const payload = auth.slice(firstSpace + 1);
|
|
if (method === "Basic") {
|
|
const decoded = Buffer.from(payload, "base64").toString("utf8");
|
|
const colon = decoded.indexOf(":");
|
|
if (colon !== -1) {
|
|
const password = decoded.slice(colon + 1);
|
|
const matches = await bcrypt.compare(password, link.password);
|
|
if (matches) return res.redirect(link.target);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
res.render("protected", {
|
|
title: "Protected short link",
|
|
id: link.uuid
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 7. Create link visit
|
|
const isBot = isbot(req.headers["user-agent"]);
|
|
if (link.user_id && !isBot) {
|
|
queue.visit.add({
|
|
userAgent: req.headers["user-agent"],
|
|
ip: req.ip,
|
|
country: req.get("cf-ipcountry"),
|
|
referrer: req.get("Referrer"),
|
|
link
|
|
});
|
|
}
|
|
|
|
// 8. Redirect to target
|
|
return res.redirect(link.target);
|
|
};
|
|
|
|
async function redirectProtected(req, res) {
|
|
// 1. Get link
|
|
const uuid = req.params.id;
|
|
const link = await query.link.find({ uuid });
|
|
|
|
// 2. Throw error if no link
|
|
if (!link || !link.password) {
|
|
throw new CustomError("Couldn't find the link.", 400);
|
|
}
|
|
|
|
// 3. Check if password matches
|
|
const matches = await bcrypt.compare(req.body.password, link.password);
|
|
|
|
if (!matches) {
|
|
throw new CustomError("Password is not correct.", 401);
|
|
}
|
|
|
|
// 4. Create visit
|
|
if (link.user_id) {
|
|
queue.visit.add({
|
|
userAgent: req.headers["user-agent"],
|
|
ip: req.ip,
|
|
country: req.get("cf-ipcountry"),
|
|
referrer: req.get("Referrer"),
|
|
link
|
|
});
|
|
}
|
|
|
|
// 5. Send target
|
|
if (req.isHTML) {
|
|
res.setHeader("HX-Redirect", link.target);
|
|
res.render("partials/protected/form", {
|
|
id: link.uuid,
|
|
message: "Redirecting...",
|
|
});
|
|
return;
|
|
}
|
|
return res.status(200).send({ target: link.target });
|
|
};
|
|
|
|
async function redirectCustomDomainHomepage(req, res, next) {
|
|
const host = utils.removeWww(req.headers.host);
|
|
if (host === env.DEFAULT_DOMAIN) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const path = req.path;
|
|
const pathName = path.replace("/", "").split("/")[0];
|
|
if (
|
|
path === "/" ||
|
|
utils.preservedURLs.includes(pathName)
|
|
) {
|
|
const domain = await query.domain.find({ address: host });
|
|
if (domain?.homepage) {
|
|
res.redirect(302, domain.homepage);
|
|
return;
|
|
}
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
async function stats(req, res) {
|
|
const { user } = req;
|
|
const uuid = req.params.id;
|
|
|
|
const link = await query.link.find({
|
|
...(!user.admin && { user_id: user.id }),
|
|
uuid
|
|
});
|
|
|
|
if (!link) {
|
|
if (req.isHTML) {
|
|
res.setHeader("HX-Redirect", "/404");
|
|
res.status(200).send("");
|
|
return;
|
|
}
|
|
throw new CustomError("Link could not be found.");
|
|
}
|
|
|
|
const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
|
|
|
|
if (!stats) {
|
|
throw new CustomError("Could not get the short link stats. Try again later.");
|
|
}
|
|
|
|
if (req.isHTML) {
|
|
res.render("partials/stats", {
|
|
link: utils.sanitize.link_html(link),
|
|
stats,
|
|
map,
|
|
});
|
|
return;
|
|
}
|
|
|
|
return res.status(200).send({
|
|
...stats,
|
|
...utils.sanitize.link(link)
|
|
});
|
|
};
|
|
|
|
module.exports = {
|
|
ban,
|
|
create,
|
|
edit,
|
|
editAdmin,
|
|
get,
|
|
getAdmin,
|
|
remove,
|
|
report,
|
|
stats,
|
|
redirect,
|
|
redirectProtected,
|
|
redirectCustomDomainHomepage,
|
|
} |