Merge pull request #6 from movie-web/dev

Version 1.0
This commit is contained in:
William Oldham 2023-11-04 15:12:26 +00:00 committed by GitHub
commit 4cf09a697d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 7015 additions and 411 deletions

1
.docker/development/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
config.env

View File

@ -0,0 +1,35 @@
# how to use docker development?
## How to setup?
1. have docker installed
2. create a `config.env` in `/.docker/development`. inspire its contents from `example.config.env`
## how to run?
1. while in directory `/.docker/development` run `docker compose up -d`
1.1 if running first time in docker, make sure you have no node_modules folder present in `/`.
## not working? try this:
1. while in directory `/.docker/development` run `docker compose down -v`
2. remove `node_modules` directory in `/` if it exists
3. remove `.env` and `config.json` file in `/` if any of them exist
4. while in directory `/.docker/development` run `docker compose up -d --build`
## how to stop?
1. while in directory `/.docker/development` run `docker compose down`
> NOTE: if you want also delete all saved data for a full reset, run `docker compose down -v` instead
## how do I access the terminal for the backend service?
make sure the docker services are running, then run `docker attach mw_backend-1`.
this will appear to show nothing at first, but all new logs will show up,
and anything you type in the terminal now affect the backend service.
> Warning: doing CTRL+C will shut down the backend service, it will not kick your terminal back to its original shell.
## how do I read logs?
1. while in directory `/.docker/development` run `docker compose ps`
2. note the name of the service you want to see the logs of
3. while in directory `/.docker/development` run `docker compose logs <NAME>`. fill in the name of the service without the brackets.
## Exposed ports
- http://localhost:8081 - backend API
- http://localhost:8082 - postgres web UI
- postgres://localhost:5432 - postgres

View File

@ -0,0 +1,47 @@
version: '3.8'
name: "mw_backend"
services:
# required services
postgres:
image: postgres
ports:
- '5432:5432'
environment:
POSTGRES_PASSWORD: postgres
volumes:
- 'postgres_data:/var/lib/postgresql/data'
# custom services
backend:
stdin_open: true
tty: true
build:
dockerfile: ./dev.Dockerfile
context: ../../
volumes:
- '../../:/app'
env_file:
- './config.env'
ports:
- '8081:8080'
depends_on:
- postgres
environment:
- WAIT_HOSTS=postgres:5432
# util services
pgweb:
image: sosedoff/pgweb
ports:
- "8082:8081"
links:
- postgres:postgres
environment:
- DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
depends_on:
- postgres
volumes:
postgres_data:

View File

@ -0,0 +1 @@
MWB_USE_PRESETS=dev,dockerdev

View File

@ -4,3 +4,5 @@ config.json
dist
.git
.vscode
.docker
.pnpm-store

View File

@ -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',

View File

@ -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

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules
.env
config.json
dist
.pnpm-store

1
.npmrc Normal file
View File

@ -0,0 +1 @@
shamefully-hoist=true

View File

@ -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"]

11
dev.Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM node:20-alpine
WORKDIR /app
# wait script for development
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.7.3/wait /wait
RUN chmod +x /wait
RUN npm i -g pnpm
VOLUME [ "/app" ]
CMD pnpm i && /wait && pnpm dev

View File

@ -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",

3214
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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",
},
};

View File

@ -0,0 +1,7 @@
import { FragmentSchema } from '@/config/fragments/types';
export const dockerFragment: FragmentSchema = {
postgres: {
connection: 'postgres://postgres:postgres@postgres:5432/postgres',
},
};

View File

@ -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-')

View File

@ -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({}),
});

View File

