21 Commits

Author SHA1 Message Date
d7f4006698 feat: add link creation and lookup
All checks were successful
Build and push Docker image / build (push) Successful in 2m50s
Release new version / release (push) Successful in 26s
Update changelog / changelog (push) Successful in 25s
finally has the bare minimum functionality to say that it works!
2026-01-03 10:51:59 +01:00
c19a098b1c feat: add sample endpoint to test JWT 2026-01-03 04:37:20 +01:00
ec5cedce5a fix: actually support JWT bearer authentication 2026-01-03 00:29:45 +01:00
4bf39c7fdf chore: offload retrieval of environment variables from jwt.ts to env.ts 2026-01-02 22:57:03 +01:00
f86630c51e feat: add link generation support (short/sentence links) + wordlist
All checks were successful
Update changelog / changelog (push) Successful in 26s
2026-01-02 17:50:52 +01:00
f0ca4e897c fix: fix CORS import
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-30 18:44:31 +01:00
6ad9c6fd61 feat: add CORS support with user-sourced trusted origins from .env
All checks were successful
Update changelog / changelog (push) Successful in 24s
2025-12-30 17:41:35 +01:00
7abf5bad09 fix: global key retrieval not converting snake_case to UPPER_SNAKE_CASE
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-30 17:00:05 +01:00
41f3b0f0f2 feat: major code refactor, add login and register endpoints with swagger
All checks were successful
Update changelog / changelog (push) Successful in 27s
2025-12-29 18:26:50 +01:00
3f225a1ecb fix: add revised migration
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-11 22:37:24 +01:00
bade2f9b86 feat: add 404 handling
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-09 12:49:55 +01:00
58460d988d chore: add new workflows (changelog, automatic releases)
All checks were successful
Update changelog / changelog (push) Successful in 26s
Docker images will now be built on every release
2025-12-09 12:32:38 +01:00
8fb05f2662 fix: use debian-based container for image building
All checks were successful
Build and push Docker image / build (push) Successful in 2m26s
fixes https://stackoverflow.com/a/75730731
2025-12-07 20:21:45 +01:00
2e325fe018 feat: refactor docker workflow; build both amd64 and arm64 docker images
Some checks failed
Build and push Docker image / build (push) Failing after 54s
2025-12-07 20:18:24 +01:00
52dbd158a9 fix: respect debug flag in .env for swagger endpoint generation
All checks were successful
Build and push Docker image / build (push) Successful in 22s
2025-12-04 17:15:20 +01:00
f897a3e4ce chore: move docker-compose to main repo
All checks were successful
Build and push Docker image / build (push) Successful in 27s
2025-12-04 15:33:54 +01:00
bf6fb81a73 fix: add .dockerignore to not build containers with .env secrets
All checks were successful
Build and push Docker image / build (push) Successful in 24s
2025-12-03 00:19:25 +01:00
7bb2dd5434 fix: typo in Gitea workflow 2
All checks were successful
Build and push Docker image / build (push) Successful in 22s
2025-12-02 23:17:10 +01:00
fa5932d270 fix: typo in Gitea workflow
Some checks failed
Build and push Docker image / build (push) Failing after 1s
2025-12-02 23:11:17 +01:00
09f0d3ce1f feat: add Gitea workflow for Docker image building
Some checks failed
Build and push Docker image / build (push) Failing after 22s
2025-12-02 23:04:19 +01:00
6b2f93b962 feat: add a Dockerfile for image building 2025-12-02 22:59:21 +01:00
37 changed files with 9753 additions and 132 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.env
*/.env
*.md

View File

@@ -1,3 +1,7 @@
# Server config
ACCESS_TOKEN_PRIVATE_KEY=CHANGE_ME_TO_SOMETHING_RANDOM # Used to generate user tokens. Make sure this is pretty random.
TRUSTED_ORIGINS=http://localhost:6568 # Comma separated list of trusted origins. Make sure to include your PUBLIC_URL here.
# TypeORM specific
# Please make sure these match with docker-compose.yml, or your own postgres server.
PG_USER=kitty

View File

@@ -0,0 +1,58 @@
# Credits: https://www.vanmeeuwen.dev/blog/automating-docker-builds-with-gitea-actions
# https://gitea.com/gitea/runner-images/src/branch/main/.gitea/workflows/release.yaml
name: Build and push Docker image
run-name: Build ${{ github.ref_name }} image
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v2
with:
registry: gitea.7o7.cx
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set outputs
id: vars
env:
BRANCH_NAME: ${{ github.ref_name }}
run: |
#echo branch_name="$BRANCH_NAME" | tee -a $GITHUB_OUTPUT
#echo short_hash=$(git rev-parse --short HEAD) | tee -a $GITHUB_OUTPUT
echo current_tag=$GITHUB_REF_NAME | tee -a $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: |
linux/amd64
linux/arm64
pull: true
push: true
# Change when working on a different branch!
tags: |
gitea.7o7.cx/kittyteam/kittybe:master-${{ steps.vars.outputs.current_tag }}
gitea.7o7.cx/kittyteam/kittybe:latest
- name: Log out from registry
if: always()
run: docker logout gitea.7o7.cx

View File

