Compare commits
22 Commits
1e60fc23ab
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| d7f4006698 | |||
| c19a098b1c | |||
| ec5cedce5a | |||
| 4bf39c7fdf | |||
| f86630c51e | |||
| f0ca4e897c | |||
| 6ad9c6fd61 | |||
| 7abf5bad09 | |||
| 41f3b0f0f2 | |||
| 3f225a1ecb | |||
| bade2f9b86 | |||
| 58460d988d | |||
| 8fb05f2662 | |||
| 2e325fe018 | |||
| 52dbd158a9 | |||
| f897a3e4ce | |||
| bf6fb81a73 | |||
| 7bb2dd5434 | |||
| fa5932d270 | |||
| 09f0d3ce1f | |||
| 6b2f93b962 | |||
| 1b8c9ccaeb |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
*/.env
|
||||
*.md
|
||||
@@ -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
|
||||
|
||||
58
.gitea/workflows/01_docker.yaml
Normal file
58
.gitea/workflows/01_docker.yaml
Normal 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
|
||||
35
.gitea/workflows/02_release.yaml
Normal file
35
.gitea/workflows/02_release.yaml
Normal 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 }}"
|
||||
37
.gitea/workflows/99_changelog.yaml
Normal file
37
.gitea/workflows/99_changelog.yaml
Normal 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
3
.gitignore
vendored
@@ -8,3 +8,6 @@ temp/
|
||||
|
||||
# .env
|
||||
.env
|
||||
|
||||
# wordlist
|
||||
src/tools/wordlist.ts
|
||||
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal 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"]
|
||||
@@ -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
616
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
package.json
19
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
144
src/app.ts
144
src/app.ts
@@ -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 = {
|
||||
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.0',
|
||||
openapi: '3.0.4',
|
||||
info: {
|
||||
title: 'kittyurl',
|
||||
version: '0.0.1'
|
||||
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'
|
||||
}
|
||||
},
|
||||
apis: ['./src/routes/*.ts']
|
||||
};
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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.'));
|
||||
// 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'
|
||||
});
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
230
src/controllers/authController.ts
Normal file
230
src/controllers/authController.ts
Normal 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);
|
||||
}
|
||||
205
src/controllers/linkController.ts
Normal file
205
src/controllers/linkController.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// 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;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.links)
|
||||
// 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
|
||||
author: User | null;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
25
src/index.ts
25
src/index.ts
@@ -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))
|
||||
47
src/middleware/inferUser.ts
Normal file
47
src/middleware/inferUser.ts
Normal 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;
|
||||
41
src/middleware/requireUser.ts
Normal file
41
src/middleware/requireUser.ts
Normal 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;
|
||||
@@ -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")
|
||||
@@ -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
138
src/routes/linkRoutes.ts
Normal 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
31
src/routes/miscRoutes.ts
Normal 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
102
src/routes/userRoutes.ts
Normal 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
112
src/schemas/authSchema.ts
Normal 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
145
src/schemas/linkSchema.ts
Normal 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
32
src/schemas/miscSchema.ts
Normal 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
224
src/services/linkService.ts
Normal 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
123
src/services/userService.ts
Normal 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();
|
||||
@@ -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
41
src/tools/cors.ts
Normal 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
87
src/tools/env.ts
Normal 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
13
src/tools/hasher.ts
Normal 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
91
src/tools/jwt.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
41
src/tools/validateSchema.ts
Normal file
41
src/tools/validateSchema.ts
Normal 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;
|
||||
@@ -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
7069
wordlist.example-large.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user