diff --git a/.example.env b/.example.env index 0555dfb..90564f6 100644 --- a/.example.env +++ b/.example.env @@ -26,6 +26,15 @@ DB_POOL_MAX=10 # Optional - Generated link length LINK_LENGTH=6 +# Optional - 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 +LINK_CUSTOM_ALPHABET=abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789 + +# Optional - Tells the app that it's running behind a proxy server +# 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 +TRUST_PROXY=true + # Optional - Redis host and port REDIS_ENABLED=false REDIS_HOST=127.0.0.1 diff --git a/README.md b/README.md index 0b34980..2e0feda 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [Docker](#docker) - [API](#api) - [Configuration](#configuration) +- [Themes and customizations](#themes-and-customizations) - [Browser extensions](#browser-extensions) - [Videos](#videos) - [Integrations](#integrations) @@ -93,8 +94,10 @@ All variables are optional except `JWT_SECRET` which is required on production. | `SITE_NAME` | Name of the website | `Kutt` | `Your Site` | | `DEFAULT_DOMAIN` | The domain address that this app runs on | `localhost:3000` | `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` | | `DISALLOW_ANONYMOUS_LINKS` | Disable anonymous link creation | `true` | `false` | +| `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` | | `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` | | `DB_HOST` | Database connection host. Only if you use Postgres or MySQL. | `localhost` | `your-db-host.com` | | `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. | `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 | - | `some.address@yoursite.com` | +| `MAIL_FROM` | Email address to send the user from | - | `example@yoursite.com` | | `MAIL_SECURE` | Whether use SSL for the email server connection | `false` | `true` | -| `REPORT_EMAIL` | The email address that will receive submitted reports | - | `some.address@yoursite.com` | -| `CONTACT_EMAIL` | The support email address to show on the app | - | `some.address@yoursite.com` | +| `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` | + +## Themes and customizations + +You can add styles, change images, or render custom HTML. Place your content inside the [`/custom`](./custom) folder according to below instructions. + +#### How it works: + +The structure of the custom folder is like this: + +``` +custom/ +├─ css/ +│ ├─ custom1.css +│ ├─ custom2.css +│ ├─ ... +├─ images/ +│ ├─ logo.png +│ ├─ favicon.ico +│ ├─ ... +├─ views/ +│ ├─ partials/ +│ │ ├─ footer.hbs +│ ├─ 404.hbs +│ ├─ ... +``` + +- **css**: Put your CSS style files here. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/css)) + - You can put as many style files as you want: `custom1.css`, `custom2.css`, etc. + - If you name your style file `styles.css`, it will replace Kutt's original `styles.css` file. + - Each file will be accessible by `/css/.css` +- **images**: Put your images here. ([View example →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson/images)) + - Name them just like the files inside the [`/static/images/`](./static/images) folder to replace Kutt's original images. + - Each image will be accessible by `/images/.` +- **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. + +[Get Crimson theme →](https://github.com/thedevs-network/kutt-customizations/tree/main/themes/crimson) + +[View list of themes and customizations →](https://github.com/thedevs-network/kutt-customizations) + + +| Homepage | Admin page | Login/signup | +| -------- | ---------- | ------------ | +| ![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) + +#### Usage with Docker: + +If you're building the image locally, then the `/custom` folder should already be included in your app. + +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) + +Then, move your files to that volume. You can do it with this Docker command: + +```sh +docker cp :/kutt +``` + +For example: + +```sh +docker cp custom kutt-server-1:/kutt +``` ## Browser extensions diff --git a/custom/.gitkeep b/custom/.gitkeep new file mode 100644 index 0000000..110e92f --- /dev/null +++ b/custom/.gitkeep @@ -0,0 +1,3 @@ +# keep this folder in git +# put supported customization files for styles and such +# if you're using docker make sure to mount this folder \ No newline at end of file diff --git a/docker-compose.mariadb.yml b/docker-compose.mariadb.yml index 96e11a0..230c628 100644 --- a/docker-compose.mariadb.yml +++ b/docker-compose.mariadb.yml @@ -2,6 +2,8 @@ services: server: build: context: . + volumes: + - custom:/kutt/custom environment: DB_CLIENT: mysql2 DB_HOST: mariadb @@ -39,4 +41,5 @@ services: expose: - 6379 volumes: - db_data_mariadb: \ No newline at end of file + db_data_mariadb: + custom: \ No newline at end of file diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml index d412726..c17ff0a 100644 --- a/docker-compose.postgres.yml +++ b/docker-compose.postgres.yml @@ -2,9 +2,12 @@ services: server: build: context: . + volumes: + - custom:/kutt/custom environment: DB_CLIENT: pg DB_HOST: postgres + DB_PORT: 5432 REDIS_ENABLED: true REDIS_HOST: redis REDIS_PORT: 6379 @@ -37,4 +40,5 @@ services: expose: - 6379 volumes: - db_data_pg: \ No newline at end of file + db_data_pg: + custom: \ No newline at end of file diff --git a/docker-compose.sqlite-redis.yml b/docker-compose.sqlite-redis.yml index 6b2d8bd..13ba3ff 100644 --- a/docker-compose.sqlite-redis.yml +++ b/docker-compose.sqlite-redis.yml @@ -3,7 +3,8 @@ services: build: context: . volumes: - - db-data:/var/lib/kutt + - db_data_sqlite:/var/lib/kutt + - custom:/kutt/custom environment: DB_FILENAME: "/var/lib/kutt/data.sqlite" REDIS_ENABLED: true @@ -20,4 +21,5 @@ services: expose: - 6379 volumes: - db-data: \ No newline at end of file + db_data_sqlite: + custom: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 01e7fe6..b2aa5c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,11 @@ services: context: . volumes: - db_data_sqlite:/var/lib/kutt + - custom:/kutt/custom environment: DB_FILENAME: "/var/lib/kutt/data.sqlite" ports: - 3000:3000 volumes: - db_data_sqlite: \ No newline at end of file + db_data_sqlite: + custom: \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8322e6c..8333acf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kutt", - "version": "3.0.4", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kutt", - "version": "3.0.4", + "version": "3.1.0", "license": "MIT", "dependencies": { "bcryptjs": "2.4.3", diff --git a/package.json b/package.json index f06045a..a928400 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "kutt", - "version": "3.0.4", + "version": "3.1.0", "description": "Modern URL shortener.", "main": "./server/server.js", "scripts": { - "dev": "cross-env NODE_ENV=development node --watch-path=./server server/server.js", + "dev": "cross-env NODE_ENV=development node --watch-path=./server --watch-path=./custom server/server.js", "start": "cross-env NODE_ENV=production node server/server.js", "migrate": "knex migrate:latest", "migrate:make": "knex migrate:make", diff --git a/server/env.js b/server/env.js index 8cdab40..6eb9547 100644 --- a/server/env.js +++ b/server/env.js @@ -10,11 +10,18 @@ const supportedDBClients = [ "mysql2" ]; +// make sure custom alphabet is not empty +if (process.env.LINK_CUSTOM_ALPHABET === "") { + delete process.env.LINK_CUSTOM_ALPHABET; +} + const env = cleanEnv(process.env, { PORT: num({ default: 3000 }), SITE_NAME: str({ example: "Kutt", default: "Kutt" }), DEFAULT_DOMAIN: str({ example: "kutt.it", default: "localhost:3000" }), LINK_LENGTH: num({ default: 6 }), + LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }), + TRUST_PROXY: bool({ default: true }), DB_CLIENT: str({ choices: supportedDBClients, default: "sqlite3" }), DB_FILENAME: str({ default: "db/data" }), DB_HOST: str({ default: "localhost" }), diff --git a/server/handlers/links.handler.js b/server/handlers/links.handler.js index 58b94e6..86c8772 100644 --- a/server/handlers/links.handler.js +++ b/server/handlers/links.handler.js @@ -338,7 +338,7 @@ async function editAdmin(req, res) { res.render("partials/admin/links/edit", { swap_oob: true, success: "Link has been updated.", - ...utils.sanitize.linkAdmin({ ...updatedLink }), + ...utils.sanitize.link_admin({ ...updatedLink }), }); return; } diff --git a/server/handlers/locals.handler.js b/server/handlers/locals.handler.js index 17a4655..7b1b4ab 100644 --- a/server/handlers/locals.handler.js +++ b/server/handlers/locals.handler.js @@ -29,6 +29,7 @@ function config(req, res, next) { res.locals.disallow_registration = env.DISALLOW_REGISTRATION; res.locals.mail_enabled = env.MAIL_ENABLED; res.locals.report_email = env.REPORT_EMAIL; + res.locals.custom_styles = utils.getCustomCSSFileNames(); next(); } diff --git a/server/handlers/validators.handler.js b/server/handlers/validators.handler.js index bef3fe1..4ba29c6 100644 --- a/server/handlers/validators.handler.js +++ b/server/handlers/validators.handler.js @@ -45,7 +45,7 @@ const createLink = [ .trim() .isLength({ min: 1, max: 64 }) .withMessage("Custom URL length must be between 1 and 64.") - .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value)) + .custom(value => utils.customAddressRegex.test(value) || utils.customAlphabetRegex.test(value)) .withMessage("Custom URL is not valid.") .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value)) .withMessage("You can't use this custom URL."), @@ -120,7 +120,7 @@ const editLink = [ .trim() .isLength({ min: 1, max: 64 }) .withMessage("Custom URL length must be between 1 and 64.") - .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value)) + .custom(value => utils.customAddressRegex.test(value) || utils.customAlphabetRegex.test(value)) .withMessage("Custom URL is not valid") .custom(value => !utils.preservedURLs.some(url => url.toLowerCase() === value)) .withMessage("You can't use this custom URL."), diff --git a/server/server.js b/server/server.js index e353500..343dff5 100644 --- a/server/server.js +++ b/server/server.js @@ -29,17 +29,20 @@ require("./passport"); // create express app const app = express(); -// this tells the express app that the app is running behind a proxy server +// this tells the express app that it's running behind a proxy server // and thus it should get the IP address from the proxy server -// IMPORTANT: users might be able to override their IP address and this -// might allow users to bypass the rate limit or lead to incorrect link stats -// read the Kutt documentation to learn how prevent users from changing their real IP address -app.set("trust proxy", true); +if (env.TRUST_PROXY) { + app.set("trust proxy", true); +} app.use(helmet({ contentSecurityPolicy: false })); app.use(cookieParser()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); + +// serve static +app.use("/images", express.static("custom/images")); +app.use("/css", express.static("custom/css", { extensions: ["css"] })); app.use(express.static("static")); app.use(passport.initialize()); @@ -47,8 +50,12 @@ app.use(locals.isHTML); app.use(locals.config); // template engine / serve html + app.set("view engine", "hbs"); -app.set("views", path.join(__dirname, "views")); +app.set("views", [ + path.join(__dirname, "../custom/views"), + path.join(__dirname, "views"), +]); utils.registerHandlebarsHelpers(); // if is custom domain, redirect to the set homepage diff --git a/server/utils/utils.js b/server/utils/utils.js index 25285cd..2095378 100644 --- a/server/utils/utils.js +++ b/server/utils/utils.js @@ -1,7 +1,8 @@ const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears, format } = require("date-fns"); const { customAlphabet } = require("nanoid"); const JWT = require("jsonwebtoken"); -const path = require("path"); +const path = require("node:path"); +const fs = require("node:fs"); const hbs = require("hbs"); const ms = require("ms"); @@ -10,10 +11,7 @@ const knexUtils = require("./knex"); const knex = require("../knex"); const env = require("../env"); -const nanoid = customAlphabet( - "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789", - env.LINK_LENGTH -); +const nanoid = customAlphabet(env.LINK_CUSTOM_ALPHABET, env.LINK_LENGTH); class CustomError extends Error { constructor(message, statusCode, data) { @@ -26,6 +24,12 @@ class CustomError extends Error { 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; +const charsNeedEscapeInRegExp = ".$*+?()[]{}|^-"; +const customAlphabetEscaped = env.LINK_CUSTOM_ALPHABET + .split("").map(c => charsNeedEscapeInRegExp.includes(c) ? "\\" + c : c).join(""); +const customAlphabetRegex = new RegExp(`^[${customAlphabetEscaped}_-]+$`); +const customAddressRegex = new RegExp("^[a-zA-Z0-9-_]+$"); + function isAdmin(user) { return user.role === ROLES.ADMIN; } @@ -360,14 +364,42 @@ function registerHandlebarsHelpers() { return val; }); hbs.registerPartials(path.join(__dirname, "../views/partials"), function (err) {}); + const customPartialsPath = path.join(__dirname, "../../custom/views/partials"); + const customPartialsExist = fs.existsSync(customPartialsPath); + if (customPartialsExist) { + hbs.registerPartials(customPartialsPath, function (err) {}); + } +} + +// grab custom styles file name from the custom/css folder +const custom_css_file_names = []; +const customCSSPath = path.join(__dirname, "../../custom/css"); +const customCSSExists = fs.existsSync(customCSSPath); +if (customCSSExists) { + fs.readdir(customCSSPath, function(error, files) { + if (error) { + console.warn("Could not read the custom CSS folder:", error); + } else { + files.forEach(function(file_name) { + custom_css_file_names.push(file_name); + }); + } + }) +} + +function getCustomCSSFileNames() { + return custom_css_file_names; } module.exports = { addProtocol, + customAddressRegex, + customAlphabetRegex, CustomError, dateToUTC, deleteCurrentToken, generateId, + getCustomCSSFileNames, getDifferenceFunction, getInitStats, getShortURL, diff --git a/server/views/layout.hbs b/server/views/layout.hbs index 8c22ba1..814339c 100644 --- a/server/views/layout.hbs +++ b/server/views/layout.hbs @@ -24,6 +24,9 @@ {{site_name}} | {{title}} + {{#each custom_styles}} + + {{/each}} {{{block "stylesheets"}}} diff --git a/server/views/partials/admin/links/edit.hbs b/server/views/partials/admin/links/edit.hbs index 902797f..24198d6 100644 --- a/server/views/partials/admin/links/edit.hbs +++ b/server/views/partials/admin/links/edit.hbs @@ -25,7 +25,7 @@ {{#if errors.target}}

{{errors.target}}

{{/if}}