@@ -0,0 +1,35 @@
# Credits: # https://gitea.com/gitea/runner-images/src/branch/main/.gitea/workflows/release.yaml
name: Release new version
run-name: Release ${{ github.ref_name }}
on:
push:
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
container: docker.io/thegeeklab/git-sv:2.0.9
steps:
- name: Install tools
run: |
apk add -q --update --no-cache nodejs
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-tags: true
fetch-depth: 0
- name: Create changelog
run: |
git sv current-version
git sv release-notes -t ${GITHUB_REF#refs/tags/} -o CHANGELOG.md
sed -i '1,2d' CHANGELOG.md # remove version
cat CHANGELOG.md
- name: Release
uses: https://github.com/akkuman/gitea-release-action@v1
with:
body_path: CHANGELOG.md
token: "${{ secrets.REPO_RW_TOKEN }}"

View File

@@ -0,0 +1,37 @@
# Credit: https://gitea.com/gitea/helm-gitea/src/branch/main/.gitea/workflows/changelog.yml
name: Update changelog
run-name: Update changelog on push
on:
push:
branches:
- master
tags:
- "*"
jobs:
changelog:
runs-on: ubuntu-latest
container: docker.io/thegeeklab/git-sv:2.0.9
steps:
- name: Install tools
run: |
apk add -q --update --no-cache nodejs curl jq sed
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate upcoming changelog
run: |
git sv rn -o changelog.md
export RELEASE_NOTES=$(cat changelog.md)
export ISSUE_NUMBER=$(curl -s -H 'Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}' "https://gitea.7o7.cx/api/v1/repos/kittyteam/kittyBE/issues?state=open&q=Changelog%20for%20upcoming%20version" | jq '.[].number')
echo $RELEASE_NOTES
JSON_DATA=$(echo "" | jq -Rs --arg title 'Changelog for upcoming version' --arg body "$(cat changelog.md)" '{title: $title, body: $body}')
if [ -z "$ISSUE_NUMBER" ]; then
curl -s -X POST "https://gitea.7o7.cx/api/v1/repos/kittyteam/kittyBE/issues" -H "Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}" -H "Content-Type: application/json" -d "$JSON_DATA" > /dev/null
else
curl -s -X PATCH "https://gitea.7o7.cx/api/v1/repos/kittyteam/kittyBE/issues/$ISSUE_NUMBER" -H "Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}" -H "Content-Type: application/json" -d "$JSON_DATA" > /dev/null
fi

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ temp/
# .env
.env
# wordlist
src/tools/wordlist.ts

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Credit: https://www.digitalocean.com/community/tutorials/how-to-build-a-node-js-application-with-docker
FROM node:24-trixie-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
FROM node:24-trixie-slim AS production
WORKDIR /app
RUN addgroup --gid 1001 nodejs && \
adduser --gid 1001 --uid 1001 nodejs
COPY --from=builder --chown=nodejs:nodejs /app /app
USER nodejs
EXPOSE 6567
CMD ["npm", "run", "server"]

View File

@@ -1,12 +0,0 @@
# This file will likely be moved into the main repo soon.
services:
postgres:
image: "postgres:17.2"
ports:
- "5432:5432"
environment:
POSTGRES_USER: "kitty"
POSTGRES_PASSWORD: "CHANGEME"
POSTGRES_DB: "kittyurl"

616
package-lock.json generated
View File

@@ -1,23 +1,32 @@
{
"name": "kittyBE",
"version": "0.0.1",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kittyBE",
"version": "0.0.1",
"version": "0.0.0",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.17.21",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"typeorm": "0.3.27"
"typeorm": "0.3.27",
"zod": "^4.2.1"
},
"devDependencies": {
"@types/node": "^22.19.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.21",
"@types/node": "^22.19.3",
"nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}
@@ -181,22 +190,145 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -269,6 +401,20 @@
"node": ">=14"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/app-root-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz",
@@ -332,6 +478,19 @@
],
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
@@ -365,6 +524,19 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -389,6 +561,12 @@
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -451,6 +629,31 @@
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -611,6 +814,19 @@
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -749,6 +965,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -875,6 +1100,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@@ -947,6 +1185,21 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1022,6 +1275,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1034,6 +1300,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -1141,6 +1417,13 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true,
"license": "ISC"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -1167,6 +1450,19 @@
"node": ">= 0.10"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -1179,6 +1475,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -1188,6 +1494,29 @@
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1248,6 +1577,55 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@@ -1255,6 +1633,18 @@
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
@@ -1262,12 +1652,42 @@
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -1375,6 +1795,78 @@
"node": ">= 0.6"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
"integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/nodemon/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/nodemon/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1563,6 +2055,19 @@
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -1624,6 +2129,13 @@
"node": ">= 0.10"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true,
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -1663,6 +2175,19 @@
"node": ">= 0.10"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -1720,6 +2245,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
@@ -1905,6 +2442,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -2035,6 +2585,19 @@
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
@@ -2148,6 +2711,19 @@
"node": ">= 0.4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2157,6 +2733,16 @@
"node": ">=0.6"
}
},
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"license": "ISC",
"bin": {
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -2363,6 +2949,13 @@
"node": ">=14.17"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -2684,6 +3277,15 @@
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/zod": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -4,24 +4,33 @@
"description": "Your go-to place for short and memorable URLs.",
"type": "commonjs",
"devDependencies": {
"@types/node": "^22.19.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.21",
"@types/node": "^22.19.3",
"nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.17.21",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"typeorm": "0.3.27"
"typeorm": "0.3.27",
"zod": "^4.2.1"
},
"scripts": {
"start": "ts-node src/index.ts",
"start": "ts-node src/app.ts",
"server": "ts-node src/app.ts",
"typeorm": "typeorm-ts-node-commonjs",
"newMigration": "typeorm-ts-node-commonjs migration:generate -p -d ./src/data-source.ts",
"pendingMigration": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts",
"server": "ts-node src/app.ts"
"pendingMigration": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts"
}
}

View File

