Skip to content

WIP - Add "admin" and "other global" domains #918

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
9 changes: 8 additions & 1 deletion .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ 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=

# Optional - Comma-separated list of domains available to all users for link shortening.
OTHER_GLOBAL_DOMAINS=

# Required - A passphrase to encrypt JWT. Use a random long string
JWT_SECRET=

Expand Down
26 changes: 14 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,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 @@ -86,7 +86,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 @@ -95,7 +95,9 @@ 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 (if multiple domains are configured - see also `OTHER_GLOBAL_DOMAINS`). 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` |
| `OTHER_GLOBAL_DOMAINS` | Comma-separated domains available globally for link shortening (in addition to `DEFAULT_DOMAIN`) | `""` | `my.site,another.site` |
| `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 @@ -120,15 +122,15 @@ 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 | - | `[email protected]` |
| `MAIL_SECURE` | Whether use SSL for the email server connection | `false` | `true` |
| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `[email protected]` |
| `CONTACT_EMAIL` | The support email address to show on the app | - | `[email protected]` |
| `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 | - | `[email protected]` |
| `MAIL_SECURE` | Whether use SSL for the email server connection | `false` | `true` |
| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `[email protected]` |
| `CONTACT_EMAIL` | The support email address to show on the app | - | `[email protected]` |

## Themes and customizations

Expand Down Expand Up @@ -165,7 +167,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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ 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: "" }),
OTHER_GLOBAL_DOMAINS: str({ example: "kutt.example.org,kutt.example.com", default: "" }),
LINK_LENGTH: num({ default: 6 }),
LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }),
TRUST_PROXY: bool({ default: true }),
Expand Down
50 changes: 25 additions & 25 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 @@ -99,9 +99,9 @@ async function getAdmin(req, res) {
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({
Expand All @@ -118,13 +118,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 +145,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 +172,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 +206,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 +265,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 +299,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 +382,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 +492,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 +659,4 @@ module.exports = {
redirect,
redirectProtected,
redirectCustomDomainHomepage,
}
}
22 changes: 20 additions & 2 deletions server/handlers/locals.handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,13 +31,30 @@ function config(req, res, next) {
res.locals.mail_enabled = env.MAIL_ENABLED;
res.locals.report_email = env.REPORT_EMAIL;
res.locals.custom_styles = utils.getCustomCSSFileNames();
res.locals.other_global_domains = utils.getGlobalDomains();
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 globalDomains = utils.getGlobalDomains().filter(d => d !== defaultDomain);
const userDomainAddresses = userDomains.map(d => d.address);

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();
}

Expand Down Expand Up @@ -86,4 +104,4 @@ module.exports = {
protected,
user,
viewTemplate,
}
}
46 changes: 26 additions & 20 deletions server/handlers/validators.handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,29 @@ 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 }) => {
let domain;
if (!address) {
domain = await query.domain.find({ address: env.DEFAULT_DOMAIN.toLowerCase() });
} else {
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 = [
Expand Down Expand Up @@ -350,7 +356,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."),
Expand Down Expand Up @@ -552,7 +558,7 @@ module.exports = {
deleteUserByAdmin,
editLink,
getStats,
login,
login,
newPassword,
redirectProtected,
removeDomain,
Expand All @@ -561,4 +567,4 @@ module.exports = {
resetPassword,
signup,
signupEmailTaken,
}
}
Loading