Skip to content

Commit 1cc7a80

Browse files
authored
Merge pull request #792 from thedevs-network/develop
Add themes, and custom alphabet and trust proxy configuration
2 parents 77114f5 + 318f754 commit 1cc7a80

33 files changed

+223
-84
lines changed

.example.env

+9
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ DB_POOL_MAX=10
2626
# Optional - Generated link length
2727
LINK_LENGTH=6
2828

29+
# Optional - Alphabet used to generate custom addresses
30+
# Default value omits o, O, 0, i, I, l, 1, and j to avoid confusion when reading the URL
31+
LINK_CUSTOM_ALPHABET=abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789
32+
33+
# Optional - Tells the app that it's running behind a proxy server
34+
# and that it should get the IP address from that proxy server
35+
# if you're not using a proxy server then set this to false, otherwise users can override their IP address
36+
TRUST_PROXY=true
37+
2938
# Optional - Redis host and port
3039
REDIS_ENABLED=false
3140
REDIS_HOST=127.0.0.1

README.md

+73-3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- [Docker](#docker)
2121
- [API](#api)
2222
- [Configuration](#configuration)
23+
- [Themes and customizations](#themes-and-customizations)
2324
- [Browser extensions](#browser-extensions)
2425
- [Videos](#videos)
2526
- [Integrations](#integrations)
@@ -93,8 +94,10 @@ All variables are optional except `JWT_SECRET` which is required on production.
9394
| `SITE_NAME` | Name of the website | `Kutt` | `Your Site` |
9495
| `DEFAULT_DOMAIN` | The domain address that this app runs on | `localhost:3000` | `yoursite.com` |
9596
| `LINK_LENGTH` | The length of of shortened address | `6` | `5` |
97+
| `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^&*()@` |
9698
| `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` |
9799
| `DISALLOW_ANONYMOUS_LINKS` | Disable anonymous link creation | `true` | `false` |
100+
| `TRUST_PROXY` | If the app is running behind a proxy server like NGINX or Cloudflare and that it should get the IP address from that proxy server. If you're not using a proxy server then set this to false, otherwise users can override their IP address. | `true` | `false` |
98101
| `DB_CLIENT` | Which database client to use. Supported clients: `pg` or `pg-native` for Postgres, `mysql2` for MySQL or MariaDB, `sqlite3` and `better-sqlite3` for SQLite. NOTE: `pg-native` and `better-sqlite3` are not installed by default, use `npm` to install them before use. | `sqlite3` | `pg` |
99102
| `DB_HOST` | Database connection host. Only if you use Postgres or MySQL. | `localhost` | `your-db-host.com` |
100103
| `DB_PORT` | Database port. Only if you use Postgres or MySQL. | `5432` (Postgres) | `3306` (MySQL) |
@@ -118,10 +121,77 @@ All variables are optional except `JWT_SECRET` which is required on production.
118121
| `MAIL_PORT` | Email server port | `587` | `465` (SSL) |
119122
| `MAIL_USER` | Email server user | - | `myuser` |
120123
| `MAIL_PASSWORD` | Email server password for the user | - | `mypassword` |
121-
| `MAIL_FROM` | Email address to send the user from | - | `some.address@yoursite.com` |
124+
| `MAIL_FROM` | Email address to send the user from | - | `example@yoursite.com` |
122125
| `MAIL_SECURE` | Whether use SSL for the email server connection | `false` | `true` |
123-
| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `[email protected]` |
124-
| `CONTACT_EMAIL` | The support email address to show on the app | - | `[email protected]` |
126+
| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `[email protected]` |
127+
| `CONTACT_EMAIL` | The support email address to show on the app | - | `[email protected]` |
128+
129+
## Themes and customizations
130+
131+
You can add styles, change images, or render custom HTML. Place your content inside the [`/custom`](./custom) folder according to below instructions.
132+
133+
#### How it works:
134+
135+
The structure of the custom folder is like this:
136+
137+
```
138+
custom/
139+
├─ css/
140+
│ ├─ custom1.css
141+
│ ├─ custom2.css
142+
│ ├─ ...
143+
├─ images/
144+
│ ├─ logo.png
145+
│ ├─ favicon.ico
146+
│ ├─ ...
147+
├─ views/
148+
│ ├─ partials/
149+
│ │ ├─ footer.hbs
150+
│ ├─ 404.hbs
151+
│ ├─ ...
152+
```
153+
154+
- **css**: Put your CSS style files here. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/css))
155+
- You can put as many style files as you want: `custom1.css`, `custom2.css`, etc.
156+
- If you name your style file `styles.css`, it will replace Kutt's original `styles.css` file.
157+
- Each file will be accessible by `<your-site.com>/css/<file>.css`
158+
- **images**: Put your images here. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/images))
159+
- Name them just like the files inside the [`/static/images/`](./static/images) folder to replace Kutt's original images.
160+
- Each image will be accessible by `<your-site.com>/images/<image>.<image-format>`
161+
- **views**: Custom HTML templates to render. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/views))
162+
- It should follow the same file naming and folder structure as [`/server/views`](./server/views)
163+
- Although we try to keep the original file names unchanged, be aware that new changes on Kutt might break your custom views.
164+
165+
#### Example theme: Crimson
166+
167+
This is an example and official theme. Crimson includes custom styles, images, and views.
168+
169+
[Get Crimson theme →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson)
170+
171+
[View list of themes and customizations →](https://github.com/thedevs-network/kutt-customizations)
172+
173+
174+
| Homepage | Admin page | Login/signup |
175+
| -------- | ---------- | ------------ |
176+
| ![crimson-homepage](https://github.com/user-attachments/assets/b74fab78-5e80-4f57-8425-f0cc73e9c68d) | ![crimson-admin](https://github.com/user-attachments/assets/a75d2430-8074-4ce4-93ec-d8bdfd75d917) | ![crimson-login-signup ](https://github.com/user-attachments/assets/b915eb77-3d66-4407-8e5d-b556f80ff453)
177+
178+
#### Usage with Docker:
179+
180+
If you're building the image locally, then the `/custom` folder should already be included in your app.
181+
182+
If you're pulling the official image, make sure `/kutt/custom` volume is mounted or you have access to it. [View Docker compose example →](https://github.com/thedevs-network/kutt/blob/main/docker-compose.yml#L7)
183+
184+
Then, move your files to that volume. You can do it with this Docker command:
185+
186+
```sh
187+
docker cp <path-to-custom-folder> <kutt-container-name>:/kutt
188+
```
189+
190+
For example:
191+
192+
```sh
193+
docker cp custom kutt-server-1:/kutt
194+
```
125195

126196
## Browser extensions
127197

custom/.gitkeep

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# keep this folder in git
2+
# put supported customization files for styles and such
3+
# if you're using docker make sure to mount this folder

docker-compose.mariadb.yml

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ services:
22
server:
33
build:
44
context: .
5+
volumes:
6+
- custom:/kutt/custom
57
environment:
68
DB_CLIENT: mysql2
79
DB_HOST: mariadb
@@ -39,4 +41,5 @@ services:
3941
expose:
4042
- 6379
4143
volumes:
42-
db_data_mariadb:
44+
db_data_mariadb:
45+
custom:

docker-compose.postgres.yml

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ services:
22
server:
33
build:
44
context: .
5+
volumes:
6+
- custom:/kutt/custom
57
environment:
68
DB_CLIENT: pg
79
DB_HOST: postgres
10+
DB_PORT: 5432
811
REDIS_ENABLED: true
912
REDIS_HOST: redis
1013
REDIS_PORT: 6379
@@ -37,4 +40,5 @@ services:
3740
expose:
3841
- 6379
3942
volumes:
40-
db_data_pg:
43+
db_data_pg:
44+
custom:

docker-compose.sqlite-redis.yml

+4-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ services:
33
build:
44
context: .
55
volumes:
6-
- db-data:/var/lib/kutt
6+
- db_data_sqlite:/var/lib/kutt
7+
- custom:/kutt/custom
78
environment:
89
DB_FILENAME: "/var/lib/kutt/data.sqlite"
910
REDIS_ENABLED: true
@@ -20,4 +21,5 @@ services:
2021
expose:
2122
- 6379
2223
volumes:
23-
db-data:
24+
db_data_sqlite:
25+
custom:

docker-compose.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ services:
44
context: .
55
volumes:
66
- db_data_sqlite:/var/lib/kutt
7+
- custom:/kutt/custom
78
environment:
89
DB_FILENAME: "/var/lib/kutt/data.sqlite"
910
ports:
1011
- 3000:3000
1112
volumes:
12-
db_data_sqlite:
13+
db_data_sqlite:
14+
custom:

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"name": "kutt",
3-
"version": "3.0.4",
3+
"version": "3.1.0",
44
"description": "Modern URL shortener.",
55
"main": "./server/server.js",
66
"scripts": {
7-
"dev": "cross-env NODE_ENV=development node --watch-path=./server server/server.js",
7+
"dev": "cross-env NODE_ENV=development node --watch-path=./server --watch-path=./custom server/server.js",
88
"start": "cross-env NODE_ENV=production node server/server.js",
99
"migrate": "knex migrate:latest",
1010
"migrate:make": "knex migrate:make",

server/env.js

+7
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@ const supportedDBClients = [
1010
"mysql2"
1111
];
1212

13+
// make sure custom alphabet is not empty
14+
if (process.env.LINK_CUSTOM_ALPHABET === "") {
15+
delete process.env.LINK_CUSTOM_ALPHABET;
16+
}
17+
1318
const env = cleanEnv(process.env, {
1419
PORT: num({ default: 3000 }),
1520
SITE_NAME: str({ example: "Kutt", default: "Kutt" }),
1621
DEFAULT_DOMAIN: str({ example: "kutt.it", default: "localhost:3000" }),
1722
LINK_LENGTH: num({ default: 6 }),
23+
LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }),
24+
TRUST_PROXY: bool({ default: true }),
1825
DB_CLIENT: str({ choices: supportedDBClients, default: "sqlite3" }),
1926
DB_FILENAME: str({ default: "db/data" }),
2027
DB_HOST: str({ default: "localhost" }),

server/handlers/links.handler.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ async function editAdmin(req, res) {
338338
res.render("partials/admin/links/edit", {
339339
swap_oob: true,
340340
success: "Link has been updated.",
341-
...utils.sanitize.linkAdmin({ ...updatedLink }),
341+
...utils.sanitize.link_admin({ ...updatedLink }),
342342
});
343343
return;
344344
}

server/handlers/locals.handler.js

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function config(req, res, next) {
2929
res.locals.disallow_registration = env.DISALLOW_REGISTRATION;
3030
res.locals.mail_enabled = env.MAIL_ENABLED;
3131
res.locals.report_email = env.REPORT_EMAIL;
32+
res.locals.custom_styles = utils.getCustomCSSFileNames();
3233
next();
3334
}
3435

server/handlers/validators.handler.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const createLink = [
4545
.trim()
4646
.isLength({ min: 1, max: 64 })
4747
.withMessage("Custom URL length must be between 1 and 64.")
48-
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
48+
.custom(value => utils.customAddressRegex.test(value) || utils.customAlphabetRegex.test(value))
4949
.withMessage("Custom URL is not valid.")
5050
.custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value))
5151
.withMessage("You can't use this custom URL."),
@@ -120,7 +120,7 @@ const editLink = [
120120
.trim()
121121
.isLength({ min: 1, max: 64 })
122122
.withMessage("Custom URL length must be between 1 and 64.")
123-
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
123+
.custom(value => utils.customAddressRegex.test(value) || utils.customAlphabetRegex.test(value))
124124
.withMessage("Custom URL is not valid")
125125
.custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value))
126126
.withMessage("You can't use this custom URL."),

server/server.js

+13-6
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,33 @@ require("./passport");
2929
// create express app
3030
const app = express();
3131

32-
// this tells the express app that the app is running behind a proxy server
32+
// this tells the express app that it's running behind a proxy server
3333
// and thus it should get the IP address from the proxy server
34-
// IMPORTANT: users might be able to override their IP address and this
35-
// might allow users to bypass the rate limit or lead to incorrect link stats
36-
// read the Kutt documentation to learn how prevent users from changing their real IP address
37-
app.set("trust proxy", true);
34+
if (env.TRUST_PROXY) {
35+
app.set("trust proxy", true);
36+
}
3837

3938
app.use(helmet({ contentSecurityPolicy: false }));
4039
app.use(cookieParser());
4140
app.use(express.json());
4241
app.use(express.urlencoded({ extended: true }));
42+
43+
// serve static
44+
app.use("/images", express.static("custom/images"));
45+
app.use("/css", express.static("custom/css", { extensions: ["css"] }));
4346
app.use(express.static("static"));
4447

4548
app.use(passport.initialize());
4649
app.use(locals.isHTML);
4750
app.use(locals.config);
4851

4952
// template engine / serve html
53+
5054
app.set("view engine", "hbs");
51-
app.set("views", path.join(__dirname, "views"));
55+
app.set("views", [
56+
path.join(__dirname, "../custom/views"),
57+
path.join(__dirname, "views"),
58+
]);
5259
utils.registerHandlebarsHelpers();
5360

5461
// if is custom domain, redirect to the set homepage

server/utils/utils.js

+37-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears, format } = require("date-fns");
22
const { customAlphabet } = require("nanoid");
33
const JWT = require("jsonwebtoken");
4-
const path = require("path");
4+
const path = require("node:path");
5+
const fs = require("node:fs");
56
const hbs = require("hbs");
67
const ms = require("ms");
78

@@ -10,10 +11,7 @@ const knexUtils = require("./knex");
1011
const knex = require("../knex");
1112
const env = require("../env");
1213

13-
const nanoid = customAlphabet(
14-
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
15-
env.LINK_LENGTH
16-
);
14+
const nanoid = customAlphabet(env.LINK_CUSTOM_ALPHABET, env.LINK_LENGTH);
1715

1816
class CustomError extends Error {
1917
constructor(message, statusCode, data) {
@@ -26,6 +24,12 @@ class CustomError extends Error {
2624

2725
const urlRegex = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i;
2826

27+
const charsNeedEscapeInRegExp = ".$*+?()[]{}|^-";
28+
const customAlphabetEscaped = env.LINK_CUSTOM_ALPHABET
29+
.split("").map(c => charsNeedEscapeInRegExp.includes(c) ? "\\" + c : c).join("");
30+
const customAlphabetRegex = new RegExp(`^[${customAlphabetEscaped}_-]+$`);
31+
const customAddressRegex = new RegExp("^[a-zA-Z0-9-_]+$");
32+
2933
function isAdmin(user) {
3034
return user.role === ROLES.ADMIN;
3135
}
@@ -360,14 +364,42 @@ function registerHandlebarsHelpers() {
360364
return val;
361365
});
362366
hbs.registerPartials(path.join(__dirname, "../views/partials"), function (err) {});
367+
const customPartialsPath = path.join(__dirname, "../../custom/views/partials");
368+
const customPartialsExist = fs.existsSync(customPartialsPath);
369+
if (customPartialsExist) {
370+
hbs.registerPartials(customPartialsPath, function (err) {});
371+
}
372+
}
373+
374+
// grab custom styles file name from the custom/css folder
375+
const custom_css_file_names = [];
376+
const customCSSPath = path.join(__dirname, "../../custom/css");
377+
const customCSSExists = fs.existsSync(customCSSPath);
378+
if (customCSSExists) {
379+
fs.readdir(customCSSPath, function(error, files) {
380+
if (error) {
381+
console.warn("Could not read the custom CSS folder:", error);
382+
} else {
383+
files.forEach(function(file_name) {
384+
custom_css_file_names.push(file_name);
385+
});
386+
}
387+
})
388+
}
389+
390+
function getCustomCSSFileNames() {
391+
return custom_css_file_names;
363392
}
364393

365394
module.exports = {
366395
addProtocol,
396+
customAddressRegex,
397+
customAlphabetRegex,
367398
CustomError,
368399
dateToUTC,
369400
deleteCurrentToken,
370401
generateId,
402+
getCustomCSSFileNames,
371403
getDifferenceFunction,
372404
getInitStats,
373405
getShortURL,

0 commit comments

Comments
 (0)