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
.
{{errors.homepage}}
{{/if}} +