@ -0,0 +1,615 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"tmdb_id": {
"name": "tmdb_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"meta": {
"name": "meta",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
}
},
"name": "bookmarks",
"schema": "public",
"indexes": [
{
"keyName": "bookmarks_tmdb_id_user_id_unique",
"columnNames": [
"tmdb_id",
"user_id"
],
"composite": true,
"primary": false,
"unique": true
},
{
"keyName": "bookmarks_pkey",
"columnNames": [
"tmdb_id",
"user_id"
],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"code": {
"name": "code",
"type": "uuid",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "uuid"
},
"flow": {
"name": "flow",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"auth_type": {
"name": "auth_type",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"created_at": {
"name": "created_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
},
"expires_at": {
"name": "expires_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
}
},
"name": "challenge_codes",
"schema": "public",
"indexes": [
{
"keyName": "challenge_codes_pkey",
"columnNames": [
"code"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "uuid",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "uuid"
},
"tmdb_id": {
"name": "tmdb_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"season_id": {
"name": "season_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"episode_id": {
"name": "episode_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"meta": {
"name": "meta",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
},
"duration": {
"name": "duration",
"type": "bigint",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "bigint"
},
"watched": {
"name": "watched",
"type": "bigint",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "bigint"
}
},
"name": "progress_items",
"schema": "public",
"indexes": [
{
"keyName": "progress_items_tmdb_id_user_id_season_id_episode_id_unique",
"columnNames": [
"tmdb_id",
"user_id",
"season_id",
"episode_id"
],
"composite": true,
"primary": false,
"unique": true
},
{
"keyName": "progress_items_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "uuid",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "uuid"
},
"tmdb_id": {
"name": "tmdb_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"type": {
"name": "type",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"title": {
"name": "title",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"season_id": {
"name": "season_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"episode_id": {
"name": "episode_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"created_at": {
"name": "created_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
},
"status": {
"name": "status",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"provider_id": {
"name": "provider_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"embed_id": {
"name": "embed_id",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"error_message": {
"name": "error_message",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"full_error": {
"name": "full_error",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
}
},
"name": "provider_metrics",
"schema": "public",
"indexes": [
{
"keyName": "provider_metrics_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "uuid",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "uuid"
},
"user": {
"name": "user",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
},
"accessed_at": {
"name": "accessed_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
},
"expires_at": {
"name": "expires_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
},
"device": {
"name": "device",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"user_agent": {
"name": "user_agent",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "sessions",
"schema": "public",
"indexes": [
{
"keyName": "sessions_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"public_key": {
"name": "public_key",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"namespace": {
"name": "namespace",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "string"
},
"created_at": {
"name": "created_at",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 0,
"mappedType": "datetime"
},
"last_logged_in": {
"name": "last_logged_in",
"type": "timestamptz(0)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 0,
"mappedType": "datetime"
},
"permissions": {
"name": "permissions",
"type": "text[]",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "array"
},
"profile": {
"name": "profile",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
}
},
"name": "users",
"schema": "public",
"indexes": [
{
"columnNames": [
"public_key"
],
"composite": false,
"keyName": "users_public_key_unique",
"primary": false,
"unique": true
},
{
"keyName": "users_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "uuid",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "uuid"
},
"application_theme": {
"name": "application_theme",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"application_language": {
"name": "application_language",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
},
"default_subtitle_language": {
"name": "default_subtitle_language",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "string"
}
},
"name": "user_settings",
"schema": "public",
"indexes": [
{
"keyName": "user_settings_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
}
]
}

View File

@ -0,0 +1,42 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20231104150702 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table "bookmarks" ("tmdb_id" varchar(255) not null, "user_id" varchar(255) not null, "meta" jsonb not null, "updated_at" timestamptz(0) not null, constraint "bookmarks_pkey" primary key ("tmdb_id", "user_id"));',
);
this.addSql(
'alter table "bookmarks" add constraint "bookmarks_tmdb_id_user_id_unique" unique ("tmdb_id", "user_id");',
);
this.addSql(
'create table "challenge_codes" ("code" uuid not null, "flow" text not null, "auth_type" varchar(255) not null, "created_at" timestamptz(0) not null, "expires_at" timestamptz(0) not null, constraint "challenge_codes_pkey" primary key ("code"));',
);
this.addSql(
'create table "progress_items" ("id" uuid not null, "tmdb_id" varchar(255) not null, "user_id" varchar(255) not null, "season_id" varchar(255) null, "episode_id" varchar(255) null, "meta" jsonb not null, "updated_at" timestamptz(0) not null, "duration" bigint not null, "watched" bigint not null, constraint "progress_items_pkey" primary key ("id"));',
);
this.addSql(
'alter table "progress_items" add constraint "progress_items_tmdb_id_user_id_season_id_episode_id_unique" unique ("tmdb_id", "user_id", "season_id", "episode_id");',
);
this.addSql(
'create table "provider_metrics" ("id" uuid not null, "tmdb_id" varchar(255) not null, "type" varchar(255) not null, "title" varchar(255) not null, "season_id" varchar(255) null, "episode_id" varchar(255) null, "created_at" timestamptz(0) not null, "status" varchar(255) not null, "provider_id" varchar(255) not null, "embed_id" varchar(255) null, "error_message" varchar(255) null, "full_error" varchar(255) null, constraint "provider_metrics_pkey" primary key ("id"));',
);
this.addSql(
'create table "sessions" ("id" uuid not null, "user" text not null, "created_at" timestamptz(0) not null, "accessed_at" timestamptz(0) not null, "expires_at" timestamptz(0) not null, "device" text not null, "user_agent" text not null, constraint "sessions_pkey" primary key ("id"));',
);
this.addSql(
'create table "users" ("id" text not null, "public_key" text not null, "namespace" varchar(255) not null, "created_at" timestamptz(0) not null, "last_logged_in" timestamptz(0) null, "permissions" text[] not null, "profile" jsonb not null, constraint "users_pkey" primary key ("id"));',
);
this.addSql(
'alter table "users" add constraint "users_public_key_unique" unique ("public_key");',
);
this.addSql(
'create table "user_settings" ("id" uuid not null, "application_theme" varchar(255) null, "application_language" varchar(255) null, "default_subtitle_language" varchar(255) null, constraint "user_settings_pkey" primary key ("id"));',
);
}
}

54
src/db/models/Bookmark.ts Normal file
View File

@ -0,0 +1,54 @@
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
import { z } from 'zod';
export const bookmarkMetaSchema = z.object({
title: z.string(),
year: z.number(),
poster: z.string().optional(),
type: z.string(),
});
export type BookmarkMeta = z.infer<typeof bookmarkMetaSchema>;
@Entity({ tableName: 'bookmarks' })
@Unique({ properties: ['tmdbId', 'userId'] })
export class Bookmark {
@PrimaryKey({ name: 'tmdb_id' })
tmdbId!: string;
@PrimaryKey({ name: 'user_id' })
userId!: string;
@Property({
name: 'meta',
type: types.json,
})
meta!: BookmarkMeta;
@Property({ name: 'updated_at', type: 'date' })
updatedAt!: Date;
}
export interface BookmarkDTO {
tmdbId: string;
meta: {
title: string;
year: number;
poster?: string;
type: string;
};
updatedAt: string;
}
export function formatBookmark(bookmark: Bookmark): BookmarkDTO {
return {
tmdbId: bookmark.tmdbId,
meta: {
title: bookmark.meta.title,
year: bookmark.meta.year,
poster: bookmark.meta.poster,
type: bookmark.meta.type,
},
updatedAt: bookmark.updatedAt.toISOString(),
};
}

View File

@ -0,0 +1,47 @@
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
// 30 seconds
const CHALLENGE_EXPIRY_MS = 3000000 * 1000;
export type ChallengeFlow = 'registration' | 'login';
export type ChallengeType = 'mnemonic';
@Entity({ tableName: 'challenge_codes' })
export class ChallengeCode {
@PrimaryKey({ name: 'code', type: 'uuid' })
code: string = randomUUID();
@Property({ name: 'flow', type: 'text' })
flow!: ChallengeFlow;
@Property({ name: 'auth_type' })
authType!: ChallengeType;
@Property({ type: 'date' })
createdAt: Date = new Date();
@Property({ type: 'date' })
expiresAt: Date = new Date(Date.now() + CHALLENGE_EXPIRY_MS);
}
export interface ChallengeCodeDTO {
code: string;
flow: string;
authType: string;
createdAt: string;
expiresAt: string;
}
export function formatChallengeCode(
challenge: ChallengeCode,
): ChallengeCodeDTO {
return {
code: challenge.code,
flow: challenge.flow,
authType: challenge.authType,
createdAt: challenge.createdAt.toISOString(),
expiresAt: challenge.expiresAt.toISOString(),
};
}

View File

@ -0,0 +1,81 @@
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
import { z } from 'zod';
export const progressMetaSchema = z.object({
title: z.string(),
year: z.number(),
poster: z.string().optional(),
type: z.string(),
});
export type ProgressMeta = z.infer<typeof progressMetaSchema>;
@Entity({ tableName: 'progress_items' })
@Unique({ properties: ['tmdbId', 'userId', 'seasonId', 'episodeId'] })
export class ProgressItem {
@PrimaryKey({ name: 'id', type: 'uuid' })
id: string = randomUUID();
@Property({ name: 'tmdb_id' })
tmdbId!: string;
@Property({ name: 'user_id' })
userId!: string;
@Property({ name: 'season_id', nullable: true })
seasonId?: string;
@Property({ name: 'episode_id', nullable: true })
episodeId?: string;
@Property({
name: 'meta',
type: types.json,
})
meta!: ProgressMeta;
@Property({ name: 'updated_at', type: 'date' })
updatedAt!: Date;
/* progress */
@Property({ name: 'duration', type: 'bigint' })
duration!: number;
@Property({ name: 'watched', type: 'bigint' })
watched!: number;
}
export interface ProgressItemDTO {
tmdbId: string;
seasonId?: string;
episodeId?: string;
meta: {
title: string;
year: number;
poster?: string;
type: string;
};
duration: number;
watched: number;
updatedAt: string;
}
export function formatProgressItem(
progressItem: ProgressItem,
): ProgressItemDTO {
return {
tmdbId: progressItem.tmdbId,
seasonId: progressItem.seasonId,
episodeId: progressItem.episodeId,
meta: {
title: progressItem.meta.title,
year: progressItem.meta.year,
poster: progressItem.meta.poster,
type: progressItem.meta.type,
},
duration: progressItem.duration,
watched: progressItem.watched,
updatedAt: progressItem.updatedAt.toISOString(),
};
}

View File

@ -0,0 +1,48 @@
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
export const status = {
failed: 'failed',
notfound: 'notfound',
success: 'success',
} as const;
type Status = keyof typeof status;
@Entity({ tableName: 'provider_metrics' })
export class ProviderMetric {
@PrimaryKey({ name: 'id', type: 'uuid' })
id: string = randomUUID();
@Property({ name: 'tmdb_id' })
tmdbId!: string;
@Property({ name: 'type' })
type!: string;
@Property({ name: 'title' })
title!: string;
@Property({ name: 'season_id', nullable: true })
seasonId?: string;
@Property({ name: 'episode_id', nullable: true })
episodeId?: string;
@Property({ name: 'created_at', type: 'date' })
createdAt = new Date();
@Property({ name: 'status' })
status!: Status;
@Property({ name: 'provider_id' })
providerId!: string;
@Property({ name: 'embed_id', nullable: true })
embedId?: string;
@Property({ name: 'error_message', nullable: true })
errorMessage?: string;
@Property({ name: 'full_error', nullable: true })
fullError?: string;
}

46
src/db/models/Session.ts Normal file
View File

@ -0,0 +1,46 @@
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
@Entity({ tableName: 'sessions' })
export class Session {
@PrimaryKey({ name: 'id', type: 'uuid' })
id: string = randomUUID();
@Property({ name: 'user', type: 'text' })
user!: string;
@Property({ type: 'date' })
createdAt: Date = new Date();
@Property({ type: 'date' })
accessedAt!: Date;
@Property({ type: 'date' })
expiresAt!: Date;
@Property({ type: 'text' })
device!: string;
@Property({ type: 'text' })
userAgent!: string;
}
export interface SessionDTO {
id: string;
userId: string;
createdAt: string;
accessedAt: string;
device: string;
userAgent: string;
}
export function formatSession(session: Session): SessionDTO {
return {
id: session.id,
userId: session.user,
createdAt: session.createdAt.toISOString(),
accessedAt: session.accessedAt.toISOString(),
device: session.device,
userAgent: session.userAgent,
};
}

66
src/db/models/User.ts Normal file
View File

@ -0,0 +1,66 @@
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
import { nanoid } from 'nanoid';
export type UserProfile = {
colorA: string;
colorB: string;
icon: string;
};
@Entity({ tableName: 'users' })
export class User {
@PrimaryKey({ name: 'id', type: 'text' })
id: string = nanoid(12);
@Property({ name: 'public_key', type: 'text' })
@Unique()
publicKey!: string;
@Property({ name: 'namespace' })
namespace!: string;
@Property({ type: 'date' })
createdAt: Date = new Date();
@Property({ type: 'date', nullable: true })
lastLoggedIn?: Date;
@Property({ name: 'permissions', type: types.array })
roles: string[] = [];
@Property({
name: 'profile',
type: types.json,
})
profile!: UserProfile;
}
export interface UserDTO {
id: string;
namespace: string;
publicKey: string;
roles: string[];
createdAt: string;
lastLoggedIn?: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
}
export function formatUser(user: User): UserDTO {
return {
id: user.id,
namespace: user.namespace,
publicKey: user.publicKey,
roles: user.roles,
createdAt: user.createdAt.toISOString(),
lastLoggedIn: user.lastLoggedIn?.toISOString(),
profile: {
colorA: user.profile.colorA,
colorB: user.profile.colorB,
icon: user.profile.icon,
},
};
}

View File

@ -0,0 +1,35 @@
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
@Entity({ tableName: 'user_settings' })
export class UserSettings {
@PrimaryKey({ name: 'id', type: 'uuid' })
id: string = randomUUID();
@Property({ name: 'application_theme', nullable: true })
applicationTheme?: string | null;
@Property({ name: 'application_language', nullable: true })
applicationLanguage?: string | null;
@Property({ name: 'default_subtitle_language', nullable: true })
defaultSubtitleLanguage?: string | null;
}
export interface UserSettingsDTO {
id: string;
applicationTheme?: string | null;
applicationLanguage?: string | null;
defaultSubtitleLanguage?: string | null;
}
export function formatUserSettings(
userSettings: UserSettings,
): UserSettingsDTO {
return {
id: userSettings.id,
applicationTheme: userSettings.applicationTheme,
applicationLanguage: userSettings.applicationLanguage,
defaultSubtitleLanguage: userSettings.defaultSubtitleLanguage,
};
}

View File

@ -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',

4
src/mikro-orm.config.ts Normal file
View File

@ -0,0 +1,4 @@
import { makeOrmConfig } from '@/modules/mikro/orm';
import { conf } from '@/config';
export default makeOrmConfig(conf.postgres.connection);

View File

@ -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,
},
);
}

View File

@ -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);
}

11
src/modules/jobs/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { challengeCodeJob } from '@/modules/jobs/list/challengeCode';
import { sessionExpiryJob } from '@/modules/jobs/list/sessionExpiry';
import { userDeletionJob } from '@/modules/jobs/list/userDeletion';
import { providerMetricCleanupJob } from '@/modules/jobs/list/providerMetricCleanup';
export async function setupJobs() {
challengeCodeJob.start();
sessionExpiryJob.start();
userDeletionJob.start();
providerMetricCleanupJob.start();
}

46
src/modules/jobs/job.ts Normal file
View File

@ -0,0 +1,46 @@
import { getORM } from '@/modules/mikro';
import { scopedLogger } from '@/services/logger';
import { EntityManager } from '@mikro-orm/postgresql';
import { CronJob } from 'cron';
import { Logger } from 'winston';
const minOffset = 0;
const maxOffset = 60 * 4;
const secondsOffset =
Math.floor(Math.random() * (maxOffset - minOffset)) + minOffset;
const wait = (sec: number) =>
new Promise<void>((resolve) => {
setTimeout(() => resolve(), sec * 1000);
});
/**
* @param cron crontime in this order: (min of hour) (hour of day) (day of month) (day of week) (sec of month)
*/
export function job(
id: string,
cron: string,
cb: (ctx: { em: EntityManager; log: Logger }) => Promise<void>,
): CronJob {
const log = scopedLogger('jobs', { jobId: id });
log.info(`Registering job '${id}' with cron '${cron}'`);
return CronJob.from({
cronTime: cron,
onTick: async () => {
// offset by random amount of seconds, just to prevent jobs running at
// the same time when running multiple instances
await wait(secondsOffset);
// actually run the job
try {
const em = getORM().em.fork();
log.info(`Starting job '${id}' with cron '${cron}'`);
await cb({ em, log: log });
} catch (err) {
log.error(`Failed to run '${id}' job!`);
log.error(err);
}
},
start: false,
});
}

View File

@ -0,0 +1,19 @@
import { ChallengeCode } from '@/db/models/ChallengeCode';
import { job } from '@/modules/jobs/job';
// every day at 12:00:00
export const challengeCodeJob = job(
'challenge-code-expiry',
'0 12 * * *',
async ({ em }) => {
await em
.createQueryBuilder(ChallengeCode)
.delete()
.where({
expiresAt: {
$lt: new Date(),
},
})
.execute();
},
);

View File

@ -0,0 +1,27 @@
import { ProviderMetric } from '@/db/models/ProviderMetrics';
import { job } from '@/modules/jobs/job';
import ms from 'ms';
// every day at 12:00:00
export const providerMetricCleanupJob = job(
'provider-metric-cleanup',
'0 12 * * *',
async ({ em, log }) => {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - ms('30d'));
const deletedMetrics = await em
.createQueryBuilder(ProviderMetric)
.delete()
.where({
createdAt: {
$lt: thirtyDaysAgo,
},
})
.execute<{ affectedRows: number }>('run');
log.info(
`Removed ${deletedMetrics.affectedRows} metrics that were older than 30 days`,
);
},
);

View File

@ -0,0 +1,23 @@
import { Session } from '@/db/models/Session';
import { job } from '@/modules/jobs/job';
// every day at 12:00:00
export const sessionExpiryJob = job(
'session-expiry',
'0 12 * * *',
async ({ em, log }) => {
const deletedSessions = await em
.createQueryBuilder(Session)
.delete()
.where({
expiresAt: {
$lt: new Date(),
},
})
.execute<{ affectedRows: number }>('run');
log.info(
`Removed ${deletedSessions.affectedRows} sessions that had expired`,
);
},
);

View File

@ -0,0 +1,42 @@
import { Session } from '@/db/models/Session';
import { User } from '@/db/models/User';
import { job } from '@/modules/jobs/job';
// every day at 12:00:00
export const userDeletionJob = job(
'user-deletion',
'0 12 * * *',
async ({ em, log }) => {
const knex = em.getKnex();
// Count all sessions for a user ID
const sessionCountForUser = em
.createQueryBuilder(Session, 'session')
.count()
.where({ user: knex.ref('user.id') })
.getKnexQuery();
const now = new Date();
const oneYearAgo = new Date();
oneYearAgo.setFullYear(now.getFullYear() - 1);
// Delete all users who do not have any sessions AND
// (their login date is null OR they last logged in over 1 year ago)
const deletedUsers = await em
.createQueryBuilder(User, 'user')
.delete()
.withSubQuery(sessionCountForUser, 'session.sessionCount')
.where({
'session.sessionCount': 0,
$or: [
{ lastLoggedIn: { $eq: undefined } },
{ lastLoggedIn: { $lt: oneYearAgo } },
],
})
.execute<{ affectedRows: number }>('run');
log.info(
`Removed ${deletedUsers.affectedRows} users older than 1 year with no sessions`,
);
},
);

View File

@ -0,0 +1,74 @@
import { getORM } from '@/modules/mikro';
import { FastifyInstance } from 'fastify';
import { Counter } from 'prom-client';
import metricsPlugin from 'fastify-metrics';
import { updateMetrics } from '@/modules/metrics/update';
import { scopedLogger } from '@/services/logger';
const log = scopedLogger('metrics');
export type Metrics = {
user: Counter<'namespace'>;
providerMetrics: Counter<
| 'title'
| 'tmdb_id'
| 'season_id'
| 'episode_id'
| 'status'
| 'type'
| 'provider_id'
| 'embed_id'
>;
};
let metrics: null | Metrics = null;
export function getMetrics() {
if (!metrics) throw new Error('metrics not initialized');
return metrics;
}
export async function setupMetrics(app: FastifyInstance) {
log.info(`Setting up metrics...`, { evt: 'start' });
await app.register(metricsPlugin, {
endpoint: '/metrics',
routeMetrics: {
enabled: true,
registeredRoutesOnly: true,
},
});
metrics = {
user: new Counter({
name: 'user_count',
help: 'user_help',
labelNames: ['namespace'],
}),
providerMetrics: new Counter({
name: 'provider_metrics',
help: 'provider_metrics',
labelNames: [
'episode_id',
'provider_id',
'season_id',
'status',
'title',
'tmdb_id',
'type',
'embed_id',
],
}),
};
const promClient = app.metrics.client;
promClient.register.registerMetric(metrics.user);
promClient.register.registerMetric(metrics.providerMetrics);
const orm = getORM();
const em = orm.em.fork();
log.info(`Syncing up metrics...`, { evt: 'sync' });
await updateMetrics(em, metrics);
log.info(`Metrics initialized!`, { evt: 'end' });
}

View File

@ -0,0 +1,23 @@
import { User } from '@/db/models/User';
import { Metrics } from '@/modules/metrics';
import { EntityManager } from '@mikro-orm/postgresql';
export async function updateMetrics(em: EntityManager, metrics: Metrics) {
const users = await em
.createQueryBuilder(User)
.groupBy('namespace')
.count()
.select(['namespace', 'count'])
.execute<
{
namespace: string;
count: string;
}[]
>();
metrics.user.reset();
users.forEach((v) => {
metrics?.user.inc({ namespace: v.namespace }, Number(v.count));
});
}

View File

@ -0,0 +1,44 @@
import { conf } from '@/config';
import { scopedLogger } from '@/services/logger';
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
import { MikroORM } from '@mikro-orm/core';
import { createORM } from './orm';
const log = scopedLogger('orm');
let orm: MikroORM<PostgreSqlDriver> | null = null;
export function getORM() {
if (!orm) throw new Error('ORM not set');
return orm;
}
export async function setupMikroORM() {
log.info(`Connecting to postgres`, { evt: 'connecting' });
const mikro = await createORM(conf.postgres.connection, (msg) =>
log.info(msg),
);
if (conf.postgres.syncSchema) {
const generator = mikro.getSchemaGenerator();
try {
await generator.updateSchema();
} catch {
try {
await generator.clearDatabase();
await generator.updateSchema();
} catch {
await generator.clearDatabase();
await generator.dropSchema();
await generator.updateSchema();
}
}
}
if (conf.postgres.migrateOnBoot) {
const migrator = mikro.getMigrator();
await migrator.up();
}
orm = mikro;
log.info(`Connected to postgres - ORM is setup!`, { evt: 'success' });
}

24
src/modules/mikro/orm.ts Normal file
View File

@ -0,0 +1,24 @@
import { Options } from '@mikro-orm/core';
import { MikroORM, PostgreSqlDriver } from '@mikro-orm/postgresql';
import path from 'path';
export function makeOrmConfig(url: string): Options<PostgreSqlDriver> {
return {
type: 'postgresql',
clientUrl: url,
entities: ['./models/**/*.js'],
entitiesTs: ['./models/**/*.ts'],
baseDir: path.join(__dirname, '../../db'),
migrations: {
pathTs: './migrations',
path: './migrations',
},
};
}
export async function createORM(url: string, log: (msg: string) => void) {
return await MikroORM.init<PostgreSqlDriver>({
...makeOrmConfig(url),
logger: log,
});
}

View File

@ -0,0 +1,24 @@
import { conf } from '@/config';
import { Limiter } from '@/modules/ratelimits/limiter';
import { connectRedis } from '@/modules/ratelimits/redis';
import { scopedLogger } from '@/services/logger';
const log = scopedLogger('ratelimits');
let limiter: null | Limiter = null;
export function getLimiter() {
return limiter;
}
export async function setupRatelimits() {
if (!conf.ratelimits.enabled) {
log.warn('Ratelimits disabled!');
return;
}
const redis = await connectRedis();
limiter = new Limiter({
redis,
});
log.info('Ratelimits have been setup!');
}

View File

@ -0,0 +1,63 @@
import Redis from 'ioredis';
import RateLimiter from 'async-ratelimiter';
import ms from 'ms';
import { StatusError } from '@/services/error';
export interface LimiterOptions {
redis: Redis;
}
interface LimitBucket {
limiter: RateLimiter;
}
interface BucketOptions {
id: string;
window: string;
max: number;
inc?: number;
}
export class Limiter {
private redis: Redis;
private buckets: Record<string, LimitBucket> = {};
constructor(ops: LimiterOptions) {
this.redis = ops.redis;
}
async bump(req: { ip: string }, ops: BucketOptions) {
const ip = req.ip;
if (!this.buckets[ops.id]) {
this.buckets[ops.id] = {
limiter: new RateLimiter({
db: this.redis,
namespace: `RATELIMIT_${ops.id}`,
duration: ms(ops.window),
max: ops.max,
}),
};
}
for (let i = 1; i < (ops.inc ?? 0); i++) {
await this.buckets[ops.id].limiter.get({
id: ip,
});
}
const currentLimit = await this.buckets[ops.id].limiter.get({
id: ip,
});
return {
hasBeenLimited: currentLimit.remaining <= 0,
limit: currentLimit,
};
}
async assertAndBump(req: { ip: string }, ops: BucketOptions) {
const { hasBeenLimited } = await this.bump(req, ops);
if (hasBeenLimited) {
throw new StatusError('Ratelimited', 429);
}
}
}

View File

@ -0,0 +1,7 @@
import { conf } from '@/config';
import Redis from 'ioredis';
export function connectRedis() {
if (!conf.ratelimits.redisUrl) throw new Error('missing redis URL');
return new Redis(conf.ratelimits.redisUrl);
}

93
src/routes/auth/login.ts Normal file
View File

@ -0,0 +1,93 @@
import { ChallengeCode } from '@/db/models/ChallengeCode';
import { formatSession } from '@/db/models/Session';
import { User } from '@/db/models/User';
import { assertChallengeCode } from '@/services/challenge';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { makeSession, makeSessionToken } from '@/services/session';
import { z } from 'zod';
const startSchema = z.object({
publicKey: z.string(),
});
const completeSchema = z.object({
publicKey: z.string(),
challenge: z.object({
code: z.string(),
signature: z.string(),
}),
device: z.string().max(500).min(1),
});
export const loginAuthRouter = makeRouter((app) => {
app.post(
'/auth/login/start',
{ schema: { body: startSchema } },
handle(async ({ em, body, limiter, req }) => {
await limiter?.assertAndBump(req, {
id: 'login_challenge_tokens',
max: 20,
window: '10m',
});
const user = await em.findOne(User, { publicKey: body.publicKey });
if (user == null) {
throw new StatusError('User cannot be found', 401);
}
const challenge = new ChallengeCode();
challenge.authType = 'mnemonic';
challenge.flow = 'login';
await em.persistAndFlush(challenge);
return {
challenge: challenge.code,
};
}),
),
app.post(
'/auth/login/complete',
{ schema: { body: completeSchema } },
handle(async ({ em, body, req, limiter }) => {
await limiter?.assertAndBump(req, {
id: 'login_complete',
max: 20,
window: '10m',
});
await assertChallengeCode(
em,
body.challenge.code,
body.publicKey,
body.challenge.signature,
'login',
'mnemonic',
);
const user = await em.findOne(User, { publicKey: body.publicKey });
if (user == null) {
throw new StatusError('User cannot be found', 401);
}
user.lastLoggedIn = new Date();
const session = makeSession(
user.id,
body.device,
req.headers['user-agent'],
);
await em.persistAndFlush([session, user]);
return {
session: formatSession(session),
token: makeSessionToken(session),
};
}),
);
});

95
src/routes/auth/manage.ts Normal file
View File

@ -0,0 +1,95 @@
import { ChallengeCode } from '@/db/models/ChallengeCode';
import { formatSession } from '@/db/models/Session';
import { User, formatUser } from '@/db/models/User';
import { getMetrics } from '@/modules/metrics';
import { assertCaptcha } from '@/services/captcha';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { makeSession, makeSessionToken } from '@/services/session';
import { z } from 'zod';
import { assertChallengeCode } from '@/services/challenge';
const startSchema = z.object({
captchaToken: z.string().optional(),
});
const completeSchema = z.object({
publicKey: z.string(),
challenge: z.object({
code: z.string(),
signature: z.string(),
}),
namespace: z.string().min(1),
device: z.string().max(500).min(1),
profile: z.object({
colorA: z.string(),
colorB: z.string(),
icon: z.string(),
}),
});
export const manageAuthRouter = makeRouter((app) => {
app.post(
'/auth/register/start',
{ schema: { body: startSchema } },
handle(async ({ em, body, limiter, req }) => {
await limiter?.assertAndBump(req, {
id: 'register_challenge_tokens',
max: 10,
window: '10m',
});
await assertCaptcha(body.captchaToken);
const challenge = new ChallengeCode();
challenge.authType = 'mnemonic';
challenge.flow = 'registration';
await em.persistAndFlush(challenge);
return {
challenge: challenge.code,
};
}),
);
app.post(
'/auth/register/complete',
{ schema: { body: completeSchema } },
handle(async ({ em, body, req, limiter }) => {
await limiter?.assertAndBump(req, {
id: 'register_complete',
max: 10,
window: '10m',
});
await assertChallengeCode(
em,
body.challenge.code,
body.publicKey,
body.challenge.signature,
'registration',
'mnemonic',
);
const user = new User();
user.namespace = body.namespace;
user.publicKey = body.publicKey;
user.profile = body.profile;
user.lastLoggedIn = new Date();
const session = makeSession(
user.id,
body.device,
req.headers['user-agent'],
);
await em.persistAndFlush([user, session]);
getMetrics().user.inc({ namespace: body.namespace }, 1);
return {
user: formatUser(user),
session: formatSession(session),
token: makeSessionToken(session),
};
}),
);
});

View File

@ -0,0 +1,35 @@
import { Session } from '@/db/models/Session';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const authSessionRouter = makeRouter((app) => {
app.delete(
'/sessions/:sid',
{
schema: {
params: z.object({
sid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
const targetedSession = await em.findOne(Session, { id: params.sid });
if (!targetedSession)
return {
id: params.sid,
};
if (targetedSession.user !== auth.user.id)
throw new StatusError('Cannot delete sessions you do not own', 401);
await em.removeAndFlush(targetedSession);
return {
id: params.sid,
};
}),
);
});

View File

@ -1,7 +0,0 @@
import { FastifyPluginAsync } from 'fastify';
export const helloRouter: FastifyPluginAsync = async (app) => {
app.get('/ping', (req, res) => {
res.send('pong!');
});
};

30
src/routes/meta.ts Normal file
View File

@ -0,0 +1,30 @@
import { conf } from '@/config';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
export const metaRouter = makeRouter((app) => {
app.get(
'/healthcheck',
handle(async ({ em }) => {
const databaseConnected = await em.config
.getDriver()
.getConnection()
.isConnected();
return {
healthy: databaseConnected,
databaseConnected,
};
}),
);
app.get(
'/meta',
handle(async () => {
return {
name: conf.meta.name,
description: conf.meta.description,
hasCaptcha: conf.captcha.enabled,
captchaClientKey: conf.captcha.clientKey,
};
}),
);
});

73
src/routes/metrics.ts Normal file
View File

@ -0,0 +1,73 @@
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
import { ProviderMetric, status } from '@/db/models/ProviderMetrics';
import { getMetrics } from '@/modules/metrics';
const metricsProviderSchema = z.object({
tmdbId: z.string(),
type: z.string(),
title: z.string(),
seasonId: z.string().optional(),
episodeId: z.string().optional(),
status: z.nativeEnum(status),
providerId: z.string(),
embedId: z.string().optional(),
errorMessage: z.string().optional(),
fullError: z.string().optional(),
});
const metricsProviderInputSchema = z.object({
items: z.array(metricsProviderSchema).max(10).min(1),
});
export const metricsRouter = makeRouter((app) => {
app.post(
'/metrics/providers',
{
schema: {
body: metricsProviderInputSchema,
},
},
handle(async ({ em, body, req, limiter }) => {
await limiter?.assertAndBump(req, {
id: 'provider_metrics',
max: 300,
inc: body.items.length,
window: '30m',
});
const entities = body.items.map((v) => {
const metric = new ProviderMetric();
em.assign(metric, {
providerId: v.providerId,
embedId: v.embedId,
fullError: v.fullError,
errorMessage: v.errorMessage,
episodeId: v.episodeId,
seasonId: v.seasonId,
status: v.status,
title: v.title,
tmdbId: v.tmdbId,
type: v.type,
});
return metric;
});
entities.forEach((entity) => {
getMetrics().providerMetrics.inc({
episode_id: entity.episodeId,
provider_id: entity.providerId,
season_id: entity.seasonId,
status: entity.status,
title: entity.title,
tmdb_id: entity.tmdbId,
type: entity.type,
});
});
await em.persistAndFlush(entities);
return true;
}),
);
});

65
src/routes/sessions.ts Normal file
View File

@ -0,0 +1,65 @@
import { Session, formatSession } from '@/db/models/Session';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const sessionsRouter = makeRouter((app) => {
app.patch(
'/sessions/:sid',
{
schema: {
params: z.object({
sid: z.string(),
}),
body: z.object({
name: z.string().max(500).min(1).optional(),
}),
},
},
handle(async ({ auth, params, em, body }) => {
await auth.assert();
const targetedSession = await em.findOne(Session, { id: params.sid });
if (!targetedSession)
throw new StatusError('Session cannot be found', 404);
if (targetedSession.user !== auth.user.id)
throw new StatusError('Cannot modify sessions you do not own', 401);
if (body.name) targetedSession.device = body.name;
await em.persistAndFlush(targetedSession);
return formatSession(targetedSession);
}),
);
app.delete(
'/sessions/:sid',
{
schema: {
params: z.object({
sid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
const targetedSession = await em.findOne(Session, { id: params.sid });
if (!targetedSession)
return {
id: params.sid,
};
if (targetedSession.user !== auth.user.id)
throw new StatusError('Cannot delete sessions you do not own', 401);
await em.removeAndFlush(targetedSession);
return {
id: params.sid,
};
}),
);
});

View File

@ -0,0 +1,35 @@
import { Session } from '@/db/models/Session';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const sessionRouter = makeRouter((app) => {
app.delete(
'/sessions/:sid',
{
schema: {
params: z.object({
sid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
const targetedSession = await em.findOne(Session, { id: params.sid });
if (!targetedSession)
return {
id: params.sid,
};
if (targetedSession.user !== auth.user.id)
throw new StatusError('Cannot delete sessions you do not own', 401);
await em.removeAndFlush(targetedSession);
return {
id: params.sid,
};
}),
);
});

View File

@ -0,0 +1,100 @@
import {
Bookmark,
bookmarkMetaSchema,
formatBookmark,
} from '@/db/models/Bookmark';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userBookmarkRouter = makeRouter((app) => {
app.get(
'/users/:uid/bookmarks',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot access other user information', 403);
const bookmarks = await em.find(Bookmark, {
userId: params.uid,
});
return bookmarks.map(formatBookmark);
}),
);
app.post(
'/users/:uid/bookmarks/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
body: z.object({
meta: bookmarkMetaSchema,
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const oldBookmark = await em.findOne(Bookmark, {
userId: params.uid,
tmdbId: params.tmdbid,
});
if (oldBookmark) throw new StatusError('Already bookmarked', 400);
const bookmark = new Bookmark();
em.assign(bookmark, {
userId: params.uid,
tmdbId: params.tmdbid,
meta: body.meta,
updatedAt: new Date(),
});
await em.persistAndFlush(bookmark);
return formatBookmark(bookmark);
}),
);
app.delete(
'/users/:uid/bookmarks/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const bookmark = await em.findOne(Bookmark, {
userId: params.uid,
tmdbId: params.tmdbid,
});
if (!bookmark) return { tmdbId: params.tmdbid };
await em.removeAndFlush(bookmark);
return { tmdbId: params.tmdbid };
}),
);
});

View File

@ -0,0 +1,63 @@
import { Bookmark } from '@/db/models/Bookmark';
import { ProgressItem } from '@/db/models/ProgressItem';
import { Session } from '@/db/models/Session';
import { User } from '@/db/models/User';
import { UserSettings } from '@/db/models/UserSettings';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userDeleteRouter = makeRouter((app) => {
app.delete(
'/users/:uid',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
const user = await em.findOne(User, { id: params.uid });
if (!user) throw new StatusError('User does not exist', 404);
if (auth.user.id !== user.id)
throw new StatusError('Cannot delete user other than yourself', 403);
// delete data
await em
.createQueryBuilder(Bookmark)
.delete()
.where({
userId: user.id,
})
.execute();
await em
.createQueryBuilder(ProgressItem)
.delete()
.where({
userId: user.id,
})
.execute();
await em
.createQueryBuilder(UserSettings)
.delete()
.where({
id: user.id,
})
.execute();
// delete account & login sessions
const sessions = await em.find(Session, { user: user.id });
await em.remove([user, ...sessions]);
await em.flush();
return {
id: user.id,
};
}),
);
});

42
src/routes/users/edit.ts Normal file
View File

@ -0,0 +1,42 @@
import { User, formatUser } from '@/db/models/User';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userEditRouter = makeRouter((app) => {
app.patch(
'/users/:uid',
{
schema: {
params: z.object({
uid: z.string(),
}),
body: z.object({
profile: z
.object({
colorA: z.string(),
colorB: z.string(),
icon: z.string(),
})
.optional(),
name: z.string().max(500).min(1).optional(),
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
const user = await em.findOne(User, { id: params.uid });
if (!user) throw new StatusError('User does not exist', 404);
if (auth.user.id !== user.id)
throw new StatusError('Cannot modify user other than yourself', 403);
if (body.profile) user.profile = body.profile;
await em.persistAndFlush(user);
return formatUser(user);
}),
);
});

31
src/routes/users/get.ts Normal file
View File

@ -0,0 +1,31 @@
import { User, formatUser } from '@/db/models/User';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userGetRouter = makeRouter((app) => {
app.get(
'/users/:uid',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
let uid = params.uid;
if (uid === '@me') uid = auth.user.id;
if (auth.user.id !== uid)
throw new StatusError('Cannot access users other than yourself', 403);
const user = await em.findOne(User, { id: uid });
if (!user) throw new StatusError('User does not exist', 404);
return formatUser(user);
}),
);
});

View File

@ -0,0 +1,126 @@
import {
ProgressItem,
formatProgressItem,
progressMetaSchema,
} from '@/db/models/ProgressItem';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userProgressRouter = makeRouter((app) => {
app.put(
'/users/:uid/progress/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
body: z.object({
meta: progressMetaSchema,
seasonId: z.string().optional(),
episodeId: z.string().optional(),
duration: z.number(),
watched: z.number(),
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
let progressItem = await em.findOne(ProgressItem, {
userId: params.uid,
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
});
if (!progressItem) {
progressItem = new ProgressItem();
progressItem.tmdbId = params.tmdbid;
progressItem.userId = params.uid;
progressItem.episodeId = body.episodeId;
progressItem.seasonId = body.seasonId;
}
em.assign(progressItem, {
duration: body.duration,
watched: body.watched,
meta: body.meta,
updatedAt: new Date(),
});
await em.persistAndFlush(progressItem);
return formatProgressItem(progressItem);
}),
);
app.delete(
'/users/:uid/progress/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
body: z.object({
seasonId: z.string().optional(),
episodeId: z.string().optional(),
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const progressItem = await em.findOne(ProgressItem, {
userId: params.uid,
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
});
if (!progressItem) {
return {
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
};
}
await em.removeAndFlush(progressItem);
return {
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
};
}),
);
app.get(
'/users/:uid/progress',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const items = await em.find(ProgressItem, {
userId: params.uid,
});
return items.map(formatProgressItem);
}),
);
});

