Compare commits
32 Commits
1b8c9ccaeb
...
v0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| e0d8849bd1 | |||
| 429613c67e | |||
| 681555fef8 | |||
| 066b9884c2 | |||
| 9311cd3c96 | |||
| 89e6832e73 | |||
| 109f22c231 | |||
| c548abc9ed | |||
| 355338e397 | |||
| 518eeec8e8 | |||
| 413aa8994a | |||
| d7f4006698 | |||
| c19a098b1c | |||
| ec5cedce5a | |||
| 4bf39c7fdf | |||
| f86630c51e | |||
| f0ca4e897c | |||
| 6ad9c6fd61 | |||
| 7abf5bad09 | |||
| 41f3b0f0f2 | |||
| 3f225a1ecb | |||
| bade2f9b86 | |||
| 58460d988d | |||
| 8fb05f2662 | |||
| 2e325fe018 | |||
| 52dbd158a9 | |||
| f897a3e4ce | |||
| bf6fb81a73 | |||
| 7bb2dd5434 | |||
| fa5932d270 | |||
| 09f0d3ce1f | |||
| 6b2f93b962 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
*/.env
|
||||
build
|
||||
node_modules
|
||||
@@ -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 && 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"]
|
||||
70
README.md
70
README.md
@@ -1,2 +1,72 @@
|
||||
# kittyBE
|
||||
*Back-end for the [KittyURL](https://gitea.7o7.cx/kittyteam/kittyurl) project -- create short and memorable URLs with ease!*
|
||||
|
||||
## Goals
|
||||
Provide endpoints for:
|
||||
- account management (`/api/v1/user/*`),
|
||||
- link management (`/api/v1/link/*`),
|
||||
- authed link management (when a link is bound to a user, `/api/v1/authed/*`),
|
||||
- user management (for admins only, `/api/v1/admin/*`),
|
||||
- general info (`/api/v1/info/*`),
|
||||
|
||||
KittyBE should also integrate nicely with [kittyFE](https://gitea.7o7.cx/kittyteam/kittyFE) and be easily dockerizable.
|
||||
|
||||
## Running kittyBE
|
||||
KittyURL has been verified to work on Node 18.20+ and PostgreSQL 16.11+.
|
||||
### On bare metal
|
||||
Running the back-end is as simple as:
|
||||
|
||||
- Installing the dependencies:
|
||||
- To just download the required dependencies:
|
||||
```sh
|
||||
npm i
|
||||
```
|
||||
- To install an exact copy of all of the dependencies:
|
||||
```sh
|
||||
npm ci
|
||||
```
|
||||
- Copying the .env.default file to .env, and customizing it to own preferences.
|
||||
|
||||
**Example:** Say, you want to add a domain to the trusted CORS origins list. To do so, your .env file in your editor of choice and append a comma (`,`) with the origin you want to add (say, `http://example.com`). Your .env file might then look as follows: `TRUSTED_ORIGINS=http://localhost:6568,http://example.com`.
|
||||
|
||||
**Important:** Make sure to change the `ACCESS_TOKEN_PRIVATE_KEY` variable to something secure, as this secret value will be used to generate user sessions. **Setting a weak key will allow attackers to potentially bruteforce your secret and forge user tokens!**
|
||||
- Pasting your wordlist file into `src/tools/wordlist.ts`.
|
||||
|
||||
No wordlist file exists by default in `src/tools/wordlist.ts`. This is because wordlists were meant to be as modular as possible (with the philosophy of "bring your own wordlist"). If you leave that as-is, you'll run into runtime errors.
|
||||
|
||||
However, if you don't want to provide your own wordlist, and just want to get up and running as fast as possible, you're free to use the provided sample `wordlist.example-large.ts` file. Just copy it into `src/tools/wordlist.ts`:
|
||||
```sh
|
||||
cp wordlist.example-large.ts src/tools/wordlist.ts
|
||||
```
|
||||
- Launching the web server:
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
- And... that's it!
|
||||
Now view your instance at http://localhost:6567, and -- if you've set the DEBUG flag in your `.env` file to `true` -- you can also visit http://localhost:6567/kttydocs/ for Swagger documentation.
|
||||
|
||||
### Using Docker
|
||||
A Docker image is built for every release of kittyBE and [kittyFE](https://gitea.7o7.cx/kittyteam/kittyFE). For more instructions on how to run the project with Docker, please refer to the [kittyurl repository](https://gitea.7o7.cx/kittyteam/kittyurl) (which contains a sample [docker-compose.yaml file](https://gitea.7o7.cx/kittyteam/kittyurl/src/branch/master/docker-compose.yaml), as well as it's own .env file).
|
||||
|
||||
## Wordlists
|
||||
You're free to provide your own wordlist file by pasting it into `src/tools/wordlist.ts`. For an example of how a wordlist file should look like, see `wordlist.example-large.ts`, and pay attention to the methods it exports.
|
||||
|
||||
## Troubleshooting
|
||||
Two supplementary scripts have been provided for aid in troubleshooting database-related errors.
|
||||
- Run pending migrations on your database
|
||||
In a rare case, when you need to run the migrations before launching the server (as it will try running pending migrations on every launch), use:
|
||||
```sh
|
||||
npm run pendingMigration
|
||||
```
|
||||
|
||||
- Issue a new migration
|
||||
During development it might be necessary to issue new migrations. To do that, use:
|
||||
```sh
|
||||
# assuming you're in the base project directory
|
||||
npm run newMigration ./src/migrations/myMigrationName
|
||||
```
|
||||
where `myMigrationName` is the name of your migration.
|
||||
|
||||
**Important:** TypeORM uses the state of your connected database when diffing for changes, unlike some other solutions, which take past migrations into consideration.
|
||||
|
||||
**Note:** If using other relational database than Postgres, make sure to do the due diligence of researching how to enable bigint support for your database driver. No other database type than Postgres has been tested.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
package.json
21
package.json
@@ -1,27 +1,36 @@
|
||||
{
|
||||
"name": "kittyBE",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
152
src/app.ts
152
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 = {
|
||||
failOnErrors: true,
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'kittyurl',
|
||||
version: '0.0.1'
|
||||
await AppDataSource.runMigrations();
|
||||
|
||||
const app: express.Express = express();
|
||||
const linkService = new LinkService();
|
||||
const rs = env.getRewriteStrings();
|
||||
|
||||
const removedExpired = await linkService.removeAllExpired();
|
||||
if (removedExpired !== 0) console.log(`[${Date.now() / 1_000}] (DB) Removed ${removedExpired} expired links.`);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(getCorsConfig());
|
||||
app.use(inferUser);
|
||||
app.use(miscRouter, userRouter, linkRouter);
|
||||
|
||||
if (env.getBool('debug', true)) {
|
||||
const swaggerJsdocOpts = {
|
||||
failOnErrors: true,
|
||||
definition: {
|
||||
openapi: '3.0.4',
|
||||
info: {
|
||||
title: 'kittyurl API',
|
||||
description: 'A Typescript API for the kittyurl url shortener project.',
|
||||
version: version,
|
||||
contact: {
|
||||
name: 'Git repository for entire project',
|
||||
url: 'https://gitea.7o7.cx/kittyteam/kittyurl'
|
||||
},
|
||||
license: {
|
||||
name: 'AGPLv3',
|
||||
url: 'https://www.gnu.org/licenses/agpl-3.0.en.html'
|
||||
}
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
BearerJWT: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT Authorization header using the Bearer scheme.<br/>Enter your JWT from /api/v1/user/signIn to authorize.'
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{ BearerJWT: [] }
|
||||
],
|
||||
},
|
||||
apis: ['./src/routes/*.ts', './src/schemas/*.ts']
|
||||
};
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
|
||||
app.use('/kttydocs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.get('/kttydocs.json', (req: express.Request, res: express.Response) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle 404s
|
||||
// https://stackoverflow.com/a/9802006
|
||||
app.use(async function(req: express.Request, res: express.Response) {
|
||||
|
||||
// Check if host header seems right
|
||||
try {
|
||||
z.string()
|
||||
.includes(rs.fqdn)
|
||||
.parse(req.headers.host);
|
||||
} catch {
|
||||
return res.status(400)
|
||||
.json({
|
||||
status: 'error',
|
||||
error: 'Invalid host. Is your browser sending the host header?',
|
||||
code: 'no_host'
|
||||
});
|
||||
}
|
||||
},
|
||||
apis: ['./src/routes/*.ts']
|
||||
};
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(router);
|
||||
app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
|
||||
// Retrieve url, subdomain from request.
|
||||
let uri: string = req.url.slice(1); // discards / from /abc, /abc -> abc
|
||||
let subdomain: string | null = req.headers.host!.replace(rs.fqdn, '').slice(0, -1) || null; // slice() to remove trailing dot
|
||||
|
||||
// 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);
|
||||
}
|
||||
204
src/controllers/linkController.ts
Normal file
204
src/controllers/linkController.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
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) {
|
||||
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",
|
||||
@@ -15,4 +15,5 @@ export const AppDataSource = new DataSource({
|
||||
entities: [__dirname + '/entities/*.ts'],
|
||||
migrations: [__dirname + '/migrations/*.ts'],
|
||||
subscribers: [],
|
||||
parseInt8: true // https://github.com/typeorm/typeorm/issues/9341#issuecomment-1268986627
|
||||
})
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from "typeorm"
|
||||
import { User } from "./User"
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from 'typeorm'
|
||||
import { User } from './User'
|
||||
|
||||
@Entity("links")
|
||||
export class Link {
|
||||
|
||||
// Unique link id.
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
subdomain: string | null
|
||||
// Experimental: subdomain which should be a part of the short url.
|
||||
// For instance in the URL "abc.example.com/def", abc is the subdomain.
|
||||
// "def.example.com/def" won't resolve to the URL that "abc.example.com/def" does.
|
||||
// https://stackoverflow.com/a/67535817
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
subdomain: string | null;
|
||||
|
||||
// Shortened Uri.
|
||||
@Column()
|
||||
shortUri: string
|
||||
shortUri: string;
|
||||
|
||||
// URL to which the user should be redirected
|
||||
@Column()
|
||||
fullUrl: string
|
||||
|
||||
@Column()
|
||||
role: string
|
||||
fullUrl: string;
|
||||
|
||||
// Unix timestamp of link creation date.
|
||||
@Column('bigint')
|
||||
createDate: number
|
||||
createDate: number;
|
||||
|
||||
// Unix timestamp of when the link should expire.
|
||||
// If null, the link will never expire unless deleted.
|
||||
@Column('bigint', { nullable: true })
|
||||
expiryDate: number | null;
|
||||
|
||||
// Aggregated amount of visits.
|
||||
@Column('bigint')
|
||||
expiryDate: number
|
||||
visits: number;
|
||||
|
||||
// Link privacy:
|
||||
// - true, if link is private
|
||||
// - false, if link can be shown in a list of recent links publicly.
|
||||
@Column()
|
||||
privacy: boolean;
|
||||
|
||||
// User to which the shortened URL belongs.
|
||||
@ManyToOne(() => User, (user) => user.links, { nullable: true })
|
||||
@JoinTable()
|
||||
author: User | null
|
||||
author: User | null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
38
src/middleware/requireAdmin.ts
Normal file
38
src/middleware/requireAdmin.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ErrorDTO } from '../schemas/miscSchema';
|
||||
import * as jwt from '../tools/jwt';
|
||||
|
||||
/**
|
||||
* Checks if user has administrative privileges.
|
||||
*
|
||||
* This needs to happen AFTER ensuring this is not a guest session.
|
||||
* So: use requireUser first, and after that requireAdmin to enforce
|
||||
* admin privilege requirement.
|
||||
*
|
||||
* @param {Request} req The request
|
||||
* @param {Response} res The resource
|
||||
* @param {(Function|NextFunction)} next The next
|
||||
* @return {any} Next function on success, unauthorized error otherwise
|
||||
*/
|
||||
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
const user: jwt.JwtStatus = res.locals.user;
|
||||
let error: ErrorDTO | null = null;
|
||||
|
||||
// Check if role is set to 1 (1 = admin, 0 = standard user).
|
||||
if (user.decoded?.role !== 1)
|
||||
error = {
|
||||
status: 'error',
|
||||
error: 'Unauthorized, admin access required',
|
||||
code: 'unauthorized_non_admin'
|
||||
};
|
||||
|
||||
// It is? Send 401 unauthorized.
|
||||
if (error !== null)
|
||||
return res.status(401)
|
||||
.send(error);
|
||||
|
||||
// Otherwise jump to next endpoint.
|
||||
return next();
|
||||
};
|
||||
|
||||
export default requireAdmin;
|
||||
54
src/middleware/requireUser.ts
Normal file
54
src/middleware/requireUser.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ErrorDTO } from '../schemas/miscSchema';
|
||||
import * as jwt from '../tools/jwt';
|
||||
|
||||
/**
|
||||
* Checks if user is singed in.
|
||||
* Returns 401 when user is unauthorized.
|
||||
*
|
||||
* To check if user is an admin, chain requireUser and requireAdmin together.
|
||||
* So: use requireUser first, and after that requireAdmin to enforce
|
||||
* admin privilege requirement.
|
||||
*
|
||||
* @param {Request} req The request
|
||||
* @param {Response} res The resource
|
||||
* @param {(Function|NextFunction)} next The next
|
||||
* @return {any} Next function on success, unauthorized error otherwise
|
||||
*/
|
||||
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;
|
||||
140
src/routes/linkRoutes.ts
Normal file
140
src/routes/linkRoutes.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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. <br/>
|
||||
* <b>Note:</b> This endpoint's functionality differs depending on the user info,
|
||||
* which means guests will be treated differently from authenticated users.
|
||||
* tags: [Link]
|
||||
* summary: "[AUTHED?] 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|healthcheck|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;
|
||||
@@ -4,12 +4,16 @@
|
||||
"es2021"
|
||||
],
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"target": "esnext",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"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