diff --git a/.example.env b/.example.env index f4352af2c..61d82e15d 100644 --- a/.example.env +++ b/.example.env @@ -4,9 +4,13 @@ PORT=3000 # Optional - The name of the site where Kutt is hosted SITE_NAME=Kutt -# Optional - The domain that this website is on +# Optional - The default domain for new links. This is also used as the admin domain if ADMIN_DOMAIN is not set DEFAULT_DOMAIN=localhost:3000 +# Optional - The domain where admin functions take place. +# Falls back to DEFAULT_DOMAIN if unset. +ADMIN_DOMAIN= + # Required - A passphrase to encrypt JWT. Use a random long string JWT_SECRET= diff --git a/README.md b/README.md index 565e1565a..2d2717315 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Support the development of Kutt by making a donation or becoming an sponsor. ## Setup -The only prerequisite is [Node.js](https://nodejs.org/) (version 20 or above). The default database is SQLite. You can optionally install Postgres or MySQL/MariaDB for the database or Redis for the cache. +The only prerequisite is [Node.js](https://nodejs.org/) (version 20 or above). The default database is SQLite. You can optionally install Postgres or MySQL/MariaDB for the database or Redis for the cache. When you first start the app, you're prompted to create an admin account. @@ -87,7 +87,7 @@ Official Kutt Docker image is available on [Docker Hub](https://hub.docker.com/r The app is configured via environment variables. You can pass environment variables directly or create a `.env` file. View [`.example.env`](./.example.env) file for the list of configurations. -All variables are optional except `JWT_SECRET` which is required on production. +All variables are optional except `JWT_SECRET` which is required on production. You can use files for each of the variables by appending `_FILE` to the name of the variable. Example: `JWT_SECRET_FILE=/path/to/secret_file`. @@ -96,7 +96,8 @@ You can use files for each of the variables by appending `_FILE` to the name of | `JWT_SECRET` | This is used to sign authentication tokens. Use a **long** **random** string. | - | - | | `PORT` | The port to start the app on | `3000` | `8888` | | `SITE_NAME` | Name of the website | `Kutt` | `Your Site` | -| `DEFAULT_DOMAIN` | The domain address that this app runs on | `localhost:3000` | `yoursite.com` | +| `DEFAULT_DOMAIN` | The default domain for new links. This is also used as the admin domain if ADMIN_DOMAIN is not set | `localhost:3000` | `yoursite.com` | +| `ADMIN_DOMAIN` | The domain where admin functions take place. If unset, falls back to `DEFAULT_DOMAIN` | `""` | `admin.yoursite.com` | | `LINK_LENGTH` | The length of of shortened address | `6` | `5` | | `LINK_CUSTOM_ALPHABET` | Alphabet used to generate custom addresses. Default value omits o, O, 0, i, I, l, 1, and j to avoid confusion when reading the URL. | (abcd..789) | `abcABC^&*()@` | | `DISALLOW_REGISTRATION` | Disable registration. Note that if `MAIL_ENABLED` is set to false, then the registration would still be disabled since it relies on emails to sign up users. | `true` | `false` | @@ -122,21 +123,66 @@ You can use files for each of the variables by appending `_FILE` to the name of | `SERVER_CNAME_ADDRESS` | The subdomain shown to the user on the setting's page. It's only for display purposes and has no other use. | - | `custom.yoursite.com` | | `CUSTOM_DOMAIN_USE_HTTPS` | Use https for links with custom domain. It's on you to generate SSL certificates for those domains manually—at least on this version for now. | `false` | `true` | | `ENABLE_RATE_LIMIT` | Enable rate limiting for some API routes. If Redis is enabled uses Redis, otherwise, uses memory. | `false` | `true` | -| `MAIL_ENABLED` | Enable emails, which are used for signup, verifying or changing email address, resetting password, and sending reports. If is disabled, all these functionalities will be disabled too. | `false` | `true` | +| `MAIL_ENABLED` | Enable emails, which are used for signup, verifying or changing email address, resetting password, and sending reports. If is disabled, all these functionalities will be disabled too. | `false` | `true` | | `MAIL_HOST` | Email server host | - | `your-mail-server.com` | -| `MAIL_PORT` | Email server port | `587` | `465` (SSL) | -| `MAIL_USER` | Email server user | - | `myuser` | -| `MAIL_PASSWORD` | Email server password for the user | - | `mypassword` | -| `MAIL_FROM` | Email address to send the user from | - | `example@yoursite.com` | -| `MAIL_SECURE` | Whether use SSL for the email server connection | `false` | `true` | -| `OIDC_ENABLED` | Enable OpenID Connect | `false` | `true` | -| `OIDC_ISSUER` | OIDC issuer URL | - | `https://example.com/some/path` | -| `OIDC_CLIENT_ID` | OIDC client id | - | `example-app` | -| `OIDC_CLIENT_SECRET` | OIDC client secret | - | `some-secret` | -| `OIDC_SCOPE` | OIDC Scope | `openid profile email` | `openid email` | -| `OIDC_EMAIL_CLAIM` | Name of the field to get user's email from | `email` | `userEmail` | -| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `example@yoursite.com` | -| `CONTACT_EMAIL` | The support email address to show on the app | - | `example@yoursite.com` | +| `MAIL_PORT` | Email server port | `587` | `465` (SSL) | +| `MAIL_USER` | Email server user | - | `myuser` | +| `MAIL_PASSWORD` | Email server password for the user | - | `mypassword` | +| `MAIL_FROM` | Email address to send the user from | - | `example@yoursite.com` | +| `MAIL_SECURE` | Whether use SSL for the email server connection | `false` | `true` | +| `OIDC_ENABLED` | Enable OpenID Connect | `false` | `true` | +| `OIDC_ISSUER` | OIDC issuer URL | - | `https://example.com/some/path` | +| `OIDC_CLIENT_ID` | OIDC client id | - | `example-app` | +| `OIDC_CLIENT_SECRET` | OIDC client secret | - | `some-secret` | +| `OIDC_SCOPE` | OIDC Scope | `openid profile email` | `openid email` | +| `OIDC_EMAIL_CLAIM` | Name of the field to get user's email from | `email` | `userEmail` | +| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `example@yoursite.com` | +| `CONTACT_EMAIL` | The support email address to show on the app | - | `example@yoursite.com` | + +## Changing DEFAULT_DOMAIN + +**WARNING:** Back up your database before running any of the SQL commands in this section. + +Default links are stored with `domain_id = null`. The redirect handler associates them with the **current** `DEFAULT_DOMAIN`. + +However, if an incoming link belongs to a domain that is **not** in the domains table, it falls through and is matched against the default domain. + +Therefore, if you simply change `DEFAULT_DOMAIN` in your `.env` and restart, existing links will work on both the new domain and the old domain, **as long as the old domain has no entry in the domains table**. + +### Splitting the old domain out + +If you (or a user of Kutt) ever add the old domain through the UI, from then on existing links will ONLY work on the new DEFAULT_DOMAIN. + +If you want to separate old links from the new domain to ensure they continue to work indefinitely, follow these steps: + +1. Add the old domain via the admin panel first (e.g. `old.example.com`) +2. Reassign all existing default-domain links to it via SQL: + ```sql + UPDATE links SET domain_id = (SELECT id FROM domains WHERE address = 'old.example.com') WHERE domain_id IS NULL; + ``` +3. Change `DEFAULT_DOMAIN` in `.env` from `old.example.com` to `new.example.com` and restart. + +New links will have `domain_id = null` and use `new.example.com`. Existing links will now be tied to `old.example.com`. + +### Promoting an existing domain to DEFAULT_DOMAIN + +If you want to make an existing custom domain your new `DEFAULT_DOMAIN`: + +1. Reassign the custom domain's links to the new default and delete the old custom domain: + ```sql + UPDATE links SET domain_id = NULL WHERE domain_id = (SELECT id FROM domains WHERE address = 'old.example.com'); + DELETE FROM domains WHERE address = 'old.example.com'; + ``` +2. Update `DEFAULT_DOMAIN` in `.env` +3. Restart to apply the `.env` change + +## Using ADMIN_DOMAIN + +By default, the admin panel is accessible at `DEFAULT_DOMAIN/admin`. Setting the optional `ADMIN_DOMAIN` variable allows you to host it at a separate domain. + +For example, you can host the admin interface at `links.example.com` but use `eg.url` as your default short link domain. + +`ADMIN_DOMAIN` has no effect on link creation or redirection — it only controls which domain serves the admin panel. ## Themes and customizations @@ -173,7 +219,7 @@ custom/ - **views**: Custom HTML templates to render. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/views)) - It should follow the same file naming and folder structure as [`/server/views`](./server/views) - Although we try to keep the original file names unchanged, be aware that new changes on Kutt might break your custom views. - + #### Example theme: Crimson This is an example and official theme. Crimson includes custom styles, images, and views. diff --git a/server/env.js b/server/env.js index 5672823d7..a2e13b4f9 100644 --- a/server/env.js +++ b/server/env.js @@ -30,6 +30,7 @@ const spec = { PORT: num({ default: 3000 }), SITE_NAME: str({ example: "Kutt", default: "Kutt" }), DEFAULT_DOMAIN: str({ example: "kutt.it", default: "localhost:3000" }), + ADMIN_DOMAIN: str({ example: "admin.kutt.it", default: "" }), LINK_LENGTH: num({ default: 6 }), LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }), TRUST_PROXY: bool({ default: true }), diff --git a/server/handlers/domains.handler.js b/server/handlers/domains.handler.js index 75a9d0197..ccc0f6336 100644 --- a/server/handlers/domains.handler.js +++ b/server/handlers/domains.handler.js @@ -28,12 +28,13 @@ async function add(req, res) { }; async function addAdmin(req, res) { - const { address, banned, homepage } = req.body; + const { address, banned, homepage, is_global } = req.body; const domain = await query.domain.add({ address, homepage, banned, + is_global, ...(banned && { banned_by_id: req.user.id }) }); @@ -94,10 +95,14 @@ async function removeAdmin(req, res) { throw new CustomError("Could not find the domain.", 400); } + if (utils.isSystemDomain(domain.address)) { + throw new CustomError("Cannot delete a system domain.", 400); + } + if (links) { await query.link.batchRemove({ domain_id: id }); } - + await query.domain.remove(domain); if (req.isHTML) { @@ -166,6 +171,10 @@ async function ban(req, res) { throw new CustomError("No domain has been found.", 400); } + if (utils.isSystemDomain(domain.address)) { + throw new CustomError("Cannot ban a system domain.", 400); + } + if (domain.banned) { throw new CustomError("Domain has been banned already.", 400); } @@ -203,6 +212,42 @@ async function ban(req, res) { return res.status(200).send({ message: "Banned domain successfully." }); } +async function updateAdmin(req, res) { + const id = req.params.id; + const { homepage, is_global } = req.body; + + const domain = await query.domain.find({ id }); + + if (!domain) { + throw new CustomError("Could not find the domain.", 400); + } + + const updateData = { homepage: homepage || null }; + if (is_global !== undefined) { + updateData.is_global = !!is_global; + } + + const [updatedDomain] = await query.domain.update( + { id: domain.id }, + updateData + ); + + if (!updatedDomain) { + throw new CustomError("Could not update the domain.", 500); + } + + if (req.isHTML) { + res.setHeader("HX-Trigger", "reloadMainTable"); + res.render("partials/admin/domains/edit", { + success: "Domain has been updated.", + ...utils.sanitize.domain_admin(updatedDomain), + }); + return; + } + + return res.status(200).send({ message: "Domain updated successfully." }); +} + module.exports = { add, addAdmin, @@ -210,4 +255,5 @@ module.exports = { getAdmin, remove, removeAdmin, + updateAdmin, } \ No newline at end of file diff --git a/server/handlers/links.handler.js b/server/handlers/links.handler.js index 93a0a6489..6129d80a0 100644 --- a/server/handlers/links.handler.js +++ b/server/handlers/links.handler.js @@ -56,20 +56,20 @@ async function getAdmin(req, res) { 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 }) @@ -97,11 +97,12 @@ async function getAdmin(req, res) { }; async function create(req, res) { - const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body; + const { reuse, password, customurl, description, target, expire_in } = req.body; + const fetched_domain = req.body.fetched_domain || null; 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({ @@ -118,13 +119,13 @@ async function create(req, res) { 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."; @@ -145,16 +146,16 @@ async function create(req, res) { }); 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, + link: shortURL.link, url: shortURL.url, }); } - + return res .status(201) .send(utils.sanitize.link({ ...link })); @@ -172,14 +173,14 @@ async function edit(req, res) { let isChanged = false; [ - [req.body.address, "address"], - [req.body.target, "target"], - [req.body.description, "description"], - [req.body.expire_in, "expire_in"], + [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) + if (name === "password" && link.password) req.body.password = null; else { delete req.body[name]; @@ -206,7 +207,7 @@ async function edit(req, res) { } 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; @@ -265,14 +266,14 @@ async function editAdmin(req, res) { let isChanged = false; [ - [req.body.address, "address"], - [req.body.target, "target"], - [req.body.description, "description"], - [req.body.expire_in, "expire_in"], + [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) + if (name === "password" && link.password) req.body.password = null; else { delete req.body[name]; @@ -299,7 +300,7 @@ async function editAdmin(req, res) { } 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; @@ -382,7 +383,7 @@ async function report(req, res) { }); return; } - + return res .status(200) .send({ message: "Thanks for the report, we'll take actions shortly." }); @@ -492,7 +493,7 @@ async function redirect(req, res, next) { const isRequestingInfo = /.*\+$/gi.test(req.params.id); if (isRequestingInfo && !link.password) { if (req.isHTML) { - res.render("url_info", { + res.render("url_info", { title: "Short link information", target: link.target, link: utils.getShortURL(link.address, link.domain).link @@ -659,4 +660,4 @@ module.exports = { redirect, redirectProtected, redirectCustomDomainHomepage, -} \ No newline at end of file +} diff --git a/server/handlers/locals.handler.js b/server/handlers/locals.handler.js index 79949eb2c..7421d1985 100644 --- a/server/handlers/locals.handler.js +++ b/server/handlers/locals.handler.js @@ -22,6 +22,7 @@ function viewTemplate(template) { function config(req, res, next) { res.locals.default_domain = env.DEFAULT_DOMAIN; + res.locals.admin_domain = utils.getAdminDomain(); res.locals.site_name = env.SITE_NAME; res.locals.contact_email = env.CONTACT_EMAIL; res.locals.server_ip_address = env.SERVER_IP_ADDRESS; @@ -38,8 +39,24 @@ function config(req, res, next) { async function user(req, res, next) { const user = req.user; + let userDomains = []; + if (user) { + userDomains = await query.domain.get({ user_id: user.id }); + userDomains = userDomains.map(utils.sanitize.domain); + } + + const defaultDomain = env.DEFAULT_DOMAIN; + const globalDomainRecords = await query.domain.get({ is_global: true }); + const globalDomains = globalDomainRecords.map(d => d.address).filter(d => d !== defaultDomain); + + const filteredUserDomains = userDomains.filter( + d => d.address !== defaultDomain && !globalDomains.includes(d.address) + ); + + res.locals.default_domain = defaultDomain; + res.locals.other_global_domains = globalDomains; + res.locals.user_domains = filteredUserDomains; res.locals.user = user; - res.locals.domains = user && (await query.domain.get({ user_id: user.id })).map(utils.sanitize.domain); next(); } @@ -89,4 +106,4 @@ module.exports = { protected, user, viewTemplate, -} \ No newline at end of file +} diff --git a/server/handlers/renders.handler.js b/server/handlers/renders.handler.js index 79058fdb4..6815973a9 100644 --- a/server/handlers/renders.handler.js +++ b/server/handlers/renders.handler.js @@ -304,12 +304,23 @@ async function linkEditAdmin(req, res) { }); } +async function domainEditAdmin(req, res) { + const domain = await query.domain.find({ id: req.params.id }); + if (!domain) { + throw new utils.CustomError("Could not find the domain.", 400); + } + res.render("partials/admin/domains/edit", { + ...utils.sanitize.domain_admin(domain), + }); +} + module.exports = { addDomainAdmin, addDomainForm, admin, banned, confirmDomainBan, + domainEditAdmin, confirmDomainDelete, confirmDomainDeleteAdmin, confirmLinkBan, diff --git a/server/handlers/validators.handler.js b/server/handlers/validators.handler.js index c479ecaa0..4f4cab67c 100644 --- a/server/handlers/validators.handler.js +++ b/server/handlers/validators.handler.js @@ -28,8 +28,12 @@ const createLink = [ .customSanitizer(utils.addProtocol) .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.`), + .custom(async value => { + const host = utils.removeWww(URL.parse(value).host); + if (utils.isSystemDomain(host)) throw new Error(`${host} URLs are not allowed.`); + const domain = await query.domain.find({ address: host }); + if (domain?.is_global) throw new Error(`${host} URLs are not allowed.`); + }), body("password") .optional({ nullable: true, checkFalsy: true }) .custom(checkUser) @@ -78,23 +82,30 @@ const createLink = [ .withMessage("Expire time should be more than 1 minute.") .customSanitizer(value => utils.dateToUTC(addMilliseconds(new Date(), value))), body("domain") - .optional({ nullable: true, checkFalsy: true }) - .customSanitizer(value => value === env.DEFAULT_DOMAIN ? null : value) - .custom(checkUser) - .withMessage("Only users can use this field.") - .isString() - .withMessage("Domain should be string.") - .customSanitizer(value => value.toLowerCase()) - .custom(async (address, { req }) => { - const domain = await query.domain.find({ - address, - user_id: req.user.id - }); - req.body.fetched_domain = domain || null; - - if (!domain) return Promise.reject(); - }) - .withMessage("You can't use this domain.") + .optional({ nullable: true, checkFalsy: true }) + .customSanitizer(value => value === env.DEFAULT_DOMAIN ? null : value) + .custom(checkUser) + .withMessage("Only users can use this field.") + .isString() + .withMessage("Domain should be string.") + .customSanitizer(value => value && value.toLowerCase()) + .custom(async (address, { req }) => { + if (!address) { + // User selected DEFAULT_DOMAIN — no DB record needed, domain_id will be null + req.body.fetched_domain = null; + return; + } + + const domain = await query.domain.find({ address }); + + if (domain && domain.user_id && domain.user_id !== req.user.id) { + return Promise.reject(); + } + + req.body.fetched_domain = domain || null; + if (!domain) return Promise.reject(); + }) + .withMessage("You can't use this domain.") ]; const editLink = [ @@ -107,8 +118,12 @@ const editLink = [ .customSanitizer(utils.addProtocol) .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.`), + .custom(async value => { + const host = utils.removeWww(URL.parse(value).host); + if (utils.isSystemDomain(host)) throw new Error(`${host} URLs are not allowed.`); + const domain = await query.domain.find({ address: host }); + if (domain?.is_global) throw new Error(`${host} URLs are not allowed.`); + }), body("password") .optional({ nullable: true, checkFalsy: true }) .isString() @@ -174,10 +189,11 @@ const addDomain = [ const parsed = URL.parse(value); return utils.removeWww(parsed.hostname || parsed.href); }) - .custom(value => value !== env.DEFAULT_DOMAIN) - .withMessage("You can't use the default domain.") + .custom(value => !utils.isSystemDomain(value)) + .withMessage("You can't use a system domain.") .custom(async value => { const domain = await query.domain.find({ address: value }); + if (domain?.is_global) return Promise.reject(); if (domain?.user_id || domain?.banned) return Promise.reject(); }) .withMessage("You can't add this domain."), @@ -200,10 +216,11 @@ const addDomainAdmin = [ const parsed = URL.parse(value); return utils.removeWww(parsed.hostname || parsed.href); }) - .custom(value => value !== env.DEFAULT_DOMAIN) - .withMessage("You can't add the default domain.") + .custom(value => !utils.isSystemDomain(value)) + .withMessage("You can't add a system domain.") .custom(async value => { const domain = await query.domain.find({ address: value }); + if (domain?.is_global) return Promise.reject(); if (domain) return Promise.reject(); }) .withMessage("Domain already exists."), @@ -216,6 +233,10 @@ const addDomainAdmin = [ .optional({ nullable: true }) .customSanitizer(sanitizeCheckbox) .isBoolean(), + body("is_global") + .optional({ nullable: true }) + .customSanitizer(sanitizeCheckbox) + .isBoolean(), ] const removeDomain = [ @@ -256,10 +277,13 @@ const reportLink = [ checkNull: true }) .customSanitizer(utils.addProtocol) - .custom( - value => utils.removeWww(URL.parse(value).host) === env.DEFAULT_DOMAIN - ) - .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`) + .custom(async value => { + const host = utils.removeWww(URL.parse(value).host); + if (utils.isSystemDomain(host)) return true; + const domain = await query.domain.find({ address: host }); + if (domain?.is_global) return true; + throw new Error("You can only report a link on a system domain."); + }) ]; const banLink = [ @@ -337,6 +361,21 @@ const banDomain = [ .isBoolean() ]; +const updateDomainAdmin = [ + param("id", "ID is invalid.") + .exists({ checkFalsy: true, checkNull: true }) + .isNumeric(), + body("homepage") + .optional({ checkFalsy: true, nullable: true }) + .customSanitizer(utils.addProtocol) + .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value)) + .withMessage("Homepage is not valid."), + body("is_global") + .optional({ nullable: true }) + .customSanitizer(value => value === true || value === "on" || value === "true") + .isBoolean(), +]; + const createUser = [ body("password", "Password is not valid.") .exists({ checkFalsy: true, checkNull: true }) @@ -350,7 +389,7 @@ const createUser = [ .isEmail() .custom(async (value, { req }) => { const user = await query.user.find({ email: value }); - if (user) + if (user) return Promise.reject(); }) .withMessage("User already exists."), @@ -552,13 +591,14 @@ module.exports = { deleteUserByAdmin, editLink, getStats, - login, + login, newPassword, redirectProtected, removeDomain, removeDomainAdmin, reportLink, resetPassword, + updateDomainAdmin, signup, signupEmailTaken, -} \ No newline at end of file +} diff --git a/server/mail/mail.js b/server/mail/mail.js index 93e5304d7..d28acf92c 100644 --- a/server/mail/mail.js +++ b/server/mail/mail.js @@ -3,7 +3,8 @@ const path = require("node:path"); const fs = require("node:fs"); const { resetMailText, verifyMailText, changeEmailText } = require("./text"); -const { CustomError } = require("../utils"); +const utils = require("../utils"); +const { CustomError } = utils; const env = require("../env"); const mailConfig = { @@ -26,7 +27,7 @@ const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html"); const changeEmailTemplatePath = path.join(__dirname,"template-change-email.html"); -let resetEmailTemplate, +let resetEmailTemplate, verifyEmailTemplate, changeEmailTemplate; @@ -34,15 +35,15 @@ let resetEmailTemplate, if (env.MAIL_ENABLED) { resetEmailTemplate = fs .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" }) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) + .replace(/{{domain}}/gm, utils.getAdminDomain()) .replace(/{{site_name}}/gm, env.SITE_NAME); verifyEmailTemplate = fs .readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" }) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) + .replace(/{{domain}}/gm, utils.getAdminDomain()) .replace(/{{site_name}}/gm, env.SITE_NAME); changeEmailTemplate = fs .readFileSync(changeEmailTemplatePath, { encoding: "utf-8" }) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) + .replace(/{{domain}}/gm, utils.getAdminDomain()) .replace(/{{site_name}}/gm, env.SITE_NAME); } @@ -57,11 +58,11 @@ async function verification(user) { subject: "Verify your account", text: verifyMailText .replace(/{{verification}}/gim, user.verification_token) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) + .replace(/{{domain}}/gm, utils.getAdminDomain()) .replace(/{{site_name}}/gm, env.SITE_NAME), html: verifyEmailTemplate .replace(/{{verification}}/gim, user.verification_token) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) + .replace(/{{domain}}/gm, utils.getAdminDomain()) .replace(/{{site_name}}/gm, env.SITE_NAME) }); @@ -74,21 +75,21 @@ async function changeEmail(user) { if (!env.MAIL_ENABLED) { throw new Error("Attempting to send change email token but email is not enabled."); }; - + const mail = await transporter.sendMail({ from: env.MAIL_FROM || env.MAIL_USER, to: user.change_email_address, subject: "Verify your new email address", text: changeEmailText .replace(/{{verification}}/gim, user.change_email_token) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) + .replace(/{{domain}}/gm, utils.getAdminDomain()) .replace(/{{site_name}}/gm, env.SITE_NAME), html: changeEmailTemplate .replace(/{{verification}}/gim, user.change_email_token) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) + .replace(/{{domain}}/gm, utils.getAdminDomain()) .replace(/{{site_name}}/gm, env.SITE_NAME) }); - + if (!mail.accepted.length) { throw new CustomError("Couldn't send verification email. Try again later."); } @@ -105,10 +106,10 @@ async function resetPasswordToken(user) { subject: "Reset your password", text: resetMailText .replace(/{{resetpassword}}/gm, user.reset_password_token) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN), + .replace(/{{domain}}/gm, utils.getAdminDomain()), html: resetEmailTemplate .replace(/{{resetpassword}}/gm, user.reset_password_token) - .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) + .replace(/{{domain}}/gm, utils.getAdminDomain()) }); if (!mail.accepted.length) { diff --git a/server/migrations/20260223000000_add_is_global_to_domains.js b/server/migrations/20260223000000_add_is_global_to_domains.js new file mode 100644 index 000000000..0843d7fa8 --- /dev/null +++ b/server/migrations/20260223000000_add_is_global_to_domains.js @@ -0,0 +1,22 @@ +async function up(knex) { + const hasColumn = await knex.schema.hasColumn("domains", "is_global"); + if (!hasColumn) { + await knex.schema.alterTable("domains", table => { + table.boolean("is_global").notNullable().defaultTo(false); + }); + } +} + +async function down(knex) { + const hasColumn = await knex.schema.hasColumn("domains", "is_global"); + if (hasColumn) { + await knex.schema.alterTable("domains", table => { + table.dropColumn("is_global"); + }); + } +} + +module.exports = { + up, + down +}; diff --git a/server/models/domain.model.js b/server/models/domain.model.js index c867aea5b..ba3a337cc 100644 --- a/server/models/domain.model.js +++ b/server/models/domain.model.js @@ -30,8 +30,9 @@ async function createDomainTable(knex) { .uuid("uuid") .notNullable() .defaultTo(knex.fn.uuid()); + table.boolean("is_global").notNullable().defaultTo(false); table.timestamps(false, true); - + }); } } diff --git a/server/queries/domain.queries.js b/server/queries/domain.queries.js index d4d15674f..21f0770aa 100644 --- a/server/queries/domain.queries.js +++ b/server/queries/domain.queries.js @@ -35,7 +35,8 @@ async function add(params) { homepage: params.homepage, user_id: params.user_id, banned: !!params.banned, - banned_by_id: params.banned_by_id + banned_by_id: params.banned_by_id, + is_global: !!params.is_global }; if (id) { @@ -114,6 +115,7 @@ const selectable_admin = [ "domains.address", "domains.homepage", "domains.banned", + "domains.is_global", "domains.created_at", "domains.updated_at", "domains.user_id", @@ -140,11 +142,15 @@ async function getAdmin(match, params) { .groupBy("users.email"); if (params?.user) { - const id = parseInt(params?.user); - if (Number.isNaN(id)) { - query[knex.compatibleILIKE]("users.email", "%" + params.user + "%"); + if (params.user.toUpperCase() === "SYSTEM") { + query.whereNull("domains.user_id"); } else { - query.andWhere("domains.user_id", id); + const id = parseInt(params?.user); + if (Number.isNaN(id)) { + query[knex.compatibleILIKE]("users.email", "%" + params.user + "%"); + } else { + query.andWhere("domains.user_id", id); + } } } @@ -178,11 +184,15 @@ async function totalAdmin(match, params) { }); if (params?.user) { - const id = parseInt(params?.user); - if (Number.isNaN(id)) { - query[knex.compatibleILIKE]("users.email", "%" + params.user + "%"); + if (params.user.toUpperCase() === "SYSTEM") { + query.whereNull("domains.user_id"); + } else { + const id = parseInt(params?.user); + if (Number.isNaN(id)) { + query[knex.compatibleILIKE]("users.email", "%" + params.user + "%"); } else { - query.andWhere("domains.user_id", id); + query.andWhere("domains.user_id", id); + } } } diff --git a/server/routes/domain.routes.js b/server/routes/domain.routes.js index bc5647824..4b3c26fd0 100644 --- a/server/routes/domain.routes.js +++ b/server/routes/domain.routes.js @@ -62,6 +62,17 @@ router.delete( asyncHandler(domains.removeAdmin) ); +router.patch( + "/admin/:id", + locals.viewTemplate("partials/admin/domains/edit"), + asyncHandler(auth.apikey), + asyncHandler(auth.jwt), + asyncHandler(auth.admin), + validators.updateDomainAdmin, + asyncHandler(helpers.verify), + asyncHandler(domains.updateAdmin) +); + router.post( "/admin/ban/:id", locals.viewTemplate("partials/admin/dialog/ban_domain"), diff --git a/server/routes/renders.routes.js b/server/routes/renders.routes.js index ad949b6ee..b4fce2170 100644 --- a/server/routes/renders.routes.js +++ b/server/routes/renders.routes.js @@ -203,10 +203,18 @@ router.get( "/admin/link/edit/:id", locals.noLayout, asyncHandler(auth.jwt), - asyncHandler(auth.admin), + asyncHandler(auth.admin), asyncHandler(renders.linkEditAdmin) ); +router.get( + "/admin/domain/edit/:id", + locals.noLayout, + asyncHandler(auth.jwt), + asyncHandler(auth.admin), + asyncHandler(renders.domainEditAdmin) +); + router.get( "/add-domain-form", locals.noLayout, diff --git a/server/server.js b/server/server.js index e1988dc7b..63a8618e0 100644 --- a/server/server.js +++ b/server/server.js @@ -16,7 +16,6 @@ const links = require("./handlers/links.handler"); const routes = require("./routes"); const utils = require("./utils"); - // run the cron jobs // the app might be running in cluster mode (multiple instances) so run the cron job only on one cluster (the first one) // NODE_APP_INSTANCE variable is added by pm2 automatically, if you're using something else to cluster your app, then make sure to set this variable @@ -86,7 +85,7 @@ app.get("*", renders.notFound); // handle errors coming from above routes app.use(helpers.error); - + app.listen(env.PORT, () => { console.log(`> Ready on http://localhost:${env.PORT}`); }); diff --git a/server/utils/utils.js b/server/utils/utils.js index 0203b811f..8ec3d7bd2 100644 --- a/server/utils/utils.js +++ b/server/utils/utils.js @@ -99,7 +99,7 @@ function statsObjectToArray(obj) { value: obj[key][name] })) .sort((a, b) => b.value - a.value); - + return { browser: objToArr("browser"), os: objToArr("os"), @@ -135,12 +135,12 @@ function dateToUTC(date) { if (knex.isSQLite) { return dateUTC.substring(0, 10) + " " + dateUTC.substring(11, 19); } - + // mysql doesn't save time in utc, so format the date in local timezone instead if (knex.isMySQL) { return format(new Date(date), "yyyy-MM-dd HH:mm:ss"); } - + // return unformatted utc string for postgres return dateUTC; } @@ -334,9 +334,16 @@ const sanitize = { }, domain_admin: domain => { const timestamps = parseTimestamps(domain); + const is_global = !!domain.is_global; + const is_default = domain.address.toLowerCase() === env.DEFAULT_DOMAIN.trim().toLowerCase(); + const is_admin = !!(env.ADMIN_DOMAIN && domain.address.toLowerCase() === env.ADMIN_DOMAIN.trim().toLowerCase()); return { ...domain, ...timestamps, + is_global, + is_default, + is_admin, + is_protected: is_default || is_admin, links_count: (domain.links_count ?? 0).toLocaleString("en-US"), relative_created_at: getTimeAgo(timestamps.created_at), relative_updated_at: getTimeAgo(timestamps.updated_at), @@ -360,7 +367,7 @@ function registerHandlebarsHelpers() { hbs.registerHelper("json", function(context) { return JSON.stringify(context); }); - + const blocks = {}; hbs.registerHelper("extend", function(name, context) { @@ -404,6 +411,16 @@ function getCustomCSSFileNames() { return custom_css_file_names; } +function getAdminDomain() { + return env.ADMIN_DOMAIN || env.DEFAULT_DOMAIN; +} + +function isSystemDomain(address) { + const normalized = address.trim().toLowerCase(); + return normalized === env.DEFAULT_DOMAIN.trim().toLowerCase() + || (env.ADMIN_DOMAIN && normalized === env.ADMIN_DOMAIN.trim().toLowerCase()); +} + module.exports = { addProtocol, customAddressRegex, @@ -413,6 +430,7 @@ module.exports = { deleteCurrentToken, generateId, generateRandomPassword, + getAdminDomain, getCustomCSSFileNames, getDifferenceFunction, getInitStats, @@ -420,6 +438,7 @@ module.exports = { getShortURL, getStatsPeriods, isAdmin, + isSystemDomain, parseBooleanQuery, parseDatetime, parseTimestamps, @@ -433,4 +452,4 @@ module.exports = { statsObjectToArray, urlRegex, ...knexUtils, -} \ No newline at end of file +} diff --git a/server/views/banned.hbs b/server/views/banned.hbs index a2434f312..6b7fe29c8 100644 --- a/server/views/banned.hbs +++ b/server/views/banned.hbs @@ -1,14 +1,14 @@ {{> header}}

- Link has been banned and removed because of + Link has been banned and removed because of malware or scam.

- If you noticed a malware/scam link shortened by {{default_domain}}, + If you noticed a malware/scam link shortened by {{admin_domain}}, send us a report .

-{{> footer}} \ No newline at end of file +{{> footer}} diff --git a/server/views/partials/admin/dialog/add_domain.hbs b/server/views/partials/admin/dialog/add_domain.hbs index 2b5b5c14c..ed2dc0ce2 100644 --- a/server/views/partials/admin/dialog/add_domain.hbs +++ b/server/views/partials/admin/dialog/add_domain.hbs @@ -30,11 +30,20 @@ {{#if errors.homepage}}

{{errors.homepage}}

{{/if}} +