├── .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-lock.json ├── 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 │ │ ├── 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 ├── .DS_Store ├── css └── styles.css ├── fonts └── nunito-variable.woff2 ├── images ├── card.png ├── favicon-16x16.png ├── favicon-196x196.png ├── favicon-32x32.png ├── favicon.ico └── logo.png ├── libs ├── chart.min.js ├── htmx.min.js └── qrcode.min.js ├── manifest.webmanifest ├── robots.txt └── scripts ├── main.js └── stats.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | # Optional - App port to run on 2 | PORT=3000 3 | 4 | # Optional - The name of the site where Kutt is hosted 5 | SITE_NAME=Kutt 6 | 7 | # Optional - The domain that this website is on 8 | DEFAULT_DOMAIN=localhost:3000 9 | 10 | # Required - A passphrase to encrypt JWT. Use a random long string 11 | JWT_SECRET= 12 | 13 | # Optional - Database client. Available clients for the supported databases: 14 | # pg | better-sqlite3 | mysql2 15 | # other supported drivers that you can use but you have to manually install them with npm: 16 | # pg-native | sqlite3 | mysql 17 | DB_CLIENT=better-sqlite3 18 | 19 | # Optional - SQLite database file path 20 | # Only if you're using SQLite 21 | DB_FILENAME=db/data 22 | 23 | # Optional - SQL database credential details 24 | # Only if you're using Postgres or MySQL 25 | DB_HOST=localhost 26 | DB_PORT=5432 27 | DB_NAME=kutt 28 | DB_USER=postgres 29 | DB_PASSWORD= 30 | DB_SSL=false 31 | DB_POOL_MIN=0 32 | DB_POOL_MAX=10 33 | 34 | # Optional - Generated link length 35 | LINK_LENGTH=6 36 | 37 | # Optional - Alphabet used to generate custom addresses 38 | # Default value omits o, O, 0, i, I, l, 1, and j to avoid confusion when reading the URL 39 | LINK_CUSTOM_ALPHABET=abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789 40 | 41 | # Optional - Tells the app that it's running behind a proxy server 42 | # and that it should get the IP address from that proxy server 43 | # if you're not using a proxy server then set this to false, otherwise users can override their IP address 44 | TRUST_PROXY=true 45 | 46 | # Optional - Redis host and port 47 | REDIS_ENABLED=false 48 | REDIS_HOST=127.0.0.1 49 | REDIS_PORT=6379 50 | REDIS_PASSWORD= 51 | # The number for Redis database, between 0 and 15. Defaults to 0. 52 | # If you don't know what this is, then you probably don't need to change it. 53 | REDIS_DB=0 54 | 55 | # Optional - Disable registration. Default is true. 56 | DISALLOW_REGISTRATION=true 57 | 58 | # Optional - Disable anonymous link creation. Default is true. 59 | DISALLOW_ANONYMOUS_LINKS=true 60 | 61 | # Optional - This would be shown to the user on the settings page 62 | # It's only for display purposes and has no other use 63 | SERVER_IP_ADDRESS= 64 | SERVER_CNAME_ADDRESS= 65 | 66 | # Optional - Use HTTPS for links with custom domain 67 | # It's on you to generate SSL certificates for those domains manually, at least on this version for now 68 | CUSTOM_DOMAIN_USE_HTTPS=false 69 | 70 | # Optional - Email is used to verify or change email address, reset password, and send reports. 71 | # If it's disabled, all the above functionality would be disabled as well. 72 | # MAIL_FROM example: "Kutt ". Leave it empty to use MAIL_USER. 73 | # More info on the configuration on http://nodemailer.com/. 74 | MAIL_ENABLED=false 75 | MAIL_HOST= 76 | MAIL_PORT=587 77 | MAIL_SECURE=true 78 | MAIL_USER= 79 | MAIL_FROM= 80 | MAIL_PASSWORD= 81 | 82 | # Optional - Enable rate limitting for some API routes 83 | ENABLE_RATE_LIMIT=false 84 | 85 | # Optional - The email address that will receive submitted reports 86 | REPORT_EMAIL= 87 | 88 | # Optional - Support email to show on the app 89 | CONTACT_EMAIL= 90 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-development.yaml: -------------------------------------------------------------------------------- 1 | name: docker-build-development 2 | 3 | env: 4 | dockerhub_repository: "kutt/kutt" 5 | dockerhub_tag: "development" 6 | 7 | on: 8 | push: 9 | branches: 10 | - develop 11 | 12 | jobs: 13 | dockerhub-build-push: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - 17 | name: Checkout 18 | uses: actions/checkout@v2 19 | - 20 | name: Set up QEMU 21 | uses: docker/setup-qemu-action@v1 22 | - 23 | name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v1 25 | - 26 | name: Login to DockerHub 27 | uses: docker/login-action@v1 28 | with: 29 | username: ${{ secrets.DOCKERHUB_USERNAME }} 30 | password: ${{ secrets.DOCKERHUB_TOKEN }} 31 | - 32 | name: Build and push 33 | uses: docker/build-push-action@v2 34 | with: 35 | platforms: linux/amd64,linux/arm64 36 | context: . 37 | push: true 38 | tags: ${{ env.dockerhub_repository }}:${{ env.dockerhub_tag }} 39 | - 40 | name: Update repo description 41 | uses: peter-evans/dockerhub-description@v2 42 | with: 43 | username: ${{ secrets.DOCKERHUB_USERNAME }} 44 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 45 | repository: ${{ env.dockerhub_repository }} 46 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-latest.yaml: -------------------------------------------------------------------------------- 1 | name: docker-build-latest 2 | 3 | env: 4 | dockerhub_repository: "kutt/kutt" 5 | dockerhub_tag: "main" 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | dockerhub-build-push: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - 17 | name: Checkout 18 | uses: actions/checkout@v2 19 | - 20 | name: Set up QEMU 21 | uses: docker/setup-qemu-action@v1 22 | - 23 | name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v1 25 | - 26 | name: Login to DockerHub 27 | uses: docker/login-action@v1 28 | with: 29 | username: ${{ secrets.DOCKERHUB_USERNAME }} 30 | password: ${{ secrets.DOCKERHUB_TOKEN }} 31 | - 32 | name: Build and push 33 | uses: docker/build-push-action@v2 34 | with: 35 | platforms: linux/amd64,linux/arm64 36 | context: . 37 | push: true 38 | tags: ${{ env.dockerhub_repository }}:${{ env.dockerhub_tag }} 39 | - 40 | name: Update repo description 41 | uses: peter-evans/dockerhub-description@v2 42 | with: 43 | username: ${{ secrets.DOCKERHUB_USERNAME }} 44 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 45 | repository: ${{ env.dockerhub_repository }} 46 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-release.yaml: -------------------------------------------------------------------------------- 1 | name: docker-build-release 2 | 3 | env: 4 | dockerhub_repository: "kutt/kutt" 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | dockerhub-build-push: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - 15 | name: Checkout 16 | uses: actions/checkout@v2 17 | - 18 | name: Set up QEMU 19 | uses: docker/setup-qemu-action@v1 20 | - 21 | name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v1 23 | - 24 | name: Login to DockerHub 25 | uses: docker/login-action@v1 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | - 30 | name: Build and push 31 | uses: docker/build-push-action@v2 32 | with: 33 | platforms: linux/amd64,linux/arm64 34 | context: . 35 | push: true 36 | tags: ${{ env.dockerhub_repository }}:${{ github.event.release.tag_name }}, ${{ env.dockerhub_repository }}:latest 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .vscode/ 3 | logs 4 | client/.next/ 5 | node_modules/ 6 | client/config.js 7 | client/old.config.js 8 | server/config.js 9 | server/old.config.js 10 | production-server 11 | .idea/ 12 | dump.rdb 13 | docs/api/static 14 | **/.DS_Store 15 | db/* 16 | !db/.gitkeep 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # specify node.js image 2 | FROM node:22-alpine 3 | 4 | # use production node environment by default 5 | ENV NODE_ENV=production 6 | 7 | # set working directory. 8 | WORKDIR /kutt 9 | 10 | # download dependencies while using Docker's caching 11 | RUN --mount=type=bind,source=package.json,target=package.json \ 12 | --mount=type=bind,source=package-lock.json,target=package-lock.json \ 13 | --mount=type=cache,target=/root/.npm \ 14 | npm ci --omit=dev 15 | 16 | RUN mkdir -p /var/lib/kutt 17 | 18 | # copy the rest of source files into the image 19 | COPY . . 20 | 21 | # expose the port that the app listens on 22 | EXPOSE 3000 23 | 24 | # intialize database and run the app 25 | CMD npm run migrate && npm start -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kutt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom/.gitkeep: -------------------------------------------------------------------------------- 1 | # keep this folder in git 2 | # put supported customization files for styles and such 3 | # if you're using docker make sure to mount this folder -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- 1 | # keep this folder in git 2 | # if you use a file-based databases such as sqlite3, the database files would be stored here -------------------------------------------------------------------------------- /docker-compose.mariadb.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: 4 | context: . 5 | volumes: 6 | - custom:/kutt/custom 7 | environment: 8 | DB_CLIENT: mysql2 9 | DB_HOST: mariadb 10 | DB_PORT: 3306 11 | REDIS_ENABLED: true 12 | REDIS_HOST: redis 13 | REDIS_PORT: 6379 14 | ports: 15 | - 3000:3000 16 | depends_on: 17 | mariadb: 18 | condition: service_healthy 19 | redis: 20 | condition: service_started 21 | mariadb: 22 | image: mariadb:10 23 | restart: always 24 | healthcheck: 25 | test: ['CMD-SHELL', 'mysql ${DB_NAME} --user=${DB_USER} --password=${DB_PASSWORD} --execute "SELECT 1;"'] 26 | interval: 3s 27 | retries: 5 28 | start_period: 30s 29 | volumes: 30 | - db_data_mariadb:/var/lib/mysql 31 | environment: 32 | MARIADB_DATABASE: ${DB_NAME} 33 | MARIADB_USER: ${DB_USER} 34 | MARIADB_PASSWORD: ${DB_PASSWORD} 35 | MARIADB_ROOT_PASSWORD: ${DB_PASSWORD} 36 | expose: 37 | - 3306 38 | redis: 39 | image: redis:alpine 40 | restart: always 41 | expose: 42 | - 6379 43 | volumes: 44 | db_data_mariadb: 45 | custom: -------------------------------------------------------------------------------- /docker-compose.postgres.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: 4 | context: . 5 | volumes: 6 | - custom:/kutt/custom 7 | environment: 8 | DB_CLIENT: pg 9 | DB_HOST: postgres 10 | DB_PORT: 5432 11 | REDIS_ENABLED: true 12 | REDIS_HOST: redis 13 | REDIS_PORT: 6379 14 | ports: 15 | - 3000:3000 16 | depends_on: 17 | postgres: 18 | condition: service_healthy 19 | redis: 20 | condition: service_started 21 | postgres: 22 | image: postgres 23 | restart: always 24 | user: ${DB_USER} 25 | volumes: 26 | - db_data_pg:/var/lib/postgresql/data 27 | environment: 28 | POSTGRES_DB: ${DB_NAME} 29 | POSTGRES_PASSWORD: ${DB_PASSWORD} 30 | expose: 31 | - 5432 32 | healthcheck: 33 | test: [ "CMD", "pg_isready" ] 34 | interval: 10s 35 | timeout: 5s 36 | retries: 5 37 | redis: 38 | image: redis:alpine 39 | restart: always 40 | expose: 41 | - 6379 42 | volumes: 43 | db_data_pg: 44 | custom: -------------------------------------------------------------------------------- /docker-compose.sqlite-redis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: 4 | context: . 5 | volumes: 6 | - db_data_sqlite:/var/lib/kutt 7 | - custom:/kutt/custom 8 | environment: 9 | DB_FILENAME: "/var/lib/kutt/data.sqlite" 10 | REDIS_ENABLED: true 11 | REDIS_HOST: redis 12 | REDIS_PORT: 6379 13 | ports: 14 | - 3000:3000 15 | depends_on: 16 | redis: 17 | condition: service_started 18 | redis: 19 | image: redis:alpine 20 | restart: always 21 | expose: 22 | - 6379 23 | volumes: 24 | db_data_sqlite: 25 | custom: -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: 4 | context: . 5 | volumes: 6 | - db_data_sqlite:/var/lib/kutt 7 | - custom:/kutt/custom 8 | environment: 9 | DB_FILENAME: "/var/lib/kutt/data.sqlite" 10 | ports: 11 | - 3000:3000 12 | volumes: 13 | db_data_sqlite: 14 | custom: -------------------------------------------------------------------------------- /docs/api/generate.js: -------------------------------------------------------------------------------- 1 | const { join, dirname } = require("node:path"); 2 | const { promises: fs } = require("node:fs"); 3 | 4 | const api = require("./api"); 5 | 6 | const Template = (output, { api, title, redoc }) => 7 | fs.writeFile(output, 8 | ` 9 | 10 | 11 | 12 | 13 | 14 | ${title} 15 | 16 | 17 | 18 | 19 | 20 | 21 | `); 22 | 23 | const Api = output => 24 | fs.writeFile(output, JSON.stringify(api)); 25 | 26 | const Redoc = output => 27 | fs.copyFile(join( 28 | dirname(require.resolve("redoc")), 29 | "redoc.standalone.js"), 30 | output); 31 | 32 | module.exports = (async () => { 33 | const out = join(__dirname, "static"); 34 | const apiFile = "api.json"; 35 | const redocFile = "redoc.js"; 36 | await fs.mkdir(out, { recursive: true }); 37 | return Promise.all([ 38 | Api(join(out, apiFile)), 39 | Redoc(join(out, redocFile)), 40 | Template(join(out, "index.html"), { 41 | api: apiFile, 42 | title: api.info.title, 43 | redoc: redocFile 44 | }), 45 | 46 | ]); 47 | })(); 48 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "allowImportingTsExtensions": false 5 | }, 6 | "exclude": [ 7 | "node_modules", 8 | "**/node_modules/*" 9 | ] 10 | } -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | // this configuration is for migrations only 2 | // and since jwt secret is not required, it's set to a placehodler string to bypass env validation 3 | if (!process.env.JWT_SECRET) { 4 | process.env.JWT_SECRET = "securekey"; 5 | } 6 | 7 | const env = require("./server/env"); 8 | 9 | const isSQLite = env.DB_CLIENT === "sqlite3" || env.DB_CLIENT === "better-sqlite3"; 10 | 11 | module.exports = { 12 | client: env.DB_CLIENT, 13 | connection: { 14 | ...(isSQLite && { filename: env.DB_FILENAME }), 15 | host: env.DB_HOST, 16 | database: env.DB_NAME, 17 | user: env.DB_USER, 18 | port: env.DB_PORT, 19 | password: env.DB_PASSWORD, 20 | ssl: env.DB_SSL, 21 | }, 22 | useNullAsDefault: true, 23 | migrations: { 24 | tableName: "knex_migrations", 25 | directory: "server/migrations", 26 | disableMigrationsListValidation: true, 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kutt", 3 | "version": "3.2.3", 4 | "description": "Modern URL shortener.", 5 | "main": "./server/server.js", 6 | "scripts": { 7 | "dev": "node --watch-path=./server --watch-path=./custom server/server.js", 8 | "start": "node server/server.js --production", 9 | "migrate": "knex migrate:latest", 10 | "migrate:make": "knex migrate:make", 11 | "docs:build": "cd docs/api && node generate && cd ../.." 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/thedevs-network/kutt.git" 16 | }, 17 | "keywords": [ 18 | "url-shortener" 19 | ], 20 | "author": "Pouria Ezzati ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/thedevs-network/kutt/issues" 24 | }, 25 | "homepage": "https://github.com/thedevs-network/kutt#readme", 26 | "dependencies": { 27 | "bcryptjs": "2.4.3", 28 | "better-sqlite3": "11.8.1", 29 | "bull": "4.16.5", 30 | "cookie-parser": "1.4.7", 31 | "cors": "2.8.5", 32 | "date-fns": "2.30.0", 33 | "dotenv": "16.4.7", 34 | "envalid": "8.0.0", 35 | "express": "4.21.2", 36 | "express-rate-limit": "7.5.0", 37 | "express-validator": "6.14.2", 38 | "geoip-lite": "1.4.10", 39 | "hbs": "4.2.0", 40 | "helmet": "7.1.0", 41 | "ioredis": "5.4.2", 42 | "isbot": "5.1.19", 43 | "jsonwebtoken": "9.0.2", 44 | "knex": "3.1.0", 45 | "ms": "2.1.3", 46 | "mysql2": "3.12.0", 47 | "nanoid": "3.3.8", 48 | "nodemailer": "6.9.16", 49 | "passport": "0.7.0", 50 | "passport-jwt": "4.0.1", 51 | "passport-local": "1.0.0", 52 | "passport-localapikey-update": "0.6.0", 53 | "pg": "8.13.1", 54 | "pg-query-stream": "4.7.1", 55 | "rate-limit-redis": "4.2.0", 56 | "useragent": "2.3.0" 57 | }, 58 | "devDependencies": { 59 | "@types/bcryptjs": "2.4.2", 60 | "@types/cookie-parser": "1.4.3", 61 | "@types/cors": "2.8.12", 62 | "@types/express": "4.17.14", 63 | "@types/hbs": "4.0.4", 64 | "@types/jsonwebtoken": "7.2.8", 65 | "@types/ms": "0.7.31", 66 | "@types/node": "18.11.9", 67 | "@types/nodemailer": "6.4.6", 68 | "@types/pg": "8.11.10", 69 | "redoc": "2.2.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /server/consts.js: -------------------------------------------------------------------------------- 1 | const ROLES = { 2 | USER: "USER", 3 | ADMIN: "ADMIN" 4 | }; 5 | 6 | module.exports = { 7 | ROLES, 8 | } -------------------------------------------------------------------------------- /server/cron.js: -------------------------------------------------------------------------------- 1 | const query = require("./queries"); 2 | const utils = require("./utils"); 3 | 4 | // check and delete links 30 secoonds 5 | setInterval(function () { 6 | query.link.batchRemove({ expire_in: ["<", utils.dateToUTC(new Date())] }).catch(); 7 | }, 30_000); 8 | -------------------------------------------------------------------------------- /server/env.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const { cleanEnv, num, str, bool } = require("envalid"); 3 | const { readFileSync } = require("node:fs"); 4 | 5 | const supportedDBClients = [ 6 | "pg", 7 | "pg-native", 8 | "sqlite3", 9 | "better-sqlite3", 10 | "mysql", 11 | "mysql2" 12 | ]; 13 | 14 | // make sure custom alphabet is not empty 15 | if (process.env.LINK_CUSTOM_ALPHABET === "") { 16 | delete process.env.LINK_CUSTOM_ALPHABET; 17 | } 18 | 19 | // make sure jwt secret is not empty 20 | if (process.env.JWT_SECRET === "") { 21 | delete process.env.JWT_SECRET; 22 | } 23 | 24 | // if is started with the --production argument, then set NODE_ENV to production 25 | if (process.argv.includes("--production")) { 26 | process.env.NODE_ENV = "production"; 27 | } 28 | 29 | const spec = { 30 | PORT: num({ default: 3000 }), 31 | SITE_NAME: str({ example: "Kutt", default: "Kutt" }), 32 | DEFAULT_DOMAIN: str({ example: "kutt.it", default: "localhost:3000" }), 33 | LINK_LENGTH: num({ default: 6 }), 34 | LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }), 35 | TRUST_PROXY: bool({ default: true }), 36 | DB_CLIENT: str({ choices: supportedDBClients, default: "better-sqlite3" }), 37 | DB_FILENAME: str({ default: "db/data" }), 38 | DB_HOST: str({ default: "localhost" }), 39 | DB_PORT: num({ default: 5432 }), 40 | DB_NAME: str({ default: "kutt" }), 41 | DB_USER: str({ default: "postgres" }), 42 | DB_PASSWORD: str({ default: "" }), 43 | DB_SSL: bool({ default: false }), 44 | DB_POOL_MIN: num({ default: 0 }), 45 | DB_POOL_MAX: num({ default: 10 }), 46 | REDIS_ENABLED: bool({ default: false }), 47 | REDIS_HOST: str({ default: "127.0.0.1" }), 48 | REDIS_PORT: num({ default: 6379 }), 49 | REDIS_PASSWORD: str({ default: "" }), 50 | REDIS_DB: num({ default: 0 }), 51 | DISALLOW_ANONYMOUS_LINKS: bool({ default: true }), 52 | DISALLOW_REGISTRATION: bool({ default: true }), 53 | SERVER_IP_ADDRESS: str({ default: "" }), 54 | SERVER_CNAME_ADDRESS: str({ default: "" }), 55 | CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }), 56 | JWT_SECRET: str({ devDefault: "securekey" }), 57 | MAIL_ENABLED: bool({ default: false }), 58 | MAIL_HOST: str({ default: "" }), 59 | MAIL_PORT: num({ default: 587 }), 60 | MAIL_SECURE: bool({ default: false }), 61 | MAIL_USER: str({ default: "" }), 62 | MAIL_FROM: str({ default: "", example: "Kutt " }), 63 | MAIL_PASSWORD: str({ default: "" }), 64 | ENABLE_RATE_LIMIT: bool({ default: false }), 65 | REPORT_EMAIL: str({ default: "" }), 66 | CONTACT_EMAIL: str({ default: "" }), 67 | NODE_APP_INSTANCE: num({ default: 0 }), 68 | }; 69 | 70 | for (const key in spec) { 71 | const file_key = key + "_FILE"; 72 | if (!(file_key in process.env)) continue; 73 | try { 74 | process.env[key] = readFileSync(process.env[file_key], "utf8").trim(); 75 | } catch { 76 | // on error, env_FILE just doesn't get applied. 77 | } 78 | } 79 | 80 | const env = cleanEnv(process.env, spec); 81 | 82 | module.exports = env; 83 | -------------------------------------------------------------------------------- /server/handlers/helpers.handler.js: -------------------------------------------------------------------------------- 1 | const { RedisStore: RateLimitRedisStore } = require("rate-limit-redis"); 2 | const { rateLimit: expressRateLimit } = require("express-rate-limit"); 3 | const { validationResult } = require("express-validator"); 4 | 5 | const { CustomError } = require("../utils"); 6 | const query = require("../queries"); 7 | const redis = require("../redis"); 8 | const env = require("../env"); 9 | 10 | function error(error, req, res, _next) { 11 | if (!(error instanceof CustomError)) { 12 | console.error(error); 13 | } else if (env.isDev) { 14 | console.error(error.message); 15 | } 16 | 17 | const message = error instanceof CustomError ? error.message : "An error occurred."; 18 | const statusCode = error.statusCode ?? 500; 19 | 20 | if (req.isHTML && req.viewTemplate) { 21 | res.locals.error = message; 22 | res.render(req.viewTemplate); 23 | return; 24 | } 25 | 26 | if (req.isHTML) { 27 | res.render("error", { 28 | message: "An error occurred. Please try again later." 29 | }); 30 | return; 31 | } 32 | 33 | 34 | return res.status(statusCode).json({ error: message }); 35 | }; 36 | 37 | 38 | function verify(req, res, next) { 39 | const result = validationResult(req); 40 | if (result.isEmpty()) return next(); 41 | 42 | const errors = result.array(); 43 | const error = errors[0].msg; 44 | 45 | res.locals.errors = {}; 46 | errors.forEach(e => { 47 | if (res.locals.errors[e.param]) return; 48 | res.locals.errors[e.param] = e.msg; 49 | }); 50 | 51 | throw new CustomError(error, 400); 52 | } 53 | 54 | function parseQuery(req, res, next) { 55 | const { admin } = req.user || {}; 56 | 57 | if ( 58 | typeof req.query.limit !== "undefined" && 59 | typeof req.query.limit !== "string" 60 | ) { 61 | return res.status(400).json({ error: "limit query is not valid." }); 62 | } 63 | 64 | if ( 65 | typeof req.query.skip !== "undefined" && 66 | typeof req.query.skip !== "string" 67 | ) { 68 | return res.status(400).json({ error: "skip query is not valid." }); 69 | } 70 | 71 | if ( 72 | typeof req.query.search !== "undefined" && 73 | typeof req.query.search !== "string" 74 | ) { 75 | return res.status(400).json({ error: "search query is not valid." }); 76 | } 77 | 78 | const limit = parseInt(req.query.limit) || 10; 79 | 80 | req.context = { 81 | limit: limit > 50 ? 50 : limit, 82 | skip: parseInt(req.query.skip) || 0, 83 | }; 84 | 85 | next(); 86 | }; 87 | 88 | function rateLimit(params) { 89 | if (!env.ENABLE_RATE_LIMIT) { 90 | return function(req, res, next) { 91 | return next(); 92 | } 93 | } 94 | 95 | let store = undefined; 96 | if (env.REDIS_ENABLED) { 97 | store = new RateLimitRedisStore({ 98 | sendCommand: (...args) => redis.client.call(...args), 99 | }) 100 | } 101 | 102 | return expressRateLimit({ 103 | windowMs: params.window * 1000, 104 | validate: { trustProxy: false }, 105 | skipSuccessfulRequests: !!params.skipSuccess, 106 | skipFailedRequests: !!params.skipFailed, 107 | ...(store && { store }), 108 | limit: function (req, res) { 109 | if (params.user && req.user) { 110 | return params.user; 111 | } 112 | return params.limit; 113 | }, 114 | keyGenerator: function(req, res) { 115 | return "rl:" + req.method + req.baseUrl + req.path + ":" + req.ip; 116 | }, 117 | requestWasSuccessful: function(req, res) { 118 | return !res.locals.error && res.statusCode < 400; 119 | }, 120 | handler: function (req, res, next, options) { 121 | throw new CustomError(options.message, options.statusCode); 122 | }, 123 | }); 124 | } 125 | 126 | // redirect to create admin page if the kutt instance is ran for the first time 127 | async function adminSetup(req, res, next) { 128 | const isThereAUser = req.user || (await query.user.findAny()); 129 | if (isThereAUser) { 130 | next(); 131 | return; 132 | } 133 | 134 | res.redirect("/create-admin"); 135 | } 136 | 137 | module.exports = { 138 | adminSetup, 139 | error, 140 | parseQuery, 141 | rateLimit, 142 | verify, 143 | } -------------------------------------------------------------------------------- /server/handlers/locals.handler.js: -------------------------------------------------------------------------------- 1 | const query = require("../queries"); 2 | const utils = require("../utils"); 3 | const env = require("../env"); 4 | 5 | function isHTML(req, res, next) { 6 | const accepts = req.accepts(["json", "html"]); 7 | req.isHTML = accepts === "html"; 8 | next(); 9 | } 10 | 11 | function noLayout(req, res, next) { 12 | res.locals.layout = null; 13 | next(); 14 | } 15 | 16 | function viewTemplate(template) { 17 | return function (req, res, next) { 18 | req.viewTemplate = template; 19 | next(); 20 | } 21 | } 22 | 23 | function config(req, res, next) { 24 | res.locals.default_domain = env.DEFAULT_DOMAIN; 25 | res.locals.site_name = env.SITE_NAME; 26 | res.locals.contact_email = env.CONTACT_EMAIL; 27 | res.locals.server_ip_address = env.SERVER_IP_ADDRESS; 28 | res.locals.server_cname_address = env.SERVER_CNAME_ADDRESS; 29 | res.locals.disallow_registration = env.DISALLOW_REGISTRATION; 30 | res.locals.mail_enabled = env.MAIL_ENABLED; 31 | res.locals.report_email = env.REPORT_EMAIL; 32 | res.locals.custom_styles = utils.getCustomCSSFileNames(); 33 | next(); 34 | } 35 | 36 | async function user(req, res, next) { 37 | const user = req.user; 38 | res.locals.user = user; 39 | res.locals.domains = user && (await query.domain.get({ user_id: user.id })).map(utils.sanitize.domain); 40 | next(); 41 | } 42 | 43 | function newPassword(req, res, next) { 44 | res.locals.reset_password_token = req.body.reset_password_token; 45 | next(); 46 | } 47 | 48 | function createLink(req, res, next) { 49 | res.locals.show_advanced = !!req.body.show_advanced; 50 | next(); 51 | } 52 | 53 | function editLink(req, res, next) { 54 | res.locals.id = req.params.id; 55 | res.locals.class = "no-animation"; 56 | next(); 57 | } 58 | 59 | function protected(req, res, next) { 60 | res.locals.id = req.params.id; 61 | next(); 62 | } 63 | 64 | function adminTable(req, res, next) { 65 | res.locals.query = { 66 | anonymous: req.query.anonymous, 67 | domain: req.query.domain, 68 | domains: req.query.domains, 69 | links: req.query.links, 70 | role: req.query.role, 71 | search: req.query.search, 72 | user: req.query.user, 73 | verified: req.query.verified, 74 | }; 75 | next(); 76 | } 77 | 78 | module.exports = { 79 | adminTable, 80 | config, 81 | createLink, 82 | editLink, 83 | isHTML, 84 | newPassword, 85 | noLayout, 86 | protected, 87 | user, 88 | viewTemplate, 89 | } -------------------------------------------------------------------------------- /server/handlers/users.handler.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require("bcryptjs"); 2 | 3 | const query = require("../queries"); 4 | const utils = require("../utils"); 5 | const mail = require("../mail"); 6 | const env = require("../env"); 7 | 8 | async function get(req, res) { 9 | const domains = await query.domain.get({ user_id: req.user.id }); 10 | 11 | const data = { 12 | apikey: req.user.apikey, 13 | email: req.user.email, 14 | domains: domains.map(utils.sanitize.domain) 15 | }; 16 | 17 | return res.status(200).send(data); 18 | }; 19 | 20 | async function remove(req, res) { 21 | await query.user.remove(req.user); 22 | 23 | if (req.isHTML) { 24 | utils.deleteCurrentToken(res); 25 | res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage"); 26 | res.render("partials/settings/delete_account", { 27 | success: "Account has been deleted. Logging out..." 28 | }); 29 | return; 30 | } 31 | 32 | return res.status(200).send("OK"); 33 | }; 34 | 35 | async function removeByAdmin(req, res) { 36 | const user = await query.user.find({ id: req.params.id }); 37 | 38 | if (!user) { 39 | const message = "Could not find the user."; 40 | if (req.isHTML) { 41 | return res.render("partials/admin/dialog/message", { 42 | layout: false, 43 | message 44 | }); 45 | } else { 46 | return res.status(400).send({ message }); 47 | } 48 | } 49 | 50 | await query.user.remove(user); 51 | 52 | if (req.isHTML) { 53 | res.setHeader("HX-Reswap", "outerHTML"); 54 | res.setHeader("HX-Trigger", "reloadMainTable"); 55 | res.render("partials/admin/dialog/delete_user_success", { 56 | email: user.email, 57 | }); 58 | return; 59 | } 60 | 61 | return res.status(200).send({ message: "User has been deleted successfully." }); 62 | }; 63 | 64 | async function getAdmin(req, res) { 65 | const { limit, skip, all } = req.context; 66 | const { role, search } = req.query; 67 | const userId = req.user.id; 68 | const verified = utils.parseBooleanQuery(req.query.verified); 69 | const banned = utils.parseBooleanQuery(req.query.banned); 70 | const domains = utils.parseBooleanQuery(req.query.domains); 71 | const links = utils.parseBooleanQuery(req.query.links); 72 | 73 | const match = { 74 | ...(role && { role }), 75 | ...(verified !== undefined && { verified }), 76 | ...(banned !== undefined && { banned }), 77 | }; 78 | 79 | const [data, total] = await Promise.all([ 80 | query.user.getAdmin(match, { limit, search, domains, links, skip }), 81 | query.user.totalAdmin(match, { search, domains, links }) 82 | ]); 83 | 84 | const users = data.map(utils.sanitize.user_admin); 85 | 86 | if (req.isHTML) { 87 | res.render("partials/admin/users/table", { 88 | total, 89 | total_formatted: total.toLocaleString("en-US"), 90 | limit, 91 | skip, 92 | users, 93 | }) 94 | return; 95 | } 96 | 97 | return res.send({ 98 | total, 99 | limit, 100 | skip, 101 | data: users, 102 | }); 103 | }; 104 | 105 | async function ban(req, res) { 106 | const { id } = req.params; 107 | 108 | const update = { 109 | banned_by_id: req.user.id, 110 | banned: true 111 | }; 112 | 113 | // 1. check if user exists 114 | const user = await query.user.find({ id }); 115 | 116 | if (!user) { 117 | throw new CustomError("No user has been found.", 400); 118 | } 119 | 120 | if (user.banned) { 121 | throw new CustomError("User has been banned already.", 400); 122 | } 123 | 124 | const tasks = []; 125 | 126 | // 2. ban user 127 | tasks.push(query.user.update({ id }, update)); 128 | 129 | // 3. ban user links 130 | if (req.body.links) { 131 | tasks.push(query.link.update({ user_id: id }, update)); 132 | } 133 | 134 | // 4. ban user domains 135 | if (req.body.domains) { 136 | tasks.push(query.domain.update({ user_id: id }, update)); 137 | } 138 | 139 | // 5. wait for all tasks to finish 140 | await Promise.all(tasks).catch((err) => { 141 | throw new CustomError("Couldn't ban entries."); 142 | }); 143 | 144 | // 6. send response 145 | if (req.isHTML) { 146 | res.setHeader("HX-Reswap", "outerHTML"); 147 | res.setHeader("HX-Trigger", "reloadMainTable"); 148 | res.render("partials/admin/dialog/ban_user_success", { 149 | email: user.email, 150 | }); 151 | return; 152 | } 153 | 154 | return res.status(200).send({ message: "Banned user successfully." }); 155 | } 156 | 157 | async function create(req, res) { 158 | const salt = await bcrypt.genSalt(12); 159 | req.body.password = await bcrypt.hash(req.body.password, salt); 160 | 161 | const user = await query.user.create(req.body); 162 | 163 | if (req.body.verification_email && !user.banned && !user.verified) { 164 | await mail.verification(user); 165 | } 166 | 167 | if (req.isHTML) { 168 | res.setHeader("HX-Trigger", "reloadMainTable"); 169 | res.render("partials/admin/dialog/create_user_success", { 170 | email: user.email, 171 | }); 172 | return; 173 | } 174 | 175 | return res.status(201).send({ message: "The user has been created successfully." }); 176 | } 177 | 178 | module.exports = { 179 | ban, 180 | create, 181 | get, 182 | getAdmin, 183 | remove, 184 | removeByAdmin, 185 | } -------------------------------------------------------------------------------- /server/knex.js: -------------------------------------------------------------------------------- 1 | const knex = require("knex"); 2 | 3 | const env = require("./env"); 4 | 5 | const isSQLite = env.DB_CLIENT === "sqlite3" || env.DB_CLIENT === "better-sqlite3"; 6 | const isPostgres = env.DB_CLIENT === "pg" || env.DB_CLIENT === "pg-native"; 7 | const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2"; 8 | 9 | const db = knex({ 10 | client: env.DB_CLIENT, 11 | connection: { 12 | ...(isSQLite && { filename: env.DB_FILENAME }), 13 | host: env.DB_HOST, 14 | port: env.DB_PORT, 15 | database: env.DB_NAME, 16 | user: env.DB_USER, 17 | password: env.DB_PASSWORD, 18 | ssl: env.DB_SSL, 19 | pool: { 20 | min: env.DB_POOL_MIN, 21 | max: env.DB_POOL_MAX 22 | } 23 | }, 24 | useNullAsDefault: true, 25 | }); 26 | 27 | db.isPostgres = isPostgres; 28 | db.isSQLite = isSQLite; 29 | db.isMySQL = isMySQL; 30 | 31 | db.compatibleILIKE = isPostgres ? "andWhereILike" : "andWhereLike"; 32 | 33 | module.exports = db; 34 | -------------------------------------------------------------------------------- /server/mail/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./mail"); -------------------------------------------------------------------------------- /server/mail/mail.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require("nodemailer"); 2 | const path = require("node:path"); 3 | const fs = require("node:fs"); 4 | 5 | const { resetMailText, verifyMailText, changeEmailText } = require("./text"); 6 | const { CustomError } = require("../utils"); 7 | const env = require("../env"); 8 | 9 | const mailConfig = { 10 | host: env.MAIL_HOST, 11 | port: env.MAIL_PORT, 12 | secure: env.MAIL_SECURE, 13 | auth: env.MAIL_USER 14 | ? { 15 | user: env.MAIL_USER, 16 | pass: env.MAIL_PASSWORD 17 | } 18 | : undefined 19 | }; 20 | 21 | const transporter = nodemailer.createTransport(mailConfig); 22 | 23 | // Read email templates 24 | const resetEmailTemplatePath = path.join(__dirname, "template-reset.html"); 25 | const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html"); 26 | const changeEmailTemplatePath = path.join(__dirname,"template-change-email.html"); 27 | 28 | 29 | let resetEmailTemplate, 30 | verifyEmailTemplate, 31 | changeEmailTemplate; 32 | 33 | // only read email templates if email is enabled 34 | if (env.MAIL_ENABLED) { 35 | resetEmailTemplate = fs 36 | .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" }) 37 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) 38 | .replace(/{{site_name}}/gm, env.SITE_NAME); 39 | verifyEmailTemplate = fs 40 | .readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" }) 41 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) 42 | .replace(/{{site_name}}/gm, env.SITE_NAME); 43 | changeEmailTemplate = fs 44 | .readFileSync(changeEmailTemplatePath, { encoding: "utf-8" }) 45 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) 46 | .replace(/{{site_name}}/gm, env.SITE_NAME); 47 | } 48 | 49 | async function verification(user) { 50 | if (!env.MAIL_ENABLED) { 51 | throw new Error("Attempting to send verification email but email is not enabled."); 52 | }; 53 | 54 | const mail = await transporter.sendMail({ 55 | from: env.MAIL_FROM || env.MAIL_USER, 56 | to: user.email, 57 | subject: "Verify your account", 58 | text: verifyMailText 59 | .replace(/{{verification}}/gim, user.verification_token) 60 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) 61 | .replace(/{{site_name}}/gm, env.SITE_NAME), 62 | html: verifyEmailTemplate 63 | .replace(/{{verification}}/gim, user.verification_token) 64 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) 65 | .replace(/{{site_name}}/gm, env.SITE_NAME) 66 | }); 67 | 68 | if (!mail.accepted.length) { 69 | throw new CustomError("Couldn't send verification email. Try again later."); 70 | } 71 | } 72 | 73 | async function changeEmail(user) { 74 | if (!env.MAIL_ENABLED) { 75 | throw new Error("Attempting to send change email token but email is not enabled."); 76 | }; 77 | 78 | const mail = await transporter.sendMail({ 79 | from: env.MAIL_FROM || env.MAIL_USER, 80 | to: user.change_email_address, 81 | subject: "Verify your new email address", 82 | text: changeEmailText 83 | .replace(/{{verification}}/gim, user.change_email_token) 84 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) 85 | .replace(/{{site_name}}/gm, env.SITE_NAME), 86 | html: changeEmailTemplate 87 | .replace(/{{verification}}/gim, user.change_email_token) 88 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) 89 | .replace(/{{site_name}}/gm, env.SITE_NAME) 90 | }); 91 | 92 | if (!mail.accepted.length) { 93 | throw new CustomError("Couldn't send verification email. Try again later."); 94 | } 95 | } 96 | 97 | async function resetPasswordToken(user) { 98 | if (!env.MAIL_ENABLED) { 99 | throw new Error("Attempting to send reset password email but email is not enabled."); 100 | }; 101 | 102 | const mail = await transporter.sendMail({ 103 | from: env.MAIL_FROM || env.MAIL_USER, 104 | to: user.email, 105 | subject: "Reset your password", 106 | text: resetMailText 107 | .replace(/{{resetpassword}}/gm, user.reset_password_token) 108 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN), 109 | html: resetEmailTemplate 110 | .replace(/{{resetpassword}}/gm, user.reset_password_token) 111 | .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN) 112 | }); 113 | 114 | if (!mail.accepted.length) { 115 | throw new CustomError( 116 | "Couldn't send reset password email. Try again later." 117 | ); 118 | } 119 | } 120 | 121 | async function sendReportEmail(link) { 122 | if (!env.MAIL_ENABLED) { 123 | throw new Error("Attempting to send report email but email is not enabled."); 124 | }; 125 | 126 | const mail = await transporter.sendMail({ 127 | from: env.MAIL_FROM || env.MAIL_USER, 128 | to: env.REPORT_EMAIL, 129 | subject: "[REPORT]", 130 | text: link, 131 | html: link 132 | }); 133 | 134 | if (!mail.accepted.length) { 135 | throw new CustomError("Couldn't submit the report. Try again later."); 136 | } 137 | } 138 | 139 | module.exports = { 140 | changeEmail, 141 | verification, 142 | resetPasswordToken, 143 | sendReportEmail, 144 | } 145 | -------------------------------------------------------------------------------- /server/mail/text.js: -------------------------------------------------------------------------------- 1 | module.exports.verifyMailText = `You're attempting to change your email address on {{site_name}}. 2 | 3 | Please verify your email address using the link below. 4 | 5 | https://{{domain}}/verify/{{verification}}`; 6 | 7 | module.exports.changeEmailText = `Thanks for creating an account on {{site_name}}. 8 | 9 | Please verify your email address using the link below. 10 | 11 | https://{{domain}}/verify-email/{{verification}}`; 12 | 13 | module.exports.resetMailText = `A password reset has been requested for your account. 14 | 15 | Please click on the button below to reset your password. There's no need to take any action if you didn't request this. 16 | 17 | https://{{domain}}/reset-password/{{resetpassword}}`; 18 | -------------------------------------------------------------------------------- /server/migrations/20200211220920_constraints.js: -------------------------------------------------------------------------------- 1 | const models = require("../models"); 2 | 3 | async function up(knex) { 4 | await models.createUserTable(knex); 5 | await models.createIPTable(knex); 6 | await models.createDomainTable(knex); 7 | await models.createHostTable(knex); 8 | await models.createLinkTable(knex); 9 | await models.createVisitTable(knex); 10 | } 11 | 12 | async function down() { 13 | // do nothing 14 | } 15 | 16 | module.exports = { 17 | up, 18 | down 19 | } 20 | -------------------------------------------------------------------------------- /server/migrations/20200510140704_domains.js: -------------------------------------------------------------------------------- 1 | const models = require("../models"); 2 | 3 | async function up(knex) { 4 | await models.createUserTable(knex); 5 | await models.createIPTable(knex); 6 | await models.createDomainTable(knex); 7 | await models.createHostTable(knex); 8 | await models.createLinkTable(knex); 9 | await models.createVisitTable(knex); 10 | 11 | // drop unique user id constraint only if database is postgres 12 | // because other databases use the new version of the app and they start fresh with the correct model 13 | // if i use table.dropUnique() method it would throw error on fresh install because the constraint does not exist 14 | // and if it throws error, the rest of the transactions fail as well 15 | if (knex.client.driverName === "pg") { 16 | knex.raw(` 17 | ALTER TABLE domains 18 | DROP CONSTRAINT IF EXISTS domains_user_id_unique 19 | `) 20 | } 21 | 22 | const hasUUID = await knex.schema.hasColumn("domains", "uuid"); 23 | 24 | if (!hasUUID) { 25 | await knex.schema.alterTable("domains", (table) => { 26 | table.uuid("uuid").notNullable().defaultTo(knex.fn.uuid()); 27 | }); 28 | } 29 | } 30 | 31 | async function down() { 32 | // do nothing 33 | } 34 | 35 | module.exports = { 36 | up, 37 | down 38 | } 39 | -------------------------------------------------------------------------------- /server/migrations/20200718124944_description.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | const hasDescription = await knex.schema.hasColumn("links", "description"); 3 | if (!hasDescription) { 4 | await knex.schema.alterTable("links", table => { 5 | table.string("description"); 6 | }); 7 | } 8 | } 9 | 10 | async function down() { 11 | return null; 12 | } 13 | 14 | module.exports = { 15 | up, 16 | down 17 | } 18 | -------------------------------------------------------------------------------- /server/migrations/20200730203154_expire_in.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | const hasExpireIn = await knex.schema.hasColumn("links", "expire_in"); 3 | if (!hasExpireIn) { 4 | await knex.schema.alterTable("links", table => { 5 | table.dateTime("expire_in"); 6 | }); 7 | } 8 | } 9 | 10 | async function down() { 11 | return null; 12 | } 13 | 14 | module.exports = { 15 | up, 16 | down 17 | } 18 | -------------------------------------------------------------------------------- /server/migrations/20200810195255_change_email.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | const hasChangeEmail = await knex.schema.hasColumn( 3 | "users", 4 | "change_email_token" 5 | ); 6 | if (!hasChangeEmail) { 7 | await knex.schema.alterTable("users", table => { 8 | table.dateTime("change_email_expires"); 9 | table.string("change_email_token"); 10 | table.string("change_email_address"); 11 | }); 12 | } 13 | } 14 | 15 | async function down() { 16 | return null; 17 | } 18 | 19 | module.exports = { 20 | up, 21 | down 22 | } 23 | 24 | -------------------------------------------------------------------------------- /server/migrations/20241103083933_user-roles.js: -------------------------------------------------------------------------------- 1 | const { ROLES } = require("../consts"); 2 | 3 | /** 4 | * @param { import("knex").Knex } knex 5 | * @returns { Promise } 6 | */ 7 | async function up(knex) { 8 | const hasRole = await knex.schema.hasColumn("users", "role"); 9 | if (!hasRole) { 10 | await knex.transaction(async function(trx) { 11 | await trx.schema.alterTable("users", table => { 12 | table 13 | .enu("role", [ROLES.USER, ROLES.ADMIN]) 14 | .notNullable() 15 | .defaultTo(ROLES.USER); 16 | }); 17 | if (typeof process.env.ADMIN_EMAILS === "string") { 18 | const adminEmails = process.env.ADMIN_EMAILS.split(",").map((e) => e.trim()); 19 | const adminRoleQuery = trx("users").update("role", ROLES.ADMIN); 20 | adminEmails.forEach((adminEmail, index) => { 21 | if (index === 0) { 22 | adminRoleQuery.where("email", adminEmail); 23 | } else { 24 | adminRoleQuery.orWhere("email", adminEmail); 25 | } 26 | }); 27 | await adminRoleQuery; 28 | } 29 | }); 30 | } 31 | }; 32 | 33 | /** 34 | * @param { import("knex").Knex } knex 35 | * @returns { Promise } 36 | */ 37 | async function down(knex) {}; 38 | 39 | module.exports = { 40 | up, 41 | down, 42 | } 43 | -------------------------------------------------------------------------------- /server/migrations/20241223062111_indexes.js: -------------------------------------------------------------------------------- 1 | const env = require("../env"); 2 | 3 | const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2"; 4 | 5 | /** 6 | * @param { import("knex").Knex } knex 7 | * @returns { Promise } 8 | */ 9 | async function up(knex) { 10 | // make apikey unique 11 | await knex.schema.alterTable("users", function(table) { 12 | table.unique("apikey"); 13 | }); 14 | 15 | // IF NOT EXISTS is not available on MySQL So if you're 16 | // using MySQL you should make sure you don't have these indexes already 17 | const ifNotExists = isMySQL ? "" : "IF NOT EXISTS"; 18 | 19 | // create them separately because one string with break lines didn't work on MySQL 20 | await Promise.all([ 21 | knex.raw(`CREATE INDEX ${ifNotExists} links_domain_id_index ON links (domain_id);`), 22 | knex.raw(`CREATE INDEX ${ifNotExists} links_user_id_index ON links (user_id);`), 23 | knex.raw(`CREATE INDEX ${ifNotExists} links_address_index ON links (address);`), 24 | knex.raw(`CREATE INDEX ${ifNotExists} links_expire_in_index ON links (expire_in);`), 25 | knex.raw(`CREATE INDEX ${ifNotExists} domains_address_index ON domains (address);`), 26 | knex.raw(`CREATE INDEX ${ifNotExists} domains_user_id_index ON domains (user_id);`), 27 | knex.raw(`CREATE INDEX ${ifNotExists} hosts_address_index ON hosts (address);`), 28 | knex.raw(`CREATE INDEX ${ifNotExists} visits_link_id_index ON visits (link_id);`), 29 | ]); 30 | }; 31 | 32 | /** 33 | * @param { import("knex").Knex } knex 34 | * @returns { Promise } 35 | */ 36 | async function down(knex) { 37 | await knex.schema.alterTable("users", function(table) { 38 | table.dropUnique(["apikey"]); 39 | }); 40 | 41 | await Promise.all([ 42 | knex.raw(`DROP INDEX links_domain_id_index;`), 43 | knex.raw(`DROP INDEX links_user_id_index;`), 44 | knex.raw(`DROP INDEX links_address_index;`), 45 | knex.raw(`DROP INDEX links_expire_in_index;`), 46 | knex.raw(`DROP INDEX domains_address_index;`), 47 | knex.raw(`DROP INDEX domains_user_id_index;`), 48 | knex.raw(`DROP INDEX hosts_address_index;`), 49 | knex.raw(`DROP INDEX visits_link_id_index;`), 50 | ]); 51 | }; 52 | 53 | module.exports = { 54 | up, 55 | down, 56 | } -------------------------------------------------------------------------------- /server/migrations/20241223103044_visits_user_id.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | async function up(knex) { 6 | const hasUserIDColumn = await knex.schema.hasColumn("visits", "user_id"); 7 | 8 | if (hasUserIDColumn) return; 9 | 10 | await knex.schema.alterTable("visits", function(table) { 11 | table 12 | .integer("user_id") 13 | .unsigned(); 14 | table 15 | .foreign("user_id") 16 | .references("id") 17 | .inTable("users") 18 | .onDelete("CASCADE") 19 | .withKeyName("visits_user_id_foreign"); 20 | }); 21 | 22 | const [{ count }] = await knex("visits").count("* as count"); 23 | 24 | const count_number = parseInt(count); 25 | if (Number.isNaN(count_number) || count_number === 0) return; 26 | 27 | if (count_number < 1_000_000) { 28 | const last_visit = await knex("visits").orderBy("id", "desc").first(); 29 | 30 | const size = 100_000; 31 | const loops = Math.floor(last_visit.id / size) + 1; 32 | 33 | await Promise.all( 34 | new Array(loops).fill(null).map((_, i) => { 35 | return knex("visits") 36 | .fromRaw(knex.raw("visits v")) 37 | .update({ user_id: knex.ref("links.user_id") }) 38 | .updateFrom("links") 39 | .where("links.id", knex.ref("link_id")) 40 | .andWhereBetween("v.id", [i * size, (i * size) + size]); 41 | }) 42 | ); 43 | } else { 44 | console.warn( 45 | "MIGRATION WARN:" + 46 | "Skipped adding user_id to visits due to high volume of visits and the potential risk of locking the database.\n" + 47 | "Please refer to Kutt's migration guide for more information." 48 | ); 49 | } 50 | }; 51 | 52 | /** 53 | * @param { import("knex").Knex } knex 54 | * @returns { Promise } 55 | */ 56 | async function down(knex) {}; 57 | 58 | module.exports = { 59 | up, 60 | down, 61 | } -------------------------------------------------------------------------------- /server/migrations/20241223155527_visits_user_id_index.js: -------------------------------------------------------------------------------- 1 | const env = require("../env"); 2 | 3 | const isMySQL = env.DB_CLIENT === "mysql" || env.DB_CLIENT === "mysql2"; 4 | 5 | /** 6 | * @param { import("knex").Knex } knex 7 | * @returns { Promise } 8 | */ 9 | async function up(knex) { 10 | // IF NOT EXISTS is not available on MySQL So if you're 11 | // using MySQL you should make sure you don't have these indexes already 12 | const ifNotExists = isMySQL ? "" : "IF NOT EXISTS"; 13 | 14 | await knex.raw(` 15 | CREATE INDEX ${ifNotExists} visits_user_id_index ON visits (user_id); 16 | `); 17 | }; 18 | 19 | /** 20 | * @param { import("knex").Knex } knex 21 | * @returns { Promise } 22 | */ 23 | async function down(knex) { 24 | await knex.raw(` 25 | DROP INDEX visits_user_id_index; 26 | `); 27 | }; 28 | 29 | module.exports = { 30 | up, 31 | down, 32 | } -------------------------------------------------------------------------------- /server/migrations/20250106070444_remove_cooldown.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | const hasCooldowns = await knex.schema.hasColumn("users", "cooldowns"); 3 | if (hasCooldowns) { 4 | await knex.schema.alterTable("users", table => { 5 | table.dropColumn("cooldowns"); 6 | }); 7 | } 8 | 9 | const hasCooldown = await knex.schema.hasColumn("users", "cooldown"); 10 | if (hasCooldown) { 11 | await knex.schema.alterTable("users", table => { 12 | table.dropColumn("cooldown"); 13 | }); 14 | } 15 | 16 | const hasMaliciousAttempts = await knex.schema.hasColumn("users", "malicious_attempts"); 17 | if (hasMaliciousAttempts) { 18 | await knex.schema.alterTable("users", table => { 19 | table.dropColumn("malicious_attempts"); 20 | }); 21 | } 22 | } 23 | 24 | async function down(knex) {} 25 | 26 | module.exports = { 27 | up, 28 | down 29 | }; 30 | 31 | -------------------------------------------------------------------------------- /server/models/domain.model.js: -------------------------------------------------------------------------------- 1 | async function createDomainTable(knex) { 2 | const hasTable = await knex.schema.hasTable("domains"); 3 | if (!hasTable) { 4 | await knex.schema.createTable("domains", table => { 5 | table.increments("id").primary(); 6 | table 7 | .boolean("banned") 8 | .notNullable() 9 | .defaultTo(false); 10 | table 11 | .integer("banned_by_id") 12 | .unsigned() 13 | .references("id") 14 | .inTable("users"); 15 | table 16 | .string("address") 17 | .unique() 18 | .notNullable(); 19 | table.string("homepage").nullable(); 20 | table 21 | .integer("user_id") 22 | .unsigned(); 23 | table 24 | .foreign("user_id") 25 | .references("id") 26 | .inTable("users") 27 | .onDelete("SET NULL") 28 | .withKeyName("domains_user_id_foreign"); 29 | table 30 | .uuid("uuid") 31 | .notNullable() 32 | .defaultTo(knex.fn.uuid()); 33 | table.timestamps(false, true); 34 | 35 | }); 36 | } 37 | } 38 | 39 | module.exports = { 40 | createDomainTable 41 | } -------------------------------------------------------------------------------- /server/models/host.model.js: -------------------------------------------------------------------------------- 1 | async function createHostTable(knex) { 2 | const hasTable = await knex.schema.hasTable("hosts"); 3 | if (!hasTable) { 4 | await knex.schema.createTable("hosts", table => { 5 | table.increments("id").primary(); 6 | table 7 | .string("address") 8 | .unique() 9 | .notNullable(); 10 | table 11 | .boolean("banned") 12 | .notNullable() 13 | .defaultTo(false); 14 | table 15 | .integer("banned_by_id") 16 | .unsigned() 17 | .references("id") 18 | .inTable("users"); 19 | table.timestamps(false, true); 20 | }); 21 | } 22 | } 23 | 24 | module.exports = { 25 | createHostTable 26 | } -------------------------------------------------------------------------------- /server/models/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("./domain.model"), 3 | ...require("./host.model"), 4 | ...require("./ip.model"), 5 | ...require("./link.model"), 6 | ...require("./user.model"), 7 | ...require("./visit.model"), 8 | } 9 | -------------------------------------------------------------------------------- /server/models/ip.model.js: -------------------------------------------------------------------------------- 1 | async function createIPTable(knex) { 2 | const hasTable = await knex.schema.hasTable("ips"); 3 | if (!hasTable) { 4 | await knex.schema.createTable("ips", table => { 5 | table.increments("id").primary(); 6 | table 7 | .string("ip") 8 | .unique() 9 | .notNullable(); 10 | table.timestamps(false, true); 11 | }); 12 | } 13 | } 14 | 15 | module.exports = { 16 | createIPTable 17 | } -------------------------------------------------------------------------------- /server/models/link.model.js: -------------------------------------------------------------------------------- 1 | async function createLinkTable(knex) { 2 | const hasTable = await knex.schema.hasTable("links"); 3 | 4 | if (!hasTable) { 5 | await knex.schema.createTable("links", table => { 6 | table.increments("id").primary(); 7 | table.string("address").notNullable(); 8 | table.string("description"); 9 | table 10 | .boolean("banned") 11 | .notNullable() 12 | .defaultTo(false); 13 | table 14 | .integer("banned_by_id") 15 | .unsigned() 16 | .references("id") 17 | .inTable("users"); 18 | table 19 | .integer("domain_id") 20 | .unsigned() 21 | .references("id") 22 | .inTable("domains"); 23 | table.string("password"); 24 | table.dateTime("expire_in"); 25 | table.string("target", 2040).notNullable(); 26 | table 27 | .integer("user_id") 28 | .unsigned(); 29 | table 30 | .foreign("user_id") 31 | .references("id") 32 | .inTable("users") 33 | .onDelete("CASCADE") 34 | .withKeyName("links_user_id_foreign"); 35 | table 36 | .integer("visit_count") 37 | .notNullable() 38 | .defaultTo(0); 39 | table 40 | .uuid("uuid") 41 | .notNullable() 42 | .defaultTo(knex.fn.uuid()); 43 | table.timestamps(false, true); 44 | }); 45 | } 46 | 47 | const hasUUID = await knex.schema.hasColumn("links", "uuid"); 48 | if (!hasUUID) { 49 | await knex.schema.alterTable("links", table => { 50 | table 51 | .uuid("uuid") 52 | .notNullable() 53 | .defaultTo(knex.fn.uuid()); 54 | }); 55 | } 56 | } 57 | 58 | module.exports = { 59 | createLinkTable 60 | } -------------------------------------------------------------------------------- /server/models/user.model.js: -------------------------------------------------------------------------------- 1 | const { ROLES } = require("../consts"); 2 | 3 | async function createUserTable(knex) { 4 | const hasTable = await knex.schema.hasTable("users"); 5 | if (!hasTable) { 6 | await knex.schema.createTable("users", table => { 7 | table.increments("id").primary(); 8 | table.string("apikey"); 9 | table 10 | .boolean("banned") 11 | .notNullable() 12 | .defaultTo(false); 13 | table 14 | .integer("banned_by_id") 15 | .unsigned() 16 | .references("id") 17 | .inTable("users"); 18 | table 19 | .string("email") 20 | .unique() 21 | .notNullable(); 22 | table 23 | .enu("role", [ROLES.USER, ROLES.ADMIN]) 24 | .notNullable() 25 | .defaultTo(ROLES.USER); 26 | table.string("password").notNullable(); 27 | table.dateTime("reset_password_expires"); 28 | table.string("reset_password_token"); 29 | table.dateTime("change_email_expires"); 30 | table.string("change_email_token"); 31 | table.string("change_email_address"); 32 | table.dateTime("verification_expires"); 33 | table.string("verification_token"); 34 | table 35 | .boolean("verified") 36 | .notNullable() 37 | .defaultTo(false); 38 | table.timestamps(false, true); 39 | }); 40 | } 41 | } 42 | 43 | module.exports = { 44 | createUserTable 45 | }; -------------------------------------------------------------------------------- /server/models/visit.model.js: -------------------------------------------------------------------------------- 1 | async function createVisitTable(knex) { 2 | const hasTable = await knex.schema.hasTable("visits"); 3 | if (!hasTable) { 4 | await knex.schema.createTable("visits", table => { 5 | table.increments("id").primary(); 6 | table.jsonb("countries"); 7 | table 8 | .dateTime("created_at") 9 | .notNullable() 10 | .defaultTo(knex.fn.now()); 11 | table.dateTime("updated_at").defaultTo(knex.fn.now()); 12 | table 13 | .integer("link_id") 14 | .unsigned(); 15 | table 16 | .foreign("link_id") 17 | .references("id") 18 | .inTable("links") 19 | .onDelete("CASCADE") 20 | .withKeyName("visits_link_id_foreign"); 21 | table 22 | .integer("user_id") 23 | .unsigned(); 24 | table 25 | .foreign("user_id") 26 | .references("id") 27 | .inTable("users") 28 | .onDelete("CASCADE") 29 | .withKeyName("visits_user_id_foreign"); 30 | table.jsonb("referrers"); 31 | table 32 | .integer("total") 33 | .notNullable() 34 | .defaultTo(0); 35 | table 36 | .integer("br_chrome") 37 | .notNullable() 38 | .defaultTo(0); 39 | table 40 | .integer("br_edge") 41 | .notNullable() 42 | .defaultTo(0); 43 | table 44 | .integer("br_firefox") 45 | .notNullable() 46 | .defaultTo(0); 47 | table 48 | .integer("br_ie") 49 | .notNullable() 50 | .defaultTo(0); 51 | table 52 | .integer("br_opera") 53 | .notNullable() 54 | .defaultTo(0); 55 | table 56 | .integer("br_other") 57 | .notNullable() 58 | .defaultTo(0); 59 | table 60 | .integer("br_safari") 61 | .notNullable() 62 | .defaultTo(0); 63 | table 64 | .integer("os_android") 65 | .notNullable() 66 | .defaultTo(0); 67 | table 68 | .integer("os_ios") 69 | .notNullable() 70 | .defaultTo(0); 71 | table 72 | .integer("os_linux") 73 | .notNullable() 74 | .defaultTo(0); 75 | table 76 | .integer("os_macos") 77 | .notNullable() 78 | .defaultTo(0); 79 | table 80 | .integer("os_other") 81 | .notNullable() 82 | .defaultTo(0); 83 | table 84 | .integer("os_windows") 85 | .notNullable() 86 | .defaultTo(0); 87 | }); 88 | } 89 | 90 | const hasUpdatedAt = await knex.schema.hasColumn("visits", "updated_at"); 91 | if (!hasUpdatedAt) { 92 | await knex.schema.alterTable("visits", table => { 93 | table.dateTime("updated_at").defaultTo(knex.fn.now()); 94 | }); 95 | } 96 | } 97 | 98 | module.exports = { 99 | createVisitTable 100 | } -------------------------------------------------------------------------------- /server/passport.js: -------------------------------------------------------------------------------- 1 | const { Strategy: LocalAPIKeyStrategy } = require("passport-localapikey-update"); 2 | const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt"); 3 | const { Strategy: LocalStrategy } = require("passport-local"); 4 | const passport = require("passport"); 5 | const bcrypt = require("bcryptjs"); 6 | 7 | const query = require("./queries"); 8 | const env = require("./env"); 9 | 10 | const jwtOptions = { 11 | jwtFromRequest: req => req.cookies?.token, 12 | secretOrKey: env.JWT_SECRET 13 | }; 14 | 15 | passport.use( 16 | new JwtStrategy(jwtOptions, async (payload, done) => { 17 | try { 18 | // 'sub' used to be the email address 19 | // this check makes sure to invalidate old JWTs where the sub is still the email address 20 | if (typeof payload.sub === "string" || !payload.sub) { 21 | return done(null, false); 22 | } 23 | const user = await query.user.find({ id: payload.sub }); 24 | if (!user) return done(null, false); 25 | return done(null, user, payload); 26 | } catch (err) { 27 | return done(err); 28 | } 29 | }) 30 | ); 31 | 32 | const localOptions = { 33 | usernameField: "email" 34 | }; 35 | 36 | passport.use( 37 | new LocalStrategy(localOptions, async (email, password, done) => { 38 | try { 39 | const user = await query.user.find({ email }); 40 | if (!user) { 41 | return done(null, false); 42 | } 43 | const isMatch = await bcrypt.compare(password, user.password); 44 | if (!isMatch) { 45 | return done(null, false); 46 | } 47 | return done(null, user); 48 | } catch (err) { 49 | return done(err); 50 | } 51 | }) 52 | ); 53 | 54 | const localAPIKeyOptions = { 55 | apiKeyField: "apikey", 56 | apiKeyHeader: "x-api-key" 57 | }; 58 | 59 | passport.use( 60 | new LocalAPIKeyStrategy(localAPIKeyOptions, async (apikey, done) => { 61 | try { 62 | const user = await query.user.find({ apikey }); 63 | if (!user) { 64 | return done(null, false); 65 | } 66 | return done(null, user); 67 | } catch (err) { 68 | return done(err); 69 | } 70 | }) 71 | ); 72 | -------------------------------------------------------------------------------- /server/queries/host.queries.js: -------------------------------------------------------------------------------- 1 | const redis = require("../redis"); 2 | const utils = require("../utils"); 3 | const knex = require("../knex"); 4 | const env = require("../env"); 5 | 6 | async function find(match) { 7 | if (match.address && env.REDIS_ENABLED) { 8 | const cachedHost = await redis.client.get(redis.key.host(match.address)); 9 | if (cachedHost) return JSON.parse(cachedHost); 10 | } 11 | 12 | const host = await knex("hosts") 13 | .where(match) 14 | .first(); 15 | 16 | if (host && env.REDIS_ENABLED) { 17 | const key = redis.key.host(host.address); 18 | redis.client.set(key, JSON.stringify(host), "EX", 60 * 15); 19 | } 20 | 21 | return host; 22 | } 23 | 24 | async function add(params) { 25 | params.address = params.address.toLowerCase(); 26 | 27 | const existingHost = await knex("hosts").where("address", params.address).first(); 28 | 29 | let id = existingHost?.id; 30 | 31 | const newHost = { 32 | address: params.address, 33 | banned: !!params.banned, 34 | banned_by_id: params.banned_by_id, 35 | }; 36 | 37 | if (id) { 38 | await knex("hosts").where("id", id).update({ 39 | ...newHost, 40 | updated_at: params.updated_at || utils.dateToUTC(new Date()) 41 | }); 42 | } else { 43 | // Mysql and sqlite don't support returning but return the inserted id by default 44 | const [createdHost] = await knex("hosts").insert(newHost, "*"); 45 | id = typeof createdHost === "number" ? createdHost : createdHost.id; 46 | } 47 | 48 | // Query domain instead of using returning as sqlite and mysql don't support it 49 | const host = await knex("hosts").where("id", id); 50 | 51 | if (env.REDIS_ENABLED) { 52 | redis.remove.host(host); 53 | } 54 | 55 | return host; 56 | } 57 | 58 | module.exports = { 59 | add, 60 | find, 61 | } 62 | -------------------------------------------------------------------------------- /server/queries/index.js: -------------------------------------------------------------------------------- 1 | const domain = require("./domain.queries"); 2 | const visit = require("./visit.queries"); 3 | const link = require("./link.queries"); 4 | const user = require("./user.queries"); 5 | const host = require("./host.queries"); 6 | 7 | module.exports = { 8 | domain, 9 | host, 10 | link, 11 | user, 12 | visit 13 | }; 14 | -------------------------------------------------------------------------------- /server/queues/index.js: -------------------------------------------------------------------------------- 1 | const { visit } = require("./queues"); 2 | 3 | module.exports = { 4 | visit, 5 | }; 6 | -------------------------------------------------------------------------------- /server/queues/queues.js: -------------------------------------------------------------------------------- 1 | const Queue = require("bull"); 2 | const path = require("node:path"); 3 | 4 | const env = require("../env"); 5 | 6 | const redis = { 7 | port: env.REDIS_PORT, 8 | host: env.REDIS_HOST, 9 | db: env.REDIS_DB, 10 | ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD }) 11 | }; 12 | 13 | let visit; 14 | 15 | if (env.REDIS_ENABLED) { 16 | visit = new Queue("visit", { redis }); 17 | visit.clean(5000, "completed"); 18 | visit.process(6, path.resolve(__dirname, "visit.js")); 19 | visit.on("completed", job => job.remove()); 20 | 21 | // TODO: handler error 22 | // visit.on("error", function (error) { 23 | // console.log("error"); 24 | // }); 25 | } else { 26 | const visitProcessor = require(path.resolve(__dirname, "visit.js")); 27 | visit = { 28 | add(data) { 29 | visitProcessor({ data }).catch(function(error) { 30 | console.error("Add visit error: ", error); 31 | }); 32 | } 33 | } 34 | } 35 | 36 | 37 | 38 | module.exports = { 39 | visit, 40 | } -------------------------------------------------------------------------------- /server/queues/visit.js: -------------------------------------------------------------------------------- 1 | const useragent = require("useragent"); 2 | const geoip = require("geoip-lite"); 3 | const URL = require("node:url"); 4 | 5 | const { removeWww } = require("../utils"); 6 | const query = require("../queries"); 7 | 8 | const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"]; 9 | const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"]; 10 | 11 | function filterInBrowser(agent) { 12 | return function(item) { 13 | return agent.family.toLowerCase().includes(item.toLocaleLowerCase()); 14 | } 15 | } 16 | 17 | function filterInOs(agent) { 18 | return function(item) { 19 | return agent.os.family.toLowerCase().includes(item.toLocaleLowerCase()); 20 | } 21 | } 22 | 23 | module.exports = function({ data }) { 24 | const tasks = []; 25 | 26 | tasks.push(query.link.incrementVisit({ id: data.link.id })); 27 | 28 | // the following line is for backward compatibility 29 | // used to send the whole header to get the user agent 30 | const userAgent = data.userAgent || data.headers?.["user-agent"]; 31 | const agent = useragent.parse(userAgent); 32 | const [browser = "Other"] = browsersList.filter(filterInBrowser(agent)); 33 | const [os = "Other"] = osList.filter(filterInOs(agent)); 34 | const referrer = 35 | data.referrer && removeWww(URL.parse(data.referrer).hostname); 36 | 37 | const country = data.country || geoip.lookup(data.ip)?.country; 38 | 39 | tasks.push( 40 | query.visit.add({ 41 | browser: browser.toLowerCase(), 42 | country: country || "Unknown", 43 | link_id: data.link.id, 44 | user_id: data.link.user_id, 45 | os: os.toLowerCase().replace(/\s/gi, ""), 46 | referrer: (referrer && referrer.replace(/\./gi, "[dot]")) || "Direct" 47 | }) 48 | ); 49 | 50 | return Promise.all(tasks); 51 | } -------------------------------------------------------------------------------- /server/redis.js: -------------------------------------------------------------------------------- 1 | const Redis = require("ioredis"); 2 | 3 | const env = require("./env"); 4 | 5 | let client; 6 | 7 | if (env.REDIS_ENABLED) { 8 | client = new Redis({ 9 | host: env.REDIS_HOST, 10 | port: env.REDIS_PORT, 11 | db: env.REDIS_DB, 12 | ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD }) 13 | }); 14 | } 15 | 16 | const key = { 17 | link: (address, domain_id) => `l:${address}:${domain_id || ""}`, 18 | domain: (address) => `d:${address}`, 19 | stats: (link_id) => `s:${link_id}`, 20 | host: (address) => `h:${address}`, 21 | user: (idOrKey) => `u:${idOrKey}` 22 | }; 23 | 24 | const remove = { 25 | domain: (domain) => { 26 | if (!domain) return; 27 | return client.del(key.domain(domain.address)); 28 | }, 29 | host: (host) => { 30 | if (!host) return; 31 | return client.del(key.host(host.address)); 32 | }, 33 | link: (link) => { 34 | if (!link) return; 35 | return client.del(key.link(link.address, link.domain_id)); 36 | }, 37 | user: (user) => { 38 | if (!user) return; 39 | return Promise.all([ 40 | client.del(key.user(user.id)), 41 | client.del(key.user(user.apikey)), 42 | ]); 43 | } 44 | }; 45 | 46 | 47 | module.exports = { 48 | client, 49 | key, 50 | remove, 51 | } -------------------------------------------------------------------------------- /server/routes/auth.routes.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | 3 | const validators = require("../handlers/validators.handler"); 4 | const helpers = require("../handlers/helpers.handler"); 5 | const asyncHandler = require("../utils/asyncHandler"); 6 | const locals = require("../handlers/locals.handler"); 7 | const auth = require("../handlers/auth.handler"); 8 | const utils = require("../utils"); 9 | const env = require("../env"); 10 | 11 | const router = Router(); 12 | 13 | router.post( 14 | "/login", 15 | locals.viewTemplate("partials/auth/form"), 16 | validators.login, 17 | asyncHandler(helpers.verify), 18 | helpers.rateLimit({ window: 60, limit: 5 }), 19 | asyncHandler(auth.local), 20 | asyncHandler(auth.login) 21 | ); 22 | 23 | router.post( 24 | "/signup", 25 | locals.viewTemplate("partials/auth/form"), 26 | auth.featureAccess([!env.DISALLOW_REGISTRATION, env.MAIL_ENABLED]), 27 | validators.signup, 28 | asyncHandler(helpers.verify), 29 | helpers.rateLimit({ window: 60, limit: 5 }), 30 | validators.signupEmailTaken, 31 | asyncHandler(helpers.verify), 32 | asyncHandler(auth.signup) 33 | ); 34 | 35 | router.post( 36 | "/create-admin", 37 | locals.viewTemplate("partials/auth/form_admin"), 38 | validators.createAdmin, 39 | asyncHandler(helpers.verify), 40 | helpers.rateLimit({ window: 60, limit: 5 }), 41 | asyncHandler(auth.createAdminUser) 42 | ); 43 | 44 | router.post( 45 | "/change-password", 46 | locals.viewTemplate("partials/settings/change_password"), 47 | asyncHandler(auth.jwt), 48 | validators.changePassword, 49 | asyncHandler(helpers.verify), 50 | helpers.rateLimit({ window: 60, limit: 5 }), 51 | asyncHandler(auth.changePassword) 52 | ); 53 | 54 | router.post( 55 | "/change-email", 56 | locals.viewTemplate("partials/settings/change_email"), 57 | asyncHandler(auth.jwt), 58 | auth.featureAccess([env.MAIL_ENABLED]), 59 | validators.changeEmail, 60 | asyncHandler(helpers.verify), 61 | helpers.rateLimit({ window: 60, limit: 3 }), 62 | asyncHandler(auth.changeEmailRequest) 63 | ); 64 | 65 | router.post( 66 | "/apikey", 67 | locals.viewTemplate("partials/settings/apikey"), 68 | asyncHandler(auth.jwt), 69 | helpers.rateLimit({ window: 60, limit: 10 }), 70 | asyncHandler(auth.generateApiKey) 71 | ); 72 | 73 | router.post( 74 | "/reset-password", 75 | locals.viewTemplate("partials/reset_password/request_form"), 76 | auth.featureAccess([env.MAIL_ENABLED]), 77 | validators.resetPassword, 78 | asyncHandler(helpers.verify), 79 | helpers.rateLimit({ window: 60, limit: 3 }), 80 | asyncHandler(auth.resetPassword) 81 | ); 82 | 83 | router.post( 84 | "/new-password", 85 | locals.viewTemplate("partials/reset_password/new_password_form"), 86 | locals.newPassword, 87 | validators.newPassword, 88 | asyncHandler(helpers.verify), 89 | helpers.rateLimit({ window: 60, limit: 5 }), 90 | asyncHandler(auth.newPassword) 91 | ); 92 | 93 | module.exports = router; 94 | -------------------------------------------------------------------------------- /server/routes/domain.routes.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | 3 | const validators = require("../handlers/validators.handler"); 4 | const helpers = require("../handlers/helpers.handler"); 5 | const domains = require("../handlers/domains.handler"); 6 | const asyncHandler = require("../utils/asyncHandler"); 7 | const locals = require("../handlers/locals.handler"); 8 | const auth = require("../handlers/auth.handler"); 9 | 10 | const router = Router(); 11 | 12 | router.get( 13 | "/admin", 14 | locals.viewTemplate("partials/admin/domains/table"), 15 | asyncHandler(auth.apikey), 16 | asyncHandler(auth.jwt), 17 | asyncHandler(auth.admin), 18 | helpers.parseQuery, 19 | locals.adminTable, 20 | asyncHandler(domains.getAdmin) 21 | ); 22 | 23 | router.post( 24 | "/", 25 | locals.viewTemplate("partials/settings/domain/add_form"), 26 | asyncHandler(auth.apikey), 27 | asyncHandler(auth.jwt), 28 | validators.addDomain, 29 | asyncHandler(helpers.verify), 30 | asyncHandler(domains.add) 31 | ); 32 | 33 | router.post( 34 | "/admin", 35 | locals.viewTemplate("partials/admin/dialog/add_domain"), 36 | asyncHandler(auth.apikey), 37 | asyncHandler(auth.jwt), 38 | asyncHandler(auth.admin), 39 | validators.addDomainAdmin, 40 | asyncHandler(helpers.verify), 41 | asyncHandler(domains.addAdmin) 42 | ); 43 | 44 | router.delete( 45 | "/:id", 46 | locals.viewTemplate("partials/settings/domain/delete"), 47 | asyncHandler(auth.apikey), 48 | asyncHandler(auth.jwt), 49 | validators.removeDomain, 50 | asyncHandler(helpers.verify), 51 | asyncHandler(domains.remove) 52 | ); 53 | 54 | router.delete( 55 | "/admin/:id", 56 | locals.viewTemplate("partials/admin/dialog/delete_domain"), 57 | asyncHandler(auth.apikey), 58 | asyncHandler(auth.jwt), 59 | asyncHandler(auth.admin), 60 | validators.removeDomainAdmin, 61 | asyncHandler(helpers.verify), 62 | asyncHandler(domains.removeAdmin) 63 | ); 64 | 65 | router.post( 66 | "/admin/ban/:id", 67 | locals.viewTemplate("partials/admin/dialog/ban_domain"), 68 | asyncHandler(auth.apikey), 69 | asyncHandler(auth.jwt), 70 | asyncHandler(auth.admin), 71 | validators.banDomain, 72 | asyncHandler(helpers.verify), 73 | asyncHandler(domains.ban) 74 | ); 75 | 76 | module.exports = router; 77 | -------------------------------------------------------------------------------- /server/routes/health.routes.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | 3 | const router = Router(); 4 | 5 | router.get("/", (_, res) => res.send("OK")); 6 | 7 | module.exports = router; 8 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./routes"); -------------------------------------------------------------------------------- /server/routes/link.routes.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const cors = require("cors"); 3 | 4 | const validators = require("../handlers/validators.handler"); 5 | const helpers = require("../handlers/helpers.handler"); 6 | const asyncHandler = require("../utils/asyncHandler"); 7 | const locals = require("../handlers/locals.handler"); 8 | const link = require("../handlers/links.handler"); 9 | const auth = require("../handlers/auth.handler"); 10 | const env = require("../env"); 11 | 12 | const router = Router(); 13 | 14 | router.get( 15 | "/", 16 | locals.viewTemplate("partials/links/table"), 17 | asyncHandler(auth.apikey), 18 | asyncHandler(auth.jwt), 19 | helpers.parseQuery, 20 | asyncHandler(link.get) 21 | ); 22 | 23 | router.get( 24 | "/admin", 25 | locals.viewTemplate("partials/admin/links/table"), 26 | asyncHandler(auth.apikey), 27 | asyncHandler(auth.jwt), 28 | asyncHandler(auth.admin), 29 | helpers.parseQuery, 30 | locals.adminTable, 31 | asyncHandler(link.getAdmin) 32 | ); 33 | 34 | router.post( 35 | "/", 36 | cors(), 37 | locals.viewTemplate("partials/shortener"), 38 | asyncHandler(auth.apikey), 39 | asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose), 40 | locals.createLink, 41 | validators.createLink, 42 | asyncHandler(helpers.verify), 43 | asyncHandler(link.create) 44 | ); 45 | 46 | router.patch( 47 | "/:id", 48 | locals.viewTemplate("partials/links/edit"), 49 | asyncHandler(auth.apikey), 50 | asyncHandler(auth.jwt), 51 | locals.editLink, 52 | validators.editLink, 53 | asyncHandler(helpers.verify), 54 | asyncHandler(link.edit) 55 | ); 56 | 57 | router.patch( 58 | "/admin/:id", 59 | locals.viewTemplate("partials/links/edit"), 60 | asyncHandler(auth.apikey), 61 | asyncHandler(auth.jwt), 62 | asyncHandler(auth.admin), 63 | locals.editLink, 64 | validators.editLink, 65 | asyncHandler(helpers.verify), 66 | asyncHandler(link.editAdmin) 67 | ); 68 | 69 | router.delete( 70 | "/:id", 71 | locals.viewTemplate("partials/links/dialog/delete"), 72 | asyncHandler(auth.apikey), 73 | asyncHandler(auth.jwt), 74 | validators.deleteLink, 75 | asyncHandler(helpers.verify), 76 | asyncHandler(link.remove) 77 | ); 78 | 79 | router.post( 80 | "/admin/ban/:id", 81 | locals.viewTemplate("partials/links/dialog/ban"), 82 | asyncHandler(auth.apikey), 83 | asyncHandler(auth.jwt), 84 | asyncHandler(auth.admin), 85 | validators.banLink, 86 | asyncHandler(helpers.verify), 87 | asyncHandler(link.ban) 88 | ); 89 | 90 | router.get( 91 | "/:id/stats", 92 | locals.viewTemplate("partials/stats"), 93 | asyncHandler(auth.apikey), 94 | asyncHandler(auth.jwt), 95 | validators.getStats, 96 | asyncHandler(helpers.verify), 97 | asyncHandler(link.stats) 98 | ); 99 | 100 | router.post( 101 | "/:id/protected", 102 | locals.viewTemplate("partials/protected/form"), 103 | locals.protected, 104 | validators.redirectProtected, 105 | asyncHandler(helpers.verify), 106 | asyncHandler(link.redirectProtected) 107 | ); 108 | 109 | router.post( 110 | "/report", 111 | locals.viewTemplate("partials/report/form"), 112 | auth.featureAccess([env.MAIL_ENABLED]), 113 | validators.reportLink, 114 | asyncHandler(helpers.verify), 115 | asyncHandler(link.report) 116 | ); 117 | 118 | 119 | module.exports = router; 120 | -------------------------------------------------------------------------------- /server/routes/renders.routes.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | 3 | const helpers = require("../handlers/helpers.handler"); 4 | const renders = require("../handlers/renders.handler"); 5 | const asyncHandler = require("../utils/asyncHandler"); 6 | const locals = require("../handlers/locals.handler"); 7 | const auth = require("../handlers/auth.handler"); 8 | const env = require("../env"); 9 | 10 | const router = Router(); 11 | 12 | // pages 13 | router.get( 14 | "/", 15 | asyncHandler(auth.jwtLoosePage), 16 | asyncHandler(helpers.adminSetup), 17 | asyncHandler(locals.user), 18 | asyncHandler(renders.homepage) 19 | ); 20 | 21 | router.get( 22 | "/login", 23 | asyncHandler(auth.jwtLoosePage), 24 | asyncHandler(helpers.adminSetup), 25 | asyncHandler(renders.login) 26 | ); 27 | 28 | router.get( 29 | "/logout", 30 | asyncHandler(renders.logout) 31 | ); 32 | 33 | router.get( 34 | "/create-admin", 35 | asyncHandler(renders.createAdmin) 36 | ); 37 | 38 | router.get( 39 | "/404", 40 | asyncHandler(auth.jwtLoosePage), 41 | asyncHandler(locals.user), 42 | asyncHandler(renders.notFound) 43 | ); 44 | 45 | router.get( 46 | "/settings", 47 | asyncHandler(auth.jwtPage), 48 | asyncHandler(locals.user), 49 | asyncHandler(renders.settings) 50 | ); 51 | 52 | router.get( 53 | "/admin", 54 | asyncHandler(auth.jwtPage), 55 | asyncHandler(auth.admin), 56 | asyncHandler(locals.user), 57 | asyncHandler(renders.admin) 58 | ); 59 | 60 | router.get( 61 | "/stats", 62 | asyncHandler(auth.jwtPage), 63 | asyncHandler(locals.user), 64 | asyncHandler(renders.stats) 65 | ); 66 | 67 | router.get( 68 | "/banned", 69 | asyncHandler(auth.jwtLoosePage), 70 | asyncHandler(locals.user), 71 | asyncHandler(renders.banned) 72 | ); 73 | 74 | router.get( 75 | "/report", 76 | asyncHandler(auth.jwtLoosePage), 77 | asyncHandler(locals.user), 78 | asyncHandler(renders.report) 79 | ); 80 | 81 | router.get( 82 | "/reset-password", 83 | auth.featureAccessPage([env.MAIL_ENABLED]), 84 | asyncHandler(auth.jwtLoosePage), 85 | asyncHandler(locals.user), 86 | asyncHandler(renders.resetPassword) 87 | ); 88 | 89 | router.get( 90 | "/reset-password/:resetPasswordToken", 91 | asyncHandler(auth.jwtLoosePage), 92 | asyncHandler(locals.user), 93 | asyncHandler(renders.resetPasswordSetNewPassword) 94 | ); 95 | 96 | router.get( 97 | "/verify-email/:changeEmailToken", 98 | asyncHandler(auth.changeEmail), 99 | asyncHandler(auth.jwtLoosePage), 100 | asyncHandler(locals.user), 101 | asyncHandler(renders.verifyChangeEmail) 102 | ); 103 | 104 | router.get( 105 | "/verify/:verificationToken", 106 | asyncHandler(auth.verify), 107 | asyncHandler(auth.jwtLoosePage), 108 | asyncHandler(locals.user), 109 | asyncHandler(renders.verify) 110 | ); 111 | 112 | router.get( 113 | "/terms", 114 | asyncHandler(auth.jwtLoosePage), 115 | asyncHandler(locals.user), 116 | asyncHandler(renders.terms) 117 | ); 118 | 119 | // partial renders 120 | router.get( 121 | "/confirm-link-delete", 122 | locals.noLayout, 123 | asyncHandler(auth.jwt), 124 | asyncHandler(renders.confirmLinkDelete) 125 | ); 126 | 127 | router.get( 128 | "/confirm-link-ban", 129 | locals.noLayout, 130 | locals.viewTemplate("partials/links/dialog/message"), 131 | asyncHandler(auth.jwt), 132 | asyncHandler(auth.admin), 133 | asyncHandler(renders.confirmLinkBan) 134 | ); 135 | 136 | router.get( 137 | "/confirm-user-delete", 138 | locals.noLayout, 139 | asyncHandler(auth.jwt), 140 | asyncHandler(auth.admin), 141 | asyncHandler(renders.confirmUserDelete) 142 | ); 143 | 144 | router.get( 145 | "/confirm-user-ban", 146 | locals.noLayout, 147 | asyncHandler(auth.jwt), 148 | asyncHandler(auth.admin), 149 | asyncHandler(renders.confirmUserBan) 150 | ); 151 | 152 | router.get( 153 | "/create-user", 154 | locals.noLayout, 155 | asyncHandler(auth.jwt), 156 | asyncHandler(auth.admin), 157 | asyncHandler(renders.createUser) 158 | ); 159 | 160 | router.get( 161 | "/add-domain", 162 | locals.noLayout, 163 | asyncHandler(auth.jwt), 164 | asyncHandler(auth.admin), 165 | asyncHandler(renders.addDomainAdmin) 166 | ); 167 | 168 | 169 | router.get( 170 | "/confirm-domain-ban", 171 | locals.noLayout, 172 | asyncHandler(auth.jwt), 173 | asyncHandler(auth.admin), 174 | asyncHandler(renders.confirmDomainBan) 175 | ); 176 | 177 | 178 | router.get( 179 | "/confirm-domain-delete-admin", 180 | locals.noLayout, 181 | asyncHandler(auth.jwt), 182 | asyncHandler(auth.admin), 183 | asyncHandler(renders.confirmDomainDeleteAdmin) 184 | ); 185 | 186 | router.get( 187 | "/link/edit/:id", 188 | locals.noLayout, 189 | asyncHandler(auth.jwt), 190 | asyncHandler(renders.linkEdit) 191 | ); 192 | 193 | router.get( 194 | "/admin/link/edit/:id", 195 | locals.noLayout, 196 | asyncHandler(auth.jwt), 197 | asyncHandler(auth.admin), 198 | asyncHandler(renders.linkEditAdmin) 199 | ); 200 | 201 | router.get( 202 | "/add-domain-form", 203 | locals.noLayout, 204 | asyncHandler(auth.jwt), 205 | asyncHandler(renders.addDomainForm) 206 | ); 207 | 208 | router.get( 209 | "/confirm-domain-delete", 210 | locals.noLayout, 211 | locals.viewTemplate("partials/settings/domain/delete"), 212 | asyncHandler(auth.jwt), 213 | asyncHandler(renders.confirmDomainDelete) 214 | ); 215 | 216 | router.get( 217 | "/get-report-email", 218 | locals.noLayout, 219 | locals.viewTemplate("partials/report/email"), 220 | asyncHandler(renders.getReportEmail) 221 | ); 222 | 223 | router.get( 224 | "/get-support-email", 225 | locals.noLayout, 226 | locals.viewTemplate("partials/support_email"), 227 | asyncHandler(renders.getSupportEmail) 228 | ); 229 | 230 | module.exports = router; 231 | -------------------------------------------------------------------------------- /server/routes/routes.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | 3 | const helpers = require("./../handlers/helpers.handler"); 4 | const locals = require("./../handlers/locals.handler"); 5 | const renders = require("./renders.routes"); 6 | const domains = require("./domain.routes"); 7 | const health = require("./health.routes"); 8 | const link = require("./link.routes"); 9 | const user = require("./user.routes"); 10 | const auth = require("./auth.routes"); 11 | 12 | const renderRouter = Router(); 13 | renderRouter.use(renders); 14 | 15 | const apiRouter = Router(); 16 | apiRouter.use(locals.noLayout); 17 | apiRouter.use("/domains", domains); 18 | apiRouter.use("/health", health); 19 | apiRouter.use("/links", link); 20 | apiRouter.use("/users", user); 21 | apiRouter.use("/auth", auth); 22 | 23 | module.exports = { 24 | api: apiRouter, 25 | render: renderRouter, 26 | }; 27 | -------------------------------------------------------------------------------- /server/routes/user.routes.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | 3 | const validators = require("../handlers/validators.handler"); 4 | const helpers = require("../handlers/helpers.handler"); 5 | const asyncHandler = require("../utils/asyncHandler"); 6 | const locals = require("../handlers/locals.handler"); 7 | const user = require("../handlers/users.handler"); 8 | const auth = require("../handlers/auth.handler"); 9 | 10 | const router = Router(); 11 | 12 | router.get( 13 | "/", 14 | asyncHandler(auth.apikey), 15 | asyncHandler(auth.jwt), 16 | asyncHandler(user.get) 17 | ); 18 | 19 | router.get( 20 | "/admin", 21 | locals.viewTemplate("partials/admin/users/table"), 22 | asyncHandler(auth.apikey), 23 | asyncHandler(auth.jwt), 24 | asyncHandler(auth.admin), 25 | helpers.parseQuery, 26 | locals.adminTable, 27 | asyncHandler(user.getAdmin) 28 | ); 29 | 30 | router.post( 31 | "/admin", 32 | locals.viewTemplate("partials/admin/dialog/create_user"), 33 | asyncHandler(auth.apikey), 34 | asyncHandler(auth.jwt), 35 | asyncHandler(auth.admin), 36 | validators.createUser, 37 | asyncHandler(helpers.verify), 38 | asyncHandler(user.create) 39 | ); 40 | 41 | router.post( 42 | "/delete", 43 | locals.viewTemplate("partials/settings/delete_account"), 44 | asyncHandler(auth.apikey), 45 | asyncHandler(auth.jwt), 46 | validators.deleteUser, 47 | asyncHandler(helpers.verify), 48 | asyncHandler(user.remove) 49 | ); 50 | 51 | router.delete( 52 | "/admin/:id", 53 | locals.viewTemplate("partials/admin/dialog/delete_user"), 54 | asyncHandler(auth.apikey), 55 | asyncHandler(auth.jwt), 56 | asyncHandler(auth.admin), 57 | validators.deleteUserByAdmin, 58 | asyncHandler(helpers.verify), 59 | asyncHandler(user.removeByAdmin) 60 | ); 61 | 62 | router.post( 63 | "/admin/ban/:id", 64 | locals.viewTemplate("partials/admin/dialog/ban_user"), 65 | asyncHandler(auth.apikey), 66 | asyncHandler(auth.jwt), 67 | asyncHandler(auth.admin), 68 | validators.banUser, 69 | asyncHandler(helpers.verify), 70 | asyncHandler(user.ban) 71 | ); 72 | 73 | module.exports = router; 74 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const env = require("./env"); 2 | 3 | const cookieParser = require("cookie-parser"); 4 | const passport = require("passport"); 5 | const express = require("express"); 6 | const helmet = require("helmet"); 7 | const path = require("node:path"); 8 | const hbs = require("hbs"); 9 | 10 | const helpers = require("./handlers/helpers.handler"); 11 | const renders = require("./handlers/renders.handler"); 12 | const asyncHandler = require("./utils/asyncHandler"); 13 | const locals = require("./handlers/locals.handler"); 14 | const links = require("./handlers/links.handler"); 15 | const routes = require("./routes"); 16 | const utils = require("./utils"); 17 | 18 | 19 | // run the cron jobs 20 | // the app might be running in cluster mode (multiple instances) so run the cron job only on one cluster (the first one) 21 | // NODE_APP_INSTANCE variable is added by pm2 automatically, if you're using something else to cluster your app, then make sure to set this variable 22 | if (env.NODE_APP_INSTANCE === 0) { 23 | require("./cron"); 24 | } 25 | 26 | // intialize passport authentication library 27 | require("./passport"); 28 | 29 | // create express app 30 | const app = express(); 31 | 32 | // this tells the express app that it's running behind a proxy server 33 | // and thus it should get the IP address from the proxy server 34 | if (env.TRUST_PROXY) { 35 | app.set("trust proxy", true); 36 | } 37 | 38 | app.use(helmet({ contentSecurityPolicy: false })); 39 | app.use(cookieParser()); 40 | app.use(express.json()); 41 | app.use(express.urlencoded({ extended: true })); 42 | 43 | // serve static 44 | app.use("/images", express.static("custom/images")); 45 | app.use("/css", express.static("custom/css", { extensions: ["css"] })); 46 | app.use(express.static("static")); 47 | 48 | app.use(passport.initialize()); 49 | app.use(locals.isHTML); 50 | app.use(locals.config); 51 | 52 | // template engine / serve html 53 | 54 | app.set("view engine", "hbs"); 55 | app.set("views", [ 56 | path.join(__dirname, "../custom/views"), 57 | path.join(__dirname, "views"), 58 | ]); 59 | utils.registerHandlebarsHelpers(); 60 | 61 | // if is custom domain, redirect to the set homepage 62 | app.use(asyncHandler(links.redirectCustomDomainHomepage)); 63 | 64 | // render html pages 65 | app.use("/", routes.render); 66 | 67 | // handle api requests 68 | app.use("/api/v2", routes.api); 69 | app.use("/api", routes.api); 70 | 71 | // finally, redirect the short link to the target 72 | app.get("/:id", asyncHandler(links.redirect)); 73 | 74 | // 404 pages that don't exist 75 | app.get("*", renders.notFound); 76 | 77 | // handle errors coming from above routes 78 | app.use(helpers.error); 79 | 80 | app.listen(env.PORT, () => { 81 | console.log(`> Ready on http://localhost:${env.PORT}`); 82 | }); 83 | -------------------------------------------------------------------------------- /server/utils/asyncHandler.js: -------------------------------------------------------------------------------- 1 | function asyncHandler(fn) { 2 | return function asyncUtilWrap(...args) { 3 | const fnReturn = fn(...args); 4 | const next = args[args.length - 1]; 5 | return Promise.resolve(fnReturn).catch(next); 6 | } 7 | } 8 | 9 | module.exports = asyncHandler; -------------------------------------------------------------------------------- /server/utils/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./utils"); -------------------------------------------------------------------------------- /server/utils/knex.js: -------------------------------------------------------------------------------- 1 | 2 | function knexUtils(knex) { 3 | function truncatedTimestamp(columnName, precision = "hour") { 4 | switch (knex.client.driverName) { 5 | case "sqlite3": 6 | case "better-sqlite3": 7 | // SQLite uses strftime for date truncation 8 | const sqliteFormats = { 9 | second: "%Y-%m-%d %H:%M:%S", 10 | minute: "%Y-%m-%d %H:%M:00", 11 | hour: "%Y-%m-%d %H:00:00", 12 | day: "%Y-%m-%d 00:00:00", 13 | }; 14 | return knex.raw(`strftime('${sqliteFormats[precision]}', ${columnName})`); // Default to 'hour' 15 | case "mssql": 16 | // For MSSQL, we can use FORMAT or CONVERT to truncate the timestamp 17 | const mssqlFormats = { 18 | second: "yyyy-MM-dd HH:mm:ss", 19 | minute: "yyyy-MM-dd HH:mm:00", 20 | hour: "yyyy-MM-dd HH:00:00", 21 | day: "yyyy-MM-dd 00:00:00", 22 | }; 23 | return knex.raw(`FORMAT(${columnName}, '${mssqlFormats[precision]}'`); 24 | case "pg": 25 | case "pgnative": 26 | case "cockroachdb": 27 | // PostgreSQL has the `date_trunc` function, which is ideal for this task 28 | return knex.raw(`date_trunc(?, ${columnName} at time zone 'Z')`, [precision]); 29 | case "oracle": 30 | case "oracledb": 31 | // Oracle truncates dates using the `TRUNC` function 32 | return knex.raw(`TRUNC(${columnName}, ?)`, [precision]); 33 | case "mysql": 34 | case "mysql2": 35 | // MySQL can use the DATE_FORMAT function to truncate 36 | const mysqlFormats = { 37 | second: "%Y-%m-%d %H:%i:%s", 38 | minute: "%Y-%m-%d %H:%i:00", 39 | hour: "%Y-%m-%d %H:00:00", 40 | day: "%Y-%m-%d 00:00:00", 41 | }; 42 | return knex.raw(`DATE_FORMAT(${columnName}, '${mysqlFormats[precision]}')`); 43 | default: 44 | throw new Error(`${this.client.driverName} does not support timestamp truncation with precision`); 45 | } 46 | } 47 | 48 | return { 49 | truncatedTimestamp 50 | } 51 | } 52 | 53 | module.exports = { 54 | knexUtils 55 | } 56 | -------------------------------------------------------------------------------- /server/views/404.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

