Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
82 changes: 64 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`.

Expand All @@ -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` |
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions server/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
50 changes: 48 additions & 2 deletions server/handlers/domains.handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
});

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -203,11 +212,48 @@ 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,
ban,
getAdmin,
remove,
removeAdmin,
updateAdmin,
}
53 changes: 27 additions & 26 deletions server/handlers/links.handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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({
Expand All @@ -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.";
Expand All @@ -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 }));
Expand All @@ -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];
Expand All @@ -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;

Expand Down Expand Up @@ -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];
Expand All @@ -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;

Expand Down Expand Up @@ -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." });
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -659,4 +660,4 @@ module.exports = {
redirect,
redirectProtected,
redirectCustomDomainHomepage,
}
}
Loading