View File

@ -0,0 +1,30 @@
import { Session, formatSession } from '@/db/models/Session';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userSessionsRouter = makeRouter((app) => {
app.get(
'/users/:uid/sessions',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const sessions = await em.find(Session, {
user: params.uid,
});
return sessions.map(formatSession);
}),
);
});

View File

@ -0,0 +1,72 @@
import { UserSettings, formatUserSettings } from '@/db/models/UserSettings';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userSettingsRouter = makeRouter((app) => {
app.get(
'/users/:uid/settings',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot get other user information', 403);
const settings = await em.findOne(UserSettings, {
id: params.uid,
});
if (!settings) return { id: params.uid };
return formatUserSettings(settings);
}),
);
app.put(
'/users/:uid/settings',
{
schema: {
params: z.object({
uid: z.string(),
}),
body: z.object({
applicationLanguage: z.string().optional(),
applicationTheme: z.string().optional(),
defaultSubtitleLanguage: z.string().optional(),
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
let settings = await em.findOne(UserSettings, {
id: params.uid,
});
if (!settings) {
settings = new UserSettings();
settings.id = params.uid;
}
if (body.applicationLanguage)
settings.applicationLanguage = body.applicationLanguage;
if (body.applicationTheme)
settings.applicationTheme = body.applicationTheme;
if (body.defaultSubtitleLanguage)
settings.defaultSubtitleLanguage = body.defaultSubtitleLanguage;
await em.persistAndFlush(settings);
return formatUserSettings(settings);
}),
);
});