@@ -1,25 +1,137 @@
import * as express from 'express';
import router from './routes';
import express from 'express';
import { version } from '../package.json';
import { AppDataSource } from './data-source'
import { Link } from './entities/Link';
import inferUser from './middleware/inferUser';
import miscRouter from './routes/miscRoutes';
import userRouter from './routes/userRoutes';
import linkRouter from './routes/linkRoutes';
import { getCorsConfig } from './tools/cors';
import { LinkService } from './services/linkService';
import * as env from './tools/env';
import * as z from 'zod';
const app = express();
AppDataSource.initialize().then(async () => {
const swaggerJsdocOpts = {
failOnErrors: true,
definition: {
openapi: '3.0.0',
info: {
title: 'kittyurl',
version: '0.0.1'
await AppDataSource.runMigrations();
const app: express.Express = express();
const linkService = new LinkService();
const rs = env.getRewriteStrings();
const removedExpired = await linkService.removeAllExpired();
if (removedExpired !== 0) console.log(`[${Date.now() / 1_000}] (DB) Removed ${removedExpired} expired links.`);
app.use(express.json());
app.use(getCorsConfig());
app.use(inferUser);
app.use(miscRouter, userRouter, linkRouter);
if (env.getBool('debug', true)) {
const swaggerJsdocOpts = {
failOnErrors: true,
definition: {
openapi: '3.0.4',
info: {
title: 'kittyurl API',
description: 'A Typescript API for the kittyurl url shortener project.',
version: version,
contact: {
name: 'Git repository for entire project',
url: 'https://gitea.7o7.cx/kittyteam/kittyurl'
},
license: {
name: 'AGPLv3',
url: 'https://www.gnu.org/licenses/agpl-3.0.en.html'
}
},
components: {
securitySchemes: {
BearerJWT: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Authorization header using the Bearer scheme.<br/>Enter your JWT from /api/v1/user/signIn to authorize.'
}
}
},
security: [
{ BearerJWT: [] }
],
},
apis: ['./src/routes/*.ts', './src/schemas/*.ts']
};
const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
app.use('/kttydocs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.get('/kttydocs.json', (req: express.Request, res: express.Response) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
}
// Handle 404s
// https://stackoverflow.com/a/9802006
app.use(async function(req: express.Request, res: express.Response) {
// Check if host header seems right
try {
z.string()
.includes(rs.fqdn)
.parse(req.headers.host);
} catch {
return res.status(400)
.json({
status: 'error',
error: 'Invalid host. Is your browser sending the host header?',
code: 'no_host'
});
}
},
apis: ['./src/routes/*.ts']
};
const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
app.use(express.json());
app.use(router);
app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
// Retrieve url, subdomain from request.
let uri: string = req.url.slice(1); // discards / from /abc, /abc -> abc
let subdomain: string | null = req.headers.host!.replace(rs.fqdn, '') || null;
// Try to lookup the url in DB
const reversedLink: Link | null = await linkService.lookupUriWithExpiryValidation(uri, subdomain);
// Found something?
if (reversedLink !== null) {
// Count this as a visit
reversedLink.visits += 1;
linkService.save(reversedLink);
// Redirect the user.
return res.redirect(302, reversedLink.fullUrl);
}
// Nothing found? Return the standard 404.
res.status(404);
if (req.accepts('json')) {
return res.json({
status: 'error',
error: 'Not found',
code: 'uri_not_found'
});
}
return res.type('txt')
.send('Not found');
});
const errorHandler: express.ErrorRequestHandler = (err, req, res, next) => {
console.error(`[${Date.now() / 1_000}] (ErrorHandler) Server error! Error stack:`);
console.error(err?.stack);
return res.status(500)
.json({
status: 'error',
error: 'Server error! Something broke',
code: 'generic_server_error'
});
};
app.use(errorHandler);
app.listen(6567, () => console.log(`[${Date.now() / 1_000}] (HTTP Server) Listening on port 6567.`));
}).catch(error => console.log(error))

View File

@@ -0,0 +1,230 @@
import { Request, Response } from 'express';
import { User } from '../entities/User';
import * as ms from '../schemas/miscSchema';
import * as as from '../schemas/authSchema';
import { UserService } from '../services/userService';
import { generateSha512 } from '../tools/hasher';
import * as jwt from '../tools/jwt';
/**
* `POST /api/v1/user/signIn`
*
* Handles requests for user logon
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function loginUserHandler(
req: Request<{}, {}, as.LoginRequestDTO['body']>,
res: Response
) {
const userService: UserService = new UserService();
const requestBody = req.body;
// Compare against what exists in the DB
let existingUser: User | null = await userService.findByCredentials({
name: requestBody.name
});
if (existingUser === null) {
// User not found? Return an error.
const error: ms.ErrorDTO = {
status: 'error',
error: 'User does not exist'
};
return res.status(404)
.json(error);
}
// For the reasoning behind this, check the long
// comment inside of createUserHandler().
// TL;DR: This is a fail-safe in case of
// a server shutdown mid user creation.
if (existingUser.dirtyPasswordHashBit) {
existingUser.passwordHash = generateSha512(`${existingUser.id}:${existingUser.passwordHash}`);
existingUser.dirtyPasswordHashBit = false;
userService.save(existingUser);
}
// Compute salted SHA512
const passwordHashed: string = generateSha512(`${existingUser.id}:${requestBody.password}`);
if (passwordHashed !== existingUser.passwordHash) {
// User not found? Return an error.
const error: ms.ErrorDTO = {
status: 'error',
error: 'Username <-> password pair not found'
};
return res.status(404)
.json(error);
}
// User found? Generate a JWT.
const token = jwt.signJwt(
{ sub: existingUser.id, role: existingUser.role },
'accessTokenPrivateKey',
{ expiresIn: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME }
);
// Respond with UserInfoDTO
const userResponse: as.UserInfoDTO = {
status: 'ok',
name: requestBody.name,
role: existingUser.role,
token: token,
ttl: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME
}
return res.status(200)
.send(userResponse);
}
/**
* `POST /api/v1/user/signUp`
*
* Handles requests for user registration
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function createUserHandler(
req: Request<{}, {}, as.LoginRequestDTO['body']>,
res: Response
) {
// Ideally, there would be a registration DTO which would incorporate
// some sort of CAPTCHA or CSRF token.
const userService = new UserService();
const requestBody = req.body;
// Compare against what exists in the DB
let usersFound: number = await userService.countAll(); // CAN THIS BE REPLACED WITH userService.lastId()??????
let role: number = 0; // Let the default role be a standard user.
// No users found? Make the new (and only) user an Administrator.
if (usersFound == 0)
role = 1;
// Users found? The new user should NOT be an Administrator.
// Since role by default is 0 (non-admin), nothing needs to change.
// Check if user already exists. If yes, return an error.
let existingUserId: number = await userService.findIdByCredentials({
name: requestBody.name
});
if (existingUserId >= 0) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'User already exists'
};
return res.status(403)
.json(error);
}
// Otherwise we're free to add him/her.
let user: User = {
id: undefined,
name: requestBody.name,
passwordHash: requestBody.password,
dirtyPasswordHashBit: true,
role: role,
createdAt: Date.now(),
links: []
};
await userService.add(user);
// Note how we're setting the retrieved password as the password hash,
// without hashing it first. This is because we don't know ahead of time
// what id will the user have (which we could use to salt the password).
// We also could hash the password with a user's name,
// but then we would either need to deem username unchangeable,
// or prompt the user for password once he/she wants to change his/her name.
// Out of the three not ideal solutions, the first has been incorporated,
// thus we add the user, then retrieve his/her id, to then salt user's
// password with it later. This requires setting a "dirty bit" and storing
// the password as received, so in a way in "plain text". Then recalculate
// the password and remove the dirty bit here, or, in a miniscule case if
// it so happens the server crashes or gets restarted while registering a user,
// the password will be recalculated on login attempt if dirty bit is set.
// TODO: explore alternative options with queryRunner's transactions?
// Check if user added successfully.
// If so, recalculate the salted hash.
const insertedUser: User | null = await userService.findByCredentials({
name: requestBody.name
});
// Return an error if - for whatever reason - the user has not been added.
if (insertedUser === null) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'Could not add a new user',
code: 'server_error'
}
return res.status(500)
.json(error);
}
// Rewrite the user's password to use the salted hash
const passwordHashed: string = generateSha512(`${insertedUser.id}:${requestBody.password}`);
insertedUser.passwordHash = passwordHashed;
insertedUser.dirtyPasswordHashBit = false;
await userService.save(insertedUser);
// Generate a JWT.
const token = jwt.signJwt(
{ sub: insertedUser.id, role: role },
'accessTokenPrivateKey',
{ expiresIn: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME }
);
// Respond with UserInfoDTO
const userResponse: as.UserInfoDTO = {
status: 'ok',
name: requestBody.name,
role: role,
token: token,
ttl: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME
}
return res.status(201)
.send(userResponse);
}
/**
* `GET /api/v1/user/account`
*
* Handles requests for user info retrieval
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function getUserHandler(
req: Request,
res: Response
) {
const userService = new UserService();
const user: jwt.JwtDecoded = res.locals.user.decoded;
// Get detailed data from DB
let existingUser: User | null = await userService.findById(user.sub);
if (existingUser === null) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'User does not exist',
code: 'deleted_user'
};
return res.status(404)
.json(error);
}
// Respond with ShortUserInfoDTO
const userResponse: as.ShortUserInfoDTO = {
status: 'ok',
id: existingUser.id!,
name: existingUser.name,
role: existingUser.role
}
return res.status(201)
.send(userResponse);
}

View File

@@ -0,0 +1,205 @@
import { Request, Response } from 'express';
import { Link } from '../entities/Link';
import { User } from '../entities/User';
import * as ms from '../schemas/miscSchema';
import * as ls from '../schemas/linkSchema';
import { LinkService, IdResponse } from '../services/linkService';
import { UserService } from '../services/userService';
import * as env from '../tools/env';
import * as jwt from '../tools/jwt';
import { generateSentenceString, generateShortString } from '../tools/wordlist';
/**
* `GET /api/v1/link/short`
*
* Handles requests for short link generation
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function generateShortLinkHandler(
req: Request<{}, {}, {}, ls.ShortLinkRequestDTO['query']>,
res: Response
) {
// Using locals here, as Request stores all parsed data
// in strings for queries (QueryString.ParsedQs).
const val = res.locals.validated as ls.ShortLinkRequestDTO;
let generatedShortString: string = generateShortString(
val.query[ 'length'] ?? 9,
val.query['alphanum'] ?? true,
val.query[ 'case'] ?? null
);
let generatedSubdomain: string | null = null;
if (val.query['withSubdomain'] === true && env.getString('useSubdomains', true) === 'true')
generatedSubdomain = generateSentenceString('[subdomain]');
const userResponse: ls.LinkResponseDTO = {
status: 'ok',
uri: generatedShortString,
subdomain: generatedSubdomain
};
return res.status(200)
.send(userResponse);
}
/**
* `GET /api/v1/link/fromWordlist`
*
* Handles requests for pseudo-sentence link generation
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function generateSentenceLinkHandler(
req: Request<{}, {}, {}, ls.SentenceLinkRequestDTO['query']>,
res: Response
) {
// Using locals here, as Request stores all parsed data
// in strings for queries (QueryString.ParsedQs).
const val = res.locals.validated as ls.SentenceLinkRequestDTO;
let generatedSentenceString: string = generateSentenceString();
let generatedSubdomain: string | null = null;
if (val.query['withSubdomain'] === true && env.getString('useSubdomains', true) === 'true')
generatedSubdomain = generateSentenceString('[subdomain]');
const userResponse: ls.LinkResponseDTO = {
status: 'ok',
uri: generatedSentenceString,
subdomain: generatedSubdomain
};
return res.status(200)
.send(userResponse);
}
/**
* `POST /api/v1/link/new`
*
* Handles requests for submitting a new shortened URL
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function createLinkHandler(
req: Request<{}, {}, ls.CreateLinkRequestDTO['body']>,
res: Response
) {
// Using locals to retrieve decoded user JWT.
const decodedUser: jwt.JwtDecoded | undefined = res.locals.user?.decoded;
const linkService = new LinkService();
const subdomainsAllowed: boolean = env.getBool('useSubdomains', true)!;
const rewriteStrings: env.RewriteStrings = env.getRewriteStrings();
// Sanity check: does the uri start with a forbidden schema?
const disallowedResult = ms.disallowedUriSchema.safeParse(req.body.uri);
// Uri is part of forbidden schema
if (disallowedResult.success) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'This uri starts with a forbidden keyword',
code: 'forbidden_schema'
}
return res.status(406)
.send(error);
}
let user: User | null = null;
if (decodedUser !== undefined) {
// If user is logged in, retrieve the account.
const userService = new UserService();
user = await userService.findById(decodedUser.sub);
}
let generatedSubdomain: string | null = null;
// Subdomain passed, but isn't supported? Return an error.
if (req.body.subdomain !== undefined && !subdomainsAllowed) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'Server configuration disallows usage of subdomain',
code: 'server_subdomain_disabled'
};
return res.status(400)
.send(error);
}
// Subdomain passed, and server config allows it? Then use it.
else if (req.body.subdomain !== undefined && subdomainsAllowed)
generatedSubdomain = req.body.subdomain;
// Similarly, check if expiry date has been passed.
let expiryDate: number | null = null;
if (req.body.expiryDate !== undefined)
expiryDate = req.body.expiryDate;
// Construct the link
const createDate = Date.now();
const link: Link = {
id: 0, // Can we? Seems like so.
subdomain: generatedSubdomain,
shortUri: req.body.uri,
fullUrl: req.body.remoteUrl,
createDate,
expiryDate,
visits: 0,
privacy: req.body.privacy ?? true,
author: user
};
// Try to add the row
let returnedId: IdResponse | null = null;
if (user === null) {
// Add anonymously
returnedId = await linkService.addIfNewAnonymous(link);
} else {
// Use a transaction to add the link,
// and link it to the user.
returnedId = await linkService.addIfNew(link, user);
}
// Failed (short uri + if enabled, subdomain combo is taken)?
if (returnedId.exists && returnedId.id == -1) {
console.log(returnedId);
const error: ms.ErrorDTO = {
status: 'error',
error: `"${req.body.uri}" is already taken. Maybe try "${generateSentenceString()}"?`,
code: 'uri_not_unique'
};
return res.status(403)
.send(error);
}
// Some other, unknown error occurred.
if (returnedId.id < 0) {
console.log(returnedId);
const error: ms.ErrorDTO = {
status: 'error',
error: 'Server error',
code: 'generic_error'
};
return res.status(500)
.send(error);
}
// If we've arrived this far, seems like it's safe
// to assume everything went OK.
const rs = rewriteStrings;
const sd = req.body.subdomain;
const shortenedUrl = `${rs.proto}://${sd ? sd + '.' : ''}${rs.fqdn}${rs.path}${req.body.uri}`;
const userResponse: ls.CreateLinkResponseDTO = {
status: 'ok',
uri: shortenedUrl,
id: returnedId.id
};
return res.status(200)
.send(userResponse);
}

View File

@@ -1,12 +1,12 @@
import "reflect-metadata"
import { DataSource } from "typeorm"
import "reflect-metadata";
import { DataSource } from "typeorm";
import * as dotenv from "dotenv";
dotenv.config();
dotenv.config({ quiet: true });
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.PG_HOST ?? "localhost",
port: parseInt(process.env.PG_PORT, 10) || 5432,
port: parseInt(process.env.PG_PORT!, 10) || 5432,
username: process.env.PG_USER ?? "kitty",
password: process.env.PG_PASS ?? "CHANGEME", // Please change your password inside of the .env file!
database: process.env.PG_DB ?? "kittyurl",

View File

@@ -1,31 +1,49 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from "typeorm"
import { User } from "./User"
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from 'typeorm'
import { User } from './User'
@Entity("links")
export class Link {
// Unique link id.
@PrimaryGeneratedColumn()
id: number
id: number;
@Column({ nullable: true })
subdomain: string | null
// Experimental: subdomain which should be a part of the short url.
// For instance in the URL "abc.example.com/def", abc is the subdomain.
// "def.example.com/def" won't resolve to the URL that "abc.example.com/def" does.
// https://stackoverflow.com/a/67535817
@Column({ type: 'varchar', nullable: true })
subdomain: string | null;
// Shortened Uri.
@Column()
shortUri: string
shortUri: string;
// URL to which the user should be redirected
@Column()
fullUrl: string
@Column()
role: string
fullUrl: string;
// Unix timestamp of link creation date.
@Column('bigint')
createDate: number
createDate: number;
// Unix timestamp of when the link should expire.
// If null, the link will never expire unless deleted.
@Column('bigint', { nullable: true })
expiryDate: number | null;
// Aggregated amount of visits.
@Column('bigint')
expiryDate: number
visits: number;
// Link privacy:
// - true, if link is private
// - false, if link can be shown in a list of recent links publicly.
@Column()
privacy: boolean;
// User to which the shortened URL belongs.
@ManyToOne(() => User, (user) => user.links, { nullable: true })
@JoinTable()
author: User | null
author: User | null;
}

View File

@@ -1,24 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm"
import { Link } from "./Link"
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'
import { Link } from './Link'
@Entity("users")
export class User {
// Unique user id.
@PrimaryGeneratedColumn()
id: number
id: number | undefined; // Is this a good idea?
// User name, must be unique.
@Column({ unique: true })
name: string
name: string;
// Salted password hash.
@Column()
passwordHash: string
passwordHash: string;
// Used to tell, whether password hash should
// be recalculated (salted) on next login.
@Column()
role: string
dirtyPasswordHashBit: boolean;
// User role:
// - 0 - means unprivileged user,
// - 1 - means administrative user.
@Column()
role: number;
// Account creation date as a Unix timestamp.
@Column('bigint')
createdAt: number
createdAt: number;
// List of shortened URLs which belong to the user.
@OneToMany(() => Link, (link) => link.author)
links: Link[];
}

View File

@@ -1,25 +0,0 @@
import { AppDataSource } from "./data-source"
import { User } from "./entities/User"
import { smokeTest } from "./smoke-test";
AppDataSource.initialize().then(async () => {
// console.log("Inserting a new user into the database...");
// const user = new User();
// // user.firstName = "Timber";
// // user.lastName = "Saw";
// // user.age = 25;
// await AppDataSource.manager.save(user);
// console.log("Saved a new user with id: " + user.id);
// console.log("Loading users from the database...");
// const users = await AppDataSource.manager.find(User);
// console.log("Loaded users: ", users);
// console.log("Here you can setup and run express / fastify / any other framework.");
await AppDataSource.runMigrations();
await smokeTest(AppDataSource);
}).catch(error => console.log(error))

View File

@@ -0,0 +1,47 @@
// Heavily based on:
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/deserializeUser.ts
import { get } from 'lodash';
import { Request, Response, NextFunction } from 'express';
import * as jwt from '../tools/jwt';
const inferUser = async (
req: Request,
res: Response,
next: NextFunction
) => {
const accessToken = get(req, 'headers.authorization', '').replace(
/^Bearer\s/,
''
);
if (!accessToken) return next();
const token = jwt.verifyJwt(accessToken, 'accessTokenPrivateKey');
if (token) {
res.locals.user = token;
return next();
}
/*
// refresh token handling is not (yet) implemented
const refreshToken = get(req, 'headers.x-refresh');
if (expired && refreshToken) {
const newAccessToken = await reIssueAccessToken({ refreshToken });
if (newAccessToken) {
res.setHeader('x-access-token', newAccessToken);
}
const result = verifyJwt(newAccessToken as string, 'accessTokenPublicKey');
res.locals.user = result.decoded;
return next();
}
*/
return next();
};
export default inferUser;

