commit
4cf09a697d
|
@ -0,0 +1 @@
|
|||
config.env
|
|
@ -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
|
|
@ -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:
|
|
@ -0,0 +1 @@
|
|||
MWB_USE_PRESETS=dev,dockerdev
|
|
@ -4,3 +4,5 @@ config.json
|
|||
dist
|
||||
.git
|
||||
.vscode
|
||||
.docker
|
||||
.pnpm-store
|
||||
|
|
|
@ -13,6 +13,7 @@ module.exports = {
|
|||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
ignorePatterns: ['./src/db/migrations/**/*'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
|
|
|
@ -15,18 +15,22 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
- name: Install packages
|
||||
run: pnpm i
|
||||
|
||||
- name: Run ESLint
|
||||
run: yarn lint
|
||||
run: pnpm run lint
|
||||
|
||||
building:
|
||||
name: Build project
|
||||
|
@ -35,18 +39,22 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
- name: Install packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Build Project
|
||||
run: yarn build
|
||||
run: pnpm build
|
||||
|
||||
docker:
|
||||
name: Build docker
|
||||
|
|
|
@ -2,3 +2,4 @@ node_modules
|
|||
.env
|
||||
config.json
|
||||
dist
|
||||
.pnpm-store
|
||||
|
|
19
Dockerfile
19
Dockerfile
|
@ -1,16 +1,21 @@
|
|||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
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
|
||||
COPY . ./
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
# start server
|
||||
EXPOSE 80
|
||||
ENV CONF_SERVER__PORT=80
|
||||
ENV MWB_SERVER__PORT=80
|
||||
ENV NODE_ENV=production
|
||||
CMD ["npm", "run", "start"]
|
||||
CMD ["pnpm", "run", "start"]
|
||||
|
|
|
@ -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:fix": "eslint --fix --ext .ts,.js,.json,.tsx src/",
|
||||
"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": {
|
||||
"@mikro-orm/cli": "^5.9.2",
|
||||
"@mikro-orm/migrations": "^5.9.2",
|
||||
"@types/jsonwebtoken": "^9.0.4",
|
||||
"@types/node": "^20.5.3",
|
||||
"@types/node-forge": "^1.3.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.47.0",
|
||||
|
@ -32,9 +44,21 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@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-metrics": "^10.3.3",
|
||||
"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",
|
||||
"node-forge": "^1.3.1",
|
||||
"prom-client": "^15.0.0",
|
||||
"type-fest": "^4.2.0",
|
||||
"winston": "^3.10.0",
|
||||
"winston-console-format": "^1.0.8",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -9,4 +9,15 @@ export const devFragment: FragmentSchema = {
|
|||
format: 'pretty',
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 { dockerFragment } from '@/config/fragments/docker';
|
||||
import { configSchema } from '@/config/schema';
|
||||
import { createConfigLoader } from 'neat-config';
|
||||
|
||||
const fragments = {
|
||||
dev: devFragment,
|
||||
dockerdev: dockerFragment,
|
||||
};
|
||||
|
||||
export const version = '1.0.0';
|
||||
|
||||
export const conf = createConfigLoader()
|
||||
.addFromEnvironment('MWB_')
|
||||
.addFromCLI('mwb-')
|
||||
|
|
|
@ -26,4 +26,44 @@ export const configSchema = z.object({
|
|||
debug: z.coerce.boolean().default(false),
|
||||
})
|
||||
.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({}),
|
||||
});
|
||||
|
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"));',
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
};
|
||||
}
|
|
@ -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(),
|
||||
};
|
||||
}
|
|
@ -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(),
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
||||
const log = scopedLogger('mw-backend');
|
||||
|
@ -8,7 +16,14 @@ async function bootstrap(): Promise<void> {
|
|||
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`, {
|
||||
evt: 'success',
|
||||
|
|
|
@ -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,
|
||||
} from 'fastify-type-provider-zod';
|
||||
import { ZodError } from 'zod';
|
||||
import { StatusError } from '@/services/error';
|
||||
|
||||
const log = scopedLogger('fastify');
|
||||
|
||||
|
@ -16,8 +17,8 @@ export async function setupFastify(): Promise<FastifyInstance> {
|
|||
// create server
|
||||
const app = Fastify({
|
||||
logger: makeFastifyLogger(log) as any,
|
||||
trustProxy: conf.server.trustProxy,
|
||||
});
|
||||
let exportedApp: FastifyInstance | null = null;
|
||||
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
app.setSerializerCompiler(serializerCompiler);
|
||||
|
@ -31,8 +32,8 @@ export async function setupFastify(): Promise<FastifyInstance> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (err.statusCode) {
|
||||
reply.status(err.statusCode).send({
|
||||
if (err instanceof StatusError) {
|
||||
reply.status(err.errorStatusCode).send({
|
||||
errorType: 'message',
|
||||
message: err.message,
|
||||
});
|
||||
|
@ -53,27 +54,20 @@ export async function setupFastify(): Promise<FastifyInstance> {
|
|||
});
|
||||
});
|
||||
|
||||
// plugins & routes
|
||||
log.info(`setting up plugins and routes`, { evt: 'setup-plugins' });
|
||||
// plugins
|
||||
log.info(`setting up plugins`, { evt: 'setup-plugins' });
|
||||
await app.register(cors, {
|
||||
origin: conf.server.cors.split(' ').filter((v) => v.length > 0),
|
||||
credentials: true,
|
||||
});
|
||||
await app.register(
|
||||
async (api, opts, done) => {
|
||||
setupRoutes(api);
|
||||
|
||||
exportedApp = api;
|
||||
done();
|
||||
},
|
||||
{
|
||||
prefix: conf.server.basePath,
|
||||
},
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
export function startFastify(app: FastifyInstance) {
|
||||
// listen to port
|
||||
log.info(`listening to port`, { evt: 'setup-listen' });
|
||||
return new Promise((resolve) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
app.listen(
|
||||
{
|
||||
port: conf.server.port,
|
||||
|
@ -90,8 +84,21 @@ export async function setupFastify(): Promise<FastifyInstance> {
|
|||
log.info(`fastify setup successfully`, {
|
||||
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';
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
);
|
|
@ -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`,
|
||||
);
|
||||
},
|
||||
);
|
|
@ -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`,
|
||||
);
|
||||
},
|
||||
);
|
|
@ -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`,
|
||||
);
|
||||
},
|
||||
);
|
|
@ -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' });
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
}
|
|
@ -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' });
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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!');
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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!');
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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;
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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 };
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
export const roles = {
|
||||
ADMIN: 'ADMIN', // has access to admin endpoints
|
||||
} as const;
|
||||
|
||||
export type Roles = (typeof roles)[keyof typeof roles];
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export class StatusError extends Error {
|
||||
errorStatusCode: number;
|
||||
|
||||
constructor(message: string, code: number) {
|
||||
super(message);
|
||||
this.errorStatusCode = code;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
export function scopedLogger(service: string) {
|
||||
export function scopedLogger(service: string, meta: object = {}) {
|
||||
const logger = createWinstonLogger();
|
||||
logger.defaultMeta = {
|
||||
...logger.defaultMeta,
|
||||
svc: service,
|
||||
...meta,
|
||||
};
|
||||
return logger;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
|
|
Loading…
Reference in New Issue