Showing preview only (601K chars total). Download the full file or copy to clipboard to get everything.
Repository: thedevs-network/kutt
Branch: main
Commit: fc832d7e7243
Files: 209
Total size: 550.6 KB
Directory structure:
gitextract_5ryo2i8m/
├── .dockerignore
├── .example.env
├── .github/
│ └── workflows/
│ ├── docker-build-development.yaml
│ ├── docker-build-latest.yaml
│ └── docker-build-release.yaml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── custom/
│ └── .gitkeep
├── db/
│ └── .gitkeep
├── docker-compose.mariadb.yml
├── docker-compose.postgres.yml
├── docker-compose.sqlite-redis.yml
├── docker-compose.yml
├── docs/
│ └── api/
│ ├── api.js
│ └── generate.js
├── jsconfig.json
├── knexfile.js
├── package.json
├── server/
│ ├── consts.js
│ ├── cron.js
│ ├── env.js
│ ├── handlers/
│ │ ├── auth.handler.js
│ │ ├── domains.handler.js
│ │ ├── helpers.handler.js
│ │ ├── links.handler.js
│ │ ├── locals.handler.js
│ │ ├── renders.handler.js
│ │ ├── users.handler.js
│ │ └── validators.handler.js
│ ├── knex.js
│ ├── mail/
│ │ ├── index.js
│ │ ├── mail.js
│ │ ├── template-change-email.html
│ │ ├── template-reset.html
│ │ ├── template-verify.html
│ │ └── text.js
│ ├── migrations/
│ │ ├── 20200211220920_constraints.js
│ │ ├── 20200510140704_domains.js
│ │ ├── 20200718124944_description.js
│ │ ├── 20200730203154_expire_in.js
│ │ ├── 20200810195255_change_email.js
│ │ ├── 20241103083933_user-roles.js
│ │ ├── 20241223062111_indexes.js
│ │ ├── 20241223103044_visits_user_id.js
│ │ ├── 20241223155527_visits_user_id_index.js
│ │ └── 20250106070444_remove_cooldown.js
│ ├── models/
│ │ ├── domain.model.js
│ │ ├── host.model.js
│ │ ├── index.js
│ │ ├── ip.model.js
│ │ ├── link.model.js
│ │ ├── user.model.js
│ │ └── visit.model.js
│ ├── passport.js
│ ├── queries/
│ │ ├── domain.queries.js
│ │ ├── host.queries.js
│ │ ├── index.js
│ │ ├── link.queries.js
│ │ ├── user.queries.js
│ │ └── visit.queries.js
│ ├── queues/
│ │ ├── index.js
│ │ ├── queues.js
│ │ └── visit.js
│ ├── redis.js
│ ├── routes/
│ │ ├── auth.routes.js
│ │ ├── domain.routes.js
│ │ ├── health.routes.js
│ │ ├── index.js
│ │ ├── link.routes.js
│ │ ├── renders.routes.js
│ │ ├── routes.js
│ │ └── user.routes.js
│ ├── server.js
│ ├── utils/
│ │ ├── asyncHandler.js
│ │ ├── index.js
│ │ ├── knex.js
│ │ ├── map.json
│ │ └── utils.js
│ └── views/
│ ├── 404.hbs
│ ├── admin.hbs
│ ├── banned.hbs
│ ├── create_admin.hbs
│ ├── error.hbs
│ ├── homepage.hbs
│ ├── layout.hbs
│ ├── login.hbs
│ ├── logout.hbs
│ ├── partials/
│ │ ├── admin/
│ │ │ ├── dialog/
│ │ │ │ ├── add_domain.hbs
│ │ │ │ ├── add_domain_success.hbs
│ │ │ │ ├── ban_domain.hbs
│ │ │ │ ├── ban_domain_success.hbs
│ │ │ │ ├── ban_user.hbs
│ │ │ │ ├── ban_user_success.hbs
│ │ │ │ ├── create_user.hbs
│ │ │ │ ├── create_user_success.hbs
│ │ │ │ ├── delete_domain.hbs
│ │ │ │ ├── delete_domain_success.hbs
│ │ │ │ ├── delete_user.hbs
│ │ │ │ ├── delete_user_success.hbs
│ │ │ │ ├── frame.hbs
│ │ │ │ └── mesasge.hbs
│ │ │ ├── domains/
│ │ │ │ ├── actions.hbs
│ │ │ │ ├── loading.hbs
│ │ │ │ ├── table.hbs
│ │ │ │ ├── tbody.hbs
│ │ │ │ ├── tfoot.hbs
│ │ │ │ ├── thead.hbs
│ │ │ │ └── tr.hbs
│ │ │ ├── index.hbs
│ │ │ ├── links/
│ │ │ │ ├── actions.hbs
│ │ │ │ ├── edit.hbs
│ │ │ │ ├── loading.hbs
│ │ │ │ ├── table.hbs
│ │ │ │ ├── tbody.hbs
│ │ │ │ ├── tfoot.hbs
│ │ │ │ ├── thead.hbs
│ │ │ │ └── tr.hbs
│ │ │ ├── table_nav.hbs
│ │ │ ├── table_tab.hbs
│ │ │ └── users/
│ │ │ ├── actions.hbs
│ │ │ ├── loading.hbs
│ │ │ ├── table.hbs
│ │ │ ├── tbody.hbs
│ │ │ ├── tfoot.hbs
│ │ │ ├── thead.hbs
│ │ │ └── tr.hbs
│ │ ├── auth/
│ │ │ ├── form.hbs
│ │ │ ├── form_admin.hbs
│ │ │ ├── login_disabled.hbs
│ │ │ ├── verify.hbs
│ │ │ └── welcome.hbs
│ │ ├── footer.hbs
│ │ ├── header.hbs
│ │ ├── icons/
│ │ │ ├── arrow_left.hbs
│ │ │ ├── chart.hbs
│ │ │ ├── check.hbs
│ │ │ ├── chevron_left.hbs
│ │ │ ├── chevron_right.hbs
│ │ │ ├── cog.hbs
│ │ │ ├── copy.hbs
│ │ │ ├── eye.hbs
│ │ │ ├── heart.hbs
│ │ │ ├── key.hbs
│ │ │ ├── login.hbs
│ │ │ ├── new_user.hbs
│ │ │ ├── pencil.hbs
│ │ │ ├── plus.hbs
│ │ │ ├── qrcode.hbs
│ │ │ ├── reload.hbs
│ │ │ ├── send.hbs
│ │ │ ├── shield.hbs
│ │ │ ├── shuffle.hbs
│ │ │ ├── spinner.hbs
│ │ │ ├── stop.hbs
│ │ │ ├── trash.hbs
│ │ │ ├── write.hbs
│ │ │ ├── x.hbs
│ │ │ └── zap.hbs
│ │ ├── links/
│ │ │ ├── actions.hbs
│ │ │ ├── dialog/
│ │ │ │ ├── ban.hbs
│ │ │ │ ├── ban_success.hbs
│ │ │ │ ├── delete.hbs
│ │ │ │ ├── delete_success.hbs
│ │ │ │ ├── frame.hbs
│ │ │ │ └── message.hbs
│ │ │ ├── edit.hbs
│ │ │ ├── loading.hbs
│ │ │ ├── nav.hbs
│ │ │ ├── table.hbs
│ │ │ ├── tbody.hbs
│ │ │ ├── tfoot.hbs
│ │ │ ├── thead.hbs
│ │ │ └── tr.hbs
│ │ ├── protected/
│ │ │ └── form.hbs
│ │ ├── report/
│ │ │ ├── email.hbs
│ │ │ └── form.hbs
│ │ ├── reset_password/
│ │ │ ├── new_password_form.hbs
│ │ │ ├── new_password_success.hbs
│ │ │ └── request_form.hbs
│ │ ├── settings/
│ │ │ ├── apikey.hbs
│ │ │ ├── change_email.hbs
│ │ │ ├── change_password.hbs
│ │ │ ├── delete_account.hbs
│ │ │ └── domain/
│ │ │ ├── add_form.hbs
│ │ │ ├── delete.hbs
│ │ │ ├── delete_success.hbs
│ │ │ ├── dialog.hbs
│ │ │ ├── index.hbs
│ │ │ └── table.hbs
│ │ ├── shortener.hbs
│ │ ├── stats.hbs
│ │ └── support_email.hbs
│ ├── protected.hbs
│ ├── report.hbs
│ ├── reset_password.hbs
│ ├── reset_password_set_new_password.hbs
│ ├── settings.hbs
│ ├── stats.hbs
│ ├── terms.hbs
│ ├── url_info.hbs
│ ├── verify.hbs
│ └── verify_change_email.hbs
└── static/
├── css/
│ └── styles.css
├── manifest.webmanifest
├── robots.txt
└── scripts/
├── main.js
└── stats.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
.git
node_modules
================================================
FILE: .example.env
================================================
# Optional - App port to run on
PORT=3000
# Optional - The name of the site where Kutt is hosted
SITE_NAME=Kutt
# Optional - The domain that this website is on
DEFAULT_DOMAIN=localhost:3000
# Required - A passphrase to encrypt JWT. Use a random long string
JWT_SECRET=
# Optional - Database client. Available clients for the supported databases:
# pg | better-sqlite3 | mysql2
# other supported drivers that you can use but you have to manually install them with npm:
# pg-native | sqlite3 | mysql
DB_CLIENT=better-sqlite3
# Optional - SQLite database file path
# Only if you're using SQLite
DB_FILENAME=db/data
# Optional - SQL database credential details
# Only if you're using Postgres or MySQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=kutt
DB_USER=postgres
DB_PASSWORD=
DB_SSL=false
DB_POOL_MIN=0
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
REDIS_PORT=6379
REDIS_PASSWORD=
# The number for Redis database, between 0 and 15. Defaults to 0.
# If you don't know what this is, then you probably don't need to change it.
REDIS_DB=0
# Optional - Disable registration. Default is true.
DISALLOW_REGISTRATION=true
# Optional - Disable form-based login. Only makes sense when OIDC is enabled.
DISALLOW_LOGIN_FORM=false
# Optional - Disable anonymous link creation. Default is true.
DISALLOW_ANONYMOUS_LINKS=true
# Optional - This would be shown to the user on the settings page
# It's only for display purposes and has no other use
SERVER_IP_ADDRESS=
SERVER_CNAME_ADDRESS=
# Optional - 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
CUSTOM_DOMAIN_USE_HTTPS=false
# Optional - Email is used to verify or change email address, reset password, and send reports.
# If it's disabled, all the above functionality would be disabled as well.
# MAIL_FROM example: "Kutt <support@kutt.it>". Leave it empty to use MAIL_USER.
# More info on the configuration on http://nodemailer.com/.
MAIL_ENABLED=false
MAIL_HOST=
MAIL_PORT=587
MAIL_SECURE=true
MAIL_USER=
MAIL_FROM=
MAIL_PASSWORD=
# Optional - Enable rate limitting for some API routes
ENABLE_RATE_LIMIT=false
# Optional - The email address that will receive submitted reports
REPORT_EMAIL=
# Optional - Support email to show on the app
CONTACT_EMAIL=
# Optional - Login with OIDC
OIDC_ENABLED=false
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_SCOPE=
OIDC_EMAIL_CLAIM=
================================================
FILE: .github/workflows/docker-build-development.yaml
================================================
name: docker-build-development
env:
dockerhub_repository: "kutt/kutt"
dockerhub_tag: "development"
on:
push:
branches:
- develop
jobs:
dockerhub-build-push:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm64
context: .
push: true
tags: ${{ env.dockerhub_repository }}:${{ env.dockerhub_tag }}
-
name: Update repo description
uses: peter-evans/dockerhub-description@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: ${{ env.dockerhub_repository }}
================================================
FILE: .github/workflows/docker-build-latest.yaml
================================================
name: docker-build-latest
env:
dockerhub_repository: "kutt/kutt"
dockerhub_tag: "main"
on:
push:
branches:
- main
jobs:
dockerhub-build-push:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm64
context: .
push: true
tags: ${{ env.dockerhub_repository }}:${{ env.dockerhub_tag }}
-
name: Update repo description
uses: peter-evans/dockerhub-description@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: ${{ env.dockerhub_repository }}
================================================
FILE: .github/workflows/docker-build-release.yaml
================================================
name: docker-build-release
env:
dockerhub_repository: "kutt/kutt"
on:
release:
types: [published]
jobs:
dockerhub-build-push:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm64
context: .
push: true
tags: ${{ env.dockerhub_repository }}:${{ github.event.release.tag_name }}, ${{ env.dockerhub_repository }}:latest
================================================
FILE: .gitignore
================================================
.env
.vscode/
logs
client/.next/
node_modules/
client/config.js
client/old.config.js
server/config.js
server/old.config.js
production-server
.idea/
dump.rdb
docs/api/static
**/.DS_Store
db/*
!db/.gitkeep
================================================
FILE: Dockerfile
================================================
# specify node.js image
FROM node:22-alpine
# use production node environment by default
ENV NODE_ENV=production
# set working directory.
WORKDIR /kutt
# download dependencies while using Docker's caching
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci --omit=dev
RUN mkdir -p /var/lib/kutt
# copy the rest of source files into the image
COPY . .
# expose the port that the app listens on
EXPOSE 3000
# intialize database and run the app
CMD npm run migrate && npm start
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2020 Kutt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
<p align="center"><a href="https://kutt.it" title="kutt.it"><img src="https://raw.githubusercontent.com/thedevs-network/kutt/9d1c873897c3f5b9a1bd0c74dc5d23f2ed01f2ec/static/images/logo-github.png" alt="Kutt.it"></a></p>
# Kutt.it
**Kutt** is a modern URL shortener with support for custom domains. Create and edit links, view statistics, manage users, and more.
[https://kutt.it](https://kutt.it)
> [!NOTE]
> [kutt.it](https://kutt.it) domain has been deactivated by the Italian TLD registrar due to the lack of identification documents. I'm in contact with the domain registrar to bring it back as soon as possible.
>
> Meanwhile, please use [kutt.to](https://kutt.to), all the previous and the future links work with this domain as well.
[](https://github.com/thedevs-network/kutt/actions/workflows/docker-build-release.yaml)
[](https://status.kutt.it)
[](https://github.com/thedevs-network/kutt/#contributing)
[](https://github.com/thedevs-network/kutt/blob/develop/LICENSE)
## Table of contents
- [Key features](#key-features)
- [Donations and sponsors](#donations-and-sponsors)
- [Setup](#setup)
- [Docker](#docker)
- [API](#api)
- [Configuration](#configuration)
- [Themes and customizations](#themes-and-customizations)
- [Browser extensions](#browser-extensions)
- [Videos](#videos)
- [Integrations](#integrations)
- [Contributing](#contributing)
## Key features
- Created with self-host in mind:
- Zero configuration needed
- Easy setup with no build step
- Supporting various databases (SQLite, Postgres, MySQL)
- Ability to disable registration and anonymous links
- OpenID Connect (OIDC) login
- Custom domain support
- Set custom URLs, password, description, and expiration time for links
- View, edit, delete and manage your links
- Private statistics for shortened URLs
- Admin page to manage users and links
- Customizability and themes
- RESTful API
## Donations and sponsors
Support the development of Kutt by making a donation or becoming an sponsor.
[Donate or sponsor →](https://btcpay.kutt.it/apps/L9Gc7PrnLykeRHkhsH2jHivBeEh/crowdfund)
## 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.
When you first start the app, you're prompted to create an admin account.
1. Clone this repository or [download the latest zip](https://github.com/thedevs-network/kutt/releases)
2. Install dependencies: `npm install`
3. Initialize database: `npm run migrate`
5. Start the app for development `npm run dev` or production `npm start`
## Docker
Make sure Docker is installed, then you can start the app from the root directory:
```sh
docker compose up
```
Various docker-compose configurations are available. Use `docker compose -f <file_name> up` to start the one you want:
- [`docker-compose.yml`](./docker-compose.yml): Default Kutt setup. Uses SQLite for the database.
- [`docker-compose.sqlite-redis.yml`](./docker-compose.sqlite-redis.yml): Starts Kutt with SQLite and Redis.
- Required environment variable: `REDIS_ENABLED`
- [`docker-compose.postgres.yml`](./docker-compose.postgres.yml): Starts Kutt with Postgres and Redis.
- Required environment variables: `REDIS_ENABLED`, `DB_PASSWORD`, `DB_NAME`, `DB_USER`
- [`docker-compose.mariadb.yml`](./docker-compose.mariadb.yml): Starts Kutt with MariaDB and Redis.
- Required environment variables: `REDIS_ENABLED`, `DB_PASSWORD`, `DB_NAME`, `DB_USER`, `DB_PORT`
Official Kutt Docker image is available on [Docker Hub](https://hub.docker.com/r/kutt/kutt).
## API
[View API documentation →](https://docs.kutt.it)
## Configuration
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.
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`.
| Variable | Description | Default | Example |
| -------- | ----------- | ------- | ------- |
| `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` |
| `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_LOGIN_FORM` | Disable login with email and password. Only makes sense if OIDC is enabled. | `false` | `true` |
| `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 `sqlite3` are not installed by default, use `npm` to install them before use. | `better-sqlite3` | `pg` |
| `DB_FILENAME` | File path for the SQLite database. Only if you use SQLite. | `db/data` | `/var/lib/data` |
| `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) |
| `DB_NAME` | Database name. Only if you use Postgres or MySQL. | `kutt` | `mydb` |
| `DB_USER` | Database user. Only if you use Postgres or MySQL. | `postgres` | `myuser` |
| `DB_PASSWORD` | Database password. Only if you use Postgres or MySQL. | - | `mypassword` |
| `DB_SSL` | Whether use SSL for the database connection. Only if you use Postgres or MySQL. | `false` | `true` |
| `DB_POOL_MIN` | Minimum number of database connection pools. Only if you use Postgres or MySQL. | `0` | `2` |
| `DB_POOL_MAX` | Maximum number of database connection pools. Only if you use Postgres or MySQL. | `10` | `5` |
| `REDIS_ENABLED` | Whether to use Redis for cache | `false` | `true` |
| `REDIS_HOST` | Redis connection host | `127.0.0.1` | `your-redis-host.com` |
| `REDIS_PORT` | Redis port | `6379` | `6379` |
| `REDIS_PASSWORD` | Redis password | - | `mypassword` |
| `REDIS_DB` | Redis database number, between 0 and 15. | `0` | `1` |
| `SERVER_IP_ADDRESS` | The IP address shown to the user on the setting's page. It's only for display purposes and has no other use. | - | `1.2.3.4` |
| `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_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` |
## 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 `<your-site.com>/css/<file>.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 `<your-site.com>/images/<image>.<image-format>`
- **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 |
| -------- | ---------- | ------------ |
|  |  | 
#### 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 <path-to-custom-folder> <kutt-container-name>:/kutt
```
For example:
```sh
docker cp custom kutt-server-1:/kutt
```
Make sure to restart the kutt server container after copying files or making changes.
## Browser extensions
Download Kutt's extension for web browsers via below links.
- [Chrome](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd)
- [Firefox](https://addons.mozilla.org/en-US/firefox/addon/kutt/)
## Videos
**Official videos**
- [Next.js to htmx – A Real World Example](https://www.youtube.com/watch?v=8RL4NvYZDT4)
## Integrations
- **ShareX** – You can use Kutt as your default URL shortener in [ShareX](https://getsharex.com/). If you host your custom instance of Kutt, refer to [ShareX wiki](https://github.com/thedevs-network/kutt/wiki/ShareX) on how to setup.
- **Alfred workflow** – Download Kutt's official workflow for [Alfred](https://www.alfredapp.com/) app from [alfred-kutt](https://github.com/thedevs-network/alfred-kutt) repository.
- **iOS shortcut** – [Kutt shortcut](https://www.icloud.com/shortcuts/a829856aea2c420e97c53437e68b752b) for your apple device which works from the iOS sharing context menu or on standalone mode. A courtesy of [@caneeeeee](https://github.com/caneeeeee).
**Third-party packages**
| Language | Link | Description |
| --------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------- |
| C# (.NET) | [KuttSharp](https://github.com/0xaryan/KuttSharp) | .NET package for Kutt.it url shortener |
| C# (.NET) | [Kutt.NET](https://github.com/AlphaNecron/Kutt.NET) | C# API Wrapper for Kutt |
| Python | [kutt-cli](https://github.com/RealAmirali/kutt-cli) | Command-line client for Kutt written in Python |
| Ruby | [kutt.rb](https://github.com/RealAmirali/kutt.rb) | Kutt library written in Ruby |
| Rust | [urlshortener](https://github.com/vityafx/urlshortener-rs) | URL shortener library written in Rust |
| Rust | [kutt-rs](https://github.com/robatipoor/kutt-rs) | Command line tool written in Rust |
| Node.js | [node-kutt](https://github.com/ardalanamini/node-kutt) | Node.js client for Kutt.it url shortener |
| JavaScript | [kutt-vscode](https://github.com/mehrad77/kutt-vscode) | Visual Studio Code extension for Kutt |
| Java | [kutt-desktop](https://github.com/cipher812/kutt-desktop) | A Cross platform Java desktop application for Kutt |
| Go | [kutt-go](https://github.com/raahii/kutt-go) | Go client for Kutt.it url shortener |
| BASH | [GitHub Gist](https://gist.github.com/hashworks/6d6e4eae8984a5018f7692a796d570b4) | Simple BASH function to access the API |
| BASH | [url-shortener](https://git.tim-peters.org/Tim/url-shortener) | Simple BASH script with GUI |
| Kubernetes/Helm | [ArtifactHub](https://artifacthub.io/packages/helm/christianhuth/kutt) | A Helm Chart to install Kutt on a Kubernetes cluster |
## Contributing
Pull requests are welcome. Open a discussion for feedback, requesting features, or discussing ideas.
Special thanks to [Thomas](https://github.com/trgwii) and [Muthu](https://github.com/MKRhere). Logo design by [Muthu](https://github.com/MKRhere).
================================================
FILE: custom/.gitkeep
================================================
# keep this folder in git
# put supported customization files for styles and such
# if you're using docker make sure to mount this folder
================================================
FILE: db/.gitkeep
================================================
# keep this folder in git
# if you use a file-based databases such as sqlite3, the database files would be stored here
================================================
FILE: docker-compose.mariadb.yml
================================================
services:
server:
build:
context: .
volumes:
- custom:/kutt/custom
environment:
DB_CLIENT: mysql2
DB_HOST: mariadb
DB_PORT: 3306
REDIS_ENABLED: true
REDIS_HOST: redis
REDIS_PORT: 6379
ports:
- 3000:3000
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_started
mariadb:
image: mariadb:10
restart: always
healthcheck:
test: ['CMD-SHELL', 'mysql ${DB_NAME} --user=${DB_USER} --password=${DB_PASSWORD} --execute "SELECT 1;"']
interval: 3s
retries: 5
start_period: 30s
volumes:
- db_data_mariadb:/var/lib/mysql
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_PASSWORD}
expose:
- 3306
redis:
image: redis:alpine
restart: always
expose:
- 6379
volumes:
db_data_mariadb:
custom:
================================================
FILE: docker-compose.postgres.yml
================================================
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
ports:
- 3000:3000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
postgres:
image: postgres
restart: always
user: ${DB_USER}
volumes:
- db_data_pg:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
expose:
- 5432
healthcheck:
test: [ "CMD", "pg_isready" ]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:alpine
restart: always
expose:
- 6379
volumes:
db_data_pg:
custom:
================================================
FILE: docker-compose.sqlite-redis.yml
================================================
services:
server:
build:
context: .
volumes:
- db_data_sqlite:/var/lib/kutt
- custom:/kutt/custom
environment:
DB_FILENAME: "/var/lib/kutt/data.sqlite"
REDIS_ENABLED: true
REDIS_HOST: redis
REDIS_PORT: 6379
ports:
- 3000:3000
depends_on:
redis:
condition: service_started
redis:
image: redis:alpine
restart: always
expose:
- 6379
volumes:
db_data_sqlite:
custom:
================================================
FILE: docker-compose.yml
================================================
services:
server:
build:
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:
custom:
================================================
FILE: docs/api/api.js
================================================
const p = require("../../package.json");
module.exports = {
openapi: "3.0.0",
info: {
title: "Kutt.it",
description: "API reference for [http://kutt.it](http://kutt.it).\n",
version: p.version
},
servers: [
{
url: "https://kutt.it/api/v2"
}
],
tags: [
{
name: "health"
},
{
name: "links"
},
{
name: "domains"
},
{
name: "users"
}
],
paths: {
"/health": {
get: {
tags: ["health"],
summary: "API health",
responses: {
"200": {
description: "Health",
content: {
"text/html": {
example: "OK"
}
}
}
}
}
},
"/links": {
get: {
tags: ["links"],
description: "Get list of links",
parameters: [
{
name: "limit",
in: "query",
description: "Limit",
required: false,
style: "form",
explode: true,
schema: {
type: "number",
example: 10
}
},
{
name: "skip",
in: "query",
description: "Skip",
required: false,
style: "form",
explode: true,
schema: {
type: "number",
example: 0
}
},
{
name: "all",
in: "query",
description: "All links (ADMIN only)",
required: false,
style: "form",
explode: true,
schema: {
type: "boolean",
example: false
}
}
],
responses: {
"200": {
description: "List of links",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/inline_response_200"
}
}
}
}
},
security: [
{
APIKeyAuth: []
}
]
},
post: {
tags: ["links"],
description: "Create a short link",
requestBody: {
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/body"
}
}
}
},
responses: {
"200": {
description: "Created link",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/Link"
}
}
}
}
},
security: [
{
APIKeyAuth: []
}
]
}
},
"/links/{id}": {
delete: {
tags: ["links"],
description: "Delete a link",
parameters: [
{
name: "id",
in: "path",
required: true,
style: "simple",
explode: false,
schema: {
type: "string",
format: "uuid"
}
}
],
responses: {
"200": {
description: "Deleted link successfully",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/inline_response_200_1"
}
}
}
}
},
security: [
{
APIKeyAuth: []
}
]
},
patch: {
tags: ["links"],
description: "Update a link",
parameters: [
{
name: "id",
in: "path",
required: true,
style: "simple",
explode: false,
schema: {
type: "string",
format: "uuid"
}
}
],
requestBody: {
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/body_1"
}
}
}
},
responses: {
"200": {
description: "Updated link successfully",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/Link"
}
}
}
}
},
security: [
{
APIKeyAuth: []
}
]
}
},
"/links/{id}/stats": {
get: {
tags: ["links"],
description: "Get link stats",
parameters: [
{
name: "id",
in: "path",
required: true,
style: "simple",
explode: false,
schema: {
type: "string",
format: "uuid"
}
}
],
responses: {
"200": {
description: "Link stats",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/Stats"
}
}
}
}
},
security: [
{
APIKeyAuth: []
}
]
}
},
"/domains": {
post: {
tags: ["domains"],
description: "Create a domain",
requestBody: {
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/body_2"
}
}
}
},
responses: {
"200": {
description: "Created domain",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/Domain"
}
}
}
}
},
security: [
{
APIKeyAuth: []
}
]
}
},
"/domains/{id}": {
delete: {
tags: ["domains"],
description: "Delete a domain",
parameters: [
{
name: "id",
in: "path",
required: true,
style: "simple",
explode: false,
schema: {
type: "string",
format: "uuid"
}
}
],
responses: {
"200": {
description: "Deleted domain successfully",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/inline_response_200_1"
}
}
}
}
},
security: [
{
APIKeyAuth: []
}
]
}
},
"/users": {
get: {
tags: ["users"],
description: "Get user info",
responses: {
"200": {
description: "User info",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/User"
}
}
}
}
},
security: [
{
APIKeyAuth: []
}
]
}
}
},
components: {
schemas: {
Link: {
type: "object",
properties: {
address: {
type: "string"
},
banned: {
type: "boolean",
default: false
},
created_at: {
type: "string",
format: "date-time"
},
id: {
type: "string",
format: "uuid"
},
link: {
type: "string"
},
password: {
type: "boolean",
default: false
},
target: {
type: "string"
},
description: {
type: "string"
},
updated_at: {
type: "string",
format: "date-time"
},
visit_count: {
type: "number"
}
}
},
Domain: {
type: "object",
properties: {
address: {
type: "string"
},
banned: {
type: "boolean",
default: false
},
created_at: {
type: "string",
format: "date-time"
},
id: {
type: "string",
format: "uuid"
},
homepage: {
type: "string"
},
updated_at: {
type: "string",
format: "date-time"
}
}
},
User: {
type: "object",
properties: {
apikey: {
type: "string"
},
email: {
type: "string"
},
domains: {
type: "array",
items: {
$ref: "#/components/schemas/Domain"
}
}
}
},
StatsItem: {
type: "object",
properties: {
stats: {
$ref: "#/components/schemas/StatsItem_stats"
},
views: {
type: "array",
items: {
type: "number"
}
}
}
},
Stats: {
type: "object",
properties: {
lastDay: {
$ref: "#/components/schemas/StatsItem"
},
lastMonth: {
$ref: "#/components/schemas/StatsItem"
},
lastWeek: {
$ref: "#/components/schemas/StatsItem"
},
lastYear: {
$ref: "#/components/schemas/StatsItem"
},
updatedAt: {
type: "string"
},
address: {
type: "string"
},
banned: {
type: "boolean",
default: false
},
created_at: {
type: "string",
format: "date-time"
},
id: {
type: "string",
format: "uuid"
},
link: {
type: "string"
},
password: {
type: "boolean",
default: false
},
target: {
type: "string"
},
updated_at: {
type: "string",
format: "date-time"
},
visit_count: {
type: "number"
}
}
},
inline_response_200: {
properties: {
limit: {
type: "number",
default: 10
},
skip: {
type: "number",
default: 0
},
total: {
type: "number",
default: 0
},
data: {
type: "array",
items: {
$ref: "#/components/schemas/Link"
}
}
}
},
body: {
required: ["target"],
properties: {
target: {
type: "string"
},
description: {
type: "string"
},
expire_in: {
type: "string",
example: "2 minutes/hours/days"
},
password: {
type: "string"
},
customurl: {
type: "string"
},
reuse: {
type: "boolean",
default: false
},
domain: {
type: "string"
}
}
},
inline_response_200_1: {
properties: {
message: {
type: "string"
}
}
},
body_1: {
required: ["target", "address"],
properties: {
target: {
type: "string"
},
address: {
type: "string"
},
description: {
type: "string"
},
expire_in: {
type: "string",
example: "2 minutes/hours/days"
}
}
},
body_2: {
required: ["address"],
properties: {
address: {
type: "string"
},
homepage: {
type: "string"
}
}
},
StatsItem_stats_browser: {
type: "object",
properties: {
name: {
type: "string"
},
value: {
type: "number"
}
}
},
StatsItem_stats: {
type: "object",
properties: {
browser: {
type: "array",
items: {
$ref: "#/components/schemas/StatsItem_stats_browser"
}
},
os: {
type: "array",
items: {
$ref: "#/components/schemas/StatsItem_stats_browser"
}
},
country: {
type: "array",
items: {
$ref: "#/components/schemas/StatsItem_stats_browser"
}
},
referrer: {
type: "array",
items: {
$ref: "#/components/schemas/StatsItem_stats_browser"
}
}
}
}
},
securitySchemes: {
APIKeyAuth: {
type: "apiKey",
name: "X-API-KEY",
in: "header"
}
}
}
};
================================================
FILE: docs/api/generate.js
================================================
const { join, dirname } = require("node:path");
const { promises: fs } = require("node:fs");
const api = require("./api");
const Template = (output, { api, title, redoc }) =>
fs.writeFile(output,
`<DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>${title}</title>
</head>
<body>
<redoc spec-url="${api}" />
<script src="${redoc}"></script>
</body>
</html>
`);
const Api = output =>
fs.writeFile(output, JSON.stringify(api));
const Redoc = output =>
fs.copyFile(join(
dirname(require.resolve("redoc")),
"redoc.standalone.js"),
output);
module.exports = (async () => {
const out = join(__dirname, "static");
const apiFile = "api.json";
const redocFile = "redoc.js";
await fs.mkdir(out, { recursive: true });
return Promise.all([
Api(join(out, apiFile)),
Redoc(join(out, redocFile)),
Template(join(out, "index.html"), {
api: apiFile,
title: api.info.title,
redoc: redocFile
}),
]);
})();
================================================
FILE: jsconfig.json
================================================
{
"compilerOptions": {
"module": "CommonJS",
"allowImportingTsExtensions": false
},
"exclude": [
"node_modules",
"**/node_modules/*"
]
}
================================================
FILE: knexfile.js
================================================
// this configuration is for migrations only
// and since jwt secret is not required, it's set to a placehodler string to bypass env validation
if (!process.env.JWT_SECRET) {
process.env.JWT_SECRET = "securekey";
}
const env = require("./server/env");
const isSQLite = env.DB_CLIENT === "sqlite3" || env.DB_CLIENT === "better-sqlite3";
module.exports = {
client: env.DB_CLIENT,
connection: {
...(isSQLite && { filename: env.DB_FILENAME }),
host: env.DB_HOST,
database: env.DB_NAME,
user: env.DB_USER,
port: env.DB_PORT,
password: env.DB_PASSWORD,
ssl: env.DB_SSL,
},
useNullAsDefault: true,
migrations: {
tableName: "knex_migrations",
directory: "server/migrations",
disableMigrationsListValidation: true,
}
};
================================================
FILE: package.json
================================================
{
"name": "kutt",
"version": "3.2.3",
"description": "Modern URL shortener.",
"main": "./server/server.js",
"scripts": {
"dev": "node --watch-path=./server --watch-path=./custom server/server.js",
"start": "node server/server.js --production",
"migrate": "knex migrate:latest",
"migrate:make": "knex migrate:make",
"docs:build": "cd docs/api && node generate && cd ../.."
},
"repository": {
"type": "git",
"url": "git+https://github.com/thedevs-network/kutt.git"
},
"keywords": [
"url-shortener"
],
"author": "Pouria Ezzati <ezzati.upt@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/thedevs-network/kutt/issues"
},
"homepage": "https://github.com/thedevs-network/kutt#readme",
"dependencies": {
"bcryptjs": "2.4.3",
"better-sqlite3": "11.8.1",
"bull": "4.16.5",
"cookie-parser": "1.4.7",
"cookie-session": "^2.1.0",
"cors": "2.8.5",
"date-fns": "2.30.0",
"dotenv": "16.4.7",
"envalid": "8.0.0",
"express": "4.21.2",
"express-rate-limit": "7.5.0",
"express-validator": "6.14.2",
"geoip-lite": "1.4.10",
"hbs": "4.2.0",
"helmet": "7.1.0",
"ioredis": "5.4.2",
"isbot": "5.1.19",
"jsonwebtoken": "9.0.2",
"knex": "3.1.0",
"ms": "2.1.3",
"mysql2": "3.12.0",
"nanoid": "3.3.8",
"nodemailer": "6.9.16",
"openid-client": "^5.7.0",
"passport": "0.7.0",
"passport-jwt": "4.0.1",
"passport-local": "1.0.0",
"passport-localapikey-update": "0.6.0",
"pg": "8.13.1",
"pg-query-stream": "4.7.1",
"rate-limit-redis": "4.2.0",
"useragent": "2.3.0"
},
"devDependencies": {
"@types/bcryptjs": "2.4.2",
"@types/cookie-parser": "1.4.3",
"@types/cors": "2.8.12",
"@types/express": "4.17.14",
"@types/hbs": "4.0.4",
"@types/jsonwebtoken": "7.2.8",
"@types/ms": "0.7.31",
"@types/node": "18.11.9",
"@types/nodemailer": "6.4.6",
"@types/pg": "8.11.10",
"redoc": "2.2.0"
}
}
================================================
FILE: server/consts.js
================================================
const ROLES = {
USER: "USER",
ADMIN: "ADMIN"
};
module.exports = {
ROLES,
}
================================================
FILE: server/cron.js
================================================
const query = require("./queries");
const utils = require("./utils");
// check and delete links 30 secoonds
setInterval(function () {
query.link.batchRemove({ expire_in: ["<", utils.dateToUTC(new Date())] }).catch();
}, 30_000);
================================================
FILE: server/env.js
================================================
require("dotenv").config();
const { cleanEnv, num, str, bool } = require("envalid");
const { readFileSync } = require("node:fs");
const supportedDBClients = [
"pg",
"pg-native",
"sqlite3",
"better-sqlite3",
"mysql",
"mysql2"
];
// make sure custom alphabet is not empty
if (process.env.LINK_CUSTOM_ALPHABET === "") {
delete process.env.LINK_CUSTOM_ALPHABET;
}
// make sure jwt secret is not empty
if (process.env.JWT_SECRET === "") {
delete process.env.JWT_SECRET;
}
// if is started with the --production argument, then set NODE_ENV to production
if (process.argv.includes("--production")) {
process.env.NODE_ENV = "production";
}
const spec = {
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: "better-sqlite3" }),
DB_FILENAME: str({ default: "db/data" }),
DB_HOST: str({ default: "localhost" }),
DB_PORT: num({ default: 5432 }),
DB_NAME: str({ default: "kutt" }),
DB_USER: str({ default: "postgres" }),
DB_PASSWORD: str({ default: "" }),
DB_SSL: bool({ default: false }),
DB_POOL_MIN: num({ default: 0 }),
DB_POOL_MAX: num({ default: 10 }),
REDIS_ENABLED: bool({ default: false }),
REDIS_HOST: str({ default: "127.0.0.1" }),
REDIS_PORT: num({ default: 6379 }),
REDIS_PASSWORD: str({ default: "" }),
REDIS_DB: num({ default: 0 }),
DISALLOW_ANONYMOUS_LINKS: bool({ default: true }),
DISALLOW_REGISTRATION: bool({ default: true }),
DISALLOW_LOGIN_FORM: bool({ default: false }),
SERVER_IP_ADDRESS: str({ default: "" }),
SERVER_CNAME_ADDRESS: str({ default: "" }),
CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }),
JWT_SECRET: str({ devDefault: "securekey" }),
MAIL_ENABLED: bool({ default: false }),
MAIL_HOST: str({ default: "" }),
MAIL_PORT: num({ default: 587 }),
MAIL_SECURE: bool({ default: false }),
MAIL_USER: str({ default: "" }),
MAIL_FROM: str({ default: "", example: "Kutt <support@kutt.it>" }),
MAIL_PASSWORD: str({ default: "" }),
OIDC_ENABLED: bool({ default: false }),
OIDC_ISSUER: str({ default: "" }),
OIDC_CLIENT_ID: str({ default: "" }),
OIDC_CLIENT_SECRET: str({ default: "" }),
OIDC_SCOPE: str({ default: "openid profile email" }),
OIDC_EMAIL_CLAIM: str({ default: "email" }),
ENABLE_RATE_LIMIT: bool({ default: false }),
REPORT_EMAIL: str({ default: "" }),
CONTACT_EMAIL: str({ default: "" }),
NODE_APP_INSTANCE: num({ default: 0 }),
};
for (const key in spec) {
const file_key = key + "_FILE";
if (!(file_key in process.env)) continue;
try {
process.env[key] = readFileSync(process.env[file_key], "utf8").trim();
} catch {
// on error, env_FILE just doesn't get applied.
}
}
const env = cleanEnv(process.env, spec);
module.exports = env;
================================================
FILE: server/handlers/auth.handler.js
================================================
const { differenceInDays, addMinutes } = require("date-fns");
const { nanoid } = require("nanoid");
const passport = require("passport");
const { randomUUID } = require("node:crypto");
const bcrypt = require("bcryptjs");
const { ROLES } = require("../consts");
const query = require("../queries");
const utils = require("../utils");
const redis = require("../redis");
const mail = require("../mail");
const env = require("../env");
const CustomError = utils.CustomError;
function authenticate(type, error, isStrict, redirect) {
return function auth(req, res, next) {
if (req.user) return next();
passport.authenticate(type, (err, user, info) => {
if (
(err || info instanceof Error) &&
type === "oidc"
) {
return next(new CustomError("OIDC authentication failed.", 401));
};
if (err) return next(err);
if (
req.isHTML &&
redirect &&
((!user && isStrict) ||
(user && isStrict && !user.verified) ||
(user && user.banned))
) {
if (redirect === "page") {
res.redirect("/logout");
return;
}
if (redirect === "header") {
res.setHeader("HX-Redirect", "/logout");
res.send("NOT_AUTHENTICATED");
return;
}
}
if (!user && isStrict) {
throw new CustomError(error, 401);
}
if (user && user.banned) {
throw new CustomError("You're banned from using this website.", 403);
}
if (user && isStrict && !user.verified) {
throw new CustomError("Your email address is not verified. " +
"Sign up to get the verification link again.", 400);
}
if (user) {
res.locals.isAdmin = utils.isAdmin(user);
req.user = {
...user,
admin: utils.isAdmin(user)
};
// renew token if it's been at least one day since the token has been created
// only do it for html page requests not api requests
if (info?.exp && req.isHTML && redirect === "page") {
const diff = Math.abs(differenceInDays(new Date(info.exp * 1000), new Date()));
if (diff < 6) {
const token = utils.signToken(user);
utils.deleteCurrentToken(res);
utils.setToken(res, token);
}
}
}
return next();
})(req, res, next);
}
}
const local = authenticate("local", "Login credentials are wrong.", true, null);
const jwt = authenticate("jwt", "Unauthorized.", true, "header");
const jwtPage = authenticate("jwt", "Unauthorized.", true, "page");
const jwtLoose = authenticate("jwt", "Unauthorized.", false, "header");
const jwtLoosePage = authenticate("jwt", "Unauthorized.", false, "page");
const apikey = authenticate("localapikey", "API key is not correct.", false, null);
const oidc = authenticate("oidc", "Unauthorized", true, "page");
function admin(req, res, next) {
if (req.user.admin) return next();
throw new CustomError("Unauthorized", 401);
}
async function signup(req, res) {
const salt = await bcrypt.genSalt(12);
const password = await bcrypt.hash(req.body.password, salt);
const user = await query.user.add(
{ email: req.body.email, password },
req.user
);
await mail.verification(user);
if (req.isHTML) {
res.render("partials/auth/verify");
return;
}
return res.status(201).send({ message: "A verification email has been sent." });
}
async function createAdminUser(req, res) {
const isThereAUser = await query.user.findAny();
if (isThereAUser) {
throw new CustomError("Can not create the admin user because a user already exists.", 400);
}
const salt = await bcrypt.genSalt(12);
const password = await bcrypt.hash(req.body.password, salt);
const user = await query.user.add({
email: req.body.email,
password,
role: ROLES.ADMIN,
verified: true
});
const token = utils.signToken(user);
if (req.isHTML) {
utils.setToken(res, token);
res.render("partials/auth/welcome");
return;
}
return res.status(201).send({ token });
}
function login(req, res) {
const token = utils.signToken(req.user);
if (req.isHTML) {
utils.setToken(res, token);
res.render("partials/auth/welcome");
return;
}
return res.status(200).send({ token });
}
async function verify(req, res, next) {
if (!req.params.verificationToken) return next();
const user = await query.user.update(
{
verification_token: req.params.verificationToken,
verification_expires: [">", utils.dateToUTC(new Date())]
},
{
verified: true,
verification_token: null,
verification_expires: null
}
);
if (user) {
const token = utils.signToken(user);
utils.deleteCurrentToken(res);
utils.setToken(res, token);
res.locals.token_verified = true;
req.cookies.token = token;
}
return next();
}
async function changePassword(req, res) {
const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password);
if (!isMatch) {
const message = "Current password is not correct.";
res.locals.errors = { currentpassword: message };
throw new CustomError(message, 401);
}
const salt = await bcrypt.genSalt(12);
const newpassword = await bcrypt.hash(req.body.newpassword, salt);
const user = await query.user.update({ id: req.user.id }, { password: newpassword });
if (!user) {
throw new CustomError("Couldn't change the password. Try again later.");
}
if (req.isHTML) {
res.setHeader("HX-Trigger-After-Swap", "resetChangePasswordForm");
res.render("partials/settings/change_password", {
success: "Password has been changed."
});
return;
}
return res
.status(200)
.send({ message: "Your password has been changed successfully." });
}
async function generateApiKey(req, res) {
const apikey = nanoid(40);
if (env.REDIS_ENABLED) {
redis.remove.user(req.user);
}
const user = await query.user.update({ id: req.user.id }, { apikey });
if (!user) {
throw new CustomError("Couldn't generate API key. Please try again later.");
}
if (req.isHTML) {
res.render("partials/settings/apikey", {
user: { apikey },
});
return;
}
return res.status(201).send({ apikey });
}
async function resetPassword(req, res) {
const user = await query.user.update(
{ email: req.body.email },
{
reset_password_token: randomUUID(),
reset_password_expires: utils.dateToUTC(addMinutes(new Date(), 30))
}
);
if (user) {
mail.resetPasswordToken(user).catch(error => {
console.error("Send reset-password token email error:\n", error);
});
}
if (req.isHTML) {
res.render("partials/reset_password/request_form", {
message: "If the email address exists, a reset password email will be sent to it."
});
return;
}
return res.status(200).send({
message: "If email address exists, a reset password email has been sent."
});
}
async function newPassword(req, res) {
const { new_password, reset_password_token } = req.body;
const salt = await bcrypt.genSalt(12);
const password = await bcrypt.hash(req.body.new_password, salt);
const user = await query.user.update(
{
reset_password_token,
reset_password_expires: [">", utils.dateToUTC(new Date())]
},
{
reset_password_expires: null,
reset_password_token: null,
password,
}
);
if (!user) {
throw new CustomError("Could not set the password. Please try again later.");
}
res.render("partials/reset_password/new_password_success");
}
async function changeEmailRequest(req, res) {
const { email, password } = req.body;
const isMatch = await bcrypt.compare(password, req.user.password);
if (!isMatch) {
const error = "Password is not correct.";
res.locals.errors = { password: error };
throw new CustomError(error, 401);
}
const user = await query.user.find({ email });
if (user) {
const error = "Can't use this email address.";
res.locals.errors = { email: error };
throw new CustomError(error, 400);
}
const updatedUser = await query.user.update(
{ id: req.user.id },
{
change_email_address: email,
change_email_token: randomUUID(),
change_email_expires: utils.dateToUTC(addMinutes(new Date(), 30))
}
);
if (updatedUser) {
await mail.changeEmail({ ...updatedUser, email });
}
const message = "A verification link has been sent to the requested email address."
if (req.isHTML) {
res.setHeader("HX-Trigger-After-Swap", "resetChangeEmailForm");
res.render("partials/settings/change_email", {
success: message
});
return;
}
return res.status(200).send({ message });
}
async function changeEmail(req, res, next) {
const changeEmailToken = req.params.changeEmailToken;
if (changeEmailToken) {
const foundUser = await query.user.find({
change_email_token: changeEmailToken,
change_email_expires: [">", utils.dateToUTC(new Date())]
});
if (!foundUser) return next();
const user = await query.user.update(
{ id: foundUser.id },
{
change_email_token: null,
change_email_expires: null,
change_email_address: null,
email: foundUser.change_email_address
}
);
if (user) {
const token = utils.signToken(user);
utils.deleteCurrentToken(res);
utils.setToken(res, token);
res.locals.token_verified = true;
req.cookies.token = token;
}
}
return next();
}
function featureAccess(features, redirect) {
return function(req, res, next) {
for (let i = 0; i < features.length; ++i) {
if (!features[i]) {
if (redirect) {
return res.redirect("/");
} else {
throw new CustomError("Request is not allowed.", 400);
}
}
}
next();
}
}
function featureAccessPage(features) {
return featureAccess(features, true);
}
module.exports = {
admin,
apikey,
changeEmail,
changeEmailRequest,
changePassword,
createAdminUser,
featureAccess,
featureAccessPage,
generateApiKey,
jwt,
jwtLoose,
jwtLoosePage,
jwtPage,
local,
login,
newPassword,
oidc,
resetPassword,
signup,
verify,
}
================================================
FILE: server/handlers/domains.handler.js
================================================
const { Handler } = require("express");
const { CustomError, sanitize } = require("../utils");
const query = require("../queries");
const redis = require("../redis");
const utils = require("../utils");
const env = require("../env");
async function add(req, res) {
const { address, homepage } = req.body;
const domain = await query.domain.add({
address,
homepage,
user_id: req.user.id
});
if (req.isHTML) {
const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain);
res.setHeader("HX-Reswap", "none");
res.render("partials/settings/domain/table", {
domains
});
return;
}
return res.status(200).send(sanitize.domain(domain));
};
async function addAdmin(req, res) {
const { address, banned, homepage } = req.body;
const domain = await query.domain.add({
address,
homepage,
banned,
...(banned && { banned_by_id: req.user.id })
});
if (req.isHTML) {
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/add_domain_success", {
address: domain.address,
});
return;
}
return res.status(200).send({ message: "The domain has been added successfully." });
};
async function remove(req, res) {
const domain = await query.domain.find({
uuid: req.params.id,
user_id: req.user.id
});
if (!domain) {
throw new CustomError("Could not delete the domain.", 400);
}
const [updatedDomain] = await query.domain.update(
{ id: domain.id },
{ user_id: null }
);
if (!updatedDomain) {
throw new CustomError("Could not delete the domain.", 500);
}
if (env.REDIS_ENABLED) {
redis.remove.domain(updatedDomain);
}
if (req.isHTML) {
const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain);
res.setHeader("HX-Reswap", "outerHTML");
res.render("partials/settings/domain/delete_success", {
domains,
address: domain.address,
});
return;
}
return res.status(200).send({ message: "Domain deleted successfully" });
};
async function removeAdmin(req, res) {
const id = req.params.id;
const links = req.query.links
const domain = await query.domain.find({ id });
if (!domain) {
throw new CustomError("Could not find the domain.", 400);
}
if (links) {
await query.link.batchRemove({ domain_id: id });
}
await query.domain.remove(domain);
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/delete_domain_success", {
address: domain.address,
});
return;
}
return res.status(200).send({ message: "Domain deleted successfully" });
}
async function getAdmin(req, res) {
const { limit, skip } = req.context;
const search = req.query.search;
const user = req.query.user;
const banned = utils.parseBooleanQuery(req.query.banned);
const owner = utils.parseBooleanQuery(req.query.owner);
const links = utils.parseBooleanQuery(req.query.links);
const match = {
...(banned !== undefined && { banned }),
...(owner !== undefined && { user_id: [owner ? "is not" : "is", null] }),
};
const [data, total] = await Promise.all([
query.domain.getAdmin(match, { limit, search, user, links, skip }),
query.domain.totalAdmin(match, { search, user, links })
]);
const domains = data.map(utils.sanitize.domain_admin);
if (req.isHTML) {
res.render("partials/admin/domains/table", {
total,
total_formatted: total.toLocaleString("en-US"),
limit,
skip,
table_domains: domains,
})
return;
}
return res.send({
total,
limit,
skip,
data: domains,
});
}
async function ban(req, res) {
const { id } = req.params;
const update = {
banned_by_id: req.user.id,
banned: true
};
// 1. check if domain exists
const domain = await query.domain.find({ id });
if (!domain) {
throw new CustomError("No domain has been found.", 400);
}
if (domain.banned) {
throw new CustomError("Domain has been banned already.", 400);
}
const tasks = [];
// 2. ban domain
tasks.push(query.domain.update({ id }, update));
// 3. ban user
if (req.body.user && domain.user_id) {
tasks.push(query.user.update({ id: domain.user_id }, update));
}
// 4. ban links
if (req.body.links) {
tasks.push(query.link.update({ domain_id: id }, update));
}
// 5. wait for all tasks to finish
await Promise.all(tasks).catch((err) => {
throw new CustomError("Couldn't ban entries.");
});
// 6. send response
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/ban_domain_success", {
address: domain.address,
});
return;
}
return res.status(200).send({ message: "Banned domain successfully." });
}
module.exports = {
add,
addAdmin,
ban,
getAdmin,
remove,
removeAdmin,
}
================================================
FILE: server/handlers/helpers.handler.js
================================================
const { RedisStore: RateLimitRedisStore } = require("rate-limit-redis");
const { rateLimit: expressRateLimit } = require("express-rate-limit");
const { validationResult } = require("express-validator");
const { CustomError } = require("../utils");
const query = require("../queries");
const redis = require("../redis");
const env = require("../env");
function error(error, req, res, _next) {
if (!(error instanceof CustomError)) {
console.error(error);
} else if (env.isDev) {
console.error(error.message);
}
const message = error instanceof CustomError ? error.message : "An error occurred.";
const statusCode = error.statusCode ?? 500;
if (req.isHTML && req.viewTemplate) {
res.locals.error = message;
res.render(req.viewTemplate);
return;
}
if (req.isHTML) {
res.render("error", {
message: "An error occurred. Please try again later."
});
return;
}
return res.status(statusCode).json({ error: message });
};
function verify(req, res, next) {
const result = validationResult(req);
if (result.isEmpty()) return next();
const errors = result.array();
const error = errors[0].msg;
res.locals.errors = {};
errors.forEach(e => {
if (res.locals.errors[e.param]) return;
res.locals.errors[e.param] = e.msg;
});
throw new CustomError(error, 400);
}
function parseQuery(req, res, next) {
const { admin } = req.user || {};
if (
typeof req.query.limit !== "undefined" &&
typeof req.query.limit !== "string"
) {
return res.status(400).json({ error: "limit query is not valid." });
}
if (
typeof req.query.skip !== "undefined" &&
typeof req.query.skip !== "string"
) {
return res.status(400).json({ error: "skip query is not valid." });
}
if (
typeof req.query.search !== "undefined" &&
typeof req.query.search !== "string"
) {
return res.status(400).json({ error: "search query is not valid." });
}
const limit = parseInt(req.query.limit) || 10;
req.context = {
limit: limit > 50 ? 50 : limit,
skip: parseInt(req.query.skip) || 0,
};
next();
};
function rateLimit(params) {
if (!env.ENABLE_RATE_LIMIT) {
return function(req, res, next) {
return next();
}
}
let store = undefined;
if (env.REDIS_ENABLED) {
store = new RateLimitRedisStore({
sendCommand: (...args) => redis.client.call(...args),
})
}
return expressRateLimit({
windowMs: params.window * 1000,
validate: { trustProxy: false },
skipSuccessfulRequests: !!params.skipSuccess,
skipFailedRequests: !!params.skipFailed,
...(store && { store }),
limit: function (req, res) {
if (params.user && req.user) {
return params.user;
}
return params.limit;
},
keyGenerator: function(req, res) {
return "rl:" + req.method + req.baseUrl + req.path + ":" + req.ip;
},
requestWasSuccessful: function(req, res) {
return !res.locals.error && res.statusCode < 400;
},
handler: function (req, res, next, options) {
throw new CustomError(options.message, options.statusCode);
},
});
}
// redirect to create admin page if the kutt instance is ran for the first time
async function adminSetup(req, res, next) {
const isThereAUser = req.user || (await query.user.findAny());
if (isThereAUser) {
next();
return;
}
res.redirect("/create-admin");
}
module.exports = {
adminSetup,
error,
parseQuery,
rateLimit,
verify,
}
================================================
FILE: server/handlers/links.handler.js
================================================
const { differenceInSeconds } = require("date-fns");
const promisify = require("node:util").promisify;
const bcrypt = require("bcryptjs");
const { isbot } = require("isbot");
const URL = require("node:url");
const dns = require("node:dns");
const validators = require("./validators.handler");
const map = require("../utils/map.json");
const transporter = require("../mail");
const query = require("../queries");
const queue = require("../queues");
const utils = require("../utils");
const env = require("../env");
const CustomError = utils.CustomError;
const dnsLookup = promisify(dns.lookup);
async function get(req, res) {
const { limit, skip } = req.context;
const search = req.query.search;
const userId = req.user.id;
const match = {
user_id: userId
};
const [data, total] = await Promise.all([
query.link.get(match, { limit, search, skip }),
query.link.total(match, { search })
]);
if (req.isHTML) {
res.render("partials/links/table", {
total,
limit,
skip,
links: data.map(utils.sanitize.link_html),
})
return;
}
return res.send({
total,
limit,
skip,
data: data.map(utils.sanitize.link),
});
};
async function getAdmin(req, res) {
const { limit, skip } = req.context;
const search = req.query.search;
const user = req.query.user;
let domain = req.query.domain;
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 })
]);
const links = data.map(utils.sanitize.link_admin);
if (req.isHTML) {
res.render("partials/admin/links/table", {
total,
total_formatted: total.toLocaleString("en-US"),
limit,
skip,
links,
})
return;
}
return res.send({
total,
limit,
skip,
data: links,
});
};
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({
target,
user_id: req.user.id,
domain_id
}),
customurl &&
query.link.find({
address: customurl,
domain_id
}),
!customurl && utils.generateId(query, domain_id),
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.";
res.locals.errors = { customurl: error };
throw new CustomError(error);
}
// Create new link
const address = customurl || tasks[2];
const link = await query.link.create({
password,
address,
domain_id,
description,
target,
expire_in,
user_id: req.user && req.user.id
});
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,
url: shortURL.url,
});
}
return res
.status(201)
.send(utils.sanitize.link({ ...link }));
}
async function edit(req, res) {
const link = await query.link.find({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
});
if (!link) {
throw new CustomError("Link was not found.");
}
let isChanged = false;
[
[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)
req.body.password = null;
else {
delete req.body[name];
return;
}
}
if (value === link[name] && name !== "password") {
delete req.body[name];
return;
}
if (name === "expire_in" && link.expire_in)
if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60)
return;
if (name === "password")
if (value && value.replace(/•/ig, "").length === 0) {
delete req.body.password;
return;
}
isChanged = true;
});
if (!isChanged) {
throw new CustomError("Should at least update one field.");
}
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;
const tasks = await Promise.all([
address &&
query.link.find({
address,
domain_id
}),
target && validators.bannedDomain(targetDomain),
target && validators.bannedHost(targetDomain)
]);
// Check if custom link already exists
if (tasks[0]) {
const error = "Custom URL is already in use.";
res.locals.errors = { address: error };
throw new CustomError("Custom URL is already in use.");
}
// Update link
const [updatedLink] = await query.link.update(
{
id: link.id
},
{
...(address && { address }),
...(description && { description }),
...(target && { target }),
...(expire_in && { expire_in }),
...((password || password === null) && { password })
}
);
if (req.isHTML) {
res.render("partials/links/edit", {
swap_oob: true,
success: "Link has been updated.",
...utils.sanitize.link_html({ ...updatedLink }),
});
return;
}
return res.status(200).send(utils.sanitize.link({ ...updatedLink }));
};
async function editAdmin(req, res) {
const link = await query.link.find({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
});
if (!link) {
throw new CustomError("Link was not found.");
}
let isChanged = false;
[
[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)
req.body.password = null;
else {
delete req.body[name];
return;
}
}
if (value === link[name] && name !== "password") {
delete req.body[name];
return;
}
if (name === "expire_in" && link.expire_in)
if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60)
return;
if (name === "password")
if (value && value.replace(/•/ig, "").length === 0) {
delete req.body.password;
return;
}
isChanged = true;
});
if (!isChanged) {
throw new CustomError("Should at least update one field.");
}
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;
const tasks = await Promise.all([
address &&
query.link.find({
address,
domain_id
}),
target && validators.bannedDomain(targetDomain),
target && validators.bannedHost(targetDomain)
]);
// Check if custom link already exists
if (tasks[0]) {
const error = "Custom URL is already in use.";
res.locals.errors = { address: error };
throw new CustomError("Custom URL is already in use.");
}
// Update link
const [updatedLink] = await query.link.update(
{
id: link.id
},
{
...(address && { address }),
...(description && { description }),
...(target && { target }),
...(expire_in && { expire_in }),
...((password || password === null) && { password })
}
);
if (req.isHTML) {
res.render("partials/admin/links/edit", {
swap_oob: true,
success: "Link has been updated.",
...utils.sanitize.link_admin({ ...updatedLink }),
});
return;
}
return res.status(200).send(utils.sanitize.link({ ...updatedLink }));
};
async function remove(req, res) {
const { error, isRemoved, link } = await query.link.remove({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
});
if (!isRemoved) {
const messsage = error || "Could not delete the link.";
throw new CustomError(messsage);
}
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/links/dialog/delete_success", {
link: utils.getShortURL(link.address, link.domain).link,
});
return;
}
return res
.status(200)
.send({ message: "Link has been deleted successfully." });
};
async function report(req, res) {
const { link } = req.body;
await transporter.sendReportEmail(link);
if (req.isHTML) {
res.render("partials/report/form", {
message: "Report was received. We'll take actions shortly."
});
return;
}
return res
.status(200)
.send({ message: "Thanks for the report, we'll take actions shortly." });
};
async function ban(req, res) {
const { id } = req.params;
const update = {
banned_by_id: req.user.id,
banned: true
};
// 1. check if link exists
const link = await query.link.find({ uuid: id });
if (!link) {
throw new CustomError("No link has been found.", 400);
}
if (link.banned) {
throw new CustomError("Link has been banned already.", 400);
}
const tasks = [];
// 2. ban link
tasks.push(query.link.update({ uuid: id }, update));
const domain = utils.removeWww(URL.parse(link.target).hostname);
// 3. ban target's domain
if (req.body.domain) {
tasks.push(query.domain.add({ ...update, address: domain }));
}
// 4. ban target's host
if (req.body.host) {
const dnsRes = await dnsLookup(domain).catch(() => {
throw new CustomError("Couldn't fetch DNS info.");
});
const host = dnsRes?.address;
tasks.push(query.host.add({ ...update, address: host }));
}
// 5. ban link owner
if (req.body.user && link.user_id) {
tasks.push(query.user.update({ id: link.user_id }, update));
}
// 6. ban all of owner's links
if (req.body.userLinks && link.user_id) {
tasks.push(query.link.update({ user_id: link.user_id }, update));
}
// 7. wait for all tasks to finish
await Promise.all(tasks).catch((err) => {
throw new CustomError("Couldn't ban entries.");
});
// 8. send response
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/links/dialog/ban_success", {
link: utils.getShortURL(link.address, link.domain).link,
});
return;
}
return res.status(200).send({ message: "Banned link successfully." });
};
async function redirect(req, res, next) {
const isPreservedUrl = utils.preservedURLs.some(
item => item === req.path.replace("/", "")
);
if (isPreservedUrl) return next();
// 1. If custom domain, get domain info
const host = utils.removeWww(req.headers.host);
const domain =
host !== env.DEFAULT_DOMAIN
? await query.domain.find({ address: host })
: null;
// 2. Get link
const address = req.params.id.replace("+", "");
const link = await query.link.find({
address,
domain_id: domain ? domain.id : null
});
// 3. When no link, if has domain redirect to domain's homepage
// otherwise redirect to 404
if (!link) {
return res.redirect(domain?.homepage || "/404");
}
// 4. If link is banned, redirect to banned page.
if (link.banned) {
return res.redirect("/banned");
}
// 5. If wants to see link info, then redirect
const isRequestingInfo = /.*\+$/gi.test(req.params.id);
if (isRequestingInfo && !link.password) {
if (req.isHTML) {
res.render("url_info", {
title: "Short link information",
target: link.target,
link: utils.getShortURL(link.address, link.domain).link
});
return;
}
return res.send({ target: link.target });
}
// 6. If link is protected, redirect to password page
if (link.password) {
if ("authorization" in req.headers) {
const auth = req.headers.authorization;
const firstSpace = auth.indexOf(" ");
if (firstSpace !== -1) {
const method = auth.slice(0, firstSpace);
const payload = auth.slice(firstSpace + 1);
if (method === "Basic") {
const decoded = Buffer.from(payload, "base64").toString("utf8");
const colon = decoded.indexOf(":");
if (colon !== -1) {
const password = decoded.slice(colon + 1);
const matches = await bcrypt.compare(password, link.password);
if (matches) return res.redirect(link.target);
}
}
}
}
res.render("protected", {
title: "Protected short link",
id: link.uuid
});
return;
}
// 7. Create link visit
const isBot = isbot(req.headers["user-agent"]);
if (link.user_id && !isBot) {
queue.visit.add({
userAgent: req.headers["user-agent"],
ip: req.ip,
country: req.get("cf-ipcountry"),
referrer: req.get("Referrer"),
link
});
}
// 8. Redirect to target
return res.redirect(link.target);
};
async function redirectProtected(req, res) {
// 1. Get link
const uuid = req.params.id;
const link = await query.link.find({ uuid });
// 2. Throw error if no link
if (!link || !link.password) {
throw new CustomError("Couldn't find the link.", 400);
}
// 3. Check if password matches
const matches = await bcrypt.compare(req.body.password, link.password);
if (!matches) {
throw new CustomError("Password is not correct.", 401);
}
// 4. Create visit
if (link.user_id) {
queue.visit.add({
userAgent: req.headers["user-agent"],
ip: req.ip,
country: req.get("cf-ipcountry"),
referrer: req.get("Referrer"),
link
});
}
// 5. Send target
if (req.isHTML) {
res.setHeader("HX-Redirect", link.target);
res.render("partials/protected/form", {
id: link.uuid,
message: "Redirecting...",
});
return;
}
return res.status(200).send({ target: link.target });
};
async function redirectCustomDomainHomepage(req, res, next) {
const host = utils.removeWww(req.headers.host);
if (host === env.DEFAULT_DOMAIN) {
next();
return;
}
const path = req.path;
const pathName = path.replace("/", "").split("/")[0];
if (
path === "/" ||
utils.preservedURLs.includes(pathName)
) {
const domain = await query.domain.find({ address: host });
if (domain?.homepage) {
res.redirect(302, domain.homepage);
return;
}
}
next();
};
async function stats(req, res) {
const { user } = req;
const uuid = req.params.id;
const link = await query.link.find({
...(!user.admin && { user_id: user.id }),
uuid
});
if (!link) {
if (req.isHTML) {
res.setHeader("HX-Redirect", "/404");
res.status(200).send("");
return;
}
throw new CustomError("Link could not be found.");
}
const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
if (!stats) {
throw new CustomError("Could not get the short link stats. Try again later.");
}
if (req.isHTML) {
res.render("partials/stats", {
link: utils.sanitize.link_html(link),
stats,
map,
});
return;
}
return res.status(200).send({
...stats,
...utils.sanitize.link(link)
});
};
module.exports = {
ban,
create,
edit,
editAdmin,
get,
getAdmin,
remove,
report,
stats,
redirect,
redirectProtected,
redirectCustomDomainHomepage,
}
================================================
FILE: server/handlers/locals.handler.js
================================================
const query = require("../queries");
const utils = require("../utils");
const env = require("../env");
function isHTML(req, res, next) {
const accepts = req.accepts(["json", "html"]);
req.isHTML = accepts === "html";
next();
}
function noLayout(req, res, next) {
res.locals.layout = null;
next();
}
function viewTemplate(template) {
return function (req, res, next) {
req.viewTemplate = template;
next();
}
}
function config(req, res, next) {
res.locals.default_domain = env.DEFAULT_DOMAIN;
res.locals.site_name = env.SITE_NAME;
res.locals.contact_email = env.CONTACT_EMAIL;
res.locals.server_ip_address = env.SERVER_IP_ADDRESS;
res.locals.server_cname_address = env.SERVER_CNAME_ADDRESS;
res.locals.disallow_registration = env.DISALLOW_REGISTRATION;
res.locals.disallow_login_form = env.DISALLOW_LOGIN_FORM;
res.locals.login_disabled = env.DISALLOW_LOGIN_FORM && !env.OIDC_ENABLED;
res.locals.oidc_enabled = env.OIDC_ENABLED;
res.locals.mail_enabled = env.MAIL_ENABLED;
res.locals.report_email = env.REPORT_EMAIL;
res.locals.custom_styles = utils.getCustomCSSFileNames();
next();
}
async function user(req, res, next) {
const user = req.user;
res.locals.user = user;
res.locals.domains = user && (await query.domain.get({ user_id: user.id })).map(utils.sanitize.domain);
next();
}
function newPassword(req, res, next) {
res.locals.reset_password_token = req.body.reset_password_token;
next();
}
function createLink(req, res, next) {
res.locals.show_advanced = !!req.body.show_advanced;
next();
}
function editLink(req, res, next) {
res.locals.id = req.params.id;
res.locals.class = "no-animation";
next();
}
function protected(req, res, next) {
res.locals.id = req.params.id;
next();
}
function adminTable(req, res, next) {
res.locals.query = {
anonymous: req.query.anonymous,
domain: req.query.domain,
domains: req.query.domains,
links: req.query.links,
role: req.query.role,
search: req.query.search,
user: req.query.user,
verified: req.query.verified,
};
next();
}
module.exports = {
adminTable,
config,
createLink,
editLink,
isHTML,
newPassword,
noLayout,
protected,
user,
viewTemplate,
}
================================================
FILE: server/handlers/renders.handler.js
================================================
const query = require("../queries");
const utils = require("../utils");
const env = require("../env");
/**
*
* PAGES
*
**/
async function homepage(req, res) {
if (env.DISALLOW_ANONYMOUS_LINKS && !req.user) {
res.redirect("/login");
return;
}
res.render("homepage", {
title: "Free modern URL shortener",
});
}
async function login(req, res) {
if (req.user) {
res.redirect("/");
return;
}
res.render("login", {
title: "Log in or sign up"
});
}
function logout(req, res) {
utils.deleteCurrentToken(res);
res.render("logout", {
title: "Logging out.."
});
}
async function createAdmin(req, res) {
const isThereAUser = await query.user.findAny();
if (isThereAUser) {
res.redirect("/login");
return;
}
res.render("create_admin", {
title: "Create admin account"
});
}
function notFound(req, res) {
res.render("404", {
title: "404 - Not found"
});
}
function settings(req, res) {
res.render("settings", {
title: "Settings"
});
}
function admin(req, res) {
res.render("admin", {
title: "Admin"
});
}
function stats(req, res) {
res.render("stats", {
title: "Stats"
});
}
async function banned(req, res) {
res.render("banned", {
title: "Banned link",
});
}
async function report(req, res) {
if (!env.REPORT_EMAIL) {
res.redirect("/");
return;
}
res.render("report", {
title: "Report abuse",
});
}
async function resetPassword(req, res) {
res.render("reset_password", {
title: "Reset password",
});
}
async function resetPasswordSetNewPassword(req, res) {
const reset_password_token = req.params.resetPasswordToken;
if (reset_password_token) {
const user = await query.user.find(
{
reset_password_token,
reset_password_expires: [">", utils.dateToUTC(new Date())]
}
);
if (user) {
res.locals.token_verified = true;
}
}
res.render("reset_password_set_new_password", {
title: "Reset password",
...(res.locals.token_verified && { reset_password_token }),
});
}
async function verifyChangeEmail(req, res) {
res.render("verify_change_email", {
title: "Verifying email",
});
}
async function verify(req, res) {
res.render("verify", {
title: "Verify",
});
}
async function terms(req, res) {
res.render("terms", {
title: "Terms of Service",
});
}
/**
*
* PARTIALS
*
**/
async function confirmLinkDelete(req, res) {
const link = await query.link.find({
uuid: req.query.id,
...(!req.user.admin && { user_id: req.user.id })
});
if (!link) {
return res.render("partials/links/dialog/message", {
layout: false,
message: "Could not find the link."
});
}
res.render("partials/links/dialog/delete", {
layout: false,
link: utils.getShortURL(link.address, link.domain).link,
id: link.uuid
});
}
async function confirmLinkBan(req, res) {
const link = await query.link.find({
uuid: req.query.id,
...(!req.user.admin && { user_id: req.user.id })
});
if (!link) {
return res.render("partials/links/dialog/message", {
message: "Could not find the link."
});
}
res.render("partials/links/dialog/ban", {
link: utils.getShortURL(link.address, link.domain).link,
id: link.uuid
});
}
async function confirmUserDelete(req, res) {
const user = await query.user.find({ id: req.query.id });
if (!user) {
return res.render("partials/admin/dialog/message", {
layout: false,
message: "Could not find the user."
});
}
res.render("partials/admin/dialog/delete_user", {
layout: false,
email: user.email,
id: user.id
});
}
async function confirmUserBan(req, res) {
const user = await query.user.find({ id: req.query.id });
if (!user) {
return res.render("partials/admin/dialog/message", {
layout: false,
message: "Could not find the user."
});
}
res.render("partials/admin/dialog/ban_user", {
layout: false,
email: user.email,
id: user.id
});
}
async function createUser(req, res) {
res.render("partials/admin/dialog/create_user", {
layout: false,
});
}
async function addDomainAdmin(req, res) {
res.render("partials/admin/dialog/add_domain", {
layout: false,
});
}
async function addDomainForm(req, res) {
res.render("partials/settings/domain/add_form");
}
async function confirmDomainDelete(req, res) {
const domain = await query.domain.find({
uuid: req.query.id,
user_id: req.user.id
});
if (!domain) {
throw new utils.CustomError("Could not find the domain.", 400);
}
res.render("partials/settings/domain/delete", {
...utils.sanitize.domain(domain)
});
}
async function confirmDomainBan(req, res) {
const domain = await query.domain.find({
id: req.query.id
});
if (!domain) {
throw new utils.CustomError("Could not find the domain.", 400);
}
const hasUser = !!domain.user_id;
const hasLink = await query.link.find({ domain_id: domain.id });
res.render("partials/admin/dialog/ban_domain", {
id: domain.id,
address: domain.address,
hasUser,
hasLink,
});
}
async function confirmDomainDeleteAdmin(req, res) {
const domain = await query.domain.find({
id: req.query.id
});
if (!domain) {
throw new utils.CustomError("Could not find the domain.", 400);
}
const hasLink = await query.link.find({ domain_id: domain.id });
res.render("partials/admin/dialog/delete_domain", {
id: domain.id,
address: domain.address,
hasLink,
});
}
async function getReportEmail(req, res) {
if (!env.REPORT_EMAIL) {
throw new utils.CustomError("No report email is available.", 400);
}
res.render("partials/report/email", {
report_email_address: env.REPORT_EMAIL.replace("@", "[at]")
});
}
async function getSupportEmail(req, res) {
if (!env.CONTACT_EMAIL) {
throw new utils.CustomError("No support email is available.", 400);
}
await utils.sleep(500);
res.render("partials/support_email", {
email: env.CONTACT_EMAIL,
});
}
async function linkEdit(req, res) {
const link = await query.link.find({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
});
res.render("partials/links/edit", {
...(link && utils.sanitize.link_html(link)),
domain: link.domain || env.DEFAULT_DOMAIN,
});
}
async function linkEditAdmin(req, res) {
const link = await query.link.find({
uuid: req.params.id,
});
res.render("partials/admin/links/edit", {
...(link && utils.sanitize.link_html(link)),
domain: link.domain || env.DEFAULT_DOMAIN,
});
}
module.exports = {
addDomainAdmin,
addDomainForm,
admin,
banned,
confirmDomainBan,
confirmDomainDelete,
confirmDomainDeleteAdmin,
confirmLinkBan,
confirmLinkDelete,
confirmUserBan,
confirmUserDelete,
createAdmin,
createUser,
getReportEmail,
getSupportEmail,
homepage,
linkEdit,
linkEditAdmin,
login,
logout,
notFound,
report,
resetPassword,
resetPasswordSetNewPassword,
settings,
stats,
terms,
verifyChangeEmail,
verify,
}
================================================
FILE: server/handlers/users.handler.js
================================================
const bcrypt = require("bcryptjs");
const query = require("../queries");
const utils = require("../utils");
const mail = require("../mail");
const env = require("../env");
async function get(req, res) {
const domains = await query.domain.get({ user_id: req.user.id });
const data = {
apikey: req.user.apikey,
email: req.user.email,
domains: domains.map(utils.sanitize.domain)
};
return res.status(200).send(data);
};
async function remove(req, res) {
await query.user.remove(req.user);
if (req.isHTML) {
utils.deleteCurrentToken(res);
res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");
res.render("partials/settings/delete_account", {
success: "Account has been deleted. Logging out..."
});
return;
}
return res.status(200).send("OK");
};
async function removeByAdmin(req, res) {
const user = await query.user.find({ id: req.params.id });
if (!user) {
const message = "Could not find the user.";
if (req.isHTML) {
return res.render("partials/admin/dialog/message", {
layout: false,
message
});
} else {
return res.status(400).send({ message });
}
}
await query.user.remove(user);
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/delete_user_success", {
email: user.email,
});
return;
}
return res.status(200).send({ message: "User has been deleted successfully." });
};
async function getAdmin(req, res) {
const { limit, skip, all } = req.context;
const { role, search } = req.query;
const userId = req.user.id;
const verified = utils.parseBooleanQuery(req.query.verified);
const banned = utils.parseBooleanQuery(req.query.banned);
const domains = utils.parseBooleanQuery(req.query.domains);
const links = utils.parseBooleanQuery(req.query.links);
const match = {
...(role && { role }),
...(verified !== undefined && { verified }),
...(banned !== undefined && { banned }),
};
const [data, total] = await Promise.all([
query.user.getAdmin(match, { limit, search, domains, links, skip }),
query.user.totalAdmin(match, { search, domains, links })
]);
const users = data.map(utils.sanitize.user_admin);
if (req.isHTML) {
res.render("partials/admin/users/table", {
total,
total_formatted: total.toLocaleString("en-US"),
limit,
skip,
users,
})
return;
}
return res.send({
total,
limit,
skip,
data: users,
});
};
async function ban(req, res) {
const { id } = req.params;
const update = {
banned_by_id: req.user.id,
banned: true
};
// 1. check if user exists
const user = await query.user.find({ id });
if (!user) {
throw new CustomError("No user has been found.", 400);
}
if (user.banned) {
throw new CustomError("User has been banned already.", 400);
}
const tasks = [];
// 2. ban user
tasks.push(query.user.update({ id }, update));
// 3. ban user links
if (req.body.links) {
tasks.push(query.link.update({ user_id: id }, update));
}
// 4. ban user domains
if (req.body.domains) {
tasks.push(query.domain.update({ user_id: id }, update));
}
// 5. wait for all tasks to finish
await Promise.all(tasks).catch((err) => {
throw new CustomError("Couldn't ban entries.");
});
// 6. send response
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/ban_user_success", {
email: user.email,
});
return;
}
return res.status(200).send({ message: "Banned user successfully." });
}
async function create(req, res) {
const salt = await bcrypt.genSalt(12);
req.body.password = await bcrypt.hash(req.body.password, salt);
const user = await query.user.create(req.body);
if (req.body.verification_email && !user.banned && !user.verified) {
await mail.verification(user);
}
if (req.isHTML) {
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/create_user_success", {
email: user.email,
});
return;
}
return res.status(201).send({ message: "The user has been created successfully." });
}
module.exports = {
ban,
create,
get,
getAdmin,
remove,
removeByAdmin,
}
================================================
FILE: server/handlers/validators.handler.js
================================================
const { addMilliseconds } = require("date-fns");
const { body, param, query: queryValidator } = require("express-validator");
const promisify = require("node:util").promisify;
const bcrypt = require("bcryptjs");
const dns = require("node:dns");
const URL = require("node:url");
const ms = require("ms");
const { ROLES } = require("../consts");
const query = require("../queries");
const utils = require("../utils");
const knex = require("../knex");
const env = require("../env");
const dnsLookup = promisify(dns.lookup);
const checkUser = (value, { req }) => !!req.user;
const sanitizeCheckbox = value => value === true || value === "on" || value;
const createLink = [
body("target")
.exists({ checkNull: true, checkFalsy: true })
.withMessage("Target is missing.")
.isString()
.trim()
.isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.")
.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.`),
body("password")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
body("customurl")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.trim()
.isLength({ min: 1, max: 64 })
.withMessage("Custom URL length must be between 1 and 64.")
.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."),
body("reuse")
.optional({ nullable: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isBoolean()
.withMessage("Reuse must be boolean."),
body("description")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 2040 })
.withMessage("Description length must be between 1 and 2040."),
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
.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.")
];
const editLink = [
body("target")
.optional({ checkFalsy: true, nullable: true })
.isString()
.trim()
.isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.")
.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.`),
body("password")
.optional({ nullable: true, checkFalsy: true })
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
body("address")
.optional({ checkFalsy: true, nullable: true })
.isString()
.trim()
.isLength({ min: 1, max: 64 })
.withMessage("Custom URL length must be between 1 and 64.")
.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."),
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
.withMessage("Expire time should be more than 1 minute.")
.customSanitizer(value => utils.dateToUTC(addMilliseconds(new Date(), value))),
body("description")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 36, max: 36 })
];
const redirectProtected = [
body("password", "Password is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 36, max: 36 })
];
const addDomain = [
body("address", "Domain is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 3, max: 64 })
.withMessage("Domain length must be between 3 and 64.")
.trim()
.customSanitizer(utils.addProtocol)
.custom(value => utils.urlRegex.test(value))
.customSanitizer(value => {
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(async value => {
const domain = await query.domain.find({ address: value });
if (domain?.user_id || domain?.banned) return Promise.reject();
})
.withMessage("You can't add this domain."),
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.")
];
const addDomainAdmin = [
body("address", "Domain is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 3, max: 64 })
.withMessage("Domain length must be between 3 and 64.")
.trim()
.customSanitizer(utils.addProtocol)
.custom(value => utils.urlRegex.test(value))
.customSanitizer(value => {
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(async value => {
const domain = await query.domain.find({ address: value });
if (domain) return Promise.reject();
})
.withMessage("Domain already exists."),
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("banned")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
]
const removeDomain = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 })
];
const removeDomainAdmin = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isNumeric(),
queryValidator("links")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
];
const deleteLink = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 })
];
const reportLink = [
body("link", "No link has been provided.")
.exists({
checkFalsy: true,
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.`)
];
const banLink = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 }),
body("host", '"host" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("user", '"user" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("userLinks", '"userLinks" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("domain", '"domain" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean()
];
const banUser = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isNumeric(),
body("links", '"links" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("domains", '"domains" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean()
];
const banDomain = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isNumeric(),
body("links", '"links" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("domains", '"domains" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean()
];
const createUser = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("Email length must be max 255.")
.isEmail()
.custom(async (value, { req }) => {
const user = await query.user.find({ email: value });
if (user)
return Promise.reject();
})
.withMessage("User already exists."),
body("role", "Role is not valid.")
.optional({ nullable: true, checkFalsy: true })
.trim()
.isIn([ROLES.USER, ROLES.ADMIN]),
body("verified")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("banned")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("verification_email")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
];
const getStats = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 })
];
const signup = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
.isEmail()
];
const signupEmailTaken = [
body("email", "Email is not valid.")
.custom(async (value, { req }) => {
const user = await query.user.find({ email: value });
if (user) {
req.user = user;
}
if (user?.verified) {
return Promise.reject();
}
})
.withMessage("You can't use this email address.")
];
const login = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("Email length must be max 255.")
.isEmail()
];
const createAdmin = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
.isEmail()
];
const changePassword = [
body("currentpassword", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("newpassword", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64.")
];
const changeEmail = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email address is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("Email length must be max 255.")
.isEmail()
];
const resetPassword = [
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
.isEmail()
];
const newPassword = [
body("reset_password_token", "Reset password token is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 36, max: 36 }),
body("new_password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("repeat_password", "Password is not valid.")
.custom((repeat_password, { req }) => {
return repeat_password === req.body.new_password;
})
.withMessage("Passwords don't match."),
];
const deleteUser = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.custom(async (password, { req }) => {
const isMatch = await bcrypt.compare(password, req.user.password);
if (!isMatch) return Promise.reject();
})
.withMessage("Password is not correct.")
];
const deleteUserByAdmin = [
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isNumeric()
];
async function bannedDomain(domain) {
const isBanned = await query.domain.find({
address: domain,
banned: true
});
if (isBanned) {
throw new utils.CustomError("Domain is banned.", 400);
}
};
async function bannedHost(domain) {
let isBanned;
try {
const dnsRes = await dnsLookup(domain);
if (!dnsRes || !dnsRes.address) return;
isBanned = await query.host.find({
address: dnsRes.address,
banned: true
});
} catch (error) {
isBanned = null;
}
if (isBanned) {
throw new utils.CustomError("URL is containing malware/scam.", 400);
}
};
module.exports = {
addDomain,
addDomainAdmin,
banDomain,
banLink,
banUser,
bannedDomain,
bannedHost,
changeEmail,
changePassword,
checkUser,
createAdmin,
createLink,
createUser,
deleteLink,
deleteUser,
deleteUserByAdmin,
editLink,
getStats,
login,
newPassword,
redirectProtected,
removeDomain,
removeDomainAdmin,
reportLink,
resetPassword,
signup,
signupEmailTaken,
}
================================================
FILE: server/knex.js
================================================
const knex = require("knex");
const env = require("./env");
const isSQLite = env.DB_CLIENT === "sqlite3" || env.DB_CLIENT === "better-sqlite3";
const isPostgres = env.DB_CLIENT === "pg" || env.DB_CLIENT === "pg-native";
const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2";
const db = knex({
client: env.DB_CLIENT,
connection: {
...(isSQLite && { filename: env.DB_FILENAME }),
host: env.DB_HOST,
port: env.DB_PORT,
database: env.DB_NAME,
user: env.DB_USER,
password: env.DB_PASSWORD,
ssl: env.DB_SSL,
pool: {
min: env.DB_POOL_MIN,
max: env.DB_POOL_MAX
}
},
useNullAsDefault: true,
});
db.isPostgres = isPostgres;
db.isSQLite = isSQLite;
db.isMySQL = isMySQL;
db.compatibleILIKE = isPostgres ? "andWhereILike" : "andWhereLike";
module.exports = db;
================================================
FILE: server/mail/index.js
================================================
module.exports = require("./mail");
================================================
FILE: server/mail/mail.js
================================================
const nodemailer = require("nodemailer");
const path = require("node:path");
const fs = require("node:fs");
const { resetMailText, verifyMailText, changeEmailText } = require("./text");
const { CustomError } = require("../utils");
const env = require("../env");
const mailConfig = {
host: env.MAIL_HOST,
port: env.MAIL_PORT,
secure: env.MAIL_SECURE,
auth: env.MAIL_USER
? {
user: env.MAIL_USER,
pass: env.MAIL_PASSWORD
}
: undefined
};
const transporter = nodemailer.createTransport(mailConfig);
// Read email templates
const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
const changeEmailTemplatePath = path.join(__dirname,"template-change-email.html");
let resetEmailTemplate,
verifyEmailTemplate,
changeEmailTemplate;
// only read email templates if email is enabled
if (env.MAIL_ENABLED) {
resetEmailTemplate = fs
.readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
.replace(/{{site_name}}/gm, env.SITE_NAME);
verifyEmailTemplate = fs
.readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
.replace(/{{site_name}}/gm, env.SITE_NAME);
changeEmailTemplate = fs
.readFileSync(changeEmailTemplatePath, { encoding: "utf-8" })
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
.replace(/{{site_name}}/gm, env.SITE_NAME);
}
async function verification(user) {
if (!env.MAIL_ENABLED) {
throw new Error("Attempting to send verification email but email is not enabled.");
};
const mail = await transporter.sendMail({
from: env.MAIL_FROM || env.MAIL_USER,
to: user.email,
subject: "Verify your account",
text: verifyMailText
.replace(/{{verification}}/gim, user.verification_token)
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
.replace(/{{site_name}}/gm, env.SITE_NAME),
html: verifyEmailTemplate
.replace(/{{verification}}/gim, user.verification_token)
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
.replace(/{{site_name}}/gm, env.SITE_NAME)
});
if (!mail.accepted.length) {
throw new CustomError("Couldn't send verification email. Try again later.");
}
}
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(/{{site_name}}/gm, env.SITE_NAME),
html: changeEmailTemplate
.replace(/{{verification}}/gim, user.change_email_token)
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
.replace(/{{site_name}}/gm, env.SITE_NAME)
});
if (!mail.accepted.length) {
throw new CustomError("Couldn't send verification email. Try again later.");
}
}
async function resetPasswordToken(user) {
if (!env.MAIL_ENABLED) {
throw new Error("Attempting to send reset password email but email is not enabled.");
};
const mail = await transporter.sendMail({
from: env.MAIL_FROM || env.MAIL_USER,
to: user.email,
subject: "Reset your password",
text: resetMailText
.replace(/{{resetpassword}}/gm, user.reset_password_token)
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN),
html: resetEmailTemplate
.replace(/{{resetpassword}}/gm, user.reset_password_token)
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
});
if (!mail.accepted.length) {
throw new CustomError(
"Couldn't send reset password email. Try again later."
);
}
}
async function sendReportEmail(link) {
if (!env.MAIL_ENABLED) {
throw new Error("Attempting to send report email but email is not enabled.");
};
const mail = await transporter.sendMail({
from: env.MAIL_FROM || env.MAIL_USER,
to: env.REPORT_EMAIL,
subject: "[REPORT]",
text: link,
html: link
});
if (!mail.accepted.length) {
throw new CustomError("Couldn't submit the report. Try again later.");
}
}
module.exports = {
changeEmail,
verification,
resetPasswordToken,
sendReportEmail,
}
================================================
FILE: server/mail/template-change-email.html
================================================
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<!--[if gte mso 9
]><xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml><!
[endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<title></title>
<style type="text/css" id="media-query">
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-browser table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors="true"] {
color: inherit !important;
text-decoration: none !important;
}
[owa] .img-container div,
[owa] .img-container button {
display: block !important;
}
[owa] .fullwidth button {
width: 100% !important;
}
[owa] .block-grid .col {
display: table-cell;
float: none !important;
vertical-align: top;
}
.ie-browser .num12,
.ie-browser .block-grid,
[owa] .num12,
[owa] .block-grid {
width: 500px !important;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.ie-browser .mixed-two-up .num4,
[owa] .mixed-two-up .num4 {
width: 164px !important;
}
.ie-browser .mixed-two-up .num8,
[owa] .mixed-two-up .num8 {
width: 328px !important;
}
.ie-browser .block-grid.two-up .col,
[owa] .block-grid.two-up .col {
width: 250px !important;
}
.ie-browser .block-grid.three-up .col,
[owa] .block-grid.three-up .col {
width: 166px !important;
}
.ie-browser .block-grid.four-up .col,
[owa] .block-grid.four-up .col {
width: 125px !important;
}
.ie-browser .block-grid.five-up .col,
[owa] .block-grid.five-up .col {
width: 100px !important;
}
.ie-browser .block-grid.six-up .col,
[owa] .block-grid.six-up .col {
width: 83px !important;
}
.ie-browser .block-grid.seven-up .col,
[owa] .block-grid.seven-up .col {
width: 71px !important;
}
.ie-browser .block-grid.eight-up .col,
[owa] .block-grid.eight-up .col {
width: 62px !important;
}
.ie-browser .block-grid.nine-up .col,
[owa] .block-grid.nine-up .col {
width: 55px !important;
}
.ie-browser .block-grid.ten-up .col,
[owa] .block-grid.ten-up .col {
width: 50px !important;
}
.ie-browser .block-grid.eleven-up .col,
[owa] .block-grid.eleven-up .col {
width: 45px !important;
}
.ie-browser .block-grid.twelve-up .col,
[owa] .block-grid.twelve-up .col {
width: 41px !important;
}
@media only screen and (min-width: 520px) {
.block-grid {
width: 500px !important;
}
.block-grid .col {
vertical-align: top;
}
.block-grid .col.num12 {
width: 500px !important;
}
.block-grid.mixed-two-up .col.num4 {
width: 164px !important;
}
.block-grid.mixed-two-up .col.num8 {
width: 328px !important;
}
.block-grid.two-up .col {
width: 250px !important;
}
.block-grid.three-up .col {
width: 166px !important;
}
.block-grid.four-up .col {
width: 125px !important;
}
.block-grid.five-up .col {
width: 100px !important;
}
.block-grid.six-up .col {
width: 83px !important;
}
.block-grid.seven-up .col {
width: 71px !important;
}
.block-grid.eight-up .col {
width: 62px !important;
}
.block-grid.nine-up .col {
width: 55px !important;
}
.block-grid.ten-up .col {
width: 50px !important;
}
.block-grid.eleven-up .col {
width: 45px !important;
}
.block-grid.twelve-up .col {
width: 41px !important;
}
}
@media (max-width: 520px) {
.block-grid,
.col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.block-grid {
width: calc(100% - 40px) !important;
}
.col {
width: 100% !important;
}
.col > div {
margin: 0 auto;
}
img.fullwidth,
img.fullwidthOnMobile {
max-width: 100% !important;
}
.no-stack .col {
min-width: 0 !important;
display: table-cell !important;
}
.no-stack.two-up .col {
width: 50% !important;
}
.no-stack.mixed-two-up .col.num4 {
width: 33% !important;
}
.no-stack.mixed-two-up .col.num8 {
width: 66% !important;
}
.no-stack.three-up .col.num4 {
width: 33% !important;
}
.no-stack.four-up .col.num3 {
width: 25% !important;
}
}
</style>
</head>
<body
class="clean-body"
style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #FFFFFF"
>
<style type="text/css" id="media-query-bodytag">
@media (max-width: 520px) {
.block-grid {
min-width: 320px !important;
max-width: 100% !important;
width: 100% !important;
display: block !important;
}
.col {
min-width: 320px !important;
max-width: 100% !important;
width: 100% !important;
display: block !important;
}
.col > div {
margin: 0 auto;
}
img.fullwidth {
max-width: 100% !important;
}
img.fullwidthOnMobile {
max-width: 100% !important;
}
.no-stack .col {
min-width: 0 !important;
display: table-cell !important;
}
.no-stack.two-up .col {
width: 50% !important;
}
.no-stack.mixed-two-up .col.num4 {
width: 33% !important;
}
.no-stack.mixed-two-up .col.num8 {
width: 66% !important;
}
.no-stack.three-up .col.num4 {
width: 33% !important;
}
.no-stack.four-up .col.num3 {
width: 25% !important;
}
}
</style>
<!--[if IE]><div class="ie-browser"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
class="nl-container"
style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #FFFFFF;width: 100%"
cellpadding="0"
cellspacing="0"
>
<tbody>
<tr style="vertical-align: top">
<td
style="word-break: break-word;border-collapse: collapse !important;vertical-align: top"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #FFFFFF;"><![endif]-->
<div style="background-color:#FFFFFF;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: #000000;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:#000000;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:#FFFFFF;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:#000000;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color:#FFFFFF; width:500px; padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: #FFFFFF; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"
>
<!--<![endif]-->
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 30px; padding-top: 10px; padding-bottom: 10px;"><![endif]-->
<div
style="color:#000000;line-height:200%;font-family:'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; padding-right: 0px; padding-left: 30px; padding-top: 10px; padding-bottom: 10px;"
>
<div
style="font-size:12px;line-height:24px;font-family:'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;color:#000000;text-align:left;"
>
<p
style="margin: 0;font-size: 14px;line-height: 28px;text-align: left"
>
<span
style="color: rgb(0, 0, 0); font-size: 14px; line-height: 28px;"
>
<strong>
<span
style="line-height: 56px; font-size: 28px;"
>
<span
style="font-size: 24px; line-height: 48px;"
>{{site_name}}</span
>.</span
>
</strong>
<span
style="line-height: 56px; font-size: 28px;"
></span>
</span>
</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<div style="background-color:transparent;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style=" width:500px; padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: transparent; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"
>
<!--<![endif]-->
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 30px; padding-left: 30px; padding-top: 30px; padding-bottom: 30px;"><![endif]-->
<div
style="color:#555555;line-height:180%;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; padding-right: 30px; padding-left: 30px; padding-top: 30px; padding-bottom: 30px;"
>
<div
style="font-size:12px;line-height:22px;color:#555555;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;text-align:left;"
>
<p
style="margin: 0;font-size: 14px;line-height: 25px"
>
You're attempting to change your email address on
{{domain}}.
<br />
</p>
<p
style="margin: 0;font-size: 14px;line-height: 25px"
>
Please verify your email address using the link
below.
</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<div style="background-color:transparent;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style=" width:500px; padding-right: 5px; padding-left: 5px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: transparent; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 5px; padding-left: 5px;"
>
<!--<![endif]-->
<div
align="left"
class="button-container left"
style="padding-right: 30px; padding-left: 30px; padding-top:30px; padding-bottom:30px;"
>
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding-right: 30px; padding-left: 30px; padding-top:30px; padding-bottom:30px;" align="left"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="http://{{domain}}/verify-email/{{verification}}" style="height:31pt; v-text-anchor:middle; width:81pt;" arcsize="143%" strokecolor="#2196F3" fillcolor="#2196F3"><w:anchorlock/><v:textbox inset="0,0,0,0"><center style="color:#ffffff; font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size:16px;"><![endif]-->
<a
href="https://{{domain}}/verify-email/{{verification}}"
target="_blank"
style="display: block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #ffffff; background-color: #2196F3; border-radius: 60px; -webkit-border-radius: 60px; -moz-border-radius: 60px; max-width: 108px; width: 48px;width: auto; border-top: 0px solid transparent; border-right: 0px solid transparent; border-bottom: 0px solid transparent; border-left: 0px solid transparent; padding-top: 5px; padding-right: 30px; padding-bottom: 5px; padding-left: 30px; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;mso-border-alt: none"
>
<span style="font-size:16px;line-height:32px;"
>Verify email</span
>
</a>
<!--[if mso]></center></v:textbox></v:roundrect></td></tr></table><![endif]-->
</div>
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 40px; padding-left: 40px; padding-top: 40px; padding-bottom: 40px;"><![endif]-->
<div
style="color:#555555;line-height:180%;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; padding-right: 40px; padding-left: 40px; padding-top: 40px; padding-bottom: 40px;"
>
<div
style="font-size:12px;line-height:22px;text-align:center;color:#555555;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;"
>
<span style="font-size:14px; line-height:25px;">
<a
style="color:#0068A5;text-decoration: underline;"
href="https://{{domain}}"
target="_blank"
rel="noopener"
data-mce-selected="1"
>{{site_name}} | Free & open source URL
shortener</a
>
</span>
<br data-mce-bogus="1" />
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if (mso)|(IE)]></div><![endif]-->
</body>
</html>
================================================
FILE: server/mail/template-reset.html
================================================
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<!--[if gte mso 9
]><xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml><!
[endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<title></title>
<style type="text/css" id="media-query">
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-browser table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors="true"] {
color: inherit !important;
text-decoration: none !important;
}
[owa] .img-container div,
[owa] .img-container button {
display: block !important;
}
[owa] .fullwidth button {
width: 100% !important;
}
[owa] .block-grid .col {
display: table-cell;
float: none !important;
vertical-align: top;
}
.ie-browser .num12,
.ie-browser .block-grid,
[owa] .num12,
[owa] .block-grid {
width: 500px !important;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.ie-browser .mixed-two-up .num4,
[owa] .mixed-two-up .num4 {
width: 164px !important;
}
.ie-browser .mixed-two-up .num8,
[owa] .mixed-two-up .num8 {
width: 328px !important;
}
.ie-browser .block-grid.two-up .col,
[owa] .block-grid.two-up .col {
width: 250px !important;
}
.ie-browser .block-grid.three-up .col,
[owa] .block-grid.three-up .col {
width: 166px !important;
}
.ie-browser .block-grid.four-up .col,
[owa] .block-grid.four-up .col {
width: 125px !important;
}
.ie-browser .block-grid.five-up .col,
[owa] .block-grid.five-up .col {
width: 100px !important;
}
.ie-browser .block-grid.six-up .col,
[owa] .block-grid.six-up .col {
width: 83px !important;
}
.ie-browser .block-grid.seven-up .col,
[owa] .block-grid.seven-up .col {
width: 71px !important;
}
.ie-browser .block-grid.eight-up .col,
[owa] .block-grid.eight-up .col {
width: 62px !important;
}
.ie-browser .block-grid.nine-up .col,
[owa] .block-grid.nine-up .col {
width: 55px !important;
}
.ie-browser .block-grid.ten-up .col,
[owa] .block-grid.ten-up .col {
width: 50px !important;
}
.ie-browser .block-grid.eleven-up .col,
[owa] .block-grid.eleven-up .col {
width: 45px !important;
}
.ie-browser .block-grid.twelve-up .col,
[owa] .block-grid.twelve-up .col {
width: 41px !important;
}
@media only screen and (min-width: 520px) {
.block-grid {
width: 500px !important;
}
.block-grid .col {
vertical-align: top;
}
.block-grid .col.num12 {
width: 500px !important;
}
.block-grid.mixed-two-up .col.num4 {
width: 164px !important;
}
.block-grid.mixed-two-up .col.num8 {
width: 328px !important;
}
.block-grid.two-up .col {
width: 250px !important;
}
.block-grid.three-up .col {
width: 166px !important;
}
.block-grid.four-up .col {
width: 125px !important;
}
.block-grid.five-up .col {
width: 100px !important;
}
.block-grid.six-up .col {
width: 83px !important;
}
.block-grid.seven-up .col {
width: 71px !important;
}
.block-grid.eight-up .col {
width: 62px !important;
}
.block-grid.nine-up .col {
width: 55px !important;
}
.block-grid.ten-up .col {
width: 50px !important;
}
.block-grid.eleven-up .col {
width: 45px !important;
}
.block-grid.twelve-up .col {
width: 41px !important;
}
}
@media (max-width: 520px) {
.block-grid,
.col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.block-grid {
width: calc(100% - 40px) !important;
}
.col {
width: 100% !important;
}
.col > div {
margin: 0 auto;
}
img.fullwidth,
img.fullwidthOnMobile {
max-width: 100% !important;
}
.no-stack .col {
min-width: 0 !important;
display: table-cell !important;
}
.no-stack.two-up .col {
width: 50% !important;
}
.no-stack.mixed-two-up .col.num4 {
width: 33% !important;
}
.no-stack.mixed-two-up .col.num8 {
width: 66% !important;
}
.no-stack.three-up .col.num4 {
width: 33% !important;
}
.no-stack.four-up .col.num3 {
width: 25% !important;
}
}
</style>
</head>
<body
class="clean-body"
style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #FFFFFF"
>
<style type="text/css" id="media-query-bodytag">
@media (max-width: 520px) {
.block-grid {
min-width: 320px !important;
max-width: 100% !important;
width: 100% !important;
display: block !important;
}
.col {
min-width: 320px !important;
max-width: 100% !important;
width: 100% !important;
display: block !important;
}
.col > div {
margin: 0 auto;
}
img.fullwidth {
max-width: 100% !important;
}
img.fullwidthOnMobile {
max-width: 100% !important;
}
.no-stack .col {
min-width: 0 !important;
display: table-cell !important;
}
.no-stack.two-up .col {
width: 50% !important;
}
.no-stack.mixed-two-up .col.num4 {
width: 33% !important;
}
.no-stack.mixed-two-up .col.num8 {
width: 66% !important;
}
.no-stack.three-up .col.num4 {
width: 33% !important;
}
.no-stack.four-up .col.num3 {
width: 25% !important;
}
}
</style>
<!--[if IE]><div class="ie-browser"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
class="nl-container"
style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #FFFFFF;width: 100%"
cellpadding="0"
cellspacing="0"
>
<tbody>
<tr style="vertical-align: top">
<td
style="word-break: break-word;border-collapse: collapse !important;vertical-align: top"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #FFFFFF;"><![endif]-->
<div style="background-color:#FFFFFF;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: #000000;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:#000000;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:#FFFFFF;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:#000000;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color:#FFFFFF; width:500px; padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: #FFFFFF; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"
>
<!--<![endif]-->
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 30px; padding-top: 10px; padding-bottom: 10px;"><![endif]-->
<div
style="color:#000000;line-height:200%;font-family:'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; padding-right: 0px; padding-left: 30px; padding-top: 10px; padding-bottom: 10px;"
>
<div
style="font-size:12px;line-height:24px;font-family:'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;color:#000000;text-align:left;"
>
<p
style="margin: 0;font-size: 14px;line-height: 28px;text-align: left"
>
<span
style="color: rgb(0, 0, 0); font-size: 14px; line-height: 28px;"
>
<strong>
<span
style="line-height: 56px; font-size: 28px;"
>
<span
style="font-size: 24px; line-height: 48px;"
>{{site_name}}</span
>.</span
>
</strong>
<span
style="line-height: 56px; font-size: 28px;"
></span>
</span>
</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<div style="background-color:transparent;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style=" width:500px; padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: transparent; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"
>
<!--<![endif]-->
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 30px; padding-left: 30px; padding-top: 30px; padding-bottom: 30px;"><![endif]-->
<div
style="color:#555555;line-height:180%;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; padding-right: 30px; padding-left: 30px; padding-top: 30px; padding-bottom: 30px;"
>
<div
style="font-size:12px;line-height:22px;color:#555555;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;text-align:left;"
>
<p
style="margin: 0;font-size: 14px;line-height: 25px"
>
A password reset has been requested for your
account.
<br />
</p>
<p
style="margin: 0;font-size: 14px;line-height: 25px"
>
Please click on the button below to reset your
password. There's no need to take any action if
you didn't request this.
</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<div style="background-color:transparent;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style=" width:500px; padding-right: 5px; padding-left: 5px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: transparent; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 5px; padding-left: 5px;"
>
<!--<![endif]-->
<div
align="left"
class="button-container left"
style="padding-right: 30px; padding-left: 30px; padding-top:30px; padding-bottom:30px;"
>
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding-right: 30px; padding-left: 30px; padding-top:30px; padding-bottom:30px;" align="left"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="http://{{domain}}/reset-password/{{resetpassword}}" style="height:31pt; v-text-anchor:middle; width:81pt;" arcsize="143%" strokecolor="#2196F3" fillcolor="#2196F3"><w:anchorlock/><v:textbox inset="0,0,0,0"><center style="color:#ffffff; font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size:16px;"><![endif]-->
<a
href="https://{{domain}}/reset-password/{{resetpassword}}"
target="_blank"
style="display: block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #ffffff; background-color: #2196F3; border-radius: 60px; -webkit-border-radius: 60px; -moz-border-radius: 60px; max-width: 128px; width: 48px;width: auto; border-top: 0px solid transparent; border-right: 0px solid transparent; border-bottom: 0px solid transparent; border-left: 0px solid transparent; padding-top: 5px; padding-right: 30px; padding-bottom: 5px; padding-left: 30px; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;mso-border-alt: none"
>
<span style="font-size:16px;line-height:32px;"
>Reset password</span
>
</a>
<!--[if mso]></center></v:textbox></v:roundrect></td></tr></table><![endif]-->
</div>
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 40px; padding-left: 40px; padding-top: 40px; padding-bottom: 40px;"><![endif]-->
<div
style="color:#555555;line-height:180%;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; padding-right: 40px; padding-left: 40px; padding-top: 40px; padding-bottom: 40px;"
>
<div
style="font-size:12px;line-height:22px;text-align:center;color:#555555;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;"
>
<span style="font-size:14px; line-height:25px;">
<a
style="color:#0068A5;text-decoration: underline;"
href="https://{{domain}}"
target="_blank"
rel="noopener"
data-mce-selected="1"
>{{site_name}} | Free & open source URL
shortener</a
>
</span>
<br data-mce-bogus="1" />
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if (mso)|(IE)]></div><![endif]-->
</body>
</html>
================================================
FILE: server/mail/template-verify.html
================================================
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<!--[if gte mso 9
]><xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml><!
[endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<title></title>
<style type="text/css" id="media-query">
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-browser table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors="true"] {
color: inherit !important;
text-decoration: none !important;
}
[owa] .img-container div,
[owa] .img-container button {
display: block !important;
}
[owa] .fullwidth button {
width: 100% !important;
}
[owa] .block-grid .col {
display: table-cell;
float: none !important;
vertical-align: top;
}
.ie-browser .num12,
.ie-browser .block-grid,
[owa] .num12,
[owa] .block-grid {
width: 500px !important;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.ie-browser .mixed-two-up .num4,
[owa] .mixed-two-up .num4 {
width: 164px !important;
}
.ie-browser .mixed-two-up .num8,
[owa] .mixed-two-up .num8 {
width: 328px !important;
}
.ie-browser .block-grid.two-up .col,
[owa] .block-grid.two-up .col {
width: 250px !important;
}
.ie-browser .block-grid.three-up .col,
[owa] .block-grid.three-up .col {
width: 166px !important;
}
.ie-browser .block-grid.four-up .col,
[owa] .block-grid.four-up .col {
width: 125px !important;
}
.ie-browser .block-grid.five-up .col,
[owa] .block-grid.five-up .col {
width: 100px !important;
}
.ie-browser .block-grid.six-up .col,
[owa] .block-grid.six-up .col {
width: 83px !important;
}
.ie-browser .block-grid.seven-up .col,
[owa] .block-grid.seven-up .col {
width: 71px !important;
}
.ie-browser .block-grid.eight-up .col,
[owa] .block-grid.eight-up .col {
width: 62px !important;
}
.ie-browser .block-grid.nine-up .col,
[owa] .block-grid.nine-up .col {
width: 55px !important;
}
.ie-browser .block-grid.ten-up .col,
[owa] .block-grid.ten-up .col {
width: 50px !important;
}
.ie-browser .block-grid.eleven-up .col,
[owa] .block-grid.eleven-up .col {
width: 45px !important;
}
.ie-browser .block-grid.twelve-up .col,
[owa] .block-grid.twelve-up .col {
width: 41px !important;
}
@media only screen and (min-width: 520px) {
.block-grid {
width: 500px !important;
}
.block-grid .col {
vertical-align: top;
}
.block-grid .col.num12 {
width: 500px !important;
}
.block-grid.mixed-two-up .col.num4 {
width: 164px !important;
}
.block-grid.mixed-two-up .col.num8 {
width: 328px !important;
}
.block-grid.two-up .col {
width: 250px !important;
}
.block-grid.three-up .col {
width: 166px !important;
}
.block-grid.four-up .col {
width: 125px !important;
}
.block-grid.five-up .col {
width: 100px !important;
}
.block-grid.six-up .col {
width: 83px !important;
}
.block-grid.seven-up .col {
width: 71px !important;
}
.block-grid.eight-up .col {
width: 62px !important;
}
.block-grid.nine-up .col {
width: 55px !important;
}
.block-grid.ten-up .col {
width: 50px !important;
}
.block-grid.eleven-up .col {
width: 45px !important;
}
.block-grid.twelve-up .col {
width: 41px !important;
}
}
@media (max-width: 520px) {
.block-grid,
.col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.block-grid {
width: calc(100% - 40px) !important;
}
.col {
width: 100% !important;
}
.col > div {
margin: 0 auto;
}
img.fullwidth,
img.fullwidthOnMobile {
max-width: 100% !important;
}
.no-stack .col {
min-width: 0 !important;
display: table-cell !important;
}
.no-stack.two-up .col {
width: 50% !important;
}
.no-stack.mixed-two-up .col.num4 {
width: 33% !important;
}
.no-stack.mixed-two-up .col.num8 {
width: 66% !important;
}
.no-stack.three-up .col.num4 {
width: 33% !important;
}
.no-stack.four-up .col.num3 {
width: 25% !important;
}
}
</style>
</head>
<body
class="clean-body"
style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #FFFFFF"
>
<style type="text/css" id="media-query-bodytag">
@media (max-width: 520px) {
.block-grid {
min-width: 320px !important;
max-width: 100% !important;
width: 100% !important;
display: block !important;
}
.col {
min-width: 320px !important;
max-width: 100% !important;
width: 100% !important;
display: block !important;
}
.col > div {
margin: 0 auto;
}
img.fullwidth {
max-width: 100% !important;
}
img.fullwidthOnMobile {
max-width: 100% !important;
}
.no-stack .col {
min-width: 0 !important;
display: table-cell !important;
}
.no-stack.two-up .col {
width: 50% !important;
}
.no-stack.mixed-two-up .col.num4 {
width: 33% !important;
}
.no-stack.mixed-two-up .col.num8 {
width: 66% !important;
}
.no-stack.three-up .col.num4 {
width: 33% !important;
}
.no-stack.four-up .col.num3 {
width: 25% !important;
}
}
</style>
<!--[if IE]><div class="ie-browser"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
class="nl-container"
style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #FFFFFF;width: 100%"
cellpadding="0"
cellspacing="0"
>
<tbody>
<tr style="vertical-align: top">
<td
style="word-break: break-word;border-collapse: collapse !important;vertical-align: top"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #FFFFFF;"><![endif]-->
<div style="background-color:#FFFFFF;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: #000000;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:#000000;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:#FFFFFF;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:#000000;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color:#FFFFFF; width:500px; padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: #FFFFFF; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"
>
<!--<![endif]-->
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 30px; padding-top: 10px; padding-bottom: 10px;"><![endif]-->
<div
style="color:#000000;line-height:200%;font-family:'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; padding-right: 0px; padding-left: 30px; padding-top: 10px; padding-bottom: 10px;"
>
<div
style="font-size:12px;line-height:24px;font-family:'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;color:#000000;text-align:left;"
>
<p
style="margin: 0;font-size: 14px;line-height: 28px;text-align: left"
>
<span
style="color: rgb(0, 0, 0); font-size: 14px; line-height: 28px;"
>
<strong>
<span
style="line-height: 56px; font-size: 28px;"
>
<span
style="font-size: 24px; line-height: 48px;"
>{{site_name}}</span
>.</span
>
</strong>
<span
style="line-height: 56px; font-size: 28px;"
></span>
</span>
</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<div style="background-color:transparent;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style=" width:500px; padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: transparent; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"
>
<!--<![endif]-->
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 30px; padding-left: 30px; padding-top: 30px; padding-bottom: 30px;"><![endif]-->
<div
style="color:#555555;line-height:180%;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; padding-right: 30px; padding-left: 30px; padding-top: 30px; padding-bottom: 30px;"
>
<div
style="font-size:12px;line-height:22px;color:#555555;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;text-align:left;"
>
<p
style="margin: 0;font-size: 14px;line-height: 25px"
>
Thanks for creating an account on {{domain}}.
<br />
</p>
<p
style="margin: 0;font-size: 14px;line-height: 25px"
>
Please verify your email address using the link
below.
</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<div style="background-color:transparent;">
<div
style="Margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;"
class="block-grid "
>
<div
style="border-collapse: collapse;display: table;width: 100%;background-color:transparent;"
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background-color:transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width: 500px;"><tr class="layout-full-width" style="background-color:transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style=" width:500px; padding-right: 5px; padding-left: 5px; padding-top:5px; padding-bottom:5px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><![endif]-->
<div
class="col num12"
style="min-width: 320px;max-width: 500px;display: table-cell;vertical-align: top;"
>
<div
style="background-color: transparent; width: 100% !important;"
>
<!--[if (!mso)&(!IE)]><!-->
<div
style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 5px; padding-left: 5px;"
>
<!--<![endif]-->
<div
align="left"
class="button-container left"
style="padding-right: 30px; padding-left: 30px; padding-top:30px; padding-bottom:30px;"
>
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding-right: 30px; padding-left: 30px; padding-top:30px; padding-bottom:30px;" align="left"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="http://{{domain}}/verify/{{verification}}" style="height:31pt; v-text-anchor:middle; width:81pt;" arcsize="143%" strokecolor="#2196F3" fillcolor="#2196F3"><w:anchorlock/><v:textbox inset="0,0,0,0"><center style="color:#ffffff; font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size:16px;"><![endif]-->
<a
href="https://{{domain}}/verify/{{verification}}"
target="_blank"
style="display: block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #ffffff; background-color: #2196F3; border-radius: 60px; -webkit-border-radius: 60px; -moz-border-radius: 60px; max-width: 108px; width: 48px;width: auto; border-top: 0px solid transparent; border-right: 0px solid transparent; border-bottom: 0px solid transparent; border-left: 0px solid transparent; padding-top: 5px; padding-right: 30px; padding-bottom: 5px; padding-left: 30px; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;mso-border-alt: none"
>
<span style="font-size:16px;line-height:32px;"
>Verify account</span
>
</a>
<!--[if mso]></center></v:textbox></v:roundrect></td></tr></table><![endif]-->
</div>
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 40px; padding-left: 40px; padding-top: 40px; padding-bottom: 40px;"><![endif]-->
<div
style="color:#555555;line-height:180%;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif; padding-right: 40px; padding-left: 40px; padding-top: 40px; padding-bottom: 40px;"
>
<div
style="font-size:12px;line-height:22px;text-align:center;color:#555555;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;"
>
<span style="font-size:14px; line-height:25px;">
<a
style="color:#0068A5;text-decoration: underline;"
href="https://{{domain}}"
target="_blank"
rel="noopener"
data-mce-selected="1"
>{{site_name}} | Free & open source URL
shortener</a
>
</span>
<br data-mce-bogus="1" />
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if (mso)|(IE)]></div><![endif]-->
</body>
</html>
================================================
FILE: server/mail/text.js
================================================
module.exports.verifyMailText = `You're attempting to change your email address on {{site_name}}.
Please verify your email address using the link below.
https://{{domain}}/verify/{{verification}}`;
module.exports.changeEmailText = `Thanks for creating an account on {{site_name}}.
Please verify your email address using the link below.
https://{{domain}}/verify-email/{{verification}}`;
module.exports.resetMailText = `A password reset has been requested for your account.
Please click on the button below to reset your password. There's no need to take any action if you didn't request this.
https://{{domain}}/reset-password/{{resetpassword}}`;
================================================
FILE: server/migrations/20200211220920_constraints.js
================================================
const models = require("../models");
async function up(knex) {
await models.createUserTable(knex);
await models.createIPTable(knex);
await models.createDomainTable(knex);
await models.createHostTable(knex);
await models.createLinkTable(knex);
await models.createVisitTable(knex);
}
async function down() {
// do nothing
}
module.exports = {
up,
down
}
================================================
FILE: server/migrations/20200510140704_domains.js
================================================
const models = require("../models");
async function up(knex) {
await models.createUserTable(knex);
await models.createIPTable(knex);
await models.createDomainTable(knex);
await models.createHostTable(knex);
await models.createLinkTable(knex);
await models.createVisitTable(knex);
// drop unique user id constraint only if database is postgres
// because other databases use the new version of the app and they start fresh with the correct model
// if i use table.dropUnique() method it would throw error on fresh install because the constraint does not exist
// and if it throws error, the rest of the transactions fail as well
if (knex.client.driverName === "pg") {
knex.raw(`
ALTER TABLE domains
DROP CONSTRAINT IF EXISTS domains_user_id_unique
`)
}
const hasUUID = await knex.schema.hasColumn("domains", "uuid");
if (!hasUUID) {
await knex.schema.alterTable("domains", (table) => {
table.uuid("uuid").notNullable().defaultTo(knex.fn.uuid());
});
}
}
async function down() {
// do nothing
}
module.exports = {
up,
down
}
================================================
FILE: server/migrations/20200718124944_description.js
================================================
async function up(knex) {
const hasDescription = await knex.schema.hasColumn("links", "description");
if (!hasDescription) {
await knex.schema.alterTable("links", table => {
table.string("description");
});
}
}
async function down() {
return null;
}
module.exports = {
up,
down
}
================================================
FILE: server/migrations/20200730203154_expire_in.js
================================================
async function up(knex) {
const hasExpireIn = await knex.schema.hasColumn("links", "expire_in");
if (!hasExpireIn) {
await knex.schema.alterTable("links", table => {
table.dateTime("expire_in");
});
}
}
async function down() {
return null;
}
module.exports = {
up,
down
}
================================================
FILE: server/migrations/20200810195255_change_email.js
================================================
async function up(knex) {
const hasChangeEmail = await knex.schema.hasColumn(
"users",
"change_email_token"
);
if (!hasChangeEmail) {
await knex.schema.alterTable("users", table => {
table.dateTime("change_email_expires");
table.string("change_email_token");
table.string("change_email_address");
});
}
}
async function down() {
return null;
}
module.exports = {
up,
down
}
================================================
FILE: server/migrations/20241103083933_user-roles.js
================================================
const { ROLES } = require("../consts");
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function up(knex) {
const hasRole = await knex.schema.hasColumn("users", "role");
if (!hasRole) {
await knex.transaction(async function(trx) {
await trx.schema.alterTable("users", table => {
table
.enu("role", [ROLES.USER, ROLES.ADMIN])
.notNullable()
.defaultTo(ROLES.USER);
});
if (typeof process.env.ADMIN_EMAILS === "string") {
const adminEmails = process.env.ADMIN_EMAILS.split(",").map((e) => e.trim());
const adminRoleQuery = trx("users").update("role", ROLES.ADMIN);
adminEmails.forEach((adminEmail, index) => {
if (index === 0) {
adminRoleQuery.where("email", adminEmail);
} else {
adminRoleQuery.orWhere("email", adminEmail);
}
});
await adminRoleQuery;
}
});
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function down(knex) {};
module.exports = {
up,
down,
}
================================================
FILE: server/migrations/20241223062111_indexes.js
================================================
const env = require("../env");
const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2";
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function up(knex) {
// make apikey unique
await knex.schema.alterTable("users", function(table) {
table.unique("apikey");
});
// IF NOT EXISTS is not available on MySQL So if you're
// using MySQL you should make sure you don't have these indexes already
const ifNotExists = isMySQL ? "" : "IF NOT EXISTS";
// create them separately because one string with break lines didn't work on MySQL
await Promise.all([
knex.raw(`CREATE INDEX ${ifNotExists} links_domain_id_index ON links (domain_id);`),
knex.raw(`CREATE INDEX ${ifNotExists} links_user_id_index ON links (user_id);`),
knex.raw(`CREATE INDEX ${ifNotExists} links_address_index ON links (address);`),
knex.raw(`CREATE INDEX ${ifNotExists} links_expire_in_index ON links (expire_in);`),
knex.raw(`CREATE INDEX ${ifNotExists} domains_address_index ON domains (address);`),
knex.raw(`CREATE INDEX ${ifNotExists} domains_user_id_index ON domains (user_id);`),
knex.raw(`CREATE INDEX ${ifNotExists} hosts_address_index ON hosts (address);`),
knex.raw(`CREATE INDEX ${ifNotExists} visits_link_id_index ON visits (link_id);`),
]);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function down(knex) {
await knex.schema.alterTable("users", function(table) {
table.dropUnique(["apikey"]);
});
await Promise.all([
knex.raw(`DROP INDEX links_domain_id_index;`),
knex.raw(`DROP INDEX links_user_id_index;`),
knex.raw(`DROP INDEX links_address_index;`),
knex.raw(`DROP INDEX links_expire_in_index;`),
knex.raw(`DROP INDEX domains_address_index;`),
knex.raw(`DROP INDEX domains_user_id_index;`),
knex.raw(`DROP INDEX hosts_address_index;`),
knex.raw(`DROP INDEX visits_link_id_index;`),
]);
};
module.exports = {
up,
down,
}
================================================
FILE: server/migrations/20241223103044_visits_user_id.js
================================================
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function up(knex) {
const hasUserIDColumn = await knex.schema.hasColumn("visits", "user_id");
if (hasUserIDColumn) return;
await knex.schema.alterTable("visits", function(table) {
table
.integer("user_id")
.unsigned();
table
.foreign("user_id")
.references("id")
.inTable("users")
.onDelete("CASCADE")
.withKeyName("visits_user_id_foreign");
});
const [{ count }] = await knex("visits").count("* as count");
const count_number = parseInt(count);
if (Number.isNaN(count_number) || count_number === 0) return;
if (count_number < 1_000_000) {
const last_visit = await knex("visits").orderBy("id", "desc").first();
const size = 100_000;
const loops = Math.floor(last_visit.id / size) + 1;
await Promise.all(
new Array(loops).fill(null).map((_, i) => {
return knex("visits")
.fromRaw(knex.raw("visits v"))
.update({ user_id: knex.ref("links.user_id") })
.updateFrom("links")
.where("links.id", knex.ref("link_id"))
.andWhereBetween("v.id", [i * size, (i * size) + size]);
})
);
} else {
console.warn(
"MIGRATION WARN:" +
"Skipped adding user_id to visits due to high volume of visits and the potential risk of locking the database.\n" +
"Please refer to Kutt's migration guide for more information."
);
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function down(knex) {};
module.exports = {
up,
down,
}
================================================
FILE: server/migrations/20241223155527_visits_user_id_index.js
================================================
const env = require("../env");
const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2";
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function up(knex) {
// IF NOT EXISTS is not available on MySQL So if you're
// using MySQL you should make sure you don't have these indexes already
const ifNotExists = isMySQL ? "" : "IF NOT EXISTS";
await knex.raw(`
CREATE INDEX ${ifNotExists} visits_user_id_index ON visits (user_id);
`);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function down(knex) {
await knex.raw(`
DROP INDEX visits_user_id_index;
`);
};
module.exports = {
up,
down,
}
================================================
FILE: server/migrations/20250106070444_remove_cooldown.js
================================================
async function up(knex) {
const hasCooldowns = await knex.schema.hasColumn("users", "cooldowns");
if (hasCooldowns) {
await knex.schema.alterTable("users", table => {
table.dropColumn("cooldowns");
});
}
const hasCooldown = await knex.schema.hasColumn("users", "cooldown");
if (hasCooldown) {
await knex.schema.alterTable("users", table => {
table.dropColumn("cooldown");
});
}
const hasMaliciousAttempts = await knex.schema.hasColumn("users", "malicious_attempts");
if (hasMaliciousAttempts) {
await knex.schema.alterTable("users", table => {
table.dropColumn("malicious_attempts");
});
}
}
async function down(knex) {}
module.exports = {
up,
down
};
================================================
FILE: server/models/domain.model.js
================================================
async function createDomainTable(knex) {
const hasTable = await knex.schema.hasTable("domains");
if (!hasTable) {
await knex.schema.createTable("domains", table => {
table.in
gitextract_5ryo2i8m/
├── .dockerignore
├── .example.env
├── .github/
│ └── workflows/
│ ├── docker-build-development.yaml
│ ├── docker-build-latest.yaml
│ └── docker-build-release.yaml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── custom/
│ └── .gitkeep
├── db/
│ └── .gitkeep
├── docker-compose.mariadb.yml
├── docker-compose.postgres.yml
├── docker-compose.sqlite-redis.yml
├── docker-compose.yml
├── docs/
│ └── api/
│ ├── api.js
│ └── generate.js
├── jsconfig.json
├── knexfile.js
├── package.json
├── server/
│ ├── consts.js
│ ├── cron.js
│ ├── env.js
│ ├── handlers/
│ │ ├── auth.handler.js
│ │ ├── domains.handler.js
│ │ ├── helpers.handler.js
│ │ ├── links.handler.js
│ │ ├── locals.handler.js
│ │ ├── renders.handler.js
│ │ ├── users.handler.js
│ │ └── validators.handler.js
│ ├── knex.js
│ ├── mail/
│ │ ├── index.js
│ │ ├── mail.js
│ │ ├── template-change-email.html
│ │ ├── template-reset.html
│ │ ├── template-verify.html
│ │ └── text.js
│ ├── migrations/
│ │ ├── 20200211220920_constraints.js
│ │ ├── 20200510140704_domains.js
│ │ ├── 20200718124944_description.js
│ │ ├── 20200730203154_expire_in.js
│ │ ├── 20200810195255_change_email.js
│ │ ├── 20241103083933_user-roles.js
│ │ ├── 20241223062111_indexes.js
│ │ ├── 20241223103044_visits_user_id.js
│ │ ├── 20241223155527_visits_user_id_index.js
│ │ └── 20250106070444_remove_cooldown.js
│ ├── models/
│ │ ├── domain.model.js
│ │ ├── host.model.js
│ │ ├── index.js
│ │ ├── ip.model.js
│ │ ├── link.model.js
│ │ ├── user.model.js
│ │ └── visit.model.js
│ ├── passport.js
│ ├── queries/
│ │ ├── domain.queries.js
│ │ ├── host.queries.js
│ │ ├── index.js
│ │ ├── link.queries.js
│ │ ├── user.queries.js
│ │ └── visit.queries.js
│ ├── queues/
│ │ ├── index.js
│ │ ├── queues.js
│ │ └── visit.js
│ ├── redis.js
│ ├── routes/
│ │ ├── auth.routes.js
│ │ ├── domain.routes.js
│ │ ├── health.routes.js
│ │ ├── index.js
│ │ ├── link.routes.js
│ │ ├── renders.routes.js
│ │ ├── routes.js
│ │ └── user.routes.js
│ ├── server.js
│ ├── utils/
│ │ ├── asyncHandler.js
│ │ ├── index.js
│ │ ├── knex.js
│ │ ├── map.json
│ │ └── utils.js
│ └── views/
│ ├── 404.hbs
│ ├── admin.hbs
│ ├── banned.hbs
│ ├── create_admin.hbs
│ ├── error.hbs
│ ├── homepage.hbs
│ ├── layout.hbs
│ ├── login.hbs
│ ├── logout.hbs
│ ├── partials/
│ │ ├── admin/
│ │ │ ├── dialog/
│ │ │ │ ├── add_domain.hbs
│ │ │ │ ├── add_domain_success.hbs
│ │ │ │ ├── ban_domain.hbs
│ │ │ │ ├── ban_domain_success.hbs
│ │ │ │ ├── ban_user.hbs
│ │ │ │ ├── ban_user_success.hbs
│ │ │ │ ├── create_user.hbs
│ │ │ │ ├── create_user_success.hbs
│ │ │ │ ├── delete_domain.hbs
│ │ │ │ ├── delete_domain_success.hbs
│ │ │ │ ├── delete_user.hbs
│ │ │ │ ├── delete_user_success.hbs
│ │ │ │ ├── frame.hbs
│ │ │ │ └── mesasge.hbs
│ │ │ ├── domains/
│ │ │ │ ├── actions.hbs
│ │ │ │ ├── loading.hbs
│ │ │ │ ├── table.hbs
│ │ │ │ ├── tbody.hbs
│ │ │ │ ├── tfoot.hbs
│ │ │ │ ├── thead.hbs
│ │ │ │ └── tr.hbs
│ │ │ ├── index.hbs
│ │ │ ├── links/
│ │ │ │ ├── actions.hbs
│ │ │ │ ├── edit.hbs
│ │ │ │ ├── loading.hbs
│ │ │ │ ├── table.hbs
│ │ │ │ ├── tbody.hbs
│ │ │ │ ├── tfoot.hbs
│ │ │ │ ├── thead.hbs
│ │ │ │ └── tr.hbs
│ │ │ ├── table_nav.hbs
│ │ │ ├── table_tab.hbs
│ │ │ └── users/
│ │ │ ├── actions.hbs
│ │ │ ├── loading.hbs
│ │ │ ├── table.hbs
│ │ │ ├── tbody.hbs
│ │ │ ├── tfoot.hbs
│ │ │ ├── thead.hbs
│ │ │ └── tr.hbs
│ │ ├── auth/
│ │ │ ├── form.hbs
│ │ │ ├── form_admin.hbs
│ │ │ ├── login_disabled.hbs
│ │ │ ├── verify.hbs
│ │ │ └── welcome.hbs
│ │ ├── footer.hbs
│ │ ├── header.hbs
│ │ ├── icons/
│ │ │ ├── arrow_left.hbs
│ │ │ ├── chart.hbs
│ │ │ ├── check.hbs
│ │ │ ├── chevron_left.hbs
│ │ │ ├── chevron_right.hbs
│ │ │ ├── cog.hbs
│ │ │ ├── copy.hbs
│ │ │ ├── eye.hbs
│ │ │ ├── heart.hbs
│ │ │ ├── key.hbs
│ │ │ ├── login.hbs
│ │ │ ├── new_user.hbs
│ │ │ ├── pencil.hbs
│ │ │ ├── plus.hbs
│ │ │ ├── qrcode.hbs
│ │ │ ├── reload.hbs
│ │ │ ├── send.hbs
│ │ │ ├── shield.hbs
│ │ │ ├── shuffle.hbs
│ │ │ ├── spinner.hbs
│ │ │ ├── stop.hbs
│ │ │ ├── trash.hbs
│ │ │ ├── write.hbs
│ │ │ ├── x.hbs
│ │ │ └── zap.hbs
│ │ ├── links/
│ │ │ ├── actions.hbs
│ │ │ ├── dialog/
│ │ │ │ ├── ban.hbs
│ │ │ │ ├── ban_success.hbs
│ │ │ │ ├── delete.hbs
│ │ │ │ ├── delete_success.hbs
│ │ │ │ ├── frame.hbs
│ │ │ │ └── message.hbs
│ │ │ ├── edit.hbs
│ │ │ ├── loading.hbs
│ │ │ ├── nav.hbs
│ │ │ ├── table.hbs
│ │ │ ├── tbody.hbs
│ │ │ ├── tfoot.hbs
│ │ │ ├── thead.hbs
│ │ │ └── tr.hbs
│ │ ├── protected/
│ │ │ └── form.hbs
│ │ ├── report/
│ │ │ ├── email.hbs
│ │ │ └── form.hbs
│ │ ├── reset_password/
│ │ │ ├── new_password_form.hbs
│ │ │ ├── new_password_success.hbs
│ │ │ └── request_form.hbs
│ │ ├── settings/
│ │ │ ├── apikey.hbs
│ │ │ ├── change_email.hbs
│ │ │ ├── change_password.hbs
│ │ │ ├── delete_account.hbs
│ │ │ └── domain/
│ │ │ ├── add_form.hbs
│ │ │ ├── delete.hbs
│ │ │ ├── delete_success.hbs
│ │ │ ├── dialog.hbs
│ │ │ ├── index.hbs
│ │ │ └── table.hbs
│ │ ├── shortener.hbs
│ │ ├── stats.hbs
│ │ └── support_email.hbs
│ ├── protected.hbs
│ ├── report.hbs
│ ├── reset_password.hbs
│ ├── reset_password_set_new_password.hbs
│ ├── settings.hbs
│ ├── stats.hbs
│ ├── terms.hbs
│ ├── url_info.hbs
│ ├── verify.hbs
│ └── verify_change_email.hbs
└── static/
├── css/
│ └── styles.css
├── manifest.webmanifest
├── robots.txt
└── scripts/
├── main.js
└── stats.js
SYMBOL INDEX (218 symbols across 39 files)
FILE: server/consts.js
constant ROLES (line 1) | const ROLES = {
FILE: server/handlers/auth.handler.js
function authenticate (line 16) | function authenticate(type, error, isStrict, redirect) {
function admin (line 92) | function admin(req, res, next) {
function signup (line 97) | async function signup(req, res) {
function createAdminUser (line 116) | async function createAdminUser(req, res) {
function login (line 143) | function login(req, res) {
function verify (line 155) | async function verify(req, res, next) {
function changePassword (line 181) | async function changePassword(req, res) {
function generateApiKey (line 211) | async function generateApiKey(req, res) {
function resetPassword (line 234) | async function resetPassword(req, res) {
function newPassword (line 261) | async function newPassword(req, res) {
function changeEmailRequest (line 286) | async function changeEmailRequest(req, res) {
function changeEmail (line 331) | async function changeEmail(req, res, next) {
function featureAccess (line 363) | function featureAccess(features, redirect) {
function featureAccessPage (line 378) | function featureAccessPage(features) {
FILE: server/handlers/domains.handler.js
function add (line 9) | async function add(req, res) {
function addAdmin (line 30) | async function addAdmin(req, res) {
function remove (line 51) | async function remove(req, res) {
function removeAdmin (line 87) | async function removeAdmin(req, res) {
function getAdmin (line 115) | async function getAdmin(req, res) {
function ban (line 154) | async function ban(req, res) {
FILE: server/handlers/helpers.handler.js
function error (line 10) | function error(error, req, res, _next) {
function verify (line 38) | function verify(req, res, next) {
function parseQuery (line 54) | function parseQuery(req, res, next) {
function rateLimit (line 88) | function rateLimit(params) {
function adminSetup (line 127) | async function adminSetup(req, res, next) {
FILE: server/handlers/links.handler.js
constant URL (line 5) | const URL = require("node:url");
function get (line 19) | async function get(req, res) {
function getAdmin (line 51) | async function getAdmin(req, res) {
function create (line 99) | async function create(req, res) {
function edit (line 163) | async function edit(req, res) {
function editAdmin (line 256) | async function editAdmin(req, res) {
function remove (line 349) | async function remove(req, res) {
function report (line 374) | async function report(req, res) {
function ban (line 391) | async function ban(req, res) {
function redirect (line 459) | async function redirect(req, res, next) {
function redirectProtected (line 547) | async function redirectProtected(req, res) {
function redirectCustomDomainHomepage (line 587) | async function redirectCustomDomainHomepage(req, res, next) {
function stats (line 610) | async function stats(req, res) {
FILE: server/handlers/locals.handler.js
function isHTML (line 5) | function isHTML(req, res, next) {
function noLayout (line 11) | function noLayout(req, res, next) {
function viewTemplate (line 16) | function viewTemplate(template) {
function config (line 23) | function config(req, res, next) {
function user (line 39) | async function user(req, res, next) {
function newPassword (line 46) | function newPassword(req, res, next) {
function createLink (line 51) | function createLink(req, res, next) {
function editLink (line 56) | function editLink(req, res, next) {
function protected (line 62) | function protected(req, res, next) {
function adminTable (line 67) | function adminTable(req, res, next) {
FILE: server/handlers/renders.handler.js
function homepage (line 11) | async function homepage(req, res) {
function login (line 21) | async function login(req, res) {
function logout (line 32) | function logout(req, res) {
function createAdmin (line 39) | async function createAdmin(req, res) {
function notFound (line 50) | function notFound(req, res) {
function settings (line 56) | function settings(req, res) {
function admin (line 62) | function admin(req, res) {
function stats (line 68) | function stats(req, res) {
function banned (line 74) | async function banned(req, res) {
function report (line 80) | async function report(req, res) {
function resetPassword (line 90) | async function resetPassword(req, res) {
function resetPasswordSetNewPassword (line 96) | async function resetPasswordSetNewPassword(req, res) {
function verifyChangeEmail (line 118) | async function verifyChangeEmail(req, res) {
function verify (line 124) | async function verify(req, res) {
function terms (line 130) | async function terms(req, res) {
function confirmLinkDelete (line 142) | async function confirmLinkDelete(req, res) {
function confirmLinkBan (line 160) | async function confirmLinkBan(req, res) {
function confirmUserDelete (line 176) | async function confirmUserDelete(req, res) {
function confirmUserBan (line 191) | async function confirmUserBan(req, res) {
function createUser (line 206) | async function createUser(req, res) {
function addDomainAdmin (line 212) | async function addDomainAdmin(req, res) {
function addDomainForm (line 218) | async function addDomainForm(req, res) {
function confirmDomainDelete (line 222) | async function confirmDomainDelete(req, res) {
function confirmDomainBan (line 235) | async function confirmDomainBan(req, res) {
function confirmDomainDeleteAdmin (line 252) | async function confirmDomainDeleteAdmin(req, res) {
function getReportEmail (line 267) | async function getReportEmail(req, res) {
function getSupportEmail (line 276) | async function getSupportEmail(req, res) {
function linkEdit (line 286) | async function linkEdit(req, res) {
function linkEditAdmin (line 297) | async function linkEditAdmin(req, res) {
FILE: server/handlers/users.handler.js
function get (line 8) | async function get(req, res) {
function remove (line 20) | async function remove(req, res) {
function removeByAdmin (line 35) | async function removeByAdmin(req, res) {
function getAdmin (line 64) | async function getAdmin(req, res) {
function ban (line 105) | async function ban(req, res) {
function create (line 157) | async function create(req, res) {
FILE: server/handlers/validators.handler.js
constant URL (line 6) | const URL = require("node:url");
function bannedDomain (line 504) | async function bannedDomain(domain) {
function bannedHost (line 515) | async function bannedHost(domain) {
FILE: server/mail/mail.js
function verification (line 49) | async function verification(user) {
function changeEmail (line 73) | async function changeEmail(user) {
function resetPasswordToken (line 97) | async function resetPasswordToken(user) {
function sendReportEmail (line 121) | async function sendReportEmail(link) {
FILE: server/migrations/20200211220920_constraints.js
function up (line 3) | async function up(knex) {
function down (line 12) | async function down() {
FILE: server/migrations/20200510140704_domains.js
function up (line 3) | async function up(knex) {
function down (line 31) | async function down() {
FILE: server/migrations/20200718124944_description.js
function up (line 1) | async function up(knex) {
function down (line 10) | async function down() {
FILE: server/migrations/20200730203154_expire_in.js
function up (line 1) | async function up(knex) {
function down (line 10) | async function down() {
FILE: server/migrations/20200810195255_change_email.js
function up (line 1) | async function up(knex) {
function down (line 15) | async function down() {
FILE: server/migrations/20241103083933_user-roles.js
function up (line 7) | async function up(knex) {
function down (line 37) | async function down(knex) {}
FILE: server/migrations/20241223062111_indexes.js
function up (line 9) | async function up(knex) {
function down (line 36) | async function down(knex) {
FILE: server/migrations/20241223103044_visits_user_id.js
function up (line 5) | async function up(knex) {
function down (line 56) | async function down(knex) {}
FILE: server/migrations/20241223155527_visits_user_id_index.js
function up (line 9) | async function up(knex) {
function down (line 23) | async function down(knex) {
FILE: server/migrations/20250106070444_remove_cooldown.js
function up (line 1) | async function up(knex) {
function down (line 24) | async function down(knex) {}
FILE: server/models/domain.model.js
function createDomainTable (line 1) | async function createDomainTable(knex) {
FILE: server/models/host.model.js
function createHostTable (line 1) | async function createHostTable(knex) {
FILE: server/models/ip.model.js
function createIPTable (line 1) | async function createIPTable(knex) {
FILE: server/models/link.model.js
function createLinkTable (line 1) | async function createLinkTable(knex) {
FILE: server/models/user.model.js
function createUserTable (line 3) | async function createUserTable(knex) {
FILE: server/models/visit.model.js
function createVisitTable (line 1) | async function createVisitTable(knex) {
FILE: server/passport.js
function enableOIDC (line 78) | async function enableOIDC() {
FILE: server/queries/domain.queries.js
function find (line 6) | async function find(match) {
function get (line 22) | function get(match) {
function add (line 26) | async function add(params) {
function update (line 63) | async function update(match, update) {
function normalizeMatch (line 85) | function normalizeMatch(match) {
function getAdmin (line 126) | async function getAdmin(match, params) {
function totalAdmin (line 173) | async function totalAdmin(match, params) {
function remove (line 213) | async function remove(domain) {
FILE: server/queries/host.queries.js
function find (line 6) | async function find(match) {
function add (line 24) | async function add(params) {
FILE: server/queries/link.queries.js
function normalizeMatch (line 32) | function normalizeMatch(match) {
function total (line 63) | async function total(match, params) {
function totalAdmin (line 85) | async function totalAdmin(match, params) {
function get (line 121) | async function get(match, params) {
function getAdmin (line 141) | async function getAdmin(match, params) {
function find (line 179) | async function find(match) {
function create (line 200) | async function create(params) {
function remove (line 232) | async function remove(match) {
function batchRemove (line 248) | async function batchRemove(match) {
function update (line 264) | async function update(match, update) {
function incrementVisit (line 294) | function incrementVisit(match) {
FILE: server/queries/user.queries.js
function find (line 10) | async function find(match) {
function add (line 39) | async function add(params, user) {
function update (line 67) | async function update(match, update, methods) {
function remove (line 98) | async function remove(user) {
function normalizeMatch (line 119) | function normalizeMatch(match) {
function getAdmin (line 130) | async function getAdmin(match, params) {
function totalAdmin (line 180) | async function totalAdmin(match, params) {
function create (line 223) | async function create(params) {
function findAny (line 242) | async function findAny() {
FILE: server/queries/visit.queries.js
function add (line 8) | async function add(params) {
function find (line 71) | async function find(match, total) {
FILE: server/queues/queues.js
method add (line 28) | add(data) {
FILE: server/queues/visit.js
constant URL (line 3) | const URL = require("node:url");
function filterInBrowser (line 11) | function filterInBrowser(agent) {
function filterInOs (line 17) | function filterInOs(agent) {
FILE: server/utils/asyncHandler.js
function asyncHandler (line 1) | function asyncHandler(fn) {
FILE: server/utils/knex.js
function knexUtils (line 2) | function knexUtils(knex) {
FILE: server/utils/utils.js
constant JWT (line 4) | const JWT = require("jsonwebtoken");
class CustomError (line 17) | class CustomError extends Error {
method constructor (line 18) | constructor(message, statusCode, data) {
function isAdmin (line 34) | function isAdmin(user) {
function signToken (line 38) | function signToken(user) {
function setToken (line 50) | function setToken(res, token) {
function deleteCurrentToken (line 58) | function deleteCurrentToken(res) {
function generateRandomPassword (line 62) | function generateRandomPassword() {
function generateId (line 68) | async function generateId(query, domain_id) {
function addProtocol (line 77) | function addProtocol(url) {
function getSiteURL (line 82) | function getSiteURL() {
function getShortURL (line 87) | function getShortURL(address, domain) {
function statsObjectToArray (line 94) | function statsObjectToArray(obj) {
function getDifferenceFunction (line 111) | function getDifferenceFunction(type) {
function parseDatetime (line 119) | function parseDatetime(date) {
function parseTimestamps (line 124) | function parseTimestamps(item) {
function dateToUTC (line 131) | function dateToUTC(date) {
function getStatsPeriods (line 148) | function getStatsPeriods(now) {
function parseBooleanQuery (line 200) | function parseBooleanQuery(query) {
function getInitStats (line 206) | function getInitStats() {
constant MINUTE (line 231) | const MINUTE = 60,
constant HOUR (line 231) | const MINUTE = 60,
constant DAY (line 231) | const MINUTE = 60,
constant WEEK (line 231) | const MINUTE = 60,
constant MONTH (line 231) | const MINUTE = 60,
constant YEAR (line 231) | const MINUTE = 60,
function getTimeAgo (line 237) | function getTimeAgo(dateString) {
function sleep (line 347) | function sleep(ms) {
function removeWww (line 351) | function removeWww(host) {
function registerHandlebarsHelpers (line 355) | function registerHandlebarsHelpers() {
function getCustomCSSFileNames (line 403) | function getCustomCSSFileNames() {
FILE: static/scripts/main.js
function resetForm (line 17) | function resetForm(id) {
function closest (line 41) | function closest(selector, elm) {
function getQueryParams (line 56) | function getQueryParams() {
function trimText (line 67) | function trimText(selector, length) {
function formatDateHour (line 78) | function formatDateHour(selector) {
function handleQRCode (line 88) | function handleQRCode(element, id) {
function handleCopyLink (line 105) | function handleCopyLink(element) {
function handleShortURLCopyLink (line 110) | function handleShortURLCopyLink(element) {
function openDialog (line 121) | function openDialog(id, name) {
function closeDialog (line 130) | function closeDialog() {
function setLinksLimit (line 147) | function setLinksLimit(event) {
function setLinksSkip (line 157) | function setLinksSkip(event, action) {
function updateLinksNav (line 175) | function updateLinksNav() {
function resetTableNav (line 191) | function resetTableNav() {
function setTab (line 213) | function setTab(event, targetId) {
function onSearchChange (line 226) | function onSearchChange(event) {
function clearSeachInput (line 232) | function clearSeachInput(event) {
function onSearchInputLoad (line 243) | function onSearchInputLoad() {
function canSendVerificationEmail (line 263) | function canSendVerificationEmail() {
FILE: static/scripts/stats.js
function createViewsChartLabel (line 2) | function createViewsChartLabel(ctx) {
function changeStatsPeriod (line 43) | function changeStatsPeriod(event) {
function beautifyBrowserName (line 62) | function beautifyBrowserName(name) {
function createViewsChart (line 75) | function createViewsChart() {
function createBrowsersChart (line 160) | function createBrowsersChart() {
function createReferrersChart (line 232) | function createReferrersChart() {
function beautifyOsName (line 302) | function beautifyOsName(name) {
function createOsChart (line 314) | function createOsChart() {
function feedMapData (line 386) | function feedMapData(period) {
function mapTooltipHoverOver (line 415) | function mapTooltipHoverOver() {
function mapTooltipHoverOut (line 428) | function mapTooltipHoverOut() {
function createCharts (line 440) | function createCharts() {
Condensed preview — 209 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (605K chars).
[
{
"path": ".dockerignore",
"chars": 18,
"preview": ".git\nnode_modules\n"
},
{
"path": ".example.env",
"chars": 2997,
"preview": "# Optional - App port to run on\nPORT=3000\n\n# Optional - The name of the site where Kutt is hosted\nSITE_NAME=Kutt\n\n# Opti"
},
{
"path": ".github/workflows/docker-build-development.yaml",
"chars": 1168,
"preview": "name: docker-build-development\n\nenv:\n dockerhub_repository: \"kutt/kutt\"\n dockerhub_tag: \"development\"\n\non:\n push:\n "
},
{
"path": ".github/workflows/docker-build-latest.yaml",
"chars": 1153,
"preview": "name: docker-build-latest\n\nenv:\n dockerhub_repository: \"kutt/kutt\"\n dockerhub_tag: \"main\"\n\non:\n push:\n branches:\n "
},
{
"path": ".github/workflows/docker-build-release.yaml",
"chars": 908,
"preview": "name: docker-build-release\n\nenv:\n dockerhub_repository: \"kutt/kutt\"\n\non:\n release:\n types: [published]\n\njobs:\n doc"
},
{
"path": ".gitignore",
"chars": 204,
"preview": ".env\n.vscode/\nlogs\nclient/.next/\nnode_modules/\nclient/config.js\nclient/old.config.js\nserver/config.js\nserver/old.config."
},
{
"path": "Dockerfile",
"chars": 621,
"preview": "# specify node.js image\nFROM node:22-alpine\n\n# use production node environment by default\nENV NODE_ENV=production\n\n# set"
},
{
"path": "LICENSE",
"chars": 1061,
"preview": "MIT License\n\nCopyright (c) 2020 Kutt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof th"
},
{
"path": "README.md",
"chars": 16021,
"preview": "<p align=\"center\"><a href=\"https://kutt.it\" title=\"kutt.it\"><img src=\"https://raw.githubusercontent.com/thedevs-network/"
},
{
"path": "custom/.gitkeep",
"chars": 137,
"preview": "# keep this folder in git\n# put supported customization files for styles and such\n# if you're using docker make sure to "
},
{
"path": "db/.gitkeep",
"chars": 118,
"preview": "# keep this folder in git\n# if you use a file-based databases such as sqlite3, the database files would be stored here"
},
{
"path": "docker-compose.mariadb.yml",
"chars": 1001,
"preview": "services:\n server:\n build:\n context: .\n volumes:\n - custom:/kutt/custom\n environment:\n DB_CLI"
},
{
"path": "docker-compose.postgres.yml",
"chars": 860,
"preview": "services:\n server:\n build:\n context: .\n volumes:\n - custom:/kutt/custom\n environment:\n DB_CLI"
},
{
"path": "docker-compose.sqlite-redis.yml",
"chars": 470,
"preview": "services:\n server:\n build:\n context: .\n volumes:\n - db_data_sqlite:/var/lib/kutt\n - custom:/kutt/c"
},
{
"path": "docker-compose.yml",
"chars": 257,
"preview": "services:\n server:\n build:\n context: .\n volumes:\n - db_data_sqlite:/var/lib/kutt\n - custom:/kutt"
},
{
"path": "docs/api/api.js",
"chars": 13486,
"preview": "\nconst p = require(\"../../package.json\");\n\nmodule.exports = {\n openapi: \"3.0.0\",\n info: {\n title: \"Kutt.it\",\n de"
},
{
"path": "docs/api/generate.js",
"chars": 1079,
"preview": "const { join, dirname } = require(\"node:path\");\nconst { promises: fs } = require(\"node:fs\");\n\nconst api = require(\"./api"
},
{
"path": "jsconfig.json",
"chars": 160,
"preview": "{\n \"compilerOptions\": {\n \"module\": \"CommonJS\",\n \"allowImportingTsExtensions\": false\n },\n \"exclude\": [\n \"node"
},
{
"path": "knexfile.js",
"chars": 768,
"preview": "// this configuration is for migrations only\n// and since jwt secret is not required, it's set to a placehodler string t"
},
{
"path": "package.json",
"chars": 2015,
"preview": "{\n \"name\": \"kutt\",\n \"version\": \"3.2.3\",\n \"description\": \"Modern URL shortener.\",\n \"main\": \"./server/server.js\",\n \"s"
},
{
"path": "server/consts.js",
"chars": 82,
"preview": "const ROLES = {\n USER: \"USER\",\n ADMIN: \"ADMIN\"\n};\n\nmodule.exports = {\n ROLES,\n}"
},
{
"path": "server/cron.js",
"chars": 232,
"preview": "const query = require(\"./queries\");\nconst utils = require(\"./utils\");\n\n// check and delete links 30 secoonds\nsetInterval"
},
{
"path": "server/env.js",
"chars": 3027,
"preview": "require(\"dotenv\").config();\nconst { cleanEnv, num, str, bool } = require(\"envalid\");\nconst { readFileSync } = require(\"n"
},
{
"path": "server/handlers/auth.handler.js",
"chars": 10377,
"preview": "const { differenceInDays, addMinutes } = require(\"date-fns\");\nconst { nanoid } = require(\"nanoid\");\nconst passport = req"
},
{
"path": "server/handlers/domains.handler.js",
"chars": 5032,
"preview": "const { Handler } = require(\"express\");\n\nconst { CustomError, sanitize } = require(\"../utils\");\nconst query = require(\"."
},
{
"path": "server/handlers/helpers.handler.js",
"chars": 3490,
"preview": "const { RedisStore: RateLimitRedisStore } = require(\"rate-limit-redis\");\nconst { rateLimit: expressRateLimit } = require"
},
{
"path": "server/handlers/links.handler.js",
"chars": 16785,
"preview": "const { differenceInSeconds } = require(\"date-fns\");\nconst promisify = require(\"node:util\").promisify;\nconst bcrypt = re"
},
{
"path": "server/handlers/locals.handler.js",
"chars": 2236,
"preview": "const query = require(\"../queries\");\nconst utils = require(\"../utils\");\nconst env = require(\"../env\");\n\nfunction isHTML("
},
{
"path": "server/handlers/renders.handler.js",
"chars": 7093,
"preview": "const query = require(\"../queries\");\nconst utils = require(\"../utils\");\nconst env = require(\"../env\");\n\n/** \n*\n* PAGES\n*"
},
{
"path": "server/handlers/users.handler.js",
"chars": 4419,
"preview": "const bcrypt = require(\"bcryptjs\");\n\nconst query = require(\"../queries\");\nconst utils = require(\"../utils\");\nconst mail "
},
{
"path": "server/handlers/validators.handler.js",
"chars": 16698,
"preview": "const { addMilliseconds } = require(\"date-fns\");\nconst { body, param, query: queryValidator } = require(\"express-validat"
},
{
"path": "server/knex.js",
"chars": 829,
"preview": "const knex = require(\"knex\");\n\nconst env = require(\"./env\");\n\nconst isSQLite = env.DB_CLIENT === \"sqlite3\" || env.DB_CLI"
},
{
"path": "server/mail/index.js",
"chars": 35,
"preview": "module.exports = require(\"./mail\");"
},
{
"path": "server/mail/mail.js",
"chars": 4432,
"preview": "const nodemailer = require(\"nodemailer\");\nconst path = require(\"node:path\");\nconst fs = require(\"node:fs\");\n\nconst { res"
},
{
"path": "server/mail/template-change-email.html",
"chars": 21514,
"preview": "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional //EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"
},
{
"path": "server/mail/template-reset.html",
"chars": 21609,
"preview": "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional //EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"
},
{
"path": "server/mail/template-verify.html",
"chars": 21458,
"preview": "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional //EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"
},
{
"path": "server/mail/text.js",
"chars": 655,
"preview": "module.exports.verifyMailText = `You're attempting to change your email address on {{site_name}}.\n\nPlease verify your em"
},
{
"path": "server/migrations/20200211220920_constraints.js",
"chars": 373,
"preview": "const models = require(\"../models\");\n\nasync function up(knex) {\n await models.createUserTable(knex);\n await models.cre"
},
{
"path": "server/migrations/20200510140704_domains.js",
"chars": 1102,
"preview": "const models = require(\"../models\");\n\nasync function up(knex) {\n await models.createUserTable(knex);\n await models.cre"
},
{
"path": "server/migrations/20200718124944_description.js",
"chars": 308,
"preview": "async function up(knex) {\n const hasDescription = await knex.schema.hasColumn(\"links\", \"description\");\n if (!hasDescri"
},
{
"path": "server/migrations/20200730203154_expire_in.js",
"chars": 300,
"preview": "async function up(knex) {\n const hasExpireIn = await knex.schema.hasColumn(\"links\", \"expire_in\");\n if (!hasExpireIn) {"
},
{
"path": "server/migrations/20200810195255_change_email.js",
"chars": 425,
"preview": "async function up(knex) {\n const hasChangeEmail = await knex.schema.hasColumn(\n \"users\",\n \"change_email_token\"\n "
},
{
"path": "server/migrations/20241103083933_user-roles.js",
"chars": 1113,
"preview": "const { ROLES } = require(\"../consts\");\n\n/**\n * @param { import(\"knex\").Knex } knex\n * @returns { Promise<void> }\n */\nas"
},
{
"path": "server/migrations/20241223062111_indexes.js",
"chars": 2004,
"preview": "const env = require(\"../env\");\n\nconst isMySQL = env.DB_CLIENT === \"mysql\" || env.DB_CLIENT === \"mysql2\";\n\n/**\n * @param "
},
{
"path": "server/migrations/20241223103044_visits_user_id.js",
"chars": 1633,
"preview": "/**\n * @param { import(\"knex\").Knex } knex\n * @returns { Promise<void> }\n */\nasync function up(knex) {\n const hasUserID"
},
{
"path": "server/migrations/20241223155527_visits_user_id_index.js",
"chars": 707,
"preview": "const env = require(\"../env\");\n\nconst isMySQL = env.DB_CLIENT === \"mysql\" || env.DB_CLIENT === \"mysql2\";\n\n/**\n * @param "
},
{
"path": "server/migrations/20250106070444_remove_cooldown.js",
"chars": 721,
"preview": "async function up(knex) {\n const hasCooldowns = await knex.schema.hasColumn(\"users\", \"cooldowns\");\n if (hasCooldowns) "
},
{
"path": "server/models/domain.model.js",
"chars": 969,
"preview": "async function createDomainTable(knex) {\n const hasTable = await knex.schema.hasTable(\"domains\");\n if (!hasTable) {\n "
},
{
"path": "server/models/host.model.js",
"chars": 586,
"preview": "async function createHostTable(knex) {\n const hasTable = await knex.schema.hasTable(\"hosts\");\n if (!hasTable) {\n aw"
},
{
"path": "server/models/index.js",
"chars": 202,
"preview": "module.exports = {\n ...require(\"./domain.model\"),\n ...require(\"./host.model\"),\n ...require(\"./ip.model\"),\n ...requir"
},
{
"path": "server/models/ip.model.js",
"chars": 366,
"preview": "async function createIPTable(knex) {\n const hasTable = await knex.schema.hasTable(\"ips\");\n if (!hasTable) {\n await "
},
{
"path": "server/models/link.model.js",
"chars": 1473,
"preview": "async function createLinkTable(knex) {\n const hasTable = await knex.schema.hasTable(\"links\");\n\n if (!hasTable) {\n a"
},
{
"path": "server/models/user.model.js",
"chars": 1220,
"preview": "const { ROLES } = require(\"../consts\");\n\nasync function createUserTable(knex) {\n const hasTable = await knex.schema.has"
},
{
"path": "server/models/visit.model.js",
"chars": 2396,
"preview": "async function createVisitTable(knex) {\n const hasTable = await knex.schema.hasTable(\"visits\");\n if (!hasTable) {\n "
},
{
"path": "server/passport.js",
"chars": 3966,
"preview": "const { Strategy: LocalAPIKeyStrategy } = require(\"passport-localapikey-update\");\nconst { Strategy: JwtStrategy, Extract"
},
{
"path": "server/queries/domain.queries.js",
"chars": 5700,
"preview": "const redis = require(\"../redis\");\nconst utils = require(\"../utils\");\nconst knex = require(\"../knex\");\nconst env = requi"
},
{
"path": "server/queries/host.queries.js",
"chars": 1543,
"preview": "const redis = require(\"../redis\");\nconst utils = require(\"../utils\");\nconst knex = require(\"../knex\");\nconst env = requi"
},
{
"path": "server/queries/index.js",
"chars": 271,
"preview": "const domain = require(\"./domain.queries\");\nconst visit = require(\"./visit.queries\");\nconst link = require(\"./link.queri"
},
{
"path": "server/queries/link.queries.js",
"chars": 7576,
"preview": "const bcrypt = require(\"bcryptjs\");\n\nconst utils = require(\"../utils\");\nconst redis = require(\"../redis\");\nconst knex = "
},
{
"path": "server/queries/user.queries.js",
"chars": 6660,
"preview": "const { addMinutes } = require(\"date-fns\");\nconst { randomUUID } = require(\"node:crypto\");\n\nconst { ROLES } = require(\"."
},
{
"path": "server/queries/visit.queries.js",
"chars": 6013,
"preview": "const { isAfter, subDays, subHours, set, format } = require(\"date-fns\");\n\nconst utils = require(\"../utils\");\nconst redis"
},
{
"path": "server/queues/index.js",
"chars": 71,
"preview": "const { visit } = require(\"./queues\");\n\nmodule.exports = {\n visit,\n};\n"
},
{
"path": "server/queues/queues.js",
"chars": 841,
"preview": "const Queue = require(\"bull\");\nconst path = require(\"node:path\");\n\nconst env = require(\"../env\");\n\nconst redis = {\n por"
},
{
"path": "server/queues/visit.js",
"chars": 1595,
"preview": "const useragent = require(\"useragent\");\nconst geoip = require(\"geoip-lite\");\nconst URL = require(\"node:url\");\n\nconst { r"
},
{
"path": "server/redis.js",
"chars": 1047,
"preview": "const Redis = require(\"ioredis\");\n\nconst env = require(\"./env\");\n\nlet client;\n\nif (env.REDIS_ENABLED) {\n client = new R"
},
{
"path": "server/routes/auth.routes.js",
"chars": 2593,
"preview": "const { Router } = require(\"express\");\n\nconst validators = require(\"../handlers/validators.handler\");\nconst helpers = re"
},
{
"path": "server/routes/domain.routes.js",
"chars": 1930,
"preview": "const { Router } = require(\"express\");\n\nconst validators = require(\"../handlers/validators.handler\");\nconst helpers = re"
},
{
"path": "server/routes/health.routes.js",
"chars": 137,
"preview": "const { Router } = require(\"express\");\n\nconst router = Router();\n\nrouter.get(\"/\", (_, res) => res.send(\"OK\"));\n\nmodule.e"
},
{
"path": "server/routes/index.js",
"chars": 37,
"preview": "module.exports = require(\"./routes\");"
},
{
"path": "server/routes/link.routes.js",
"chars": 2774,
"preview": "const { Router } = require(\"express\");\nconst cors = require(\"cors\");\n\nconst validators = require(\"../handlers/validators"
},
{
"path": "server/routes/renders.routes.js",
"chars": 4793,
"preview": "const { Router } = require(\"express\");\n\nconst helpers = require(\"../handlers/helpers.handler\");\nconst renders = require("
},
{
"path": "server/routes/routes.js",
"chars": 750,
"preview": "const { Router } = require(\"express\");\n\nconst helpers = require(\"./../handlers/helpers.handler\");\nconst locals = require"
},
{
"path": "server/routes/user.routes.js",
"chars": 1779,
"preview": "const { Router } = require(\"express\");\n\nconst validators = require(\"../handlers/validators.handler\");\nconst helpers = re"
},
{
"path": "server/server.js",
"chars": 2695,
"preview": "const env = require(\"./env\");\n\nconst cookieParser = require(\"cookie-parser\");\nconst passport = require(\"passport\");\ncons"
},
{
"path": "server/utils/asyncHandler.js",
"chars": 232,
"preview": "function asyncHandler(fn) {\n return function asyncUtilWrap(...args) {\n const fnReturn = fn(...args);\n const next "
},
{
"path": "server/utils/index.js",
"chars": 36,
"preview": "module.exports = require(\"./utils\");"
},
{
"path": "server/utils/knex.js",
"chars": 1901,
"preview": "\nfunction knexUtils(knex) {\n function truncatedTimestamp(columnName, precision = \"hour\") {\n switch (knex.client.driv"
},
{
"path": "server/utils/map.json",
"chars": 120869,
"preview": "{\n \"id\": \"world-low-res\",\n \"name\": \"World Low Res\",\n \"viewBox\": \"0 0 1008.8549 651.45282\",\n \"layers\": [\n {\n "
},
{
"path": "server/utils/utils.js",
"chars": 11718,
"preview": "const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, s"
},
{
"path": "server/views/404.hbs",
"chars": 194,
"preview": "{{> header}}\n<div id=\"notfound\" class=\"section-container\">\n <h2>\n 404 | Link could not be found.\n </h2>\n <a class="
},
{
"path": "server/views/admin.hbs",
"chars": 44,
"preview": "{{> header}}\n{{> admin/index}}\n{{> footer}}\n"
},
{
"path": "server/views/banned.hbs",
"chars": 369,
"preview": "{{> header}}\n<section id=\"banned\" class=\"section-container\">\n <h2>\n Link has been banned and removed because of \n "
},
{
"path": "server/views/create_admin.hbs",
"chars": 48,
"preview": "{{> header}}\n{{> auth/form_admin}}\n{{> footer}}\n"
},
{
"path": "server/views/error.hbs",
"chars": 193,
"preview": "{{> header}}\n<div id=\"error-page\" class=\"section-container\">\n <h2>\n Error!\n </h2>\n <p>{{message}}</p>\n <a class=\""
},
{
"path": "server/views/homepage.hbs",
"chars": 83,
"preview": "{{> header}}\n{{> shortener}}\n{{#if user}}\n {{> links/table}}\n{{/if}}\n{{> footer}}\n"
},
{
"path": "server/views/layout.hbs",
"chars": 1927,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, in"
},
{
"path": "server/views/login.hbs",
"chars": 112,
"preview": "{{> header}}\n{{#if login_disabled}}\n {{> auth/login_disabled}}\n{{else}}\n {{> auth/form}}\n{{/if}}\n{{> footer}}\n"
},
{
"path": "server/views/logout.hbs",
"chars": 196,
"preview": "{{> header}}\n<div class=\"login-signup-message\" hx-get=\"/\" hx-trigger=\"load delay:1s\" hx-target=\"body\" hx-push-url=\"/\">\n "
},
{
"path": "server/views/partials/admin/dialog/add_domain.hbs",
"chars": 1473,
"preview": "<div class=\"content admin-create\">\n <h2>Add domain</h2>\n <form\n id=\"add-domain-form\"\n hx-post=\"/api/domains/admi"
},
{
"path": "server/views/partials/admin/dialog/add_domain_success.hbs",
"chars": 272,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n The domain <b>\"{{address}}\"<"
},
{
"path": "server/views/partials/admin/dialog/ban_domain.hbs",
"chars": 1161,
"preview": "<div class=\"content\">\n <h2>Ban domain?</h2>\n <p>\n Are you sure do you want to ban the domain "<b>{{address}}</"
},
{
"path": "server/views/partials/admin/dialog/ban_domain_success.hbs",
"chars": 252,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n The domain <b>\"{{address}}\"<"
},
{
"path": "server/views/partials/admin/dialog/ban_user.hbs",
"chars": 1087,
"preview": "<div class=\"content\">\n <h2>Ban user?</h2>\n <p>\n Are you sure do you want to ban the user "<b>{{email}}</b>&quo"
},
{
"path": "server/views/partials/admin/dialog/ban_user_success.hbs",
"chars": 248,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n The user <b>\"{{email}}\"</b> "
},
{
"path": "server/views/partials/admin/dialog/create_user.hbs",
"chars": 2383,
"preview": "<div class=\"content create-user\">\n <h2>Create user</h2>\n <form\n id=\"create-user-form\"\n hx-post=\"/api/users/admin"
},
{
"path": "server/views/partials/admin/dialog/create_user_success.hbs",
"chars": 268,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n The user <b>\"{{email}}\"</b> "
},
{
"path": "server/views/partials/admin/dialog/delete_domain.hbs",
"chars": 1063,
"preview": "<div class=\"content\">\n <h2>Delete domain?</h2>\n <p>\n Are you sure do you want to delete the domain "<b>{{addre"
},
{
"path": "server/views/partials/admin/dialog/delete_domain_success.hbs",
"chars": 259,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n The domain <b>\"{{address}}\"<"
},
{
"path": "server/views/partials/admin/dialog/delete_user.hbs",
"chars": 809,
"preview": "<div class=\"content\">\n <h2>Delete user?</h2>\n <p>\n Are you sure do you want to delete the user "<b>{{email}}</"
},
{
"path": "server/views/partials/admin/dialog/delete_user_success.hbs",
"chars": 255,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n The user <b>\"{{email}}\"</b> "
},
{
"path": "server/views/partials/admin/dialog/frame.hbs",
"chars": 183,
"preview": "<div id=\"admin-table-dialog\" class=\"dialog\">\n <div class=\"box\">\n <div class=\"content-wrapper\"></div>\n <div class="
},
{
"path": "server/views/partials/admin/dialog/mesasge.hbs",
"chars": 213,
"preview": "<div class=\"content\">\n {{#if error}}\n <p>{{error}}</p>\n {{else}}\n <p>{{message}}</p>\n {{/if}}\n <div class=\"but"
},
{
"path": "server/views/partials/admin/domains/actions.hbs",
"chars": 811,
"preview": "<td class=\"actions domains-actions\">\n {{#if banned}}\n <button class=\"action banned\" disabled=\"true\" data-tooltip=\"Ba"
},
{
"path": "server/views/partials/admin/domains/loading.hbs",
"chars": 306,
"preview": "{{#unless table_domains}}\n {{#ifEquals table_domains.length 0}}\n <tr class=\"no-data\">\n <td>\n No domains."
},
{
"path": "server/views/partials/admin/domains/table.hbs",
"chars": 878,
"preview": "<table \n hx-get=\"/api/domains/admin\"\n hx-target=\"tbody\"\n hx-swap=\"outerHTML\" \n hx-select=\"tbody\"\n hx-disinherit=\"*\""
},
{
"path": "server/views/partials/admin/domains/tbody.hbs",
"chars": 111,
"preview": "<tbody>\n {{> admin/domains/loading}}\n {{#each table_domains}}\n {{> admin/domains/tr}}\n {{/each}}\n</tbody>"
},
{
"path": "server/views/partials/admin/domains/tfoot.hbs",
"chars": 91,
"preview": "<tfoot>\n <tr class=\"controls domains-controls\">\n {{> admin/table_nav}}\n </tr>\n</tfoot>"
},
{
"path": "server/views/partials/admin/domains/thead.hbs",
"chars": 3319,
"preview": "<thead>\n {{> admin/table_tab title='domains'}}\n <tr class=\"controls domains-controls with-filters\">\n <th class=\"fil"
},
{
"path": "server/views/partials/admin/domains/tr.hbs",
"chars": 2471,
"preview": "<tr id=\"tr-{{id}}\" {{#if swap_oob}}hx-swap-oob=\"true\"{{/if}}>\n <td class=\"domains-id\">\n {{id}}\n </td>\n <td class=\""
},
{
"path": "server/views/partials/admin/index.hbs",
"chars": 195,
"preview": "<section id=\"main-table-wrapper\" class=\"admin-table-wrapper\">\n <h2 id=\"admin-table-title\">Recent shortened links.</h2>\n"
},
{
"path": "server/views/partials/admin/links/actions.hbs",
"chars": 1825,
"preview": "<td class=\"actions\">\n {{#if password}}\n <button class=\"action password\" disabled=\"true\" data-tooltip=\"Password prote"
},
{
"path": "server/views/partials/admin/links/edit.hbs",
"chars": 3468,
"preview": "<td class=\"content\">\n {{#if id}}\n <form \n id=\"edit-form-{{id}}\"\n hx-patch=\"/api/links/admin/{id}\"\n hx"
},
{
"path": "server/views/partials/admin/links/loading.hbs",
"chars": 286,
"preview": "{{#unless links}}\n {{#ifEquals links.length 0}}\n <tr class=\"no-data\">\n <td>\n No links.\n </td>\n <"
},
{
"path": "server/views/partials/admin/links/table.hbs",
"chars": 932,
"preview": "<table \n hx-get=\"/api/links/admin\"\n hx-target=\"tbody\"\n hx-swap=\"outerHTML\" \n hx-select=\"tbody\"\n hx-disinherit=\"*\"\n "
},
{
"path": "server/views/partials/admin/links/tbody.hbs",
"chars": 99,
"preview": "<tbody>\n {{> admin/links/loading}}\n {{#each links}}\n {{> admin/links/tr}}\n {{/each}}\n</tbody>"
},
{
"path": "server/views/partials/admin/links/tfoot.hbs",
"chars": 89,
"preview": "<tfoot>\n <tr class=\"controls links-controls\">\n {{> admin/table_nav}}\n </tr>\n</tfoot>"
},
{
"path": "server/views/partials/admin/links/thead.hbs",
"chars": 3699,
"preview": "<thead>\n {{> admin/table_tab title='links'}}\n <tr class=\"controls links-controls with-filters\">\n <th class=\"filters"
},
{
"path": "server/views/partials/admin/links/tr.hbs",
"chars": 2754,
"preview": "<tr id=\"tr-{{id}}\" {{#if swap_oob}}hx-swap-oob=\"true\"{{/if}}>\n <td class=\"original-url right-fade\">\n <a href=\"{{targ"
},
{
"path": "server/views/partials/admin/table_nav.hbs",
"chars": 670,
"preview": "<th class=\"nav\" >\n <div class=\"limit\">\n <button type=\"button\" class=\"nav\" onclick=\"setLinksLimit(event)\" disabled=\"t"
},
{
"path": "server/views/partials/admin/table_tab.hbs",
"chars": 1653,
"preview": "<tr class=\"category\">\n <th class=\"category-total\">\n <p id=\"category-total\">\n Total {{title}}: <b>{{#if total in"
},
{
"path": "server/views/partials/admin/users/actions.hbs",
"chars": 799,
"preview": "<td class=\"actions users-actions\">\n {{#if banned}}\n <button class=\"action banned\" disabled=\"true\" data-tooltip=\"Bann"
},
{
"path": "server/views/partials/admin/users/loading.hbs",
"chars": 286,
"preview": "{{#unless users}}\n {{#ifEquals users.length 0}}\n <tr class=\"no-data\">\n <td>\n No users.\n </td>\n <"
},
{
"path": "server/views/partials/admin/users/table.hbs",
"chars": 900,
"preview": "<table \n hx-get=\"/api/users/admin\"\n hx-target=\"tbody\"\n hx-swap=\"outerHTML\" \n hx-select=\"tbody\"\n hx-disinherit=\"*\"\n "
},
{
"path": "server/views/partials/admin/users/tbody.hbs",
"chars": 99,
"preview": "<tbody>\n {{> admin/users/loading}}\n {{#each users}}\n {{> admin/users/tr}}\n {{/each}}\n</tbody>"
},
{
"path": "server/views/partials/admin/users/tfoot.hbs",
"chars": 89,
"preview": "<tfoot>\n <tr class=\"controls users-controls\">\n {{> admin/table_nav}}\n </tr>\n</tfoot>"
},
{
"path": "server/views/partials/admin/users/thead.hbs",
"chars": 3526,
"preview": "<thead>\n {{> admin/table_tab title='users'}}\n <tr class=\"controls users-controls with-filters\">\n <th class=\"filters"
},
{
"path": "server/views/partials/admin/users/tr.hbs",
"chars": 1711,
"preview": "<tr id=\"tr-{{id}}\" {{#if swap_oob}}hx-swap-oob=\"true\"{{/if}}>\n <td class=\"users-id\">\n {{id}}\n </td>\n <td class=\"us"
},
{
"path": "server/views/partials/auth/form.hbs",
"chars": 2337,
"preview": "<form id=\"login-signup\" hx-post=\"/api/auth/login\" hx-swap=\"outerHTML\">\n {{#unless disallow_login_form}}\n <label clas"
},
{
"path": "server/views/partials/auth/form_admin.hbs",
"chars": 1085,
"preview": "<form id=\"login-signup\" hx-post=\"/api/auth/create-admin\" hx-swap=\"outerHTML\">\n <h2 class=\"admin-form-title\">\n Create"
},
{
"path": "server/views/partials/auth/login_disabled.hbs",
"chars": 78,
"preview": "<div class=\"login-signup-message\">\n <h1>\n Login is closed.\n </h1>\n</div>\n"
},
{
"path": "server/views/partials/auth/verify.hbs",
"chars": 103,
"preview": "<div class=\"login-signup-message\">\n <h1>\n A verification email has been sent to you.\n </h1>\n</div>"
},
{
"path": "server/views/partials/auth/welcome.hbs",
"chars": 167,
"preview": "<div class=\"login-signup-message\" hx-get=\"/\" hx-trigger=\"load delay:1s\" hx-target=\"body\" hx-push-url=\"/\">\n <h1>\n Wel"
},
{
"path": "server/views/partials/footer.hbs",
"chars": 578,
"preview": "<footer>\n <p>\n Powered by <a href=\"https://github.com/thedevs-network/kutt\" title=\"The Devs\" target=\"_blank\" rel=\"no"
},
{
"path": "server/views/partials/header.hbs",
"chars": 1430,
"preview": "<header>\n <div class=\"logo-wrapper\">\n <a class=\"logo nav\" href=\"/\" title=\"Homepage\">\n <img src=\"/images/logo.pn"
},
{
"path": "server/views/partials/icons/arrow_left.hbs",
"chars": 222,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" viewBox=\"-5 -5 24 24\"><path d=\"m3.41 7.66 3.95 3.95a1 1 0 0 "
},
{
"path": "server/views/partials/icons/chart.hbs",
"chars": 159,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 24 24\"><path d=\"M21.2 15.9A10 10 0 1 1 8 "
},
{
"path": "server/views/partials/icons/check.hbs",
"chars": 133,
"preview": "<svg class=\"check\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 24 24\"><path d=\"M20 6 9 17l"
},
{
"path": "server/views/partials/icons/chevron_left.hbs",
"chars": 126,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"m15 18-6-6 6-6\"/>"
},
{
"path": "server/views/partials/icons/chevron_right.hbs",
"chars": 125,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"m9 18 6-6-6-6\"/><"
},
{
"path": "server/views/partials/icons/cog.hbs",
"chars": 961,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke"
},
{
"path": "server/views/partials/icons/copy.hbs",
"chars": 236,
"preview": "<svg class=\"copy\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><rect width=\""
},
{
"path": "server/views/partials/icons/eye.hbs",
"chars": 265,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" viewBox=\"-2 -6 24 24\"><path d=\"M18 6c0-1.8-3.8-4-8-4S2 4.2 2"
},
{
"path": "server/views/partials/icons/heart.hbs",
"chars": 213,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 24 24\"><path d=\"M20.8 4.6a5.5 5.5 0 0 0-7"
},
{
"path": "server/views/partials/icons/key.hbs",
"chars": 219,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"m21 2-2 2m-7.6 7."
},
{
"path": "server/views/partials/icons/login.hbs",
"chars": 189,
"preview": "<svg class=\"with-text icon\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 24 24\"><path d=\"M1"
},
{
"path": "server/views/partials/icons/new_user.hbs",
"chars": 202,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 24 24\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H5"
},
{
"path": "server/views/partials/icons/pencil.hbs",
"chars": 127,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#5c666b\" viewBox=\"0 0 24 24\"><path d=\"m16 3 5 5L8 21H3v-5z\"/"
},
{
"path": "server/views/partials/icons/plus.hbs",
"chars": 128,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M12 5v14m-7-7h14\""
},
{
"path": "server/views/partials/icons/qrcode.hbs",
"chars": 405,
"preview": "\n<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" preserveAspectRatio=\"xMinYMin\" viewBox=\"-2 -2 24 24\"><path "
},
{
"path": "server/views/partials/icons/reload.hbs",
"chars": 205,
"preview": "\n<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M1 4v6h6m16 10v-"
},
{
"path": "server/views/partials/icons/send.hbs",
"chars": 138,
"preview": "<svg class=\"send\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"m2 21 21-9L2 3v7l1"
},
{
"path": "server/views/partials/icons/shield.hbs",
"chars": 265,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke"
},
{
"path": "server/views/partials/icons/shuffle.hbs",
"chars": 160,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 24 24\"><path d=\"M16 3h5v5M4 20 20.2 3.8M2"
},
{
"path": "server/views/partials/icons/spinner.hbs",
"chars": 213,
"preview": "<svg class=\"spinner\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M"
},
{
"path": "server/views/partials/icons/stop.hbs",
"chars": 168,
"preview": "\n<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#5c666b\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"10\""
},
{
"path": "server/views/partials/icons/trash.hbs",
"chars": 210,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M3 6h18m-2 0v14a2"
},
{
"path": "server/views/partials/icons/write.hbs",
"chars": 198,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 24 24\"><path d=\"M20 14.7V20a2 2 0 0 1-2 2"
},
{
"path": "server/views/partials/icons/x.hbs",
"chars": 124,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 24 24\"><path d=\"M18 6 6 18M6 6l12 12\"/></"
},
{
"path": "server/views/partials/icons/zap.hbs",
"chars": 139,
"preview": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M13 2 3 14h9l-1 8"
},
{
"path": "server/views/partials/links/actions.hbs",
"chars": 1456,
"preview": "<td class=\"actions\">\n {{#if password}}\n <button class=\"action password\" disabled=\"true\" data-tooltip=\"Password prote"
},
{
"path": "server/views/partials/links/dialog/ban.hbs",
"chars": 1282,
"preview": "<div class=\"content\">\n <h2>Ban link?</h2>\n <p>\n Are you sure do you want to ban the link "<b>{{link}}</b>""
},
{
"path": "server/views/partials/links/dialog/ban_success.hbs",
"chars": 247,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n The link <b>\"{{link}}\"</b> i"
},
{
"path": "server/views/partials/links/dialog/delete.hbs",
"chars": 732,
"preview": "<div class=\"content\">\n <h2>Delete link?</h2>\n <p>\n Are you sure do you want to delete the link "<b>{{link}}</b"
},
{
"path": "server/views/partials/links/dialog/delete_success.hbs",
"chars": 255,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n Your link <b>\"{{link}}\"</b> "
},
{
"path": "server/views/partials/links/dialog/frame.hbs",
"chars": 176,
"preview": "<div id=\"link-dialog\" class=\"dialog\">\n <div class=\"box\">\n <div class=\"content-wrapper\"></div>\n <div class=\"loadin"
},
{
"path": "server/views/partials/links/dialog/message.hbs",
"chars": 213,
"preview": "<div class=\"content\">\n {{#if error}}\n <p>{{error}}</p>\n {{else}}\n <p>{{message}}</p>\n {{/if}}\n <div class=\"but"
},
{
"path": "server/views/partials/links/edit.hbs",
"chars": 3456,
"preview": "<td class=\"content\">\n {{#if id}}\n <form \n id=\"edit-form-{{id}}\"\n hx-patch=\"/api/links/{id}\"\n hx-ext=\""
},
{
"path": "server/views/partials/links/loading.hbs",
"chars": 286,
"preview": "{{#unless links}}\n {{#ifEquals links.length 0}}\n <tr class=\"no-data\">\n <td>\n No links.\n </td>\n <"
},
{
"path": "server/views/partials/links/nav.hbs",
"chars": 670,
"preview": "<th class=\"nav\" >\n <div class=\"limit\">\n <button type=\"button\" class=\"nav\" onclick=\"setLinksLimit(event)\" disabled=\"t"
},
{
"path": "server/views/partials/links/table.hbs",
"chars": 645,
"preview": "<section id=\"main-table-wrapper\">\n <h2>Recent shortened links.</h2>\n <table \n hx-get=\"/api/links\"\n hx-target=\"tb"
},
{
"path": "server/views/partials/links/tbody.hbs",
"chars": 87,
"preview": "<tbody>\n {{> links/loading}}\n {{#each links}}\n {{> links/tr}}\n {{/each}}\n</tbody>"
},
{
"path": "server/views/partials/links/tfoot.hbs",
"chars": 83,
"preview": "<tfoot>\n <tr class=\"controls links-controls\">\n {{> links/nav}}\n </tr>\n</tfoot>"
},
{
"path": "server/views/partials/links/thead.hbs",
"chars": 662,
"preview": "<thead>\n <tr class=\"controls links-controls\">\n <th class=\"search\">\n <input class=\"table-input search\" id=\"searc"
},
{
"path": "server/views/partials/links/tr.hbs",
"chars": 1037,
"preview": "<tr id=\"tr-{{id}}\" {{#if swap_oob}}hx-swap-oob=\"true\"{{/if}}>\n <td class=\"original-url right-fade\">\n <a href=\"{{targ"
},
{
"path": "server/views/partials/protected/form.hbs",
"chars": 808,
"preview": "<form\n id=\"report-form\"\n hx-post=\"/api/links/{id}/protected\"\n hx-sync=\"this:abort\"\n hx-ext=\"path-params\"\n hx-vals='"
},
{
"path": "server/views/partials/report/email.hbs",
"chars": 405,
"preview": "<div id=\"report-email\">\n {{#unless report_email_address}}\n <button \n class=\"link\"\n hx-get=\"/get-report-ema"
},
{
"path": "server/views/partials/report/form.hbs",
"chars": 719,
"preview": "<form\n id=\"report-form\"\n hx-post=\"/api/links/report\"\n hx-sync=\"this:abort\"\n hx-swap=\"outerHTML\"\n> \n {{#if message}}"
},
{
"path": "server/views/partials/reset_password/new_password_form.hbs",
"chars": 1200,
"preview": "<form\n id=\"new-password-form\"\n class=\"htmx-spinner\"\n hx-post=\"/api/auth/new-password\"\n hx-vals='{\"reset_pass"
},
{
"path": "server/views/partials/reset_password/new_password_success.hbs",
"chars": 156,
"preview": "<p class=\"success\">\n Your password is updated successfully. \n You can now log in with your new password.\n</p>\n<a href="
},
{
"path": "server/views/partials/reset_password/request_form.hbs",
"chars": 755,
"preview": "<form\n id=\"reset-password-form\"\n class=\"htmx-spinner\"\n hx-post=\"/api/auth/reset-password\"\n hx-sync=\"this:abort\"\n hx"
},
{
"path": "server/views/partials/settings/apikey.hbs",
"chars": 1278,
"preview": "<section id=\"apikey-wrapper\">\n <h2>API</h2>\n <p>\n In additional to this website, you can use the API to create, del"
},
{
"path": "server/views/partials/settings/change_email.hbs",
"chars": 1384,
"preview": "<section id=\"change-email-wrapper\">\n <h2>\n Change email\n </h2>\n <p>Enter your password and a new email address to "
},
{
"path": "server/views/partials/settings/change_password.hbs",
"chars": 1469,
"preview": "<section id=\"change-password-wrapper\">\n <h2>\n Change password\n </h2>\n <p>Enter your current password and a new pas"
},
{
"path": "server/views/partials/settings/delete_account.hbs",
"chars": 1091,
"preview": "<section id=\"delete-account-wrapper\">\n <h2>\n Delete account\n </h2>\n <p>Delete your account from {{default_domain}}"
},
{
"path": "server/views/partials/settings/domain/add_form.hbs",
"chars": 1580,
"preview": "<form \n id=\"add-domain\"\n hx-post=\"/api/domains\"\n hx-sync=\"this:abort\"\n hx-swap=\"outerHTML\"\n hx-on::after-request=\"\n"
},
{
"path": "server/views/partials/settings/domain/delete.hbs",
"chars": 741,
"preview": "<div class=\"content\">\n <h2>Delete domain?</h2>\n <p>\n Are you sure do you want to delete the domain "<b>{{addre"
},
{
"path": "server/views/partials/settings/domain/delete_success.hbs",
"chars": 286,
"preview": "<div class=\"content\">\n <div class=\"icon success\">\n {{> icons/check}}\n </div>\n <p>\n Your domain <b>\"{{address}}\""
},
{
"path": "server/views/partials/settings/domain/dialog.hbs",
"chars": 178,
"preview": "<div id=\"domain-dialog\" class=\"dialog\">\n <div class=\"box\">\n <div class=\"content-wrapper\"></div>\n <div class=\"load"
},
{
"path": "server/views/partials/settings/domain/index.hbs",
"chars": 1322,
"preview": "<h2>\n Custom domain\n</h2>\n<p>\n You can set a custom domain for your short URLs, so instead of\n <b>{{default_domain}}/"
},
{
"path": "server/views/partials/settings/domain/table.hbs",
"chars": 1022,
"preview": "<table id=\"domains-table\" hx-swap-oob=\"true\">\n <thead>\n <tr>\n <th class=\"domain\">Domain</th>\n <th class=\"h"
},
{
"path": "server/views/partials/shortener.hbs",
"chars": 4258,
"preview": "<main>\n <div id=\"shorturl\">\n {{#if link}}\n <div class=\"clipboard\">\n <button \n type=\"button\"\n "
},
{
"path": "server/views/partials/stats.hbs",
"chars": 5142,
"preview": "{{#if error}}\n <div class=\"stats-error\">\n <p>{{> icons/x}} {{error}}</p>\n <div class=\"stats-back-to-home\">\n "
},
{
"path": "server/views/partials/support_email.hbs",
"chars": 59,
"preview": "<a href=\"mailto:{{email}}\" title=\"Contact us\">{{email}}</a>"
},
{
"path": "server/views/protected.hbs",
"chars": 211,
"preview": "{{> header}}\n<section id=\"protected\" class=\"section-container\">\n <h2>\n Protected link.\n </h2>\n <p>\n Enter the p"
},
{
"path": "server/views/report.hbs",
"chars": 361,
"preview": "{{> header}}\n<section id=\"report\" class=\"section-container\">\n <h2>\n Report abuse.\n </h2>\n <p>\n Report abuses, m"
},
{
"path": "server/views/reset_password.hbs",
"chars": 268,
"preview": "{{> header}}\n<section id=\"reset-password\" class=\"section-container\">\n <h2>\n Reset password.\n </h2>\n <p>\n If you"
},
{
"path": "server/views/reset_password_set_new_password.hbs",
"chars": 473,
"preview": "{{> header}}\n<section \n id=\"new-password\"\n class=\"section-container {{#unless token_verified}}verify-page{{/unless}}\"\n"
},
{
"path": "server/views/settings.hbs",
"chars": 399,
"preview": "{{> header}}\n<section id=\"settings\" class=\"section-container\">\n <h1 class=\"settings-welcome\">\n Welcome, <span>{{user"
},
{
"path": "server/views/stats.hbs",
"chars": 586,
"preview": "{{> header}}\n<section\n id=\"stats-section\"\n class=\"section-container\"\n hx-get=\"/api/links/{id}/stats\"\n hx-swap=\"inner"
}
]
// ... and 9 more files (download for full content)
About this extraction
This page contains the full source code of the thedevs-network/kutt GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 209 files (550.6 KB), approximately 207.2k tokens, and a symbol index with 218 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.