View File

@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from "express";
import { ErrorDTO } from "../schemas/miscSchema";
import * as jwt from "../tools/jwt";
const requireUser = (req: Request, res: Response, next: NextFunction) => {
const user: jwt.JwtStatus = res.locals.user;
let error: ErrorDTO | null = null;
// No user? Something errored partway. Display an error.
if (!user)
error = {
status: 'error',
error: 'Unauthorized, please sign in',
code: 'unauthorized_generic'
};
// Check if token is expired first.
// This is because a token can be valid
// (if signature matches) while being expired.
else if (user.expired)
error = {
status: 'error',
error: 'Token expired, please sign in again',
code: 'expired_token'
};
// Previous checks failed?
// As a last resort, check if the token is valid.
else if (!user.valid)
error = {
status: 'error',
error: 'Invalid token, please sign in',
code: 'invalid_token'
};
if (error !== null)
return res.status(401)
.send(error);
return next();
};
export default requireUser;

View File

@@ -1,7 +1,7 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialMigration1764549652720 implements MigrationInterface {
name = 'InitialMigration1764549652720'
export class InitialMigration1767018509215 implements MigrationInterface {
name = 'InitialMigration1767018509215'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
@@ -10,9 +10,10 @@ export class InitialMigration1764549652720 implements MigrationInterface {
"subdomain" character varying,
"shortUri" character varying NOT NULL,
"fullUrl" character varying NOT NULL,
"role" character varying NOT NULL,
"createDate" bigint NOT NULL,
"expiryDate" bigint NOT NULL,
"expiryDate" bigint,
"visits" bigint NOT NULL,
"privacy" boolean NOT NULL,
"authorId" integer,
CONSTRAINT "PK_ecf17f4a741d3c5ba0b4c5ab4b6" PRIMARY KEY ("id")
)
@@ -22,7 +23,8 @@ export class InitialMigration1764549652720 implements MigrationInterface {
"id" SERIAL NOT NULL,
"name" character varying NOT NULL,
"passwordHash" character varying NOT NULL,
"role" character varying NOT NULL,
"dirtyPasswordHashBit" boolean NOT NULL,
"role" integer NOT NULL,
"createdAt" bigint NOT NULL,
CONSTRAINT "UQ_51b8b26ac168fbe7d6f5653e6cf" UNIQUE ("name"),
CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id")

View File

@@ -1,20 +0,0 @@
import { Router } from 'express';
const router = Router();
/**
* @swagger
*
* /:
* get:
* description: Hello world!
* tags: [Default]
* responses:
* 200:
* description: Returns "Hello world!"
*/
router.get('/', (req, res) => {
res.send("Hello world!");
});
export default router;

138
src/routes/linkRoutes.ts Normal file
View File

@@ -0,0 +1,138 @@
import { Router } from 'express';
import validateSchema from '../tools/validateSchema';
import * as lc from '../controllers/linkController';
import * as ls from '../schemas/linkSchema';
import requireUser from '../middleware/requireUser';
const linkRouter = Router();
/**
* @openapi
*
* /api/v1/link/short:
* get:
* description: Generates a new short link
* tags: [Link]
* summary: Get a new short link
* parameters:
* - name: length
* in: query
* description: generated URL's length
* required: false
* schema:
* type: integer
* format: int32
* default: 9
* minimum: 2
* maximum: 127
* - name: alphanum
* in: query
* description: whether to use numbers in generated URL
* required: false
* schema:
* type: boolean
* default: true
* - name: case
* in: query
* description: whether to use uppercase ("upper"), lowercase ("lower") or mixed case (default)
* schema:
* type: string
* - name: withSubdomain
* in: query
* description: whether to generate a subdomain too (will be generated only if supported by server)
* required: false
* schema:
* type: boolean
* default: false
* produces:
* - application/json
* responses:
* 200:
* description: Link generated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LinkResponseDTO'
* 400:
* description: Bad request
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
linkRouter.get('/api/v1/link/short', validateSchema(ls.shortLinkRequestSchema), lc.generateShortLinkHandler);
/**
* @openapi
*
* /api/v1/link/fromWordlist:
* get:
* description: Generates a new pseudosentence link from wordlist.
* tags: [Link]
* summary: Get a new "sentence" link
* parameters:
* - name: withSubdomain
* in: query
* description: whether to generate a subdomain too (will be generated only if supported by server)
* required: false
* schema:
* type: boolean
* default: false
* produces:
* - application/json
* responses:
* 200:
* description: Link generated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LinkResponseDTO'
* 400:
* description: Bad request
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
linkRouter.get('/api/v1/link/fromWordlist', validateSchema(ls.sentenceLinkRequestSchema), lc.generateSentenceLinkHandler);
/**
* @openapi
*
* /api/v1/link/new:
* post:
* description:
* Register a new shortened URL. <br/>
* See linkSchema.ts for constraints.
* tags: [Link]
* summary: Shorten a link
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateLinkRequestDTO'
* produces:
* - application/json
* responses:
* 200:
* description: New link created successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateLinkResponseDTO'
* 400:
* description: Bad request
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
linkRouter.post('/api/v1/link/new',
validateSchema(ls.createLinkRequestSchema),
lc.createLinkHandler
);
export default linkRouter;

31
src/routes/miscRoutes.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Router, Request, Response } from 'express';
const miscRouter = Router();
/**
* @openapi
*
* /:
* get:
* description: Hello world!
* tags: [Default]
* responses:
* 200:
* description: Returns "Hello world!"
*/
miscRouter.get('/', (req: Request, res: Response) => res.send("Hello world!"));
/**
* @openapi
* /healthcheck:
* get:
* tags: [Healthcheck]
* description: Provides a response when backend is running
* summary: Check if backend is running
* responses:
* 200:
* description: Backend is online
*/
miscRouter.get('/healthcheck', (req: Request, res: Response) => res.sendStatus(200));
export default miscRouter;

102
src/routes/userRoutes.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Router } from 'express';
import validateSchema from '../tools/validateSchema';
import * as ac from '../controllers/authController';
import * as as from '../schemas/authSchema';
import requireUser from '../middleware/requireUser';
const userRouter = Router();
/**
* @openapi
*
* /api/v1/user/signUp:
* post:
* description: Add user
* tags: [User]
* summary: Register a user
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginRequestDTO'
* produces:
* - application/json
* responses:
* 200:
* description: User created successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserInfoDTO'
* 400:
* description: Bad request
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
userRouter.post('/api/v1/user/signUp', validateSchema(as.loginRequestSchema), ac.createUserHandler);
/**
* @openapi
*
* /api/v1/user/signIn:
* post:
* description: Log in
* tags: [User]
* summary: Log in to an account
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginRequestDTO'
* produces:
* - application/json
* responses:
* 200:
* description: User logged in successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserInfoDTO'
* 400:
* description: Wrong password/non-existent user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
userRouter.post('/api/v1/user/signIn', validateSchema(as.loginRequestSchema), ac.loginUserHandler);
/**
* @openapi
*
* /api/v1/user/account:
* get:
* description: Get authenticated user info
* tags: [User]
* summary: "[AUTHED] Get user info"
* produces:
* - application/json
* responses:
* 200:
* description: User logged in successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ShortUserInfoDTO'
* 400:
* description: Wrong password/non-existent user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
userRouter.get('/api/v1/user/account',
requireUser,
ac.getUserHandler
);
export default userRouter;

112
src/schemas/authSchema.ts Normal file
View File

@@ -0,0 +1,112 @@
import z from 'zod';
// Maybe load this from .env? 24 hours in seconds.
export const DEFAULT_TOKEN_LIFETIME = 86_400;
/**
* @openapi
* components:
* schemas:
* LoginRequestDTO:
* type: object
* required:
* - name
* - password
* properties:
* name:
* type: string
* default: mail@example.com or username
* password:
* type: string
* default: sha512(Hunter2)
* ttl:
* type: number
* default: 86400 # 24 hours * 60 minutes * 60 seconds
*/
const loginRequestSchemaBody = z.object({
name: z.string({
error: (e) => e.input === undefined ? 'Name is required' : 'Name must be a string'
})
.min( 3, 'Name is too short (try something longer than 3 characters)')
.max(64, 'Name is too long (try something shorter than 64 characters)'),
password: z.hash('sha512', {
error: (e) => e.input === undefined ? 'Password is required' : 'Password must be a SHA512 hash'
}),
ttl: z.number('TTL must be a number between 120 and 2 592 000 seconds')
.min( 120, 'TTL is too short (try something longer than 120 seconds)') // 120s
.max(2_592_000, 'TTL is too long (try something shorter than 30 days)') // 30d
.optional()
});
// export type LoginRequestBodyDTO = z.TypeOf<typeof loginRequestSchemaBody>;
export const loginRequestSchema = z.object({
body: loginRequestSchemaBody
});
export type LoginRequestDTO = z.TypeOf<typeof loginRequestSchema>;
/**
* @swagger
* components:
* schemas:
* UserInfoDTO:
* type: object
* required:
* - status
* - name
* - role
* - token
* properties:
* status:
* type: string
* default: ok on success, otherwise ErrorDTO with error
* name:
* type: string
* default: username
* role:
* type: number
* default: 0 # 0 - standard user, 1 - administrator
* token:
* type: string
* default: JWT
* ttl:
* type: number
* default: 86400 # 24 hours * 60 minutes * 60 seconds
*/
export type UserInfoDTO = {
status: 'ok';
name: string;
role: number;
token: string;
ttl: number | null;
};
/**
* @swagger
* components:
* schemas:
* ShortUserInfoDTO:
* type: object
* required:
* - status
* - id
* - name
* - role
* properties:
* status:
* type: string
* default: ok on success, otherwise ErrorDTO with error
* id:
* type: number
* name:
* type: string
* default: username
* role:
* type: number
* default: 0 # 0 - standard user, 1 - administrator
*/
export type ShortUserInfoDTO = {
status: 'ok';
id: number;
name: string;
role: number;
};

145
src/schemas/linkSchema.ts Normal file
View File

@@ -0,0 +1,145 @@
import z from 'zod';
// GET /api/v1/link/short
const shortLinkRequestSchemaQuery = z.object({
// https://zod.dev/v4?id=stringbool
length: z.coerce
.number('Length must be a number')
.min( 3, 'Length is too small (try something longer than 3)')
.max(128, 'Length is too long (try something shorter than 128)')
.optional(),
alphanum: z.stringbool('Alphanum must be a boolean')
.optional(),
case: z.enum(['lower', 'upper'])
.optional(),
withSubdomain: z.stringbool('WithSubdomain must be a boolean')
.optional()
});
export const shortLinkRequestSchema = z.object({
query: shortLinkRequestSchemaQuery
});
export type ShortLinkRequestDTO = z.TypeOf<typeof shortLinkRequestSchema>;
// GET /api/v1/link/fromWordlist
const sentenceLinkRequestSchemaQuery = z.object({
// https://zod.dev/v4?id=stringbool
withSubdomain: z.stringbool('WithSubdomain must be a boolean')
.optional()
});
export const sentenceLinkRequestSchema = z.object({
query: sentenceLinkRequestSchemaQuery
});
export type SentenceLinkRequestDTO = z.TypeOf<typeof sentenceLinkRequestSchema>;
// response for both /api/v1/link/short and /api/v1/link/fromWordlist
/**
* @swagger
* components:
* schemas:
* LinkResponseDTO:
* type: object
* required:
* - status
* - uri
* - subdomain
* properties:
* status:
* type: string
* default: ok on success, otherwise ErrorDTO with error
* uri:
* type: string
* default: generated uri
* subdomain:
* type: string
* default: subdomain or null
*/
export type LinkResponseDTO = {
status: 'ok';
uri: string;
subdomain?: string | null; // null when server does not support generating subdomains
};
// POST /api/v1/link/short
/**
* @openapi
* components:
* schemas:
* CreateLinkRequestDTO:
* type: object
* required:
* - uri
* - remoteUrl
* properties:
* uri:
* type: string
* default: short uri
* remoteUrl:
* type: string
* default: source url
* subdomain:
* type: string
* default: optional subdomain
* privacy:
* type: boolean
* default: true # privacy by default
* expiryDate:
* type: number
* default: 1767413102824 # UNIX timestamp in ms, Date.now()
*/
const createLinkRequestSchemaBody = z.object({
uri: z.string({
error: (e) =>
e.input === undefined ? 'Uri is required' : '/, ?, &, = and & are not allowed'
}).min( 3, 'Shortened Uri must be at least 3 characters long')
.max(128, 'Shortened Uri cannot be longer than 128 characters')
.regex(/^[^\/?&=#]*$/),
remoteUrl: z.url({
error: (e) =>
e.input === undefined ? 'RemoteUrl is required' : 'RemoteUrl is not a valid URL'
}),
privacy: z.boolean({ error: 'Privacy must be a boolean (true by default)' })
.optional(),
subdomain: z.string('Subdomain must be a string of length between 1 and 32')
.min( 1, 'Subdomain should be at least 1 character long')
.max(32, 'Subdomain should not be longer than 32 characters')
.regex(/^[a-zA-Z0-9]*$/)
.optional(),
expiryDate: z.number('Expiry date must be a number (UNIX timestamp with milliseconds)')
.min(Date.now(), 'Expiry date is a UNIX timestamp with milliseconds')
.optional()
});
export const createLinkRequestSchema = z.object({
body: createLinkRequestSchemaBody
});
export type CreateLinkRequestDTO = z.TypeOf<typeof createLinkRequestSchema>;
/**
* @swagger
* components:
* schemas:
* CreateLinkResponseDTO:
* type: object
* required:
* - status
* - uri
* - subdomain
* properties:
* status:
* type: string
* default: ok on success, otherwise ErrorDTO with error
* uri:
* type: string
* default: full public, shortened url
* id:
* type: number
*/
export type CreateLinkResponseDTO = {
status: 'ok';
uri: string;
id: number;
};

32
src/schemas/miscSchema.ts Normal file
View File

@@ -0,0 +1,32 @@
import * as z from 'zod';
/**
* @openapi
* components:
* schemas:
* ErrorDTO:
* type: object
* required:
* - status
* - error
* properties:
* status:
* type: string
* default: error
* error:
* type: string
* default: error message
* code:
* type: string
* default: error code (may not be returned for every request)
*/
export type ErrorDTO = {
status: 'error';
error: string;
code?: string | undefined;
};
// Used to check against reserved names.
export const disallowedUriSchema = z
.string()
.regex(/^(about|assets|kttydocs|panel)/);

224
src/services/linkService.ts Normal file
View File

@@ -0,0 +1,224 @@
import { Link } from '../entities/Link';
import { User } from '../entities/User';
import { AppDataSource } from '../data-source';
import * as env from '../tools/env';
import { IsNull, LessThan } from 'typeorm';
export type IdResponse = {
id: number;
exists: boolean;
};
export class LinkService {
dataSource = AppDataSource;
linkRepo = this.dataSource.getRepository(Link);
// Retrieve config to check whether subdomains are allowed
useSubdomains: boolean = env.getBool('useSubdomains', true)!;
/**
* Simply insert a new link entity anonymously.
*
* @param {Link} link The link to insert
*/
async addIfNewAnonymous(link: Link): Promise<IdResponse> {
let result: IdResponse = { id: -1, exists: true };
// Sanity check: don't allow for adding links
// with subdomains if server has it disabled.
if (link.subdomain && !this.useSubdomains) link.subdomain = null;
// Check if entry can be inserted.
if (await this.canInsert(link)) {
await this.linkRepo.insert(link);
// Then get new link's ID
const insertedLink: Link[] = await this.linkRepo.findBy({
// Add subdomain if used, https://stackoverflow.com/a/69151874
subdomain: link.subdomain ?? IsNull(),
shortUri: link.shortUri
});
// Return appropriate id (or error)
if (insertedLink.length === 1) {
result.id = insertedLink[0].id;
result.exists = false;
} else {
result.id = -2;
result.exists = true;
}
}
return result;
}
/**
* Adds a new link entity, and links it with passed user.
*
* USE ONLY FOR AUTHENTICATED USERS. For unauthenticated users
* use addAnonymous() instead. Returns IdResponse, containing
* exists field indicating if an existing field has been found,
* and if so, what is it's id. If exists is false, ID of the
* newly created entity gets returned.
*
* Errors: an error ocurred if ID is negative.
*
* This can be:
*
* - -1 - link can't be inserted (because an entry with shortUri or shortUri+subdomain combo exist),
*
* - -2 - no conflicting entry exists but transaction failed anyway.
*
* @param {Link} link The link
* @param {User} user The user
* @return {Promise<IdResponse>} Dictionary containing link ID and whether it already existed, or was just inserted
*/
async addIfNew(link: Link, user: User): Promise<IdResponse> {
let result: IdResponse = { id: -1, exists: true };
// If no conflicts are found,
// proceed with creating a new link entry.
if (await this.canInsert(link)) {
// Commit a transaction
await this.dataSource.transaction(async (t) => {
link.author = user;
t.insert(Link, link);
});
// Then get new link's ID
const insertedLink: Link[] = await this.linkRepo.findBy({
subdomain: link.subdomain ?? IsNull(),
shortUri: link.shortUri
});
// Return appropriate id (or error)
if (insertedLink.length === 1) {
result.id = insertedLink[0].id;
result.exists = false;
} else {
result.id = -2;
result.exists = true;
}
}
return result;
}
async canInsert(link: Link): Promise<boolean> {
let shortUriExists: boolean = true
// If both subdomains are enabled, and user
// provided a subdomain, find if a record exists
// that either has the exact shortUri
// or shortUri and subdomain combo.
// If any of these turns out to be true, the request
// must be invalidated and we can't proceed
// with creation of a new link.
if (this.useSubdomains && link.subdomain)
{
shortUriExists = await this.linkRepo.existsBy({
shortUri: link.shortUri,
subdomain: link.subdomain
});
}
// If custom subdomains are disabled, fallback to
// checking only by the URIs - thus discarding
// any possible subdomains.
else
{
shortUriExists = await this.linkRepo.existsBy({
shortUri: link.shortUri,
subdomain: IsNull()
});
}
return !shortUriExists;
}
/**
* Removes all expired links.
*
* @return {Promise<number>} Amount of removed rows.
*/
async removeAllExpired(): Promise<number> {
// https://github.com/typeorm/typeorm/issues/960#issuecomment-489554674
let rowsRemoved = 0;
const currentDate = Date.now();
rowsRemoved = await this.linkRepo.countBy({ expiryDate: LessThan(currentDate) });
const affectedRows = await this.linkRepo.delete({ expiryDate: LessThan(currentDate) });
return rowsRemoved;
}
/**
* Finds a Link by it's identifier.
*
* @param {number} id The identifier
* @return {(Promise<Link|null>)} Link (or null if not found)
*/
async findById(id: number): Promise<Link | null> {
return await this.linkRepo.findOneBy({id});
}
/**
* Lookup the uri + subdomain combo.
*
* @param {string} uri The uri
* @param {(null|string)} [subdomain=null] The subdomain
* @return {(Promise<Link|null>)} Link entity containing provided params
*/
async lookupUri(uri: string, subdomain: string | null = null): Promise<Link | null> {
let result: Link | null = null;
result = await this.linkRepo.findOneBy({
shortUri: uri,
subdomain: subdomain ?? IsNull()
}) ?? null;
return result;
}
/**
* Lookup the uri + subdomain combo.
* AUTOMATICALLY REMOVES the entry if it expired.
*
* @param {string} uri The uri
* @param {(null|string)} [subdomain=null] The subdomain
* @return {(Promise<Link|null>)} Link entity containing provided params
*/
async lookupUriWithExpiryValidation(uri: string, subdomain: string | null = null): Promise<Link | null> {
let result: Link | null = null;
result = await this.linkRepo.findOneBy({
shortUri: uri,
subdomain: subdomain ?? IsNull()
}) ?? null;
const now = Date.now();
if (result !== null && result.expiryDate !== null)
if (now > (result.expiryDate ?? now + 1)) {
// Remove found entry
this.linkRepo.remove(result);
// Set to null to not propagate expired link
result = null;
}
return result;
}
/**
* Save a link.
* Saves all changes made to the link entity.
*
* @param {Link} link The link
* @return {Promise} None
*/
async save(link: Link) {
await this.linkRepo.save(link);
}
}

123
src/services/userService.ts Normal file
View File

@@ -0,0 +1,123 @@
import { User } from '../entities/User';
import { AppDataSource } from '../data-source';
export type UserCredentials = {
name?: string | undefined;
password?: string | undefined;
}
export class UserService {
dataSource = AppDataSource;
userRepo = this.dataSource.getRepository(User);
//
// Phased out in favor of in-controller logic.
//
// /**
// * Register a new user. Checks if a user with provided name exists.
// * Returns `true` if user has been registered successfully.
// *
// * @param {string} name Username
// * @param {string} password User's password
// * @param {(null|number)} role User's role (0 | null = standard user, 1 = admin)
// * @return {Promise<boolean>} True if an account has been registered successfully.
// */
// async register(name: string, password: string, role: number | null): Promise<boolean> {
//
// // Check if user by this name already exists.
// // If so, return here to not create a new account.
// if (await this.userRepo.existsBy({ name: name }))
// return false;
//
// // TODO: insert "dirty" entity...
// // await this.userRepo.insert();
//
// // TODO: salt the password...
//
// return true;
// }
/**
* Finds entity id by any credentials passed.
* Returns user id (typically number > 0) on success,
* -1 on no match, and -2 on multiple matches (if that were to ever occur).
*
* @param {UserCredentials} creds The creds
* @return {Promise<number>} User id
*/
async findIdByCredentials(creds: UserCredentials): Promise<number> {
let users: User[] = await this.userRepo.find({
where: { ...creds }
});
if (users.length === 0) return -1; // no match
else if (users.length === 1) return users[0].id!; // exact match, return id
else return -2; // sus, too many matches
}
/**
* Finds the user entity by any credentials passed.
* Returns user on success, otherwise null.
*
* @param {UserCredentials} creds The creds
* @return {(Promise<User|null>)} User entity
*/
async findByCredentials(creds: UserCredentials): Promise<User | null> {
let users: User[] = await this.userRepo.find({
where: { ...creds }
});
if (users.length !== 1) return null; // no match, or too many matches, sus
else return users[0]; // exact match, return user
}
/**
* Finds a User by it's identifier.
*
* @param {number} id The identifier
* @return {(Promise<User|null>)} User (or null if not found)
*/
async findById(id: number): Promise<User | null> {
return await this.userRepo.findOneBy({id});
}
/**
* Counts all the user entities in DB.
* Used to tell whether the next created account should be an admin
* (No users in DB? That means most likely it's a fresh instance.
* Then the next user should be an admin.)
*
* @return {Promise<number>} Number of user entities in DB (0...).
*/
async countAll(): Promise<number> {
return await this.userRepo.count();
}
/**
* Simply insert a new user entity.
*
* @param {User} user The user to insert
*/
async add(user: User): Promise<any> {
await this.userRepo.insert(user);
}
/**
* Simply save user entity to DB.
* New user passed? New entity will get created.
* Existing entity passed? It'll get updated.
*
* @param {User} user The user to insert or update
*/
async save(user: User) {
// assert(user.id !== undefined, new Error("Passed user MUST contain an id!"));
await this.userRepo.save(user);
}
}
// export default new UserService();

View File

@@ -1,13 +0,0 @@
import { Connection } from "typeorm";
import { User } from "./entities/User";
export async function smokeTest(connection: Connection) {
const user = new User();
user.name = "admin";
user.role = "admin";
user.createdAt = Date.now();
user.passwordHash = "pretend this is a hash";
await connection.manager.save(user);
}

41
src/tools/cors.ts Normal file
View File

@@ -0,0 +1,41 @@
import * as env from './env';
let cors = require('cors');
/**
* Returns user-trusted origins from the .env file.
* Defaults to http://localhost:6568 if no user config is found.
*
* @return {string[]} A list of user-trusted origins.
*/
export function getTrustedOrigins(): string[] {
let trustedOrigins: string[] = ['http://localhost:6568'];
const configOriginsString: string | undefined = env.getString('trustedOrigins', true);
// No config available.
if (configOriginsString === undefined) {
console.log('WARN: trustedOrigins is unknown. Defaulting to http://localhost:6568. CORS might not work.');
return trustedOrigins;
}
// Config available
else if (typeof configOriginsString === 'string')
// But if it's empty, return defaults.
if (configOriginsString === '')
return trustedOrigins;
// Otherwise overwrite trustedOrigins with user-provided comma-separated values.
else
trustedOrigins = configOriginsString.split(',');
return trustedOrigins;
}
/**
* Retruns the CORS configuration containing user-provided origins.
* If none were found, they default to http://localhost:6568.
*
* @return {any} The cors configuration.
*/
export function getCorsConfig(): any {
return cors({
origin: getTrustedOrigins()
});
}

87
src/tools/env.ts Normal file
View File

@@ -0,0 +1,87 @@
import * as dotenv from 'dotenv';
dotenv.config({ quiet: true });
/**
* Get the environmental string from .env.
* Supports rewriting names to UPPER_SNAKE_CASE if isGlobal is set.
*
* @param {string} key The key
* @param {boolean} [isGlobal=true] Indicates if global
* @return {(string|undefined)} The environment string.
*/
export function getString(
key: string,
isGlobal: boolean = true
): string | undefined {
let keyName: string = '';
if (isGlobal) {
// Global values are DECLARED_LIKE_THIS=...
for (let i: number = 0; i < key.length; i++) {
if (key[i].toLowerCase() === key[i]) {
// If is lowercase, skip.
keyName += key[i];
} else {
// If is uppercase, convert to snake case.
keyName += `_${key[i].toLowerCase()}`;
}
}
keyName = keyName.toUpperCase();
} else {
// Non-global keys are parsed as passed
keyName = key;
}
return process.env[keyName];
}
/**
* Get the environmental boolean from .env.
* Supports rewriting names to UPPER_SNAKE_CASE if isGlobal is set.
*
* @param {string} key The key
* @param {boolean} [isGlobal=true] Indicates if global
* @return {(boolean|undefined)} The environment boolean.
*/
export function getBool(
key: string,
isGlobal: boolean = true
): boolean | undefined {
const valueRead: string | undefined = getString(key, isGlobal);
if (valueRead === undefined) return undefined;
if (valueRead.toLowerCase() === 'true')
return true;
return false;
}
/**
* Processes public url, returning protocol, fqdn and path.
* proto://fqdn/path/ = fullPublicUrl
* @return {RewriteStrings} The rewrite strings.
*/
export function getRewriteStrings(): RewriteStrings {
const fullPublicUrl: string = getString('publicUrl', true)!;
const url = new URL(fullPublicUrl);
const proto = url.protocol.slice(0, -1); // https: -> https
const fqdn = url.host;
const path = url.pathname.replace(/\/+$/, '') + '/'; // /abc -> /abc/
const result: RewriteStrings = {
fullPublicUrl,
proto,
fqdn,
path
};
return result;
};
export type RewriteStrings = {
fullPublicUrl: string;
proto: string;
fqdn: string;
path: string;
};

13
src/tools/hasher.ts Normal file
View File

@@ -0,0 +1,13 @@
// Credits:
// https://mojoauth.com/hashing/sha-512-in-typescript/
import { createHash } from 'crypto';
/**
* Generates a SHA-512 hash for the given input string.
* @param input - The input string to hash.
* @returns The SHA-512 hash as a hexadecimal string.
*/
export function generateSha512(input: string): string {
const hash = createHash('sha512');
hash.update(input);
return hash.digest('hex');
}

91
src/tools/jwt.ts Normal file
View File

@@ -0,0 +1,91 @@
// Heavily based on:
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/utils/jwt.utils.ts
import jwt from 'jsonwebtoken';
import { DEFAULT_TOKEN_LIFETIME } from '../schemas/authSchema';
import * as env from './env';
export type JwtDecoded = {
sub: number;
role: number;
iat: number;
exp: number;
};
export type JwtStatus = {
valid: boolean;
expired: boolean;
decoded: JwtDecoded | null; // null if decoding failed
};
/**
* Sign a JWT containing sub (number), role (number, 0/1), iat/exp (unix timestamp) claims.
*
* @param {Object} object The object
* @param {('accessTokenPrivateKey'|'refreshTokenPrivateKey')} keyName The key name
* @param {} options?:jwt.SignOptions|undefined The sign options undefined
* @return {string} JWT string
*/
export function signJwt(
object: Object,
keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey',
options?: jwt.SignOptions | undefined
): string {
// refresh tokens aren't (yet) supported
// const signingKey = Buffer.from(
// process.env[keyName]!,
// 'base64'
// ).toString('utf8');
const secret: string = env.getString(keyName, true)!;
// Use the default expiration time of 24 hours.
if (options === undefined)
options = { expiresIn: DEFAULT_TOKEN_LIFETIME };
return jwt.sign(object, secret, {
...options, // (options && options)?
// algorithm: 'RS256', // requires a valid private key, not a secret
});
}
/**
* Verify a JWT against one of the keys.
* Returns JwtStatus, which contains fields for checking validity, expiry and decoded subject claim (id).
*
* @param {string} token The token
* @param {('accessTokenPublicKey'|'refreshTokenPublicKey')} keyName The key name
* @return {JwtStatus} JWT status.
*/
export function verifyJwt(
token: string,
keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey'
): JwtStatus {
// refresh tokens aren't (yet) supported
// const publicKey = Buffer.from(
// process.env[keyName]!,
// 'base64'
// ).toString('utf8');
const secret: string = env.getString(keyName, true)!;
try {
const decoded: jwt.JwtPayload | string = jwt.verify(token, secret);
// TODO: Can this be done better, smarter?
return {
valid: true,
expired: false,
decoded: decoded as unknown as JwtDecoded
};
} catch (e: any) {
console.error('JWT verify error:', e);
return {
valid: e.message !== 'jwt malformed',
expired: e.message === 'jwt expired',
decoded: null
};
}
}

View File

@@ -0,0 +1,41 @@
// Heavily based on:
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/validateResource.ts
import { Request, Response, NextFunction } from 'express';
import { ErrorDTO } from '../schemas/miscSchema';
import z from 'zod';
const validate =
(schema: z.ZodObject) =>
(req: Request, res: Response, next: NextFunction) => {
try {
let validatedData = schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
res.locals.validated = validatedData;
next();
} catch (e: any) {
if (e instanceof z.ZodError) {
let errorResponse: ErrorDTO = {
status: 'error',
error: e.issues[0]?.message ?? 'Unknown error',
code: e.issues[0]?.code
};
return res.status(400)
.json(errorResponse);
} else {
console.log('Generic validation error triggered:', e);
let errorResponse: ErrorDTO = {
status: 'error',
error: 'Unknown error',
code: 'generic-error'
};
return res.status(400)
.json(errorResponse);
}
}
};
export default validate;

View File

@@ -10,6 +10,10 @@
"outDir": "./build",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"strict": true,
"strictPropertyInitialization": false
}
}

7069
wordlist.example-large.ts Normal file

File diff suppressed because it is too large Load Diff