5
src/services/access.ts Normal file
View File

@ -0,0 +1,5 @@
export const roles = {
ADMIN: 'ADMIN', // has access to admin endpoints
} as const;
export type Roles = (typeof roles)[keyof typeof roles];

62
src/services/auth.ts Normal file
View File

@ -0,0 +1,62 @@
import { Session } from '@/db/models/Session';
import { User } from '@/db/models/User';
import { Roles } from '@/services/access';
import { StatusError } from '@/services/error';
import { getSessionAndBump, verifySessionToken } from '@/services/session';
import { EntityManager } from '@mikro-orm/postgresql';
import { FastifyRequest } from 'fastify';
export function makeAuthContext(manager: EntityManager, req: FastifyRequest) {
let userCache: User | null = null;
let sessionCache: Session | null = null;
const em = manager.fork();
return {
getSessionId(): string | null {
const header = req.headers.authorization;
if (!header) return null;
const [type, token] = header.split(' ', 2);
if (type.toLowerCase() !== 'bearer')
throw new StatusError('Invalid authentication', 400);
const payload = verifySessionToken(token);
if (!payload) throw new StatusError('Invalid authentication', 400);
return payload.sid;
},
async getSession() {
if (sessionCache) return sessionCache;
const sid = this.getSessionId();
if (!sid) return null;
const session = await getSessionAndBump(em, sid);
if (!session) return null;
sessionCache = session;
return session;
},
async getUser() {
if (userCache) return userCache;
const session = await this.getSession();
if (!session) return null;
const user = await em.findOne(User, { id: session.user });
if (!user) return null;
userCache = user;
return user;
},
async assert() {
const user = await this.getUser();
if (!user) throw new StatusError('Not logged in', 401);
return user;
},
get user() {
if (!userCache) throw new Error('call assert before getting user');
return userCache;
},
get session() {
if (!sessionCache) throw new Error('call assert before getting session');
return sessionCache;
},
async assertHasRole(role: Roles) {
const user = await this.assert();
const hasRole = user.roles.includes(role);
if (!hasRole) throw new StatusError('No permissions', 403);
},
};
}

