mirror of
https://github.com/movie-web/backend.git
synced 2025-09-13 13:03:26 +00:00
1
.docker/development/.gitignore
vendored
Normal file
1
.docker/development/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
config.env
|
35
.docker/development/HOW-TO-USE.md
Normal file
35
.docker/development/HOW-TO-USE.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# how to use docker development?
|
||||||
|
|
||||||
|
## How to setup?
|
||||||
|
1. have docker installed
|
||||||
|
2. create a `config.env` in `/.docker/development`. inspire its contents from `example.config.env`
|
||||||
|
|
||||||
|
## how to run?
|
||||||
|
1. while in directory `/.docker/development` run `docker compose up -d`
|
||||||
|
1.1 if running first time in docker, make sure you have no node_modules folder present in `/`.
|
||||||
|
|
||||||
|
## not working? try this:
|
||||||
|
1. while in directory `/.docker/development` run `docker compose down -v`
|
||||||
|
2. remove `node_modules` directory in `/` if it exists
|
||||||
|
3. remove `.env` and `config.json` file in `/` if any of them exist
|
||||||
|
4. while in directory `/.docker/development` run `docker compose up -d --build`
|
||||||
|
|
||||||
|
## how to stop?
|
||||||
|
1. while in directory `/.docker/development` run `docker compose down`
|
||||||
|
> NOTE: if you want also delete all saved data for a full reset, run `docker compose down -v` instead
|
||||||
|
|
||||||
|
## how do I access the terminal for the backend service?
|
||||||
|
make sure the docker services are running, then run `docker attach mw_backend-1`.
|
||||||
|
this will appear to show nothing at first, but all new logs will show up,
|
||||||
|
and anything you type in the terminal now affect the backend service.
|
||||||
|
> Warning: doing CTRL+C will shut down the backend service, it will not kick your terminal back to its original shell.
|
||||||
|
|
||||||
|
## how do I read logs?
|
||||||
|
1. while in directory `/.docker/development` run `docker compose ps`
|
||||||
|
2. note the name of the service you want to see the logs of
|
||||||
|
3. while in directory `/.docker/development` run `docker compose logs <NAME>`. fill in the name of the service without the brackets.
|
||||||
|
|
||||||
|
## Exposed ports
|
||||||
|
- http://localhost:8081 - backend API
|
||||||
|
- http://localhost:8082 - postgres web UI
|
||||||
|
- postgres://localhost:5432 - postgres
|
47
.docker/development/docker-compose.yml
Normal file
47
.docker/development/docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
name: "mw_backend"
|
||||||
|
|
||||||
|
services:
|
||||||
|
# required services
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
volumes:
|
||||||
|
- 'postgres_data:/var/lib/postgresql/data'
|
||||||
|
|
||||||
|
# custom services
|
||||||
|
backend:
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
build:
|
||||||
|
dockerfile: ./dev.Dockerfile
|
||||||
|
context: ../../
|
||||||
|
volumes:
|
||||||
|
- '../../:/app'
|
||||||
|
env_file:
|
||||||
|
- './config.env'
|
||||||
|
ports:
|
||||||
|
- '8081:8080'
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
environment:
|
||||||
|
- WAIT_HOSTS=postgres:5432
|
||||||
|
|
||||||
|
# util services
|
||||||
|
pgweb:
|
||||||
|
image: sosedoff/pgweb
|
||||||
|
ports:
|
||||||
|
- "8082:8081"
|
||||||
|
links:
|
||||||
|
- postgres:postgres
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
1
.docker/development/example.config.env
Normal file
1
.docker/development/example.config.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MWB_USE_PRESETS=dev,dockerdev
|
@@ -4,3 +4,5 @@ config.json
|
|||||||
dist
|
dist
|
||||||
.git
|
.git
|
||||||
.vscode
|
.vscode
|
||||||
|
.docker
|
||||||
|
.pnpm-store
|
||||||
|
@@ -13,6 +13,7 @@ module.exports = {
|
|||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint'],
|
plugins: ['@typescript-eslint'],
|
||||||
|
ignorePatterns: ['./src/db/migrations/**/*'],
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/interface-name-prefix': 'off',
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
28
.github/workflows/linting_testing.yml
vendored
28
.github/workflows/linting_testing.yml
vendored
@@ -15,18 +15,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: 'yarn'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install Yarn packages
|
- name: Install packages
|
||||||
run: yarn install
|
run: pnpm i
|
||||||
|
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: yarn lint
|
run: pnpm run lint
|
||||||
|
|
||||||
building:
|
building:
|
||||||
name: Build project
|
name: Build project
|
||||||
@@ -35,18 +39,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: 'yarn'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install Yarn packages
|
- name: Install packages
|
||||||
run: yarn install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Build Project
|
- name: Build Project
|
||||||
run: yarn build
|
run: pnpm build
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Build docker
|
name: Build docker
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
config.json
|
config.json
|
||||||
dist
|
dist
|
||||||
|
.pnpm-store
|
||||||
|
19
Dockerfile
19
Dockerfile
@@ -1,16 +1,21 @@
|
|||||||
FROM node:18-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# install dependencies
|
ENV PNPM_HOME="/pnpm"
|
||||||
COPY package*.json ./
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN npm ci
|
RUN corepack enable
|
||||||
|
|
||||||
|
# install packages
|
||||||
|
COPY package.json ./
|
||||||
|
COPY pnpm-lock.yaml ./
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# build source
|
# build source
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN npm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# start server
|
# start server
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
ENV CONF_SERVER__PORT=80
|
ENV MWB_SERVER__PORT=80
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["pnpm", "run", "start"]
|
||||||
|
11
dev.Dockerfile
Normal file
11
dev.Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# wait script for development
|
||||||
|
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.7.3/wait /wait
|
||||||
|
RUN chmod +x /wait
|
||||||
|
|
||||||
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
|
VOLUME [ "/app" ]
|
||||||
|
CMD pnpm i && /wait && pnpm dev
|
26
package.json
26
package.json
@@ -13,10 +13,22 @@
|
|||||||
"lint": "eslint --ext .ts,.js,.json,.tsx src/",
|
"lint": "eslint --ext .ts,.js,.json,.tsx src/",
|
||||||
"lint:fix": "eslint --fix --ext .ts,.js,.json,.tsx src/",
|
"lint:fix": "eslint --fix --ext .ts,.js,.json,.tsx src/",
|
||||||
"build:pre": "rimraf dist/",
|
"build:pre": "rimraf dist/",
|
||||||
"build:compile": "tsc && tsc-alias"
|
"build:compile": "tsc && tsc-alias",
|
||||||
|
"preinstall": "npx -y only-allow pnpm",
|
||||||
|
"migration:create": "npx -y mikro-orm migration:create"
|
||||||
|
},
|
||||||
|
"mikro-orm": {
|
||||||
|
"useTsNode": true,
|
||||||
|
"configPaths": [
|
||||||
|
"./src/mikro-orm.config.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@mikro-orm/cli": "^5.9.2",
|
||||||
|
"@mikro-orm/migrations": "^5.9.2",
|
||||||
|
"@types/jsonwebtoken": "^9.0.4",
|
||||||
"@types/node": "^20.5.3",
|
"@types/node": "^20.5.3",
|
||||||
|
"@types/node-forge": "^1.3.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||||
"@typescript-eslint/parser": "^6.4.1",
|
"@typescript-eslint/parser": "^6.4.1",
|
||||||
"eslint": "^8.47.0",
|
"eslint": "^8.47.0",
|
||||||
@@ -32,9 +44,21 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.3.0",
|
"@fastify/cors": "^8.3.0",
|
||||||
|
"@mikro-orm/core": "^5.9.0",
|
||||||
|
"@mikro-orm/postgresql": "^5.9.2",
|
||||||
|
"@types/ms": "^0.7.33",
|
||||||
|
"async-ratelimiter": "^1.3.12",
|
||||||
|
"cron": "^3.1.5",
|
||||||
"fastify": "^4.21.0",
|
"fastify": "^4.21.0",
|
||||||
|
"fastify-metrics": "^10.3.3",
|
||||||
"fastify-type-provider-zod": "^1.1.9",
|
"fastify-type-provider-zod": "^1.1.9",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"ms": "^2.1.3",
|
||||||
|
"nanoid": "^3.3.6",
|
||||||
"neat-config": "^2.0.0",
|
"neat-config": "^2.0.0",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
|
"prom-client": "^15.0.0",
|
||||||
"type-fest": "^4.2.0",
|
"type-fest": "^4.2.0",
|
||||||
"winston": "^3.10.0",
|
"winston": "^3.10.0",
|
||||||
"winston-console-format": "^1.0.8",
|
"winston-console-format": "^1.0.8",
|
||||||
|
3214
pnpm-lock.yaml
generated
Normal file
3214
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,4 +9,15 @@ export const devFragment: FragmentSchema = {
|
|||||||
format: 'pretty',
|
format: 'pretty',
|
||||||
debug: true,
|
debug: true,
|
||||||
},
|
},
|
||||||
|
postgres: {
|
||||||
|
syncSchema: true,
|
||||||
|
},
|
||||||
|
crypto: {
|
||||||
|
sessionSecret: 'aINCithRivERecKENdmANDRaNKenSiNi',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
name: 'movie-web development',
|
||||||
|
description:
|
||||||
|
"This backend is only used in development, do not create an account if you this isn't your own instance",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
7
src/config/fragments/docker.ts
Normal file
7
src/config/fragments/docker.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { FragmentSchema } from '@/config/fragments/types';
|
||||||
|
|
||||||
|
export const dockerFragment: FragmentSchema = {
|
||||||
|
postgres: {
|
||||||
|
connection: 'postgres://postgres:postgres@postgres:5432/postgres',
|
||||||
|
},
|
||||||
|
};
|
@@ -1,11 +1,15 @@
|
|||||||
import { devFragment } from '@/config/fragments/dev';
|
import { devFragment } from '@/config/fragments/dev';
|
||||||
|
import { dockerFragment } from '@/config/fragments/docker';
|
||||||
import { configSchema } from '@/config/schema';
|
import { configSchema } from '@/config/schema';
|
||||||
import { createConfigLoader } from 'neat-config';
|
import { createConfigLoader } from 'neat-config';
|
||||||
|
|
||||||
const fragments = {
|
const fragments = {
|
||||||
dev: devFragment,
|
dev: devFragment,
|
||||||
|
dockerdev: dockerFragment,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const version = '1.0.0';
|
||||||
|
|
||||||
export const conf = createConfigLoader()
|
export const conf = createConfigLoader()
|
||||||
.addFromEnvironment('MWB_')
|
.addFromEnvironment('MWB_')
|
||||||
.addFromCLI('mwb-')
|
.addFromCLI('mwb-')
|
||||||
|
@@ -26,4 +26,44 @@ export const configSchema = z.object({
|
|||||||
debug: z.coerce.boolean().default(false),
|
debug: z.coerce.boolean().default(false),
|
||||||
})
|
})
|
||||||
.default({}),
|
.default({}),
|
||||||
|
postgres: z.object({
|
||||||
|
// connection URL for postgres database
|
||||||
|
connection: z.string(),
|
||||||
|
|
||||||
|
// run all migrations on boot of the application
|
||||||
|
migrateOnBoot: z.coerce.boolean().default(false),
|
||||||
|
|
||||||
|
// try to sync the schema on boot, useful for development
|
||||||
|
// will always keep the database schema in sync with the connected database
|
||||||
|
// it is extremely destructive, do not use it EVER in production
|
||||||
|
syncSchema: z.coerce.boolean().default(false),
|
||||||
|
}),
|
||||||
|
crypto: z.object({
|
||||||
|
// session secret. used for signing session tokens
|
||||||
|
sessionSecret: z.string().min(32),
|
||||||
|
}),
|
||||||
|
meta: z.object({
|
||||||
|
// name and description of this backend
|
||||||
|
// this is displayed to the client when making an account
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
}),
|
||||||
|
captcha: z
|
||||||
|
.object({
|
||||||
|
// enabled captchas on register
|
||||||
|
enabled: z.coerce.boolean().default(false),
|
||||||
|
|
||||||
|
// captcha secret
|
||||||
|
secret: z.string().min(1).optional(),
|
||||||
|
|
||||||
|
clientKey: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.default({}),
|
||||||
|
ratelimits: z
|
||||||
|
.object({
|
||||||
|
// enabled captchas on register
|
||||||
|
enabled: z.coerce.boolean().default(false),
|
||||||
|
redisUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.default({}),
|
||||||
});
|
});
|
||||||
|
615
src/db/migrations/.snapshot-movie_web.json
Normal file
615
src/db/migrations/.snapshot-movie_web.json
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
{
|
||||||
|
"namespaces": [
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"name": "public",
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"tmdb_id": {
|
||||||
|
"name": "tmdb_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"name": "meta",
|
||||||
|
"type": "jsonb",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "json"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "bookmarks",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "bookmarks_tmdb_id_user_id_unique",
|
||||||
|
"columnNames": [
|
||||||
|
"tmdb_id",
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"composite": true,
|
||||||
|
"primary": false,
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "bookmarks_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"tmdb_id",
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"composite": true,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "uuid",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "uuid"
|
||||||
|
},
|
||||||
|
"flow": {
|
||||||
|
"name": "flow",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"auth_type": {
|
||||||
|
"name": "auth_type",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "challenge_codes",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "challenge_codes_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"code"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "uuid"
|
||||||
|
},
|
||||||
|
"tmdb_id": {
|
||||||
|
"name": "tmdb_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"season_id": {
|
||||||
|
"name": "season_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"episode_id": {
|
||||||
|
"name": "episode_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"name": "meta",
|
||||||
|
"type": "jsonb",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "json"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "duration",
|
||||||
|
"type": "bigint",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "bigint"
|
||||||
|
},
|
||||||
|
"watched": {
|
||||||
|
"name": "watched",
|
||||||
|
"type": "bigint",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "bigint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "progress_items",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "progress_items_tmdb_id_user_id_season_id_episode_id_unique",
|
||||||
|
"columnNames": [
|
||||||
|
"tmdb_id",
|
||||||
|
"user_id",
|
||||||
|
"season_id",
|
||||||
|
"episode_id"
|
||||||
|
],
|
||||||
|
"composite": true,
|
||||||
|
"primary": false,
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "progress_items_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "uuid"
|
||||||
|
},
|
||||||
|
"tmdb_id": {
|
||||||
|
"name": "tmdb_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"season_id": {
|
||||||
|
"name": "season_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"episode_id": {
|
||||||
|
"name": "episode_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"embed_id": {
|
||||||
|
"name": "embed_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"error_message": {
|
||||||
|
"name": "error_message",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"full_error": {
|
||||||
|
"name": "full_error",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "provider_metrics",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "provider_metrics_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "uuid"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"accessed_at": {
|
||||||
|
"name": "accessed_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"name": "device",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "sessions_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"name": "public_key",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"namespace": {
|
||||||
|
"name": "namespace",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"last_logged_in": {
|
||||||
|
"name": "last_logged_in",
|
||||||
|
"type": "timestamptz(0)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"length": 0,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"name": "permissions",
|
||||||
|
"type": "text[]",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "array"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"name": "profile",
|
||||||
|
"type": "jsonb",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "users",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"columnNames": [
|
||||||
|
"public_key"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"keyName": "users_public_key_unique",
|
||||||
|
"primary": false,
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "users_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "uuid"
|
||||||
|
},
|
||||||
|
"application_theme": {
|
||||||
|
"name": "application_theme",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"application_language": {
|
||||||
|
"name": "application_language",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
},
|
||||||
|
"default_subtitle_language": {
|
||||||
|
"name": "default_subtitle_language",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "user_settings",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "user_settings_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
42
src/db/migrations/Migration20231104150702.ts
Normal file
42
src/db/migrations/Migration20231104150702.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20231104150702 extends Migration {
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
'create table "bookmarks" ("tmdb_id" varchar(255) not null, "user_id" varchar(255) not null, "meta" jsonb not null, "updated_at" timestamptz(0) not null, constraint "bookmarks_pkey" primary key ("tmdb_id", "user_id"));',
|
||||||
|
);
|
||||||
|
this.addSql(
|
||||||
|
'alter table "bookmarks" add constraint "bookmarks_tmdb_id_user_id_unique" unique ("tmdb_id", "user_id");',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
'create table "challenge_codes" ("code" uuid not null, "flow" text not null, "auth_type" varchar(255) not null, "created_at" timestamptz(0) not null, "expires_at" timestamptz(0) not null, constraint "challenge_codes_pkey" primary key ("code"));',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
'create table "progress_items" ("id" uuid not null, "tmdb_id" varchar(255) not null, "user_id" varchar(255) not null, "season_id" varchar(255) null, "episode_id" varchar(255) null, "meta" jsonb not null, "updated_at" timestamptz(0) not null, "duration" bigint not null, "watched" bigint not null, constraint "progress_items_pkey" primary key ("id"));',
|
||||||
|
);
|
||||||
|
this.addSql(
|
||||||
|
'alter table "progress_items" add constraint "progress_items_tmdb_id_user_id_season_id_episode_id_unique" unique ("tmdb_id", "user_id", "season_id", "episode_id");',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
'create table "provider_metrics" ("id" uuid not null, "tmdb_id" varchar(255) not null, "type" varchar(255) not null, "title" varchar(255) not null, "season_id" varchar(255) null, "episode_id" varchar(255) null, "created_at" timestamptz(0) not null, "status" varchar(255) not null, "provider_id" varchar(255) not null, "embed_id" varchar(255) null, "error_message" varchar(255) null, "full_error" varchar(255) null, constraint "provider_metrics_pkey" primary key ("id"));',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
'create table "sessions" ("id" uuid not null, "user" text not null, "created_at" timestamptz(0) not null, "accessed_at" timestamptz(0) not null, "expires_at" timestamptz(0) not null, "device" text not null, "user_agent" text not null, constraint "sessions_pkey" primary key ("id"));',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
'create table "users" ("id" text not null, "public_key" text not null, "namespace" varchar(255) not null, "created_at" timestamptz(0) not null, "last_logged_in" timestamptz(0) null, "permissions" text[] not null, "profile" jsonb not null, constraint "users_pkey" primary key ("id"));',
|
||||||
|
);
|
||||||
|
this.addSql(
|
||||||
|
'alter table "users" add constraint "users_public_key_unique" unique ("public_key");',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
'create table "user_settings" ("id" uuid not null, "application_theme" varchar(255) null, "application_language" varchar(255) null, "default_subtitle_language" varchar(255) null, constraint "user_settings_pkey" primary key ("id"));',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
54
src/db/models/Bookmark.ts
Normal file
54
src/db/models/Bookmark.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const bookmarkMetaSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
year: z.number(),
|
||||||
|
poster: z.string().optional(),
|
||||||
|
type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BookmarkMeta = z.infer<typeof bookmarkMetaSchema>;
|
||||||
|
|
||||||
|
@Entity({ tableName: 'bookmarks' })
|
||||||
|
@Unique({ properties: ['tmdbId', 'userId'] })
|
||||||
|
export class Bookmark {
|
||||||
|
@PrimaryKey({ name: 'tmdb_id' })
|
||||||
|
tmdbId!: string;
|
||||||
|
|
||||||
|
@PrimaryKey({ name: 'user_id' })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
name: 'meta',
|
||||||
|
type: types.json,
|
||||||
|
})
|
||||||
|
meta!: BookmarkMeta;
|
||||||
|
|
||||||
|
@Property({ name: 'updated_at', type: 'date' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookmarkDTO {
|
||||||
|
tmdbId: string;
|
||||||
|
meta: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
poster?: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBookmark(bookmark: Bookmark): BookmarkDTO {
|
||||||
|
return {
|
||||||
|
tmdbId: bookmark.tmdbId,
|
||||||
|
meta: {
|
||||||
|
title: bookmark.meta.title,
|
||||||
|
year: bookmark.meta.year,
|
||||||
|
poster: bookmark.meta.poster,
|
||||||
|
type: bookmark.meta.type,
|
||||||
|
},
|
||||||
|
updatedAt: bookmark.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
47
src/db/models/ChallengeCode.ts
Normal file
47
src/db/models/ChallengeCode.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
// 30 seconds
|
||||||
|
const CHALLENGE_EXPIRY_MS = 3000000 * 1000;
|
||||||
|
|
||||||
|
export type ChallengeFlow = 'registration' | 'login';
|
||||||
|
|
||||||
|
export type ChallengeType = 'mnemonic';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'challenge_codes' })
|
||||||
|
export class ChallengeCode {
|
||||||
|
@PrimaryKey({ name: 'code', type: 'uuid' })
|
||||||
|
code: string = randomUUID();
|
||||||
|
|
||||||
|
@Property({ name: 'flow', type: 'text' })
|
||||||
|
flow!: ChallengeFlow;
|
||||||
|
|
||||||
|
@Property({ name: 'auth_type' })
|
||||||
|
authType!: ChallengeType;
|
||||||
|
|
||||||
|
@Property({ type: 'date' })
|
||||||
|
createdAt: Date = new Date();
|
||||||
|
|
||||||
|
@Property({ type: 'date' })
|
||||||
|
expiresAt: Date = new Date(Date.now() + CHALLENGE_EXPIRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeCodeDTO {
|
||||||
|
code: string;
|
||||||
|
flow: string;
|
||||||
|
authType: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatChallengeCode(
|
||||||
|
challenge: ChallengeCode,
|
||||||
|
): ChallengeCodeDTO {
|
||||||
|
return {
|
||||||
|
code: challenge.code,
|
||||||
|
flow: challenge.flow,
|
||||||
|
authType: challenge.authType,
|
||||||
|
createdAt: challenge.createdAt.toISOString(),
|
||||||
|
expiresAt: challenge.expiresAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
81
src/db/models/ProgressItem.ts
Normal file
81
src/db/models/ProgressItem.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const progressMetaSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
year: z.number(),
|
||||||
|
poster: z.string().optional(),
|
||||||
|
type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProgressMeta = z.infer<typeof progressMetaSchema>;
|
||||||
|
|
||||||
|
@Entity({ tableName: 'progress_items' })
|
||||||
|
@Unique({ properties: ['tmdbId', 'userId', 'seasonId', 'episodeId'] })
|
||||||
|
export class ProgressItem {
|
||||||
|
@PrimaryKey({ name: 'id', type: 'uuid' })
|
||||||
|
id: string = randomUUID();
|
||||||
|
|
||||||
|
@Property({ name: 'tmdb_id' })
|
||||||
|
tmdbId!: string;
|
||||||
|
|
||||||
|
@Property({ name: 'user_id' })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Property({ name: 'season_id', nullable: true })
|
||||||
|
seasonId?: string;
|
||||||
|
|
||||||
|
@Property({ name: 'episode_id', nullable: true })
|
||||||
|
episodeId?: string;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
name: 'meta',
|
||||||
|
type: types.json,
|
||||||
|
})
|
||||||
|
meta!: ProgressMeta;
|
||||||
|
|
||||||
|
@Property({ name: 'updated_at', type: 'date' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
/* progress */
|
||||||
|
@Property({ name: 'duration', type: 'bigint' })
|
||||||
|
duration!: number;
|
||||||
|
|
||||||
|
@Property({ name: 'watched', type: 'bigint' })
|
||||||
|
watched!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressItemDTO {
|
||||||
|
tmdbId: string;
|
||||||
|
seasonId?: string;
|
||||||
|
episodeId?: string;
|
||||||
|
meta: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
poster?: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
duration: number;
|
||||||
|
watched: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatProgressItem(
|
||||||
|
progressItem: ProgressItem,
|
||||||
|
): ProgressItemDTO {
|
||||||
|
return {
|
||||||
|
tmdbId: progressItem.tmdbId,
|
||||||
|
seasonId: progressItem.seasonId,
|
||||||
|
episodeId: progressItem.episodeId,
|
||||||
|
meta: {
|
||||||
|
title: progressItem.meta.title,
|
||||||
|
year: progressItem.meta.year,
|
||||||
|
poster: progressItem.meta.poster,
|
||||||
|
type: progressItem.meta.type,
|
||||||
|
},
|
||||||
|
duration: progressItem.duration,
|
||||||
|
watched: progressItem.watched,
|
||||||
|
updatedAt: progressItem.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
48
src/db/models/ProviderMetrics.ts
Normal file
48
src/db/models/ProviderMetrics.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const status = {
|
||||||
|
failed: 'failed',
|
||||||
|
notfound: 'notfound',
|
||||||
|
success: 'success',
|
||||||
|
} as const;
|
||||||
|
type Status = keyof typeof status;
|
||||||
|
|
||||||
|
@Entity({ tableName: 'provider_metrics' })
|
||||||
|
export class ProviderMetric {
|
||||||
|
@PrimaryKey({ name: 'id', type: 'uuid' })
|
||||||
|
id: string = randomUUID();
|
||||||
|
|
||||||
|
@Property({ name: 'tmdb_id' })
|
||||||
|
tmdbId!: string;
|
||||||
|
|
||||||
|
@Property({ name: 'type' })
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@Property({ name: 'title' })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Property({ name: 'season_id', nullable: true })
|
||||||
|
seasonId?: string;
|
||||||
|
|
||||||
|
@Property({ name: 'episode_id', nullable: true })
|
||||||
|
episodeId?: string;
|
||||||
|
|
||||||
|
@Property({ name: 'created_at', type: 'date' })
|
||||||
|
createdAt = new Date();
|
||||||
|
|
||||||
|
@Property({ name: 'status' })
|
||||||
|
status!: Status;
|
||||||
|
|
||||||
|
@Property({ name: 'provider_id' })
|
||||||
|
providerId!: string;
|
||||||
|
|
||||||
|
@Property({ name: 'embed_id', nullable: true })
|
||||||
|
embedId?: string;
|
||||||
|
|
||||||
|
@Property({ name: 'error_message', nullable: true })
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
|
@Property({ name: 'full_error', nullable: true })
|
||||||
|
fullError?: string;
|
||||||
|
}
|
46
src/db/models/Session.ts
Normal file
46
src/db/models/Session.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'sessions' })
|
||||||
|
export class Session {
|
||||||
|
@PrimaryKey({ name: 'id', type: 'uuid' })
|
||||||
|
id: string = randomUUID();
|
||||||
|
|
||||||
|
@Property({ name: 'user', type: 'text' })
|
||||||
|
user!: string;
|
||||||
|
|
||||||
|
@Property({ type: 'date' })
|
||||||
|
createdAt: Date = new Date();
|
||||||
|
|
||||||
|
@Property({ type: 'date' })
|
||||||
|
accessedAt!: Date;
|
||||||
|
|
||||||
|
@Property({ type: 'date' })
|
||||||
|
expiresAt!: Date;
|
||||||
|
|
||||||
|
@Property({ type: 'text' })
|
||||||
|
device!: string;
|
||||||
|
|
||||||
|
@Property({ type: 'text' })
|
||||||
|
userAgent!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDTO {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: string;
|
||||||
|
accessedAt: string;
|
||||||
|
device: string;
|
||||||
|
userAgent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSession(session: Session): SessionDTO {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
userId: session.user,
|
||||||
|
createdAt: session.createdAt.toISOString(),
|
||||||
|
accessedAt: session.accessedAt.toISOString(),
|
||||||
|
device: session.device,
|
||||||
|
userAgent: session.userAgent,
|
||||||
|
};
|
||||||
|
}
|
66
src/db/models/User.ts
Normal file
66
src/db/models/User.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export type UserProfile = {
|
||||||
|
colorA: string;
|
||||||
|
colorB: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Entity({ tableName: 'users' })
|
||||||
|
export class User {
|
||||||
|
@PrimaryKey({ name: 'id', type: 'text' })
|
||||||
|
id: string = nanoid(12);
|
||||||
|
|
||||||
|
@Property({ name: 'public_key', type: 'text' })
|
||||||
|
@Unique()
|
||||||
|
publicKey!: string;
|
||||||
|
|
||||||
|
@Property({ name: 'namespace' })
|
||||||
|
namespace!: string;
|
||||||
|
|
||||||
|
@Property({ type: 'date' })
|
||||||
|
createdAt: Date = new Date();
|
||||||
|
|
||||||
|
@Property({ type: 'date', nullable: true })
|
||||||
|
lastLoggedIn?: Date;
|
||||||
|
|
||||||
|
@Property({ name: 'permissions', type: types.array })
|
||||||
|
roles: string[] = [];
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
name: 'profile',
|
||||||
|
type: types.json,
|
||||||
|
})
|
||||||
|
profile!: UserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDTO {
|
||||||
|
id: string;
|
||||||
|
namespace: string;
|
||||||
|
publicKey: string;
|
||||||
|
roles: string[];
|
||||||
|
createdAt: string;
|
||||||
|
lastLoggedIn?: string;
|
||||||
|
profile: {
|
||||||
|
colorA: string;
|
||||||
|
colorB: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUser(user: User): UserDTO {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
namespace: user.namespace,
|
||||||
|
publicKey: user.publicKey,
|
||||||
|
roles: user.roles,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
lastLoggedIn: user.lastLoggedIn?.toISOString(),
|
||||||
|
profile: {
|
||||||
|
colorA: user.profile.colorA,
|
||||||
|
colorB: user.profile.colorB,
|
||||||
|
icon: user.profile.icon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
35
src/db/models/UserSettings.ts
Normal file
35
src/db/models/UserSettings.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'user_settings' })
|
||||||
|
export class UserSettings {
|
||||||
|
@PrimaryKey({ name: 'id', type: 'uuid' })
|
||||||
|
id: string = randomUUID();
|
||||||
|
|
||||||
|
@Property({ name: 'application_theme', nullable: true })
|
||||||
|
applicationTheme?: string | null;
|
||||||
|
|
||||||
|
@Property({ name: 'application_language', nullable: true })
|
||||||
|
applicationLanguage?: string | null;
|
||||||
|
|
||||||
|
@Property({ name: 'default_subtitle_language', nullable: true })
|
||||||
|
defaultSubtitleLanguage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSettingsDTO {
|
||||||
|
id: string;
|
||||||
|
applicationTheme?: string | null;
|
||||||
|
applicationLanguage?: string | null;
|
||||||
|
defaultSubtitleLanguage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUserSettings(
|
||||||
|
userSettings: UserSettings,
|
||||||
|
): UserSettingsDTO {
|
||||||
|
return {
|
||||||
|
id: userSettings.id,
|
||||||
|
applicationTheme: userSettings.applicationTheme,
|
||||||
|
applicationLanguage: userSettings.applicationLanguage,
|
||||||
|
defaultSubtitleLanguage: userSettings.defaultSubtitleLanguage,
|
||||||
|
};
|
||||||
|
}
|
19
src/main.ts
19
src/main.ts
@@ -1,4 +1,12 @@
|
|||||||
import { setupFastify } from '@/modules/fastify';
|
import {
|
||||||
|
setupFastify,
|
||||||
|
setupFastifyRoutes,
|
||||||
|
startFastify,
|
||||||
|
} from '@/modules/fastify';
|
||||||
|
import { setupJobs } from '@/modules/jobs';
|
||||||
|
import { setupMetrics } from '@/modules/metrics';
|
||||||
|
import { setupMikroORM } from '@/modules/mikro';
|
||||||
|
import { setupRatelimits } from '@/modules/ratelimits';
|
||||||
import { scopedLogger } from '@/services/logger';
|
import { scopedLogger } from '@/services/logger';
|
||||||
|
|
||||||
const log = scopedLogger('mw-backend');
|
const log = scopedLogger('mw-backend');
|
||||||
@@ -8,7 +16,14 @@ async function bootstrap(): Promise<void> {
|
|||||||
evt: 'setup',
|
evt: 'setup',
|
||||||
});
|
});
|
||||||
|
|
||||||
await setupFastify();
|
await setupRatelimits();
|
||||||
|
const app = await setupFastify();
|
||||||
|
await setupMikroORM();
|
||||||
|
await setupMetrics(app);
|
||||||
|
await setupJobs();
|
||||||
|
|
||||||
|
await setupFastifyRoutes(app);
|
||||||
|
await startFastify(app);
|
||||||
|
|
||||||
log.info(`App setup, ready to accept connections`, {
|
log.info(`App setup, ready to accept connections`, {
|
||||||
evt: 'success',
|
evt: 'success',
|
||||||
|
4
src/mikro-orm.config.ts
Normal file
4
src/mikro-orm.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { makeOrmConfig } from '@/modules/mikro/orm';
|
||||||
|
import { conf } from '@/config';
|
||||||
|
|
||||||
|
export default makeOrmConfig(conf.postgres.connection);
|
@@ -8,6 +8,7 @@ import {
|
|||||||
validatorCompiler,
|
validatorCompiler,
|
||||||
} from 'fastify-type-provider-zod';
|
} from 'fastify-type-provider-zod';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
|
||||||
const log = scopedLogger('fastify');
|
const log = scopedLogger('fastify');
|
||||||
|
|
||||||
@@ -16,8 +17,8 @@ export async function setupFastify(): Promise<FastifyInstance> {
|
|||||||
// create server
|
// create server
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: makeFastifyLogger(log) as any,
|
logger: makeFastifyLogger(log) as any,
|
||||||
|
trustProxy: conf.server.trustProxy,
|
||||||
});
|
});
|
||||||
let exportedApp: FastifyInstance | null = null;
|
|
||||||
|
|
||||||
app.setValidatorCompiler(validatorCompiler);
|
app.setValidatorCompiler(validatorCompiler);
|
||||||
app.setSerializerCompiler(serializerCompiler);
|
app.setSerializerCompiler(serializerCompiler);
|
||||||
@@ -31,8 +32,8 @@ export async function setupFastify(): Promise<FastifyInstance> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err.statusCode) {
|
if (err instanceof StatusError) {
|
||||||
reply.status(err.statusCode).send({
|
reply.status(err.errorStatusCode).send({
|
||||||
errorType: 'message',
|
errorType: 'message',
|
||||||
message: err.message,
|
message: err.message,
|
||||||
});
|
});
|
||||||
@@ -53,27 +54,20 @@ export async function setupFastify(): Promise<FastifyInstance> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// plugins & routes
|
// plugins
|
||||||
log.info(`setting up plugins and routes`, { evt: 'setup-plugins' });
|
log.info(`setting up plugins`, { evt: 'setup-plugins' });
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: conf.server.cors.split(' ').filter((v) => v.length > 0),
|
origin: conf.server.cors.split(' ').filter((v) => v.length > 0),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
await app.register(
|
|
||||||
async (api, opts, done) => {
|
|
||||||
setupRoutes(api);
|
|
||||||
|
|
||||||
exportedApp = api;
|
return app;
|
||||||
done();
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
prefix: conf.server.basePath,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
export function startFastify(app: FastifyInstance) {
|
||||||
// listen to port
|
// listen to port
|
||||||
log.info(`listening to port`, { evt: 'setup-listen' });
|
log.info(`listening to port`, { evt: 'setup-listen' });
|
||||||
return new Promise((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
app.listen(
|
app.listen(
|
||||||
{
|
{
|
||||||
port: conf.server.port,
|
port: conf.server.port,
|
||||||
@@ -90,8 +84,21 @@ export async function setupFastify(): Promise<FastifyInstance> {
|
|||||||
log.info(`fastify setup successfully`, {
|
log.info(`fastify setup successfully`, {
|
||||||
evt: 'setup-success',
|
evt: 'setup-success',
|
||||||
});
|
});
|
||||||
resolve(exportedApp as FastifyInstance);
|
resolve();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setupFastifyRoutes(app: FastifyInstance) {
|
||||||
|
log.info(`setting up routes`, { evt: 'setup-plugins' });
|
||||||
|
await app.register(
|
||||||
|
async (api, opts, done) => {
|
||||||
|
setupRoutes(api);
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: conf.server.basePath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,28 @@
|
|||||||
import { helloRouter } from '@/routes/hello';
|
import { loginAuthRouter } from '@/routes/auth/login';
|
||||||
|
import { manageAuthRouter } from '@/routes/auth/manage';
|
||||||
|
import { metaRouter } from '@/routes/meta';
|
||||||
|
import { metricsRouter } from '@/routes/metrics';
|
||||||
|
import { sessionsRouter } from '@/routes/sessions';
|
||||||
|
import { userBookmarkRouter } from '@/routes/users/bookmark';
|
||||||
|
import { userDeleteRouter } from '@/routes/users/delete';
|
||||||
|
import { userEditRouter } from '@/routes/users/edit';
|
||||||
|
import { userGetRouter } from '@/routes/users/get';
|
||||||
|
import { userProgressRouter } from '@/routes/users/progress';
|
||||||
|
import { userSessionsRouter } from '@/routes/users/sessions';
|
||||||
|
import { userSettingsRouter } from '@/routes/users/settings';
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
export async function setupRoutes(app: FastifyInstance) {
|
export async function setupRoutes(app: FastifyInstance) {
|
||||||
app.register(helloRouter);
|
await app.register(manageAuthRouter.register);
|
||||||
|
await app.register(loginAuthRouter.register);
|
||||||
|
await app.register(userSessionsRouter.register);
|
||||||
|
await app.register(sessionsRouter.register);
|
||||||
|
await app.register(userEditRouter.register);
|
||||||
|
await app.register(userDeleteRouter.register);
|
||||||
|
await app.register(metaRouter.register);
|
||||||
|
await app.register(userProgressRouter.register);
|
||||||
|
await app.register(userBookmarkRouter.register);
|
||||||
|
await app.register(userSettingsRouter.register);
|
||||||
|
await app.register(userGetRouter.register);
|
||||||
|
await app.register(metricsRouter.register);
|
||||||
}
|
}
|
||||||
|
11
src/modules/jobs/index.ts
Normal file
11
src/modules/jobs/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { challengeCodeJob } from '@/modules/jobs/list/challengeCode';
|
||||||
|
import { sessionExpiryJob } from '@/modules/jobs/list/sessionExpiry';
|
||||||
|
import { userDeletionJob } from '@/modules/jobs/list/userDeletion';
|
||||||
|
import { providerMetricCleanupJob } from '@/modules/jobs/list/providerMetricCleanup';
|
||||||
|
|
||||||
|
export async function setupJobs() {
|
||||||
|
challengeCodeJob.start();
|
||||||
|
sessionExpiryJob.start();
|
||||||
|
userDeletionJob.start();
|
||||||
|
providerMetricCleanupJob.start();
|
||||||
|
}
|
46
src/modules/jobs/job.ts
Normal file
46
src/modules/jobs/job.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { getORM } from '@/modules/mikro';
|
||||||
|
import { scopedLogger } from '@/services/logger';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { CronJob } from 'cron';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
|
||||||
|
const minOffset = 0;
|
||||||
|
const maxOffset = 60 * 4;
|
||||||
|
const secondsOffset =
|
||||||
|
Math.floor(Math.random() * (maxOffset - minOffset)) + minOffset;
|
||||||
|
|
||||||
|
const wait = (sec: number) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => resolve(), sec * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param cron crontime in this order: (min of hour) (hour of day) (day of month) (day of week) (sec of month)
|
||||||
|
*/
|
||||||
|
export function job(
|
||||||
|
id: string,
|
||||||
|
cron: string,
|
||||||
|
cb: (ctx: { em: EntityManager; log: Logger }) => Promise<void>,
|
||||||
|
): CronJob {
|
||||||
|
const log = scopedLogger('jobs', { jobId: id });
|
||||||
|
log.info(`Registering job '${id}' with cron '${cron}'`);
|
||||||
|
return CronJob.from({
|
||||||
|
cronTime: cron,
|
||||||
|
onTick: async () => {
|
||||||
|
// offset by random amount of seconds, just to prevent jobs running at
|
||||||
|
// the same time when running multiple instances
|
||||||
|
await wait(secondsOffset);
|
||||||
|
|
||||||
|
// actually run the job
|
||||||
|
try {
|
||||||
|
const em = getORM().em.fork();
|
||||||
|
log.info(`Starting job '${id}' with cron '${cron}'`);
|
||||||
|
await cb({ em, log: log });
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`Failed to run '${id}' job!`);
|
||||||
|
log.error(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
start: false,
|
||||||
|
});
|
||||||
|
}
|
19
src/modules/jobs/list/challengeCode.ts
Normal file
19
src/modules/jobs/list/challengeCode.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ChallengeCode } from '@/db/models/ChallengeCode';
|
||||||
|
import { job } from '@/modules/jobs/job';
|
||||||
|
|
||||||
|
// every day at 12:00:00
|
||||||
|
export const challengeCodeJob = job(
|
||||||
|
'challenge-code-expiry',
|
||||||
|
'0 12 * * *',
|
||||||
|
async ({ em }) => {
|
||||||
|
await em
|
||||||
|
.createQueryBuilder(ChallengeCode)
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
expiresAt: {
|
||||||
|
$lt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
},
|
||||||
|
);
|
27
src/modules/jobs/list/providerMetricCleanup.ts
Normal file
27
src/modules/jobs/list/providerMetricCleanup.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ProviderMetric } from '@/db/models/ProviderMetrics';
|
||||||
|
import { job } from '@/modules/jobs/job';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
|
// every day at 12:00:00
|
||||||
|
export const providerMetricCleanupJob = job(
|
||||||
|
'provider-metric-cleanup',
|
||||||
|
'0 12 * * *',
|
||||||
|
async ({ em, log }) => {
|
||||||
|
const now = new Date();
|
||||||
|
const thirtyDaysAgo = new Date(now.getTime() - ms('30d'));
|
||||||
|
|
||||||
|
const deletedMetrics = await em
|
||||||
|
.createQueryBuilder(ProviderMetric)
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
createdAt: {
|
||||||
|
$lt: thirtyDaysAgo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.execute<{ affectedRows: number }>('run');
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`Removed ${deletedMetrics.affectedRows} metrics that were older than 30 days`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
23
src/modules/jobs/list/sessionExpiry.ts
Normal file
23
src/modules/jobs/list/sessionExpiry.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Session } from '@/db/models/Session';
|
||||||
|
import { job } from '@/modules/jobs/job';
|
||||||
|
|
||||||
|
// every day at 12:00:00
|
||||||
|
export const sessionExpiryJob = job(
|
||||||
|
'session-expiry',
|
||||||
|
'0 12 * * *',
|
||||||
|
async ({ em, log }) => {
|
||||||
|
const deletedSessions = await em
|
||||||
|
.createQueryBuilder(Session)
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
expiresAt: {
|
||||||
|
$lt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.execute<{ affectedRows: number }>('run');
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`Removed ${deletedSessions.affectedRows} sessions that had expired`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
42
src/modules/jobs/list/userDeletion.ts
Normal file
42
src/modules/jobs/list/userDeletion.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Session } from '@/db/models/Session';
|
||||||
|
import { User } from '@/db/models/User';
|
||||||
|
import { job } from '@/modules/jobs/job';
|
||||||
|
|
||||||
|
// every day at 12:00:00
|
||||||
|
export const userDeletionJob = job(
|
||||||
|
'user-deletion',
|
||||||
|
'0 12 * * *',
|
||||||
|
async ({ em, log }) => {
|
||||||
|
const knex = em.getKnex();
|
||||||
|
|
||||||
|
// Count all sessions for a user ID
|
||||||
|
const sessionCountForUser = em
|
||||||
|
.createQueryBuilder(Session, 'session')
|
||||||
|
.count()
|
||||||
|
.where({ user: knex.ref('user.id') })
|
||||||
|
.getKnexQuery();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const oneYearAgo = new Date();
|
||||||
|
oneYearAgo.setFullYear(now.getFullYear() - 1);
|
||||||
|
|
||||||
|
// Delete all users who do not have any sessions AND
|
||||||
|
// (their login date is null OR they last logged in over 1 year ago)
|
||||||
|
const deletedUsers = await em
|
||||||
|
.createQueryBuilder(User, 'user')
|
||||||
|
.delete()
|
||||||
|
.withSubQuery(sessionCountForUser, 'session.sessionCount')
|
||||||
|
.where({
|
||||||
|
'session.sessionCount': 0,
|
||||||
|
$or: [
|
||||||
|
{ lastLoggedIn: { $eq: undefined } },
|
||||||
|
{ lastLoggedIn: { $lt: oneYearAgo } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.execute<{ affectedRows: number }>('run');
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`Removed ${deletedUsers.affectedRows} users older than 1 year with no sessions`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
74
src/modules/metrics/index.ts
Normal file
74
src/modules/metrics/index.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { getORM } from '@/modules/mikro';
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { Counter } from 'prom-client';
|
||||||
|
import metricsPlugin from 'fastify-metrics';
|
||||||
|
import { updateMetrics } from '@/modules/metrics/update';
|
||||||
|
import { scopedLogger } from '@/services/logger';
|
||||||
|
|
||||||
|
const log = scopedLogger('metrics');
|
||||||
|
|
||||||
|
export type Metrics = {
|
||||||
|
user: Counter<'namespace'>;
|
||||||
|
providerMetrics: Counter<
|
||||||
|
| 'title'
|
||||||
|
| 'tmdb_id'
|
||||||
|
| 'season_id'
|
||||||
|
| 'episode_id'
|
||||||
|
| 'status'
|
||||||
|
| 'type'
|
||||||
|
| 'provider_id'
|
||||||
|
| 'embed_id'
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let metrics: null | Metrics = null;
|
||||||
|
|
||||||
|
export function getMetrics() {
|
||||||
|
if (!metrics) throw new Error('metrics not initialized');
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupMetrics(app: FastifyInstance) {
|
||||||
|
log.info(`Setting up metrics...`, { evt: 'start' });
|
||||||
|
|
||||||
|
await app.register(metricsPlugin, {
|
||||||
|
endpoint: '/metrics',
|
||||||
|
routeMetrics: {
|
||||||
|
enabled: true,
|
||||||
|
registeredRoutesOnly: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics = {
|
||||||
|
user: new Counter({
|
||||||
|
name: 'user_count',
|
||||||
|
help: 'user_help',
|
||||||
|
labelNames: ['namespace'],
|
||||||
|
}),
|
||||||
|
providerMetrics: new Counter({
|
||||||
|
name: 'provider_metrics',
|
||||||
|
help: 'provider_metrics',
|
||||||
|
labelNames: [
|
||||||
|
'episode_id',
|
||||||
|
'provider_id',
|
||||||
|
'season_id',
|
||||||
|
'status',
|
||||||
|
'title',
|
||||||
|
'tmdb_id',
|
||||||
|
'type',
|
||||||
|
'embed_id',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const promClient = app.metrics.client;
|
||||||
|
|
||||||
|
promClient.register.registerMetric(metrics.user);
|
||||||
|
promClient.register.registerMetric(metrics.providerMetrics);
|
||||||
|
|
||||||
|
const orm = getORM();
|
||||||
|
const em = orm.em.fork();
|
||||||
|
log.info(`Syncing up metrics...`, { evt: 'sync' });
|
||||||
|
await updateMetrics(em, metrics);
|
||||||
|
log.info(`Metrics initialized!`, { evt: 'end' });
|
||||||
|
}
|
23
src/modules/metrics/update.ts
Normal file
23
src/modules/metrics/update.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { User } from '@/db/models/User';
|
||||||
|
import { Metrics } from '@/modules/metrics';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
|
||||||
|
export async function updateMetrics(em: EntityManager, metrics: Metrics) {
|
||||||
|
const users = await em
|
||||||
|
.createQueryBuilder(User)
|
||||||
|
.groupBy('namespace')
|
||||||
|
.count()
|
||||||
|
.select(['namespace', 'count'])
|
||||||
|
.execute<
|
||||||
|
{
|
||||||
|
namespace: string;
|
||||||
|
count: string;
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
|
|
||||||
|
metrics.user.reset();
|
||||||
|
|
||||||
|
users.forEach((v) => {
|
||||||
|
metrics?.user.inc({ namespace: v.namespace }, Number(v.count));
|
||||||
|
});
|
||||||
|
}
|
44
src/modules/mikro/index.ts
Normal file
44
src/modules/mikro/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { conf } from '@/config';
|
||||||
|
import { scopedLogger } from '@/services/logger';
|
||||||
|
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
|
||||||
|
import { MikroORM } from '@mikro-orm/core';
|
||||||
|
import { createORM } from './orm';
|
||||||
|
|
||||||
|
const log = scopedLogger('orm');
|
||||||
|
let orm: MikroORM<PostgreSqlDriver> | null = null;
|
||||||
|
|
||||||
|
export function getORM() {
|
||||||
|
if (!orm) throw new Error('ORM not set');
|
||||||
|
return orm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupMikroORM() {
|
||||||
|
log.info(`Connecting to postgres`, { evt: 'connecting' });
|
||||||
|
const mikro = await createORM(conf.postgres.connection, (msg) =>
|
||||||
|
log.info(msg),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conf.postgres.syncSchema) {
|
||||||
|
const generator = mikro.getSchemaGenerator();
|
||||||
|
try {
|
||||||
|
await generator.updateSchema();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await generator.clearDatabase();
|
||||||
|
await generator.updateSchema();
|
||||||
|
} catch {
|
||||||
|
await generator.clearDatabase();
|
||||||
|
await generator.dropSchema();
|
||||||
|
await generator.updateSchema();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conf.postgres.migrateOnBoot) {
|
||||||
|
const migrator = mikro.getMigrator();
|
||||||
|
await migrator.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
orm = mikro;
|
||||||
|
log.info(`Connected to postgres - ORM is setup!`, { evt: 'success' });
|
||||||
|
}
|
24
src/modules/mikro/orm.ts
Normal file
24
src/modules/mikro/orm.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Options } from '@mikro-orm/core';
|
||||||
|
import { MikroORM, PostgreSqlDriver } from '@mikro-orm/postgresql';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export function makeOrmConfig(url: string): Options<PostgreSqlDriver> {
|
||||||
|
return {
|
||||||
|
type: 'postgresql',
|
||||||
|
clientUrl: url,
|
||||||
|
entities: ['./models/**/*.js'],
|
||||||
|
entitiesTs: ['./models/**/*.ts'],
|
||||||
|
baseDir: path.join(__dirname, '../../db'),
|
||||||
|
migrations: {
|
||||||
|
pathTs: './migrations',
|
||||||
|
path: './migrations',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createORM(url: string, log: (msg: string) => void) {
|
||||||
|
return await MikroORM.init<PostgreSqlDriver>({
|
||||||
|
...makeOrmConfig(url),
|
||||||
|
logger: log,
|
||||||
|
});
|
||||||
|
}
|
24
src/modules/ratelimits/index.ts
Normal file
24
src/modules/ratelimits/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { conf } from '@/config';
|
||||||
|
import { Limiter } from '@/modules/ratelimits/limiter';
|
||||||
|
import { connectRedis } from '@/modules/ratelimits/redis';
|
||||||
|
import { scopedLogger } from '@/services/logger';
|
||||||
|
|
||||||
|
const log = scopedLogger('ratelimits');
|
||||||
|
|
||||||
|
let limiter: null | Limiter = null;
|
||||||
|
|
||||||
|
export function getLimiter() {
|
||||||
|
return limiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupRatelimits() {
|
||||||
|
if (!conf.ratelimits.enabled) {
|
||||||
|
log.warn('Ratelimits disabled!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const redis = await connectRedis();
|
||||||
|
limiter = new Limiter({
|
||||||
|
redis,
|
||||||
|
});
|
||||||
|
log.info('Ratelimits have been setup!');
|
||||||
|
}
|
63
src/modules/ratelimits/limiter.ts
Normal file
63
src/modules/ratelimits/limiter.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
import RateLimiter from 'async-ratelimiter';
|
||||||
|
import ms from 'ms';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
|
||||||
|
export interface LimiterOptions {
|
||||||
|
redis: Redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimitBucket {
|
||||||
|
limiter: RateLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BucketOptions {
|
||||||
|
id: string;
|
||||||
|
window: string;
|
||||||
|
max: number;
|
||||||
|
inc?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Limiter {
|
||||||
|
private redis: Redis;
|
||||||
|
private buckets: Record<string, LimitBucket> = {};
|
||||||
|
|
||||||
|
constructor(ops: LimiterOptions) {
|
||||||
|
this.redis = ops.redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bump(req: { ip: string }, ops: BucketOptions) {
|
||||||
|
const ip = req.ip;
|
||||||
|
if (!this.buckets[ops.id]) {
|
||||||
|
this.buckets[ops.id] = {
|
||||||
|
limiter: new RateLimiter({
|
||||||
|
db: this.redis,
|
||||||
|
namespace: `RATELIMIT_${ops.id}`,
|
||||||
|
duration: ms(ops.window),
|
||||||
|
max: ops.max,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < (ops.inc ?? 0); i++) {
|
||||||
|
await this.buckets[ops.id].limiter.get({
|
||||||
|
id: ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const currentLimit = await this.buckets[ops.id].limiter.get({
|
||||||
|
id: ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasBeenLimited: currentLimit.remaining <= 0,
|
||||||
|
limit: currentLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertAndBump(req: { ip: string }, ops: BucketOptions) {
|
||||||
|
const { hasBeenLimited } = await this.bump(req, ops);
|
||||||
|
if (hasBeenLimited) {
|
||||||
|
throw new StatusError('Ratelimited', 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7
src/modules/ratelimits/redis.ts
Normal file
7
src/modules/ratelimits/redis.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { conf } from '@/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
export function connectRedis() {
|
||||||
|
if (!conf.ratelimits.redisUrl) throw new Error('missing redis URL');
|
||||||
|
return new Redis(conf.ratelimits.redisUrl);
|
||||||
|
}
|
93
src/routes/auth/login.ts
Normal file
93
src/routes/auth/login.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { ChallengeCode } from '@/db/models/ChallengeCode';
|
||||||
|
import { formatSession } from '@/db/models/Session';
|
||||||
|
import { User } from '@/db/models/User';
|
||||||
|
import { assertChallengeCode } from '@/services/challenge';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { makeSession, makeSessionToken } from '@/services/session';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const startSchema = z.object({
|
||||||
|
publicKey: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeSchema = z.object({
|
||||||
|
publicKey: z.string(),
|
||||||
|
challenge: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
signature: z.string(),
|
||||||
|
}),
|
||||||
|
device: z.string().max(500).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginAuthRouter = makeRouter((app) => {
|
||||||
|
app.post(
|
||||||
|
'/auth/login/start',
|
||||||
|
{ schema: { body: startSchema } },
|
||||||
|
handle(async ({ em, body, limiter, req }) => {
|
||||||
|
await limiter?.assertAndBump(req, {
|
||||||
|
id: 'login_challenge_tokens',
|
||||||
|
max: 20,
|
||||||
|
window: '10m',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { publicKey: body.publicKey });
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
throw new StatusError('User cannot be found', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = new ChallengeCode();
|
||||||
|
challenge.authType = 'mnemonic';
|
||||||
|
challenge.flow = 'login';
|
||||||
|
|
||||||
|
await em.persistAndFlush(challenge);
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: challenge.code,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
app.post(
|
||||||
|
'/auth/login/complete',
|
||||||
|
{ schema: { body: completeSchema } },
|
||||||
|
handle(async ({ em, body, req, limiter }) => {
|
||||||
|
await limiter?.assertAndBump(req, {
|
||||||
|
id: 'login_complete',
|
||||||
|
max: 20,
|
||||||
|
window: '10m',
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertChallengeCode(
|
||||||
|
em,
|
||||||
|
body.challenge.code,
|
||||||
|
body.publicKey,
|
||||||
|
body.challenge.signature,
|
||||||
|
'login',
|
||||||
|
'mnemonic',
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { publicKey: body.publicKey });
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
throw new StatusError('User cannot be found', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.lastLoggedIn = new Date();
|
||||||
|
|
||||||
|
const session = makeSession(
|
||||||
|
user.id,
|
||||||
|
body.device,
|
||||||
|
req.headers['user-agent'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await em.persistAndFlush([session, user]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: formatSession(session),
|
||||||
|
token: makeSessionToken(session),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
95
src/routes/auth/manage.ts
Normal file
95
src/routes/auth/manage.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { ChallengeCode } from '@/db/models/ChallengeCode';
|
||||||
|
import { formatSession } from '@/db/models/Session';
|
||||||
|
import { User, formatUser } from '@/db/models/User';
|
||||||
|
import { getMetrics } from '@/modules/metrics';
|
||||||
|
import { assertCaptcha } from '@/services/captcha';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { makeSession, makeSessionToken } from '@/services/session';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { assertChallengeCode } from '@/services/challenge';
|
||||||
|
|
||||||
|
const startSchema = z.object({
|
||||||
|
captchaToken: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeSchema = z.object({
|
||||||
|
publicKey: z.string(),
|
||||||
|
challenge: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
signature: z.string(),
|
||||||
|
}),
|
||||||
|
namespace: z.string().min(1),
|
||||||
|
device: z.string().max(500).min(1),
|
||||||
|
profile: z.object({
|
||||||
|
colorA: z.string(),
|
||||||
|
colorB: z.string(),
|
||||||
|
icon: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const manageAuthRouter = makeRouter((app) => {
|
||||||
|
app.post(
|
||||||
|
'/auth/register/start',
|
||||||
|
{ schema: { body: startSchema } },
|
||||||
|
handle(async ({ em, body, limiter, req }) => {
|
||||||
|
await limiter?.assertAndBump(req, {
|
||||||
|
id: 'register_challenge_tokens',
|
||||||
|
max: 10,
|
||||||
|
window: '10m',
|
||||||
|
});
|
||||||
|
await assertCaptcha(body.captchaToken);
|
||||||
|
|
||||||
|
const challenge = new ChallengeCode();
|
||||||
|
challenge.authType = 'mnemonic';
|
||||||
|
challenge.flow = 'registration';
|
||||||
|
|
||||||
|
await em.persistAndFlush(challenge);
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: challenge.code,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/auth/register/complete',
|
||||||
|
{ schema: { body: completeSchema } },
|
||||||
|
handle(async ({ em, body, req, limiter }) => {
|
||||||
|
await limiter?.assertAndBump(req, {
|
||||||
|
id: 'register_complete',
|
||||||
|
max: 10,
|
||||||
|
window: '10m',
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertChallengeCode(
|
||||||
|
em,
|
||||||
|
body.challenge.code,
|
||||||
|
body.publicKey,
|
||||||
|
body.challenge.signature,
|
||||||
|
'registration',
|
||||||
|
'mnemonic',
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = new User();
|
||||||
|
user.namespace = body.namespace;
|
||||||
|
user.publicKey = body.publicKey;
|
||||||
|
user.profile = body.profile;
|
||||||
|
user.lastLoggedIn = new Date();
|
||||||
|
|
||||||
|
const session = makeSession(
|
||||||
|
user.id,
|
||||||
|
body.device,
|
||||||
|
req.headers['user-agent'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await em.persistAndFlush([user, session]);
|
||||||
|
getMetrics().user.inc({ namespace: body.namespace }, 1);
|
||||||
|
return {
|
||||||
|
user: formatUser(user),
|
||||||
|
session: formatSession(session),
|
||||||
|
token: makeSessionToken(session),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
35
src/routes/auth/session.ts
Normal file
35
src/routes/auth/session.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Session } from '@/db/models/Session';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const authSessionRouter = makeRouter((app) => {
|
||||||
|
app.delete(
|
||||||
|
'/sessions/:sid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
sid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
const targetedSession = await em.findOne(Session, { id: params.sid });
|
||||||
|
if (!targetedSession)
|
||||||
|
return {
|
||||||
|
id: params.sid,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (targetedSession.user !== auth.user.id)
|
||||||
|
throw new StatusError('Cannot delete sessions you do not own', 401);
|
||||||
|
|
||||||
|
await em.removeAndFlush(targetedSession);
|
||||||
|
return {
|
||||||
|
id: params.sid,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
@@ -1,7 +0,0 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
|
||||||
|
|
||||||
export const helloRouter: FastifyPluginAsync = async (app) => {
|
|
||||||
app.get('/ping', (req, res) => {
|
|
||||||
res.send('pong!');
|
|
||||||
});
|
|
||||||
};
|
|
30
src/routes/meta.ts
Normal file
30
src/routes/meta.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { conf } from '@/config';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
|
||||||
|
export const metaRouter = makeRouter((app) => {
|
||||||
|
app.get(
|
||||||
|
'/healthcheck',
|
||||||
|
handle(async ({ em }) => {
|
||||||
|
const databaseConnected = await em.config
|
||||||
|
.getDriver()
|
||||||
|
.getConnection()
|
||||||
|
.isConnected();
|
||||||
|
return {
|
||||||
|
healthy: databaseConnected,
|
||||||
|
databaseConnected,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.get(
|
||||||
|
'/meta',
|
||||||
|
handle(async () => {
|
||||||
|
return {
|
||||||
|
name: conf.meta.name,
|
||||||
|
description: conf.meta.description,
|
||||||
|
hasCaptcha: conf.captcha.enabled,
|
||||||
|
captchaClientKey: conf.captcha.clientKey,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
73
src/routes/metrics.ts
Normal file
73
src/routes/metrics.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ProviderMetric, status } from '@/db/models/ProviderMetrics';
|
||||||
|
import { getMetrics } from '@/modules/metrics';
|
||||||
|
|
||||||
|
const metricsProviderSchema = z.object({
|
||||||
|
tmdbId: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
seasonId: z.string().optional(),
|
||||||
|
episodeId: z.string().optional(),
|
||||||
|
status: z.nativeEnum(status),
|
||||||
|
providerId: z.string(),
|
||||||
|
embedId: z.string().optional(),
|
||||||
|
errorMessage: z.string().optional(),
|
||||||
|
fullError: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const metricsProviderInputSchema = z.object({
|
||||||
|
items: z.array(metricsProviderSchema).max(10).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metricsRouter = makeRouter((app) => {
|
||||||
|
app.post(
|
||||||
|
'/metrics/providers',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
body: metricsProviderInputSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ em, body, req, limiter }) => {
|
||||||
|
await limiter?.assertAndBump(req, {
|
||||||
|
id: 'provider_metrics',
|
||||||
|
max: 300,
|
||||||
|
inc: body.items.length,
|
||||||
|
window: '30m',
|
||||||
|
});
|
||||||
|
|
||||||
|
const entities = body.items.map((v) => {
|
||||||
|
const metric = new ProviderMetric();
|
||||||
|
em.assign(metric, {
|
||||||
|
providerId: v.providerId,
|
||||||
|
embedId: v.embedId,
|
||||||
|
fullError: v.fullError,
|
||||||
|
errorMessage: v.errorMessage,
|
||||||
|
episodeId: v.episodeId,
|
||||||
|
seasonId: v.seasonId,
|
||||||
|
status: v.status,
|
||||||
|
title: v.title,
|
||||||
|
tmdbId: v.tmdbId,
|
||||||
|
type: v.type,
|
||||||
|
});
|
||||||
|
return metric;
|
||||||
|
});
|
||||||
|
|
||||||
|
entities.forEach((entity) => {
|
||||||
|
getMetrics().providerMetrics.inc({
|
||||||
|
episode_id: entity.episodeId,
|
||||||
|
provider_id: entity.providerId,
|
||||||
|
season_id: entity.seasonId,
|
||||||
|
status: entity.status,
|
||||||
|
title: entity.title,
|
||||||
|
tmdb_id: entity.tmdbId,
|
||||||
|
type: entity.type,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await em.persistAndFlush(entities);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
65
src/routes/sessions.ts
Normal file
65
src/routes/sessions.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Session, formatSession } from '@/db/models/Session';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const sessionsRouter = makeRouter((app) => {
|
||||||
|
app.patch(
|
||||||
|
'/sessions/:sid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
sid: z.string(),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
name: z.string().max(500).min(1).optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em, body }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
const targetedSession = await em.findOne(Session, { id: params.sid });
|
||||||
|
|
||||||
|
if (!targetedSession)
|
||||||
|
throw new StatusError('Session cannot be found', 404);
|
||||||
|
|
||||||
|
if (targetedSession.user !== auth.user.id)
|
||||||
|
throw new StatusError('Cannot modify sessions you do not own', 401);
|
||||||
|
|
||||||
|
if (body.name) targetedSession.device = body.name;
|
||||||
|
|
||||||
|
await em.persistAndFlush(targetedSession);
|
||||||
|
|
||||||
|
return formatSession(targetedSession);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.delete(
|
||||||
|
'/sessions/:sid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
sid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
const targetedSession = await em.findOne(Session, { id: params.sid });
|
||||||
|
if (!targetedSession)
|
||||||
|
return {
|
||||||
|
id: params.sid,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (targetedSession.user !== auth.user.id)
|
||||||
|
throw new StatusError('Cannot delete sessions you do not own', 401);
|
||||||
|
|
||||||
|
await em.removeAndFlush(targetedSession);
|
||||||
|
return {
|
||||||
|
id: params.sid,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
35
src/routes/sessions/session.ts
Normal file
35
src/routes/sessions/session.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Session } from '@/db/models/Session';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const sessionRouter = makeRouter((app) => {
|
||||||
|
app.delete(
|
||||||
|
'/sessions/:sid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
sid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
const targetedSession = await em.findOne(Session, { id: params.sid });
|
||||||
|
if (!targetedSession)
|
||||||
|
return {
|
||||||
|
id: params.sid,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (targetedSession.user !== auth.user.id)
|
||||||
|
throw new StatusError('Cannot delete sessions you do not own', 401);
|
||||||
|
|
||||||
|
await em.removeAndFlush(targetedSession);
|
||||||
|
return {
|
||||||
|
id: params.sid,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
100
src/routes/users/bookmark.ts
Normal file
100
src/routes/users/bookmark.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
Bookmark,
|
||||||
|
bookmarkMetaSchema,
|
||||||
|
formatBookmark,
|
||||||
|
} from '@/db/models/Bookmark';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const userBookmarkRouter = makeRouter((app) => {
|
||||||
|
app.get(
|
||||||
|
'/users/:uid/bookmarks',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot access other user information', 403);
|
||||||
|
|
||||||
|
const bookmarks = await em.find(Bookmark, {
|
||||||
|
userId: params.uid,
|
||||||
|
});
|
||||||
|
|
||||||
|
return bookmarks.map(formatBookmark);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/users/:uid/bookmarks/:tmdbid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
tmdbid: z.string(),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
meta: bookmarkMetaSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, body, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||||
|
|
||||||
|
const oldBookmark = await em.findOne(Bookmark, {
|
||||||
|
userId: params.uid,
|
||||||
|
tmdbId: params.tmdbid,
|
||||||
|
});
|
||||||
|
if (oldBookmark) throw new StatusError('Already bookmarked', 400);
|
||||||
|
|
||||||
|
const bookmark = new Bookmark();
|
||||||
|
em.assign(bookmark, {
|
||||||
|
userId: params.uid,
|
||||||
|
tmdbId: params.tmdbid,
|
||||||
|
meta: body.meta,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await em.persistAndFlush(bookmark);
|
||||||
|
return formatBookmark(bookmark);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
'/users/:uid/bookmarks/:tmdbid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
tmdbid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||||
|
|
||||||
|
const bookmark = await em.findOne(Bookmark, {
|
||||||
|
userId: params.uid,
|
||||||
|
tmdbId: params.tmdbid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bookmark) return { tmdbId: params.tmdbid };
|
||||||
|
|
||||||
|
await em.removeAndFlush(bookmark);
|
||||||
|
return { tmdbId: params.tmdbid };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
63
src/routes/users/delete.ts
Normal file
63
src/routes/users/delete.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Bookmark } from '@/db/models/Bookmark';
|
||||||
|
import { ProgressItem } from '@/db/models/ProgressItem';
|
||||||
|
import { Session } from '@/db/models/Session';
|
||||||
|
import { User } from '@/db/models/User';
|
||||||
|
import { UserSettings } from '@/db/models/UserSettings';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const userDeleteRouter = makeRouter((app) => {
|
||||||
|
app.delete(
|
||||||
|
'/users/:uid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { id: params.uid });
|
||||||
|
if (!user) throw new StatusError('User does not exist', 404);
|
||||||
|
|
||||||
|
if (auth.user.id !== user.id)
|
||||||
|
throw new StatusError('Cannot delete user other than yourself', 403);
|
||||||
|
|
||||||
|
// delete data
|
||||||
|
await em
|
||||||
|
.createQueryBuilder(Bookmark)
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await em
|
||||||
|
.createQueryBuilder(ProgressItem)
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await em
|
||||||
|
.createQueryBuilder(UserSettings)
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
id: user.id,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// delete account & login sessions
|
||||||
|
const sessions = await em.find(Session, { user: user.id });
|
||||||
|
await em.remove([user, ...sessions]);
|
||||||
|
await em.flush();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
42
src/routes/users/edit.ts
Normal file
42
src/routes/users/edit.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { User, formatUser } from '@/db/models/User';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const userEditRouter = makeRouter((app) => {
|
||||||
|
app.patch(
|
||||||
|
'/users/:uid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
profile: z
|
||||||
|
.object({
|
||||||
|
colorA: z.string(),
|
||||||
|
colorB: z.string(),
|
||||||
|
icon: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
name: z.string().max(500).min(1).optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, body, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { id: params.uid });
|
||||||
|
if (!user) throw new StatusError('User does not exist', 404);
|
||||||
|
|
||||||
|
if (auth.user.id !== user.id)
|
||||||
|
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||||
|
|
||||||
|
if (body.profile) user.profile = body.profile;
|
||||||
|
|
||||||
|
await em.persistAndFlush(user);
|
||||||
|
return formatUser(user);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
31
src/routes/users/get.ts
Normal file
31
src/routes/users/get.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { User, formatUser } from '@/db/models/User';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const userGetRouter = makeRouter((app) => {
|
||||||
|
app.get(
|
||||||
|
'/users/:uid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
let uid = params.uid;
|
||||||
|
if (uid === '@me') uid = auth.user.id;
|
||||||
|
|
||||||
|
if (auth.user.id !== uid)
|
||||||
|
throw new StatusError('Cannot access users other than yourself', 403);
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { id: uid });
|
||||||
|
if (!user) throw new StatusError('User does not exist', 404);
|
||||||
|
|
||||||
|
return formatUser(user);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
126
src/routes/users/progress.ts
Normal file
126
src/routes/users/progress.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
ProgressItem,
|
||||||
|
formatProgressItem,
|
||||||
|
progressMetaSchema,
|
||||||
|
} from '@/db/models/ProgressItem';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const userProgressRouter = makeRouter((app) => {
|
||||||
|
app.put(
|
||||||
|
'/users/:uid/progress/:tmdbid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
tmdbid: z.string(),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
meta: progressMetaSchema,
|
||||||
|
seasonId: z.string().optional(),
|
||||||
|
episodeId: z.string().optional(),
|
||||||
|
duration: z.number(),
|
||||||
|
watched: z.number(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, body, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||||
|
|
||||||
|
let progressItem = await em.findOne(ProgressItem, {
|
||||||
|
userId: params.uid,
|
||||||
|
tmdbId: params.tmdbid,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seasonId: body.seasonId,
|
||||||
|
});
|
||||||
|
if (!progressItem) {
|
||||||
|
progressItem = new ProgressItem();
|
||||||
|
progressItem.tmdbId = params.tmdbid;
|
||||||
|
progressItem.userId = params.uid;
|
||||||
|
progressItem.episodeId = body.episodeId;
|
||||||
|
progressItem.seasonId = body.seasonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
em.assign(progressItem, {
|
||||||
|
duration: body.duration,
|
||||||
|
watched: body.watched,
|
||||||
|
meta: body.meta,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await em.persistAndFlush(progressItem);
|
||||||
|
return formatProgressItem(progressItem);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
'/users/:uid/progress/:tmdbid',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
tmdbid: z.string(),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
seasonId: z.string().optional(),
|
||||||
|
episodeId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, body, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||||
|
|
||||||
|
const progressItem = await em.findOne(ProgressItem, {
|
||||||
|
userId: params.uid,
|
||||||
|
tmdbId: params.tmdbid,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seasonId: body.seasonId,
|
||||||
|
});
|
||||||
|
if (!progressItem) {
|
||||||
|
return {
|
||||||
|
tmdbId: params.tmdbid,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seasonId: body.seasonId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await em.removeAndFlush(progressItem);
|
||||||
|
return {
|
||||||
|
tmdbId: params.tmdbid,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seasonId: body.seasonId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/users/:uid/progress',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||||
|
|
||||||
|
const items = await em.find(ProgressItem, {
|
||||||
|
userId: params.uid,
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.map(formatProgressItem);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
30
src/routes/users/sessions.ts
Normal file
30
src/routes/users/sessions.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Session, formatSession } from '@/db/models/Session';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const userSessionsRouter = makeRouter((app) => {
|
||||||
|
app.get(
|
||||||
|
'/users/:uid/sessions',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||||
|
|
||||||
|
const sessions = await em.find(Session, {
|
||||||
|
user: params.uid,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessions.map(formatSession);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
72
src/routes/users/settings.ts
Normal file
72
src/routes/users/settings.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { UserSettings, formatUserSettings } from '@/db/models/UserSettings';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { handle } from '@/services/handler';
|
||||||
|
import { makeRouter } from '@/services/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const userSettingsRouter = makeRouter((app) => {
|
||||||
|
app.get(
|
||||||
|
'/users/:uid/settings',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot get other user information', 403);
|
||||||
|
|
||||||
|
const settings = await em.findOne(UserSettings, {
|
||||||
|
id: params.uid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return { id: params.uid };
|
||||||
|
|
||||||
|
return formatUserSettings(settings);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put(
|
||||||
|
'/users/:uid/settings',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
uid: z.string(),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
applicationLanguage: z.string().optional(),
|
||||||
|
applicationTheme: z.string().optional(),
|
||||||
|
defaultSubtitleLanguage: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handle(async ({ auth, params, body, em }) => {
|
||||||
|
await auth.assert();
|
||||||
|
|
||||||
|
if (auth.user.id !== params.uid)
|
||||||
|
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||||
|
|
||||||
|
let settings = await em.findOne(UserSettings, {
|
||||||
|
id: params.uid,
|
||||||
|
});
|
||||||
|
if (!settings) {
|
||||||
|
settings = new UserSettings();
|
||||||
|
settings.id = params.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.applicationLanguage)
|
||||||
|
settings.applicationLanguage = body.applicationLanguage;
|
||||||
|
if (body.applicationTheme)
|
||||||
|
settings.applicationTheme = body.applicationTheme;
|
||||||
|
if (body.defaultSubtitleLanguage)
|
||||||
|
settings.defaultSubtitleLanguage = body.defaultSubtitleLanguage;
|
||||||
|
|
||||||
|
await em.persistAndFlush(settings);
|
||||||
|
return formatUserSettings(settings);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
5
src/services/access.ts
Normal file
5
src/services/access.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const roles = {
|
||||||
|
ADMIN: 'ADMIN', // has access to admin endpoints
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Roles = (typeof roles)[keyof typeof roles];
|
62
src/services/auth.ts
Normal file
62
src/services/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Session } from '@/db/models/Session';
|
||||||
|
import { User } from '@/db/models/User';
|
||||||
|
import { Roles } from '@/services/access';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { getSessionAndBump, verifySessionToken } from '@/services/session';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
export function makeAuthContext(manager: EntityManager, req: FastifyRequest) {
|
||||||
|
let userCache: User | null = null;
|
||||||
|
let sessionCache: Session | null = null;
|
||||||
|
const em = manager.fork();
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSessionId(): string | null {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (!header) return null;
|
||||||
|
const [type, token] = header.split(' ', 2);
|
||||||
|
if (type.toLowerCase() !== 'bearer')
|
||||||
|
throw new StatusError('Invalid authentication', 400);
|
||||||
|
const payload = verifySessionToken(token);
|
||||||
|
if (!payload) throw new StatusError('Invalid authentication', 400);
|
||||||
|
return payload.sid;
|
||||||
|
},
|
||||||
|
async getSession() {
|
||||||
|
if (sessionCache) return sessionCache;
|
||||||
|
const sid = this.getSessionId();
|
||||||
|
if (!sid) return null;
|
||||||
|
const session = await getSessionAndBump(em, sid);
|
||||||
|
if (!session) return null;
|
||||||
|
sessionCache = session;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
async getUser() {
|
||||||
|
if (userCache) return userCache;
|
||||||
|
const session = await this.getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
const user = await em.findOne(User, { id: session.user });
|
||||||
|
if (!user) return null;
|
||||||
|
userCache = user;
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
async assert() {
|
||||||
|
const user = await this.getUser();
|
||||||
|
if (!user) throw new StatusError('Not logged in', 401);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
get user() {
|
||||||
|
if (!userCache) throw new Error('call assert before getting user');
|
||||||
|
return userCache;
|
||||||
|
},
|
||||||
|
get session() {
|
||||||
|
if (!sessionCache) throw new Error('call assert before getting session');
|
||||||
|
return sessionCache;
|
||||||
|
},
|
||||||
|
async assertHasRole(role: Roles) {
|
||||||
|
const user = await this.assert();
|
||||||
|
const hasRole = user.roles.includes(role);
|
||||||
|
if (!hasRole) throw new StatusError('No permissions', 403);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
28
src/services/captcha.ts
Normal file
28
src/services/captcha.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { conf } from '@/config';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
|
||||||
|
export async function isValidCaptcha(token: string): Promise<boolean> {
|
||||||
|
if (!conf.captcha.secret)
|
||||||
|
throw new Error('isValidCaptcha() is called but no secret set');
|
||||||
|
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
secret: conf.captcha.secret,
|
||||||
|
response: token,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
return !!json.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCaptcha(token?: string) {
|
||||||
|
// early return if captchas arent enabled
|
||||||
|
if (!conf.captcha.enabled) return;
|
||||||
|
if (!token) throw new StatusError('captcha token is required', 400);
|
||||||
|
|
||||||
|
const isValid = await isValidCaptcha(token);
|
||||||
|
if (!isValid) throw new StatusError('captcha token is invalid', 400);
|
||||||
|
}
|
55
src/services/challenge.ts
Normal file
55
src/services/challenge.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
ChallengeCode,
|
||||||
|
ChallengeFlow,
|
||||||
|
ChallengeType,
|
||||||
|
} from '@/db/models/ChallengeCode';
|
||||||
|
import { StatusError } from '@/services/error';
|
||||||
|
import { EntityManager } from '@mikro-orm/core';
|
||||||
|
import forge from 'node-forge';
|
||||||
|
|
||||||
|
const {
|
||||||
|
pki: { ed25519 },
|
||||||
|
util: { ByteStringBuffer },
|
||||||
|
} = forge;
|
||||||
|
|
||||||
|
export async function assertChallengeCode(
|
||||||
|
em: EntityManager,
|
||||||
|
code: string,
|
||||||
|
publicKey: string,
|
||||||
|
signature: string,
|
||||||
|
validFlow: ChallengeFlow,
|
||||||
|
validType: ChallengeType,
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const challenge = await em.findOne(ChallengeCode, {
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!challenge ||
|
||||||
|
challenge.flow !== validFlow ||
|
||||||
|
challenge.authType !== validType
|
||||||
|
) {
|
||||||
|
throw new StatusError('Challenge Code Invalid', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenge.expiresAt.getTime() <= now)
|
||||||
|
throw new StatusError('Challenge Code Expired', 401);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verifiedChallenge = ed25519.verify({
|
||||||
|
publicKey: new ByteStringBuffer(Buffer.from(publicKey, 'base64url')),
|
||||||
|
encoding: 'utf8',
|
||||||
|
signature: new ByteStringBuffer(Buffer.from(signature, 'base64url')),
|
||||||
|
message: code,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verifiedChallenge)
|
||||||
|
throw new StatusError('Challenge Code Signature Invalid', 401);
|
||||||
|
|
||||||
|
em.remove(challenge);
|
||||||
|
} catch (e) {
|
||||||
|
throw new StatusError('Challenge Code Signature Invalid', 401);
|
||||||
|
}
|
||||||
|
}
|
9
src/services/error.ts
Normal file
9
src/services/error.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export class StatusError extends Error {
|
||||||
|
errorStatusCode: number;
|
||||||
|
|
||||||
|
constructor(message: string, code: number) {
|
||||||
|
super(message);
|
||||||
|
this.errorStatusCode = code;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
135
src/services/handler.ts
Normal file
135
src/services/handler.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { getORM } from '@/modules/mikro';
|
||||||
|
import { getLimiter } from '@/modules/ratelimits';
|
||||||
|
import { Limiter } from '@/modules/ratelimits/limiter';
|
||||||
|
import { makeAuthContext } from '@/services/auth';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import {
|
||||||
|
ContextConfigDefault,
|
||||||
|
FastifyBaseLogger,
|
||||||
|
FastifyReply,
|
||||||
|
FastifyRequest,
|
||||||
|
FastifySchema,
|
||||||
|
RawReplyDefaultExpression,
|
||||||
|
RawRequestDefaultExpression,
|
||||||
|
RawServerBase,
|
||||||
|
RawServerDefault,
|
||||||
|
RouteGenericInterface,
|
||||||
|
RouteHandlerMethod,
|
||||||
|
} from 'fastify';
|
||||||
|
import { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { ResolveFastifyReplyReturnType } from 'fastify/types/type-provider';
|
||||||
|
|
||||||
|
export type RequestContext<
|
||||||
|
RawServer extends RawServerBase = RawServerDefault,
|
||||||
|
RawRequest extends
|
||||||
|
RawRequestDefaultExpression<RawServer> = RawRequestDefaultExpression<RawServer>,
|
||||||
|
RawReply extends
|
||||||
|
RawReplyDefaultExpression<RawServer> = RawReplyDefaultExpression<RawServer>,
|
||||||
|
RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
|
||||||
|
ContextConfig = ContextConfigDefault,
|
||||||
|
SchemaCompiler extends FastifySchema = FastifySchema,
|
||||||
|
Logger extends FastifyBaseLogger = FastifyBaseLogger,
|
||||||
|
> = {
|
||||||
|
req: FastifyRequest<
|
||||||
|
RouteGeneric,
|
||||||
|
RawServer,
|
||||||
|
RawRequest,
|
||||||
|
SchemaCompiler,
|
||||||
|
ZodTypeProvider,
|
||||||
|
ContextConfig,
|
||||||
|
Logger
|
||||||
|
>;
|
||||||
|
res: FastifyReply<
|
||||||
|
RawServer,
|
||||||
|
RawRequest,
|
||||||
|
RawReply,
|
||||||
|
RouteGeneric,
|
||||||
|
ContextConfig,
|
||||||
|
SchemaCompiler,
|
||||||
|
ZodTypeProvider
|
||||||
|
>;
|
||||||
|
body: FastifyRequest<
|
||||||
|
RouteGeneric,
|
||||||
|
RawServer,
|
||||||
|
RawRequest,
|
||||||
|
SchemaCompiler,
|
||||||
|
ZodTypeProvider,
|
||||||
|
ContextConfig,
|
||||||
|
Logger
|
||||||
|
>['body'];
|
||||||
|
params: FastifyRequest<
|
||||||
|
RouteGeneric,
|
||||||
|
RawServer,
|
||||||
|
RawRequest,
|
||||||
|
SchemaCompiler,
|
||||||
|
ZodTypeProvider,
|
||||||
|
ContextConfig,
|
||||||
|
Logger
|
||||||
|
>['params'];
|
||||||
|
query: FastifyRequest<
|
||||||
|
RouteGeneric,
|
||||||
|
RawServer,
|
||||||
|
RawRequest,
|
||||||
|
SchemaCompiler,
|
||||||
|
ZodTypeProvider,
|
||||||
|
ContextConfig,
|
||||||
|
Logger
|
||||||
|
>['query'];
|
||||||
|
em: EntityManager;
|
||||||
|
limiter: Limiter | null;
|
||||||
|
auth: ReturnType<typeof makeAuthContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function handle<
|
||||||
|
RawServer extends RawServerBase = RawServerDefault,
|
||||||
|
RawRequest extends
|
||||||
|
RawRequestDefaultExpression<RawServer> = RawRequestDefaultExpression<RawServer>,
|
||||||
|
RawReply extends
|
||||||
|
RawReplyDefaultExpression<RawServer> = RawReplyDefaultExpression<RawServer>,
|
||||||
|
RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
|
||||||
|
ContextConfig = ContextConfigDefault,
|
||||||
|
SchemaCompiler extends FastifySchema = FastifySchema,
|
||||||
|
Logger extends FastifyBaseLogger = FastifyBaseLogger,
|
||||||
|
>(
|
||||||
|
handler: (
|
||||||
|
ctx: RequestContext<
|
||||||
|
RawServer,
|
||||||
|
RawRequest,
|
||||||
|
RawReply,
|
||||||
|
RouteGeneric,
|
||||||
|
ContextConfig,
|
||||||
|
SchemaCompiler,
|
||||||
|
Logger
|
||||||
|
>,
|
||||||
|
) => ResolveFastifyReplyReturnType<
|
||||||
|
ZodTypeProvider,
|
||||||
|
SchemaCompiler,
|
||||||
|
RouteGeneric
|
||||||
|
>,
|
||||||
|
): RouteHandlerMethod<
|
||||||
|
RawServer,
|
||||||
|
RawRequest,
|
||||||
|
RawReply,
|
||||||
|
RouteGeneric,
|
||||||
|
ContextConfig,
|
||||||
|
SchemaCompiler,
|
||||||
|
ZodTypeProvider,
|
||||||
|
Logger
|
||||||
|
> {
|
||||||
|
const reqHandler: any = async (req: any, res: any) => {
|
||||||
|
const em = getORM().em.fork();
|
||||||
|
res.send(
|
||||||
|
await handler({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
body: req.body,
|
||||||
|
params: req.params,
|
||||||
|
query: req.query,
|
||||||
|
em,
|
||||||
|
auth: makeAuthContext(em, req),
|
||||||
|
limiter: getLimiter(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return reqHandler;
|
||||||
|
}
|
@@ -54,11 +54,12 @@ function createWinstonLogger() {
|
|||||||
return loggerObj;
|
return loggerObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scopedLogger(service: string) {
|
export function scopedLogger(service: string, meta: object = {}) {
|
||||||
const logger = createWinstonLogger();
|
const logger = createWinstonLogger();
|
||||||
logger.defaultMeta = {
|
logger.defaultMeta = {
|
||||||
...logger.defaultMeta,
|
...logger.defaultMeta,
|
||||||
svc: service,
|
svc: service,
|
||||||
|
...meta,
|
||||||
};
|
};
|
||||||
return logger;
|
return logger;
|
||||||
}
|
}
|
||||||
|
32
src/services/router.ts
Normal file
32
src/services/router.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
FastifyBaseLogger,
|
||||||
|
FastifyInstance,
|
||||||
|
FastifyPluginAsync,
|
||||||
|
RawReplyDefaultExpression,
|
||||||
|
RawRequestDefaultExpression,
|
||||||
|
RawServerBase,
|
||||||
|
} from 'fastify';
|
||||||
|
import { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
|
||||||
|
export type Instance = FastifyInstance<
|
||||||
|
RawServerBase,
|
||||||
|
RawRequestDefaultExpression<RawServerBase>,
|
||||||
|
RawReplyDefaultExpression<RawServerBase>,
|
||||||
|
FastifyBaseLogger,
|
||||||
|
ZodTypeProvider
|
||||||
|
>;
|
||||||
|
export type RegisterPlugin = FastifyPluginAsync<
|
||||||
|
Record<never, never>,
|
||||||
|
RawServerBase,
|
||||||
|
ZodTypeProvider
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function makeRouter(cb: (app: Instance) => void): {
|
||||||
|
register: RegisterPlugin;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
register: async (app) => {
|
||||||
|
cb(app);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
69
src/services/session.ts
Normal file
69
src/services/session.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { conf } from '@/config';
|
||||||
|
import { Session } from '@/db/models/Session';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { sign, verify } from 'jsonwebtoken';
|
||||||
|
|
||||||
|
// 21 days in ms
|
||||||
|
const SESSION_EXPIRY_MS = 21 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function getSession(
|
||||||
|
em: EntityManager,
|
||||||
|
id: string,
|
||||||
|
): Promise<Session | null> {
|
||||||
|
const session = await em.findOne(Session, { id });
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
if (session.expiresAt < new Date()) return null;
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionAndBump(
|
||||||
|
em: EntityManager,
|
||||||
|
id: string,
|
||||||
|
): Promise<Session | null> {
|
||||||
|
const session = await getSession(em, id);
|
||||||
|
if (!session) return null;
|
||||||
|
em.assign(session, {
|
||||||
|
accessedAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + SESSION_EXPIRY_MS),
|
||||||
|
});
|
||||||
|
await em.persistAndFlush(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSession(
|
||||||
|
user: string,
|
||||||
|
device: string,
|
||||||
|
userAgent?: string,
|
||||||
|
): Session {
|
||||||
|
if (!userAgent) throw new Error('No useragent provided');
|
||||||
|
|
||||||
|
const session = new Session();
|
||||||
|
session.accessedAt = new Date();
|
||||||
|
session.createdAt = new Date();
|
||||||
|
session.expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
||||||
|
session.userAgent = userAgent;
|
||||||
|
session.device = device;
|
||||||
|
session.user = user;
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSessionToken(session: Session): string {
|
||||||
|
return sign({ sid: session.id }, conf.crypto.sessionSecret, {
|
||||||
|
algorithm: 'HS256',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySessionToken(token: string): { sid: string } | null {
|
||||||
|
try {
|
||||||
|
const payload = verify(token, conf.crypto.sessionSecret, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
});
|
||||||
|
if (typeof payload === 'string') return null;
|
||||||
|
return payload as { sid: string };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@@ -14,6 +14,7 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
|
Reference in New Issue
Block a user