4 | 404 | Link could not be found. 5 |

6 | 7 | ← Back to homepage 8 | 9 |
10 | {{> footer}} -------------------------------------------------------------------------------- /server/views/admin.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | {{> admin/index}} 3 | {{> footer}} 4 | -------------------------------------------------------------------------------- /server/views/banned.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

4 | Link has been banned and removed because of 5 | malware or scam. 6 |

7 |

8 | If you noticed a malware/scam link shortened by {{default_domain}}, 9 | 10 | send us a report 11 | . 12 |

13 |
14 | {{> footer}} -------------------------------------------------------------------------------- /server/views/create_admin.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | {{> auth/form_admin}} 3 | {{> footer}} 4 | -------------------------------------------------------------------------------- /server/views/error.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

4 | Error! 5 |

6 |

{{message}}

7 | 8 | ← Back to homepage 9 | 10 |
11 | {{> footer}} -------------------------------------------------------------------------------- /server/views/homepage.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | {{> shortener}} 3 | {{#if user}} 4 | {{> links/table}} 5 | {{/if}} 6 | {{> footer}} 7 | -------------------------------------------------------------------------------- /server/views/layout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{site_name}} | {{title}} 26 | 27 | {{#each custom_styles}} 28 | 29 | {{/each}} 30 | {{{block "stylesheets"}}} 31 | 32 | 33 |
34 | {{{body}}} 35 |
36 | 37 | {{{block "scripts"}}} 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /server/views/login.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | {{> auth/form}} 3 | {{> footer}} 4 | -------------------------------------------------------------------------------- /server/views/logout.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | 7 | {{> footer}} -------------------------------------------------------------------------------- /server/views/partials/admin/dialog/add_domain.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Add domain

3 |
10 | 21 | 32 | 42 |
43 | 44 | 48 | {{> icons/spinner}} 49 |
50 |
51 |
52 | {{#if error}} 53 |

{{error}}

54 | {{/if}} 55 |
56 |
-------------------------------------------------------------------------------- /server/views/partials/admin/dialog/add_domain_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | The domain "{{address}}" has been created successfully. 7 |

8 |
9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /server/views/partials/admin/dialog/ban_domain.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Ban domain?

3 |

4 | Are you sure do you want to ban the domain "{{address}}"? 5 |

6 |
7 | {{#if hasUser}} 8 | 12 | {{/if}} 13 | {{#if hasLink}} 14 | 18 | {{/if}} 19 |
20 |
21 | 22 | 39 | {{> icons/spinner}} 40 |
41 |
42 | {{#if error}} 43 |

{{error}}

44 | {{/if}} 45 |
46 |
-------------------------------------------------------------------------------- /server/views/partials/admin/dialog/ban_domain_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | The domain "{{address}}" is banned. 7 |

8 |
9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /server/views/partials/admin/dialog/ban_user.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Ban user?

3 |

4 | Are you sure do you want to ban the user "{{email}}"? 5 |

6 |
7 | 11 | 15 |
16 |
17 | 18 | 35 | {{> icons/spinner}} 36 |
37 |
38 | {{#if error}} 39 |

{{error}}

40 | {{/if}} 41 |
42 |
-------------------------------------------------------------------------------- /server/views/partials/admin/dialog/ban_user_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | The user "{{email}}" is banned. 7 |

8 |
9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /server/views/partials/admin/dialog/create_user.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Create user

3 |
10 | 21 | 32 | 40 |
41 | 52 | 62 |
63 | 67 |
68 | 69 | 73 | {{> icons/spinner}} 74 |
75 |
76 |
77 | {{#if error}} 78 |

{{error}}

79 | {{/if}} 80 |
81 |
-------------------------------------------------------------------------------- /server/views/partials/admin/dialog/create_user_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | The user "{{email}}" has been created successfully. 7 |

8 |
9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /server/views/partials/admin/dialog/delete_domain.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Delete domain?

3 |

4 | Are you sure do you want to delete the domain "{{address}}"?
5 |

6 | {{#if hasLink}} 7 |
8 | 12 |
13 | {{/if }} 14 |
15 | 16 | 33 | {{> icons/spinner}} 34 |
35 |
36 | {{#if error}} 37 |

{{error}}

38 | {{/if}} 39 |
40 |
-------------------------------------------------------------------------------- /server/views/partials/admin/dialog/delete_domain_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | The domain "{{address}}" has been deleted. 7 |

8 |
9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /server/views/partials/admin/dialog/delete_user.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Delete user?

3 |

4 | Are you sure do you want to delete the user "{{email}}"?
5 | All their data including their links will be deleted. 6 |

7 |
8 | 9 | 23 | {{> icons/spinner}} 24 |
25 |
26 | {{#if error}} 27 |

{{error}}

28 | {{/if}} 29 |
30 |
-------------------------------------------------------------------------------- /server/views/partials/admin/dialog/delete_user_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | The user "{{email}}" has been deleted. 7 |

8 |
9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /server/views/partials/admin/dialog/frame.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | {{> icons/spinner}} 6 |
7 |
8 |
-------------------------------------------------------------------------------- /server/views/partials/admin/dialog/mesasge.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if error}} 3 |

{{error}}

4 | {{else}} 5 |

{{message}}

6 | {{/if}} 7 |
8 | 9 |
10 |
11 | 12 | -------------------------------------------------------------------------------- /server/views/partials/admin/domains/actions.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{#if banned}} 3 | 6 | {{/if}} 7 | {{#unless banned}} 8 | 18 | {{/unless}} 19 | 29 | -------------------------------------------------------------------------------- /server/views/partials/admin/domains/loading.hbs: -------------------------------------------------------------------------------- 1 | {{#unless table_domains}} 2 | {{#ifEquals table_domains.length 0}} 3 | 4 | 5 | No domains. 6 | 7 | 8 | {{else}} 9 | 10 | 11 | {{> icons/spinner}} 12 | Loading domains... 13 | 14 | 15 | {{/ifEquals}} 16 | {{/unless}} -------------------------------------------------------------------------------- /server/views/partials/admin/domains/table.hbs: -------------------------------------------------------------------------------- 1 | 24 | {{> admin/domains/thead}} 25 | {{> admin/domains/tbody}} 26 | {{> admin/domains/tfoot}} 27 |
28 | -------------------------------------------------------------------------------- /server/views/partials/admin/domains/tbody.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{> admin/domains/loading}} 3 | {{#each table_domains}} 4 | {{> admin/domains/tr}} 5 | {{/each}} 6 | -------------------------------------------------------------------------------- /server/views/partials/admin/domains/tfoot.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{> admin/table_nav}} 4 | 5 | -------------------------------------------------------------------------------- /server/views/partials/admin/domains/thead.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{> admin/table_tab title='domains'}} 3 | 4 | 5 |
6 |
7 | 17 | 25 |
26 |
27 | 37 | 45 |
46 | 51 |
52 |
53 | 58 | 63 | 64 | 65 | 66 | 76 |
77 | 78 | {{> admin/table_nav}} 79 | 80 | 81 | ID 82 | Address 83 | Homepage 84 | Created at 85 | Total links 86 | 87 | 88 | -------------------------------------------------------------------------------- /server/views/partials/admin/domains/tr.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{id}} 4 | 5 | 6 | {{address}} 7 |

8 | by  9 | {{~#if user_id~}} 10 | 21 | {{email}} 22 | 23 | {{#ifEquals @root.query.user email}} 24 | {{else}} 25 |  ( 26 | 36 | view domains 37 | ) 38 | {{/ifEquals}} 39 | {{~else~}} 40 | 50 | System 51 | 52 | {{~/if~}} 53 |  {{~#if description~}}· {{description}}{{~/if}} 54 |

55 | 56 | 57 | {{#if homepage}} 58 | 59 | {{homepage}} 60 | 61 | {{else}} 62 | No homepage 63 | {{/if}} 64 | 65 | 66 | {{relative_created_at}} 67 | 68 | 69 | {{#ifEquals links_count '0'}} 70 | {{links_count}} 71 | {{else}} 72 | 83 | {{links_count}} 84 | 85 | {{/ifEquals}} 86 | 87 | {{> admin/domains/actions}} 88 | 89 | 90 | 91 | {{> icons/spinner}} 92 | 93 | -------------------------------------------------------------------------------- /server/views/partials/admin/index.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Recent shortened links.

3 | {{> admin/links/table onload=true}} 4 | {{> admin/dialog/frame}} 5 |
-------------------------------------------------------------------------------- /server/views/partials/admin/links/actions.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{#if password}} 3 | 6 | {{/if}} 7 | {{#if banned}} 8 | 11 | {{/if}} 12 | 18 | {{> icons/chart}} 19 | 20 | 27 | 49 | {{#unless banned}} 50 | 60 | {{/unless}} 61 | 71 | -------------------------------------------------------------------------------- /server/views/partials/admin/links/edit.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{#if id}} 3 |
13 |
14 | 27 | 40 | 52 |
53 |
54 | 66 | 78 |
79 |
80 | 91 | 100 |
101 |
102 | {{#if error}} 103 | {{#unless errors}} 104 |

{{error}}

105 | {{/unless}} 106 | {{else if success}} 107 |

{{success}}

108 | {{/if}} 109 |
110 | 113 |
114 | {{else}} 115 |

No link was found.

116 | {{/if}} 117 | -------------------------------------------------------------------------------- /server/views/partials/admin/links/loading.hbs: -------------------------------------------------------------------------------- 1 | {{#unless links}} 2 | {{#ifEquals links.length 0}} 3 | 4 | 5 | No links. 6 | 7 | 8 | {{else}} 9 | 10 | 11 | {{> icons/spinner}} 12 | Loading links... 13 | 14 | 15 | {{/ifEquals}} 16 | {{/unless}} -------------------------------------------------------------------------------- /server/views/partials/admin/links/table.hbs: -------------------------------------------------------------------------------- 1 | 25 | {{> admin/links/thead}} 26 | {{> admin/links/tbody}} 27 | {{> admin/links/tfoot}} 28 |
29 | -------------------------------------------------------------------------------- /server/views/partials/admin/links/tbody.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{> admin/links/loading}} 3 | {{#each links}} 4 | {{> admin/links/tr}} 5 | {{/each}} 6 | -------------------------------------------------------------------------------- /server/views/partials/admin/links/tfoot.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{> admin/table_nav}} 4 | 5 | -------------------------------------------------------------------------------- /server/views/partials/admin/links/thead.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{> admin/table_tab title='links'}} 3 | 4 | 5 |
6 |
7 | 17 | 25 |
26 |
27 | 37 | 45 |
46 |
47 | 57 | 65 |
66 |
67 |
68 | 78 | 88 | 98 | 99 | 100 | 101 |
102 | 103 | {{> admin/table_nav}} 104 | 105 | 106 | Original URL 107 | Created at 108 | Short link 109 | Views 110 | 111 | 112 | -------------------------------------------------------------------------------- /server/views/partials/admin/links/tr.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{target}} 5 | 6 |

7 | by  8 | {{~#if user_id~}} 9 | 20 | {{email}} 21 | 22 | {{#ifEquals @root.query.user email}} 23 | {{else}} 24 |  ( 25 | 35 | view links 36 | ) 37 | {{/ifEquals}} 38 | {{~else~}} 39 | 49 | Anonymous 50 | 51 | {{~/if~}} 52 |  {{~#if description~}}· {{description}}{{~/if}} 53 |

54 | 55 | 56 | {{relative_created_at}} 57 | {{#if relative_expire_in}} 58 |

59 | Expires in {{relative_expire_in}} 60 |

61 | {{/if}} 62 | 63 | 64 | 77 |

78 | {{domain}} 88 |

89 | 90 | 91 | {{visit_count}} 92 | 93 | {{> admin/links/actions}} 94 | 95 | 96 | 97 | {{> icons/spinner}} 98 | 99 | -------------------------------------------------------------------------------- /server/views/partials/admin/table_nav.hbs: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 |
7 | 8 | 16 | -------------------------------------------------------------------------------- /server/views/partials/admin/table_tab.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Total {{title}}: {{#if total includeZero=true}}{{total_formatted}}{{else}}-{{/if}} 5 |

6 | 7 | 8 | 61 | 62 | -------------------------------------------------------------------------------- /server/views/partials/admin/users/actions.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{#if banned}} 3 | 6 | {{/if}} 7 | {{#unless banned}} 8 | 18 | {{/unless}} 19 | 29 | -------------------------------------------------------------------------------- /server/views/partials/admin/users/loading.hbs: -------------------------------------------------------------------------------- 1 | {{#unless users}} 2 | {{#ifEquals users.length 0}} 3 | 4 | 5 | No users. 6 | 7 | 8 | {{else}} 9 | 10 | 11 | {{> icons/spinner}} 12 | Loading users... 13 | 14 | 15 | {{/ifEquals}} 16 | {{/unless}} -------------------------------------------------------------------------------- /server/views/partials/admin/users/table.hbs: -------------------------------------------------------------------------------- 1 | 25 | {{> admin/users/thead}} 26 | {{> admin/users/tbody}} 27 | {{> admin/users/tfoot}} 28 |
29 | -------------------------------------------------------------------------------- /server/views/partials/admin/users/tbody.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{> admin/users/loading}} 3 | {{#each users}} 4 | {{> admin/users/tr}} 5 | {{/each}} 6 | -------------------------------------------------------------------------------- /server/views/partials/admin/users/tfoot.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{> admin/table_nav}} 4 | 5 | -------------------------------------------------------------------------------- /server/views/partials/admin/users/thead.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{> admin/table_tab title='users'}} 3 | 4 | 5 |
6 |
7 | 17 | 25 |
26 | 31 | 36 | 41 |
42 |
43 | 48 | 53 | 54 | 55 | 56 | 66 |
67 | 68 | {{> admin/table_nav}} 69 | 70 | 71 | ID 72 | Email 73 | Created at 74 | Verified 75 | Role 76 | Total links 77 | 78 | 79 | -------------------------------------------------------------------------------- /server/views/partials/admin/users/tr.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{id}} 4 | 5 | 6 | {{email}} 7 |

8 | {{#if domains}} 9 | 20 | {{domains}} 21 | 22 | {{else}} 23 | No domains 24 | {{/if}} 25 |

26 | 27 | 28 | {{relative_created_at}} 29 | 30 | 31 | {{#if verified}} 32 | VERIFIED 33 | {{else}} 34 | NOT VERIFIED 35 | {{/if}} 36 | 37 | 38 | {{#ifEquals role "ADMIN"}} 39 | ADMIN 40 | {{else}} 41 | USER 42 | {{/ifEquals}} 43 | 44 | 45 | {{#ifEquals links_count '0'}} 46 | {{links_count}} 47 | {{else}} 48 | 59 | {{links_count}} 60 | 61 | {{/ifEquals}} 62 | 63 | {{> admin/users/actions}} 64 | 65 | 66 | 67 | {{> icons/spinner}} 68 | 69 | -------------------------------------------------------------------------------- /server/views/partials/auth/form.hbs: -------------------------------------------------------------------------------- 1 |
2 | 14 | 25 |
26 | 34 | {{#unless disallow_registration}} 35 | {{#if mail_enabled}} 36 | 52 | {{/if}} 53 | {{/unless}} 54 |
55 | {{#if mail_enabled}} 56 | Forgot your password? 57 | {{/if}} 58 | {{#unless errors}} 59 | {{#if error}} 60 |

{{error}}

61 | {{/if}} 62 | {{/unless}} 63 |
-------------------------------------------------------------------------------- /server/views/partials/auth/form_admin.hbs: -------------------------------------------------------------------------------- 1 |
2 |

3 | Create an Admin account first: 4 |

5 | 17 | 28 |
29 | 34 |
35 | {{#unless errors}} 36 | {{#if error}} 37 |

{{error}}

38 | {{/if}} 39 | {{/unless}} 40 |
-------------------------------------------------------------------------------- /server/views/partials/auth/verify.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/auth/welcome.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/footer.hbs: -------------------------------------------------------------------------------- 1 |
2 |

3 | Powered by Kutt | 4 | Terms of Service 5 | {{#if report_email}} 6 | | 7 | Report Abuse 8 | {{/if}} 9 | {{#if contact_email}} 10 | | 11 | 15 | {{/if}} 16 |

17 |
-------------------------------------------------------------------------------- /server/views/partials/header.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 21 |
22 | 54 |
-------------------------------------------------------------------------------- /server/views/partials/icons/arrow_left.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/chart.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/check.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/chevron_left.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/chevron_right.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/cog.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/copy.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/eye.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/heart.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/key.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/login.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/new_user.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/pencil.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/plus.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/qrcode.hbs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/views/partials/icons/reload.hbs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/views/partials/icons/send.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/shield.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/shuffle.hbs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/views/partials/icons/spinner.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/stop.hbs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/views/partials/icons/trash.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/write.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/x.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/icons/zap.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/links/actions.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{#if password}} 3 | 6 | {{/if}} 7 | {{#if banned}} 8 | 11 | {{/if}} 12 | 18 | {{> icons/chart}} 19 | 20 | 27 | 49 | 59 | -------------------------------------------------------------------------------- /server/views/partials/links/dialog/ban.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Ban link?

3 |

4 | Are you sure do you want to ban the link "{{link}}"? 5 |

6 |
7 | 11 | 15 | 19 | 23 |
24 |
25 | 26 | 43 | {{> icons/spinner}} 44 |
45 |
46 | {{#if error}} 47 |

{{error}}

48 | {{/if}} 49 |
50 |
-------------------------------------------------------------------------------- /server/views/partials/links/dialog/ban_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | The link "{{link}}" is banned. 7 |

8 |
9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /server/views/partials/links/dialog/delete.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Delete link?

3 |

4 | Are you sure do you want to delete the link "{{link}}"? 5 |

6 |
7 | 8 | 22 | {{> icons/spinner}} 23 |
24 |
25 | {{#if error}} 26 |

{{error}}

27 | {{/if}} 28 |
29 |
-------------------------------------------------------------------------------- /server/views/partials/links/dialog/delete_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | Your link "{{link}}" has been deleted. 7 |

8 |
9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /server/views/partials/links/dialog/frame.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/views/partials/links/dialog/message.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if error}} 3 |

{{error}}

4 | {{else}} 5 |

{{message}}

6 | {{/if}} 7 |
8 | 9 |
10 |
11 | 12 | -------------------------------------------------------------------------------- /server/views/partials/links/edit.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{#if id}} 3 |
13 |
14 | 27 | 40 | 52 |
53 |
54 | 66 | 78 |
79 |
80 | 91 | 100 |
101 |
102 | {{#if error}} 103 | {{#unless errors}} 104 |

{{error}}

105 | {{/unless}} 106 | {{else if success}} 107 |

{{success}}

108 | {{/if}} 109 |
110 | 113 |
114 | {{else}} 115 |

No link was found.

116 | {{/if}} 117 | -------------------------------------------------------------------------------- /server/views/partials/links/loading.hbs: -------------------------------------------------------------------------------- 1 | {{#unless links}} 2 | {{#ifEquals links.length 0}} 3 | 4 | 5 | No links. 6 | 7 | 8 | {{else}} 9 | 10 | 11 | {{> icons/spinner}} 12 | Loading links... 13 | 14 | 15 | {{/ifEquals}} 16 | {{/unless}} -------------------------------------------------------------------------------- /server/views/partials/links/nav.hbs: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 |
7 | 8 | 16 | -------------------------------------------------------------------------------- /server/views/partials/links/table.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Recent shortened links.

3 | 21 | {{> links/thead}} 22 | {{> links/tbody}} 23 | {{> links/tfoot}} 24 |
25 | {{> links/dialog/frame}} 26 |
-------------------------------------------------------------------------------- /server/views/partials/links/tbody.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{> links/loading}} 3 | {{#each links}} 4 | {{> links/tr}} 5 | {{/each}} 6 | -------------------------------------------------------------------------------- /server/views/partials/links/tfoot.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{> links/nav}} 4 | 5 | -------------------------------------------------------------------------------- /server/views/partials/links/thead.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{> links/nav}} 10 | 11 | 12 | Original URL 13 | Created at 14 | Short link 15 | Views 16 | 17 | 18 | -------------------------------------------------------------------------------- /server/views/partials/links/tr.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{target}} 5 | 6 | {{#if description}} 7 |

8 | {{description}} 9 |

10 | {{/if}} 11 | 12 | 13 | {{relative_created_at}} 14 | {{#if relative_expire_in}} 15 |

16 | Expires in {{relative_expire_in}} 17 |

18 | {{/if}} 19 | 20 | 21 |
22 | 29 | {{> icons/check}} 30 |
31 | 32 | {{link.link}} 33 | 34 | 35 | 36 | {{visit_count}} 37 | 38 | {{> links/actions}} 39 | 40 | 41 | 42 | {{> icons/spinner}} 43 | 44 | -------------------------------------------------------------------------------- /server/views/partials/protected/form.hbs: -------------------------------------------------------------------------------- 1 |
9 | {{#if message}} 10 |

{{message}}

11 | {{else}} 12 |
13 | 25 | 30 |
31 | {{#if error}}

{{error}}

{{/if}} 32 | {{/if}} 33 |
-------------------------------------------------------------------------------- /server/views/partials/report/email.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#unless report_email_address}} 3 | 14 | {{else}} 15 | {{report_email_address}} 16 | {{/unless}} 17 |
-------------------------------------------------------------------------------- /server/views/partials/report/form.hbs: -------------------------------------------------------------------------------- 1 |
7 | {{#if message}} 8 |

{{message}}

9 | {{else}} 10 |
11 | 23 | 27 |
28 | {{#if error}}

{{error}}

{{/if}} 29 | {{/if}} 30 |
-------------------------------------------------------------------------------- /server/views/partials/reset_password/new_password_form.hbs: -------------------------------------------------------------------------------- 1 |
9 | 21 | 33 | 37 | {{#unless errors}} 38 | {{#if error}} 39 |

{{error}}

40 | {{/if}} 41 | {{/unless}} 42 |
-------------------------------------------------------------------------------- /server/views/partials/reset_password/new_password_success.hbs: -------------------------------------------------------------------------------- 1 |

2 | Your password is updated successfully. 3 | You can now log in with your new password. 4 |

5 | Log in → -------------------------------------------------------------------------------- /server/views/partials/reset_password/request_form.hbs: -------------------------------------------------------------------------------- 1 |
8 | {{#if message}} 9 |

{{message}}

10 | {{else}} 11 |
12 | 24 | 28 |
29 | {{#if error}}

{{error}}

{{/if}} 30 | {{/if}} 31 |
-------------------------------------------------------------------------------- /server/views/partials/settings/apikey.hbs: -------------------------------------------------------------------------------- 1 |
2 |

API

3 |

4 | In additional to this website, you can use the API to create, delete and 5 | get shortened URLs. If you're not familiar with API, don't generate the key. 6 | DO NOT share this key on the client side of your website. 7 | 8 | Read API docs. 9 | 10 |

11 |
12 | {{#if user.apikey}} 13 |
14 | 22 | {{> icons/check}} 23 |
24 |

28 | {{user.apikey}} 29 |

30 | {{/if}} 31 | {{#if error}} 32 |

{{error}}

33 | {{/if}} 34 |
35 |
41 | 46 |
47 |
-------------------------------------------------------------------------------- /server/views/partials/settings/change_email.hbs: -------------------------------------------------------------------------------- 1 |
2 |

3 | Change email 4 |

5 |

Enter your password and a new email address to change your email address.

6 |
13 |
14 | 25 | 36 |
37 | 42 | {{#if error}} 43 | {{#unless errors}} 44 |

{{error}}

45 | {{/unless}} 46 | {{else if success}} 47 |

{{success}}

48 | {{/if}} 49 |
50 |
-------------------------------------------------------------------------------- /server/views/partials/settings/change_password.hbs: -------------------------------------------------------------------------------- 1 |
2 |

3 | Change password 4 |

5 |

Enter your current password and a new password to change it to.

6 |
13 |
14 | 25 | 36 |
37 | 42 | {{#if error}} 43 | {{#unless errors}} 44 |

{{error}}

45 | {{/unless}} 46 | {{else if success}} 47 |

{{success}}

48 | {{/if}} 49 |
50 |
-------------------------------------------------------------------------------- /server/views/partials/settings/delete_account.hbs: -------------------------------------------------------------------------------- 1 |
2 |

3 | Delete account 4 |

5 |

Delete your account from {{default_domain}}.

6 |
14 | {{#if success}} 15 |

{{success}}

16 | {{else}} 17 |
18 | 29 |
30 | 35 | {{#if error}} 36 | {{#unless errors}} 37 |

{{error}}

38 | {{/unless}} 39 | {{/if}} 40 | {{/if}} 41 |
42 |
-------------------------------------------------------------------------------- /server/views/partials/settings/domain/add_form.hbs: -------------------------------------------------------------------------------- 1 |
11 |
12 | 25 | 37 |
38 |

39 | 40 | If you leave homepage empty, yoursite.com will be redirected to {{default_domain}}. 41 | 42 |

43 |
44 | 50 | 54 |
55 | {{> icons/spinner}} 56 | {{#unless errors}} 57 | {{#if error}} 58 |

{{error}}

59 | {{/if}} 60 | {{/unless}} 61 |
-------------------------------------------------------------------------------- /server/views/partials/settings/domain/delete.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Delete domain?

3 |

4 | Are you sure do you want to delete the domain "{{address}}"? 5 |

6 |
7 | 8 | 22 | {{> icons/spinner}} 23 |
24 |
25 | {{#if error}} 26 |

{{error}}

27 | {{/if}} 28 |
29 |
-------------------------------------------------------------------------------- /server/views/partials/settings/domain/delete_success.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> icons/check}} 4 |
5 |

6 | Your domain "{{address}}" has been deleted. 7 |

8 |
9 | 10 |
11 |
12 | {{> settings/domain/table}} -------------------------------------------------------------------------------- /server/views/partials/settings/domain/dialog.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | {{> icons/spinner}} 6 |
7 |
8 |
-------------------------------------------------------------------------------- /server/views/partials/settings/domain/index.hbs: -------------------------------------------------------------------------------- 1 |

2 | Custom domain 3 |

4 |

5 | You can set a custom domain for your short URLs, so instead of 6 | {{default_domain}}/shorturl you can have 7 | yoursite.com/shorturl. 8 |

9 | 10 | {{#if server_cname_address}} 11 |

12 | Point your domain's A record to 13 | {{#if server_ip_address}} 14 | {{server_ip_address}} 15 | {{else}} 16 | our IP address 17 | {{/if}} or your subdomain's CNAME record to 18 | {{server_cname_address}}. If you're using Cloudflare, 19 | make sure to use DNS only mode for your subdomain. 20 |

21 |

Then, add the domain via the form below:

22 | {{else}} 23 |

24 | Point your domain's A record to 25 | {{#if server_ip_address}} 26 | {{server_ip_address}} 27 | {{else}} 28 | our IP address 29 | {{/if}} 30 | then add the domain via the form below: 31 |

32 | {{/if}} 33 | 34 | {{> settings/domain/table}} 35 |
36 | 48 | {{> icons/spinner}} 49 |
50 |
51 |
52 | {{> settings/domain/dialog}} -------------------------------------------------------------------------------- /server/views/partials/settings/domain/table.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{#if domains}} 11 | {{#each domains}} 12 | 13 | 16 | 19 | 32 | 33 | {{/each}} 34 | {{else}} 35 | 36 | 39 | 40 | {{/if}} 41 | 42 |
DomainHomepage
14 | {{address}} 15 | 17 | {{homepage}} 18 | 20 | 31 |
37 | No domains yet. 38 |
-------------------------------------------------------------------------------- /server/views/partials/shortener.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{#if link}} 4 |
5 | 13 | {{> icons/check}} 14 |
15 |

20 | {{link}} 21 |

22 | {{/if}} 23 | {{#unless link}} 24 |

Cut your links shorter.

25 | {{/unless}} 26 |
27 |
35 |
36 | 46 | 50 | {{#if errors.target}}

{{errors.target}}

{{/if}} 51 | {{#unless errors}} 52 | {{#if error}} 53 |

{{error}}

54 | {{/if}} 55 | {{/unless}} 56 |
57 | 66 |
67 |
68 | 87 | 99 | 111 |
112 |
113 | 124 | 135 |
136 |
137 |
138 |
-------------------------------------------------------------------------------- /server/views/partials/stats.hbs: -------------------------------------------------------------------------------- 1 | {{#if error}} 2 |
3 |

{{> icons/x}} {{error}}

4 | 9 |
10 | {{else}} 11 |
12 |

13 | Stats for: 14 | 15 | {{link.link.link}} 16 | 17 |

18 |

{{link.target}}

19 |
20 |
21 |
22 |

23 | Total views: {{link.visit_count}} 24 |

25 | 31 |
32 | 33 |
34 |

{{stats.lastDay.total}} tracked visits in the last day.

35 | 36 | 37 | 38 |

Last update at .

39 | 40 | 41 | 42 | 43 |
44 |
45 |
46 |

Referrers.

47 | 48 | 49 | 50 | 51 |
52 |
53 |

Browsers.

54 | 55 | 56 | 57 | 58 |
59 |
60 |
61 |
62 |
63 |

Countries.

64 |
65 | 79 | {{#each map.layers}} 80 | 81 | {{/each}} 82 | 83 |
84 |
85 |

Operating systems.

86 | 87 | 88 | 89 | 90 |
91 |
92 |
93 |
94 | 95 | 100 | {{/if}} -------------------------------------------------------------------------------- /server/views/partials/support_email.hbs: -------------------------------------------------------------------------------- 1 | {{email}} -------------------------------------------------------------------------------- /server/views/protected.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

4 | Protected link. 5 |

6 |

7 | Enter the password to be redirected to the link. 8 |

9 | {{> protected/form}} 10 |
11 | {{> footer}} -------------------------------------------------------------------------------- /server/views/report.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

4 | Report abuse. 5 |

6 |

7 | Report abuses, malware and phishing links to the email address below {{#if mail_enabled}}or use the form{{/if}}. 8 | We will review as soon as we can. 9 |

10 | {{> report/email}} 11 | {{#if mail_enabled}} 12 | {{> report/form}} 13 | {{/if}} 14 |
15 | {{> footer}} -------------------------------------------------------------------------------- /server/views/reset_password.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

4 | Reset password. 5 |

6 |

7 | If you forgot you password you can use the form below to get a reset 8 | password link. 9 |

10 | {{> reset_password/request_form}} 11 |
12 | {{> footer}} -------------------------------------------------------------------------------- /server/views/reset_password_set_new_password.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
6 | {{#if token_verified}} 7 |

8 | Reset password. 9 |

10 |

Set your new password.

11 | {{> reset_password/new_password_form}} 12 | {{else}} 13 |

14 | {{> icons/x}} 15 | Password token is invalid. Please try again. 16 |

17 | Reset password → 18 | {{/if}} 19 |
20 | {{> footer}} -------------------------------------------------------------------------------- /server/views/settings.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

4 | Welcome, {{user.email}}. 5 |

6 |
7 | {{> settings/domain/index}} 8 |
9 | {{> settings/apikey}} 10 |
11 | {{> settings/change_password}} 12 |
13 | {{#if mail_enabled}} 14 | {{> settings/change_email}} 15 |
16 | {{/if}} 17 | {{> settings/delete_account}} 18 |
19 | {{> footer}} -------------------------------------------------------------------------------- /server/views/stats.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
16 |
17 | {{> icons/spinner}} 18 | Loading stats... 19 |
20 |
21 | {{> footer}} 22 | {{#extend "scripts"}} 23 | 24 | 25 | {{/extend}} -------------------------------------------------------------------------------- /server/views/terms.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

{{default_domain}} Terms of Service

4 |

5 | By accessing the website at 6 | https://{{default_domain}}, you are agreeing to be bound by these terms of service, all applicable 7 | laws and regulations, and agree that you are responsible for compliance 8 | with any applicable local laws. If you do not agree with any of these 9 | terms, you are prohibited from using or accessing this site. The 10 | materials contained in this website are protected by applicable 11 | copyright and trademark law. 12 |

13 |

14 | In no event shall {{site_name}} or its suppliers be 15 | liable for any damages (including, without limitation, damages for loss 16 | of data or profit, or due to business interruption) arising out of the 17 | use or inability to use the materials on 18 | {{default_domain}} website, even if 19 | {{site_name}} or a {{site_name}} 20 | authorized representative has been notified orally or in writing of the 21 | possibility of such damage. Because some jurisdictions do not allow 22 | limitations on implied warranties, or limitations of liability for 23 | consequential or incidental damages, these limitations may not apply to 24 | you. 25 |

26 |

27 | The materials appearing on {{site_name}} website could 28 | include technical, typographical, or photographic errors. 29 | {{site_name}} does not warrant that any of the 30 | materials on its website are accurate, complete or current. 31 | {{site_name}} may make changes to the materials 32 | contained on its website at any time without notice. However 33 | {{site_name}} does not make any commitment to update 34 | the materials. 35 |

36 |

37 | {{site_name}} has not reviewed all of the sites linked 38 | to its website and is not responsible for the contents of any such 39 | linked site. The inclusion of any link does not imply endorsement by 40 | {{site_name}} of the site. Use of any such linked 41 | website is at the "user's" own risk. 42 |

43 |

44 | {{site_name}} may revise these terms of service for 45 | its website at any time without notice. By using this website you are 46 | agreeing to be bound by the then current version of these terms of 47 | service. 48 |

49 |
50 | {{> footer}} -------------------------------------------------------------------------------- /server/views/url_info.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 |

Target for {{link}}:

4 |

{{target}}

5 |
6 | {{> footer}} -------------------------------------------------------------------------------- /server/views/verify.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 | {{#if token_verified}} 4 |

5 | Your account has been verified. Redirecting to homepage... 6 |

7 | {{else}} 8 |

9 | {{> icons/x}} 10 | Invalid verification. Please try again. 11 |

12 | Log in / sign up → 13 | {{/if}} 14 |
15 | {{> footer}} -------------------------------------------------------------------------------- /server/views/verify_change_email.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 |
3 | {{#if token_verified}} 4 |

5 | Email address is verified. Redirecting to homepage... 6 |

7 | {{else}} 8 |

9 | {{> icons/x}} 10 | Couldn't verify the email address. Please try again. 11 |

12 | {{#if user}} 13 | Settings → 14 | {{else}} 15 | Log in / sign up → 16 | {{/if}} 17 | {{/if}} 18 |
19 | {{> footer}} -------------------------------------------------------------------------------- /static/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt/c2f6c7fc201c9c6a701d79aeef4093d9041834bc/static/.DS_Store -------------------------------------------------------------------------------- /static/fonts/nunito-variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt/c2f6c7fc201c9c6a701d79aeef4093d9041834bc/static/fonts/nunito-variable.woff2 -------------------------------------------------------------------------------- /static/images/card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt/c2f6c7fc201c9c6a701d79aeef4093d9041834bc/static/images/card.png -------------------------------------------------------------------------------- /static/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt/c2f6c7fc201c9c6a701d79aeef4093d9041834bc/static/images/favicon-16x16.png -------------------------------------------------------------------------------- /static/images/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt/c2f6c7fc201c9c6a701d79aeef4093d9041834bc/static/images/favicon-196x196.png -------------------------------------------------------------------------------- /static/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt/c2f6c7fc201c9c6a701d79aeef4093d9041834bc/static/images/favicon-32x32.png -------------------------------------------------------------------------------- /static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt/c2f6c7fc201c9c6a701d79aeef4093d9041834bc/static/images/favicon.ico -------------------------------------------------------------------------------- /static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt/c2f6c7fc201c9c6a701d79aeef4093d9041834bc/static/images/logo.png -------------------------------------------------------------------------------- /static/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kutt", 3 | "short_name": "Kutt", 4 | "theme_color": "#f3f3f3", 5 | "background_color": "#f3f3f3", 6 | "display": "standalone", 7 | "description": "Kutt.it is a free and open source URL shortener with custom domains and stats.", 8 | "Scope": "/", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "images/icons/icon-72x72.png", 13 | "sizes": "72x72", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "images/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "images/icons/icon-128x128.png", 23 | "sizes": "128x128", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "images/icons/icon-144x144.png", 28 | "sizes": "144x144", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "images/icons/icon-152x152.png", 33 | "sizes": "152x152", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "images/icons/icon-192x192.png", 38 | "sizes": "192x192", 39 | "type": "image/png" 40 | }, 41 | { 42 | "src": "images/icons/icon-384x384.png", 43 | "sizes": "384x384", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "images/icons/icon-512x512.png", 48 | "sizes": "512x512", 49 | "type": "image/png" 50 | } 51 | ], 52 | "splash_pages": null 53 | } -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | --------------------------------------------------------------------------------