28
src/services/captcha.ts Normal file
View File

@ -0,0 +1,28 @@
import { conf } from '@/config';
import { StatusError } from '@/services/error';
export async function isValidCaptcha(token: string): Promise<boolean> {
if (!conf.captcha.secret)
throw new Error('isValidCaptcha() is called but no secret set');
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
body: JSON.stringify({
secret: conf.captcha.secret,
response: token,
}),
headers: {
'content-type': 'application/json',
},
});
const json = await res.json();
return !!json.success;
}
export async function assertCaptcha(token?: string) {
// early return if captchas arent enabled
if (!conf.captcha.enabled) return;
if (!token) throw new StatusError('captcha token is required', 400);
const isValid = await isValidCaptcha(token);
if (!isValid) throw new StatusError('captcha token is invalid', 400);
}

55
src/services/challenge.ts Normal file
View File

@ -0,0 +1,55 @@
import {
ChallengeCode,
ChallengeFlow,
ChallengeType,
} from '@/db/models/ChallengeCode';
import { StatusError } from '@/services/error';
import { EntityManager } from '@mikro-orm/core';
import forge from 'node-forge';
const {
pki: { ed25519 },
util: { ByteStringBuffer },
} = forge;
export async function assertChallengeCode(
em: EntityManager,
code: string,
publicKey: string,
signature: string,
validFlow: ChallengeFlow,
validType: ChallengeType,
) {
const now = Date.now();
const challenge = await em.findOne(ChallengeCode, {
code,
});
if (
!challenge ||
challenge.flow !== validFlow ||
challenge.authType !== validType
) {
throw new StatusError('Challenge Code Invalid', 401);
}
if (challenge.expiresAt.getTime() <= now)
throw new StatusError('Challenge Code Expired', 401);
try {
const verifiedChallenge = ed25519.verify({
publicKey: new ByteStringBuffer(Buffer.from(publicKey, 'base64url')),
encoding: 'utf8',
signature: new ByteStringBuffer(Buffer.from(signature, 'base64url')),
message: code,
});
if (!verifiedChallenge)
throw new StatusError('Challenge Code Signature Invalid', 401);
em.remove(challenge);
} catch (e) {
throw new StatusError('Challenge Code Signature Invalid', 401);
}
}

9
src/services/error.ts Normal file
View File

@ -0,0 +1,9 @@
export class StatusError extends Error {
errorStatusCode: number;
constructor(message: string, code: number) {
super(message);
this.errorStatusCode = code;
this.message = message;
}
}

135
src/services/handler.ts Normal file
View File

@ -0,0 +1,135 @@
import { getORM } from '@/modules/mikro';
import { getLimiter } from '@/modules/ratelimits';
import { Limiter } from '@/modules/ratelimits/limiter';
import { makeAuthContext } from '@/services/auth';
import { EntityManager } from '@mikro-orm/postgresql';
import {
ContextConfigDefault,
FastifyBaseLogger,
FastifyReply,
FastifyRequest,
FastifySchema,
RawReplyDefaultExpression,
RawRequestDefaultExpression,
RawServerBase,
RawServerDefault,
RouteGenericInterface,
RouteHandlerMethod,
} from 'fastify';
import { ZodTypeProvider } from 'fastify-type-provider-zod';
import { ResolveFastifyReplyReturnType } from 'fastify/types/type-provider';
export type RequestContext<
RawServer extends RawServerBase = RawServerDefault,
RawRequest extends
RawRequestDefaultExpression<RawServer> = RawRequestDefaultExpression<RawServer>,
RawReply extends
RawReplyDefaultExpression<RawServer> = RawReplyDefaultExpression<RawServer>,
RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
ContextConfig = ContextConfigDefault,
SchemaCompiler extends FastifySchema = FastifySchema,
Logger extends FastifyBaseLogger = FastifyBaseLogger,
> = {
req: FastifyRequest<
RouteGeneric,
RawServer,
RawRequest,
SchemaCompiler,
ZodTypeProvider,
ContextConfig,
Logger
>;
res: FastifyReply<
RawServer,
RawRequest,
RawReply,
RouteGeneric,
ContextConfig,
SchemaCompiler,
ZodTypeProvider
>;
body: FastifyRequest<
RouteGeneric,
RawServer,
RawRequest,
SchemaCompiler,
ZodTypeProvider,
ContextConfig,
Logger
>['body'];
params: FastifyRequest<
RouteGeneric,
RawServer,
RawRequest,
SchemaCompiler,
ZodTypeProvider,
ContextConfig,
Logger
>['params'];
query: FastifyRequest<
RouteGeneric,
RawServer,
RawRequest,
SchemaCompiler,
ZodTypeProvider,
ContextConfig,
Logger
>['query'];
em: EntityManager;
limiter: Limiter | null;
auth: ReturnType<typeof makeAuthContext>;
};
export function handle<
RawServer extends RawServerBase = RawServerDefault,
RawRequest extends
RawRequestDefaultExpression<RawServer> = RawRequestDefaultExpression<RawServer>,
RawReply extends
RawReplyDefaultExpression<RawServer> = RawReplyDefaultExpression<RawServer>,
RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
ContextConfig = ContextConfigDefault,
SchemaCompiler extends FastifySchema = FastifySchema,
Logger extends FastifyBaseLogger = FastifyBaseLogger,
>(
handler: (
ctx: RequestContext<
RawServer,
RawRequest,
RawReply,
RouteGeneric,
ContextConfig,
SchemaCompiler,
Logger
>,
) => ResolveFastifyReplyReturnType<
ZodTypeProvider,
SchemaCompiler,
RouteGeneric
>,
): RouteHandlerMethod<
RawServer,
RawRequest,
RawReply,
RouteGeneric,
ContextConfig,
SchemaCompiler,
ZodTypeProvider,
Logger
> {
const reqHandler: any = async (req: any, res: any) => {
const em = getORM().em.fork();
res.send(
await handler({
req,
res,
body: req.body,
params: req.params,
query: req.query,
em,
auth: makeAuthContext(em, req),
limiter: getLimiter(),
}),
);
};
return reqHandler;
}

View File

@ -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;
}

32
src/services/router.ts Normal file
View File

@ -0,0 +1,32 @@
import {
FastifyBaseLogger,
FastifyInstance,
FastifyPluginAsync,
RawReplyDefaultExpression,
RawRequestDefaultExpression,
RawServerBase,
} from 'fastify';
import { ZodTypeProvider } from 'fastify-type-provider-zod';
export type Instance = FastifyInstance<
RawServerBase,
RawRequestDefaultExpression<RawServerBase>,
RawReplyDefaultExpression<RawServerBase>,
FastifyBaseLogger,
ZodTypeProvider
>;
export type RegisterPlugin = FastifyPluginAsync<
Record<never, never>,
RawServerBase,
ZodTypeProvider
>;
export function makeRouter(cb: (app: Instance) => void): {
register: RegisterPlugin;
} {
return {
register: async (app) => {
cb(app);
},
};
}

69
src/services/session.ts Normal file
View File

@ -0,0 +1,69 @@
import { conf } from '@/config';
import { Session } from '@/db/models/Session';
import { EntityManager } from '@mikro-orm/postgresql';
import { sign, verify } from 'jsonwebtoken';
// 21 days in ms
const SESSION_EXPIRY_MS = 21 * 24 * 60 * 60 * 1000;
export async function getSession(
em: EntityManager,
id: string,
): Promise<Session | null> {
const session = await em.findOne(Session, { id });
if (!session) return null;
if (session.expiresAt < new Date()) return null;
return session;
}
export async function getSessionAndBump(
em: EntityManager,
id: string,
): Promise<Session | null> {
const session = await getSession(em, id);
if (!session) return null;
em.assign(session, {
accessedAt: new Date(),
expiresAt: new Date(Date.now() + SESSION_EXPIRY_MS),
});
await em.persistAndFlush(session);
return session;
}
export function makeSession(
user: string,
device: string,
userAgent?: string,
): Session {
if (!userAgent) throw new Error('No useragent provided');
const session = new Session();
session.accessedAt = new Date();
session.createdAt = new Date();
session.expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
session.userAgent = userAgent;
session.device = device;
session.user = user;
return session;
}
export function makeSessionToken(session: Session): string {
return sign({ sid: session.id }, conf.crypto.sessionSecret, {
algorithm: 'HS256',
});
}
export function verifySessionToken(token: string): { sid: string } | null {
try {
const payload = verify(token, conf.crypto.sessionSecret, {
algorithms: ['HS256'],
});
if (typeof payload === 'string') return null;
return payload as { sid: string };
} catch {
return null;
}
}

View File

@ -14,6 +14,7 @@
"experimentalDecorators": true,
"isolatedModules": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"strict": true,
"paths": {
"@/*": ["./*"]

1130
yarn.lock

File diff suppressed because it is too large Load Diff