Fresh Start

This commit is contained in:
FifthWit 2025-03-05 19:23:08 -06:00
parent 49452e8dea
commit e9fa3b2d58
68 changed files with 0 additions and 9807 deletions

View file

@ -1,23 +0,0 @@
FROM node:20-alpine
WORKDIR /app
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
# 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 pnpm run build
# start server
EXPOSE 80
ENV MWB_SERVER__PORT=80
ENV NODE_ENV=production
CMD ["pnpm", "run", "start"]

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 movie-web
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,2 +0,0 @@
# backend
Backend for sudo-flix

View file

@ -1,11 +0,0 @@
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

@ -1,68 +0,0 @@
{
"name": "backend",
"version": "1.3.2",
"private": true,
"homepage": "https://github.com/movie-web/backend",
"engines": {
"node": ">=18"
},
"scripts": {
"dev": "nodemon -r tsconfig-paths/register src/main.ts",
"build": "pnpm run build:pre && pnpm run build:compile",
"start": "node dist/main.js",
"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",
"migration:create": "pnpm exec mikro-orm migration:create",
"migration:up": "pnpm exec mikro-orm migration:up",
"migration:down": "pnpm exec mikro-orm migration:down"
},
"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",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"nodemon": "^3.0.1",
"prettier": "^3.0.2",
"rimraf": "^5.0.1",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.7",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.6"
},
"dependencies": {
"@fastify/cors": "^8.3.0",
"@mikro-orm/core": "^5.9.2",
"@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",
"zod": "^3.22.2"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,23 +0,0 @@
import { FragmentSchema } from '@/config/fragments/types';
export const devFragment: FragmentSchema = {
server: {
allowAnySite: true,
trustProxy: true,
},
logging: {
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

@ -1,11 +0,0 @@
import { FragmentSchema } from '@/config/fragments/types';
export const dockerFragment: FragmentSchema = {
postgres: {
connection: 'postgres://postgres:postgres@postgres:5432/postgres',
},
ratelimits: {
enabled: true,
redisUrl: 'redis://redis:6379',
},
};

View file

@ -1,5 +0,0 @@
import { configSchema } from '@/config/schema';
import { PartialDeep } from 'type-fest';
import { z } from 'zod';
export type FragmentSchema = PartialDeep<z.infer<typeof configSchema>>;

View file

@ -1,24 +0,0 @@
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 = process.env.npm_package_version ?? 'unknown';
export const conf = createConfigLoader()
.addFromEnvironment('MWB_')
.addFromCLI('mwb-')
.addFromFile('.env', {
prefix: 'MWB_',
})
.addFromFile('config.json')
.addZodSchema(configSchema)
.setFragmentKey('usePresets')
.addConfigFragments(fragments)
.freeze()
.load();

View file

@ -1,32 +0,0 @@
import { devFragment } from '@/config/fragments/dev';
import { dockerFragment } from '@/config/fragments/docker';
import { createConfigLoader } from 'neat-config';
import { z } from 'zod';
import { booleanSchema } from './schema';
const fragments = {
dev: devFragment,
dockerdev: dockerFragment,
};
export const ormConfigSchema = z.object({
postgres: z.object({
// connection URL for postgres database
connection: z.string(),
// whether to use SSL for the connection
ssl: booleanSchema.default(false),
}),
});
export const ormConf = createConfigLoader()
.addFromEnvironment('MWB_')
.addFromCLI('mwb-')
.addFromFile('.env', {
prefix: 'MWB_',
})
.addFromFile('config.json')
.setFragmentKey('usePresets')
.addConfigFragments(fragments)
.addZodSchema(ormConfigSchema)
.freeze()
.load();

View file

@ -1,85 +0,0 @@
import { z } from 'zod';
export const booleanSchema = z.preprocess((val) => val === 'true', z.boolean());
export const configSchema = z.object({
server: z
.object({
// port of web server
port: z.coerce.number().default(8080),
// space seperated list of allowed cors domains
cors: z.string().default(''),
// disable cross origin restrictions, allow any site.
// overwrites the cors option above
allowAnySite: booleanSchema.default(false),
// should it trust reverse proxy headers? (for ip gathering)
trustProxy: booleanSchema.default(false),
// should it trust cloudflare headers? (for ip gathering, cloudflare has priority)
trustCloudflare: booleanSchema.default(false),
// prefix for where the instance is run on. for example set it to /backend if you're hosting it on example.com/backend
// if this is set, do not apply url rewriting before proxing
basePath: z.string().default('/'),
})
.default({}),
logging: z
.object({
// format of the logs, JSON is recommended for production
format: z.enum(['json', 'pretty']).default('pretty'),
// show debug logs?
debug: booleanSchema.default(false),
})
.default({}),
postgres: z.object({
// connection URL for postgres database
connection: z.string(),
// run all migrations on boot of the application
migrateOnBoot: booleanSchema.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: booleanSchema.default(false),
// Enable debug logging for MikroORM - Outputs queries and entity management logs
// Do NOT use in production, leaks all sensitive data
debugLogging: booleanSchema.default(false),
// Enable SSL for the postgres connection
ssl: booleanSchema.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: booleanSchema.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: booleanSchema.default(false),
redisUrl: z.string().optional(),
})
.default({}),
});

View file

@ -1,514 +0,0 @@
{
"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"
},
"season_number": {
"name": "season_number",
"type": "int",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "integer"
},
"episode_number": {
"name": "episode_number",
"type": "int",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "integer"
},
"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"
},
"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": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"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"
},
"proxy_urls": {
"name": "proxy_urls",
"type": "text[]",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "array"
}
},
"name": "user_settings",
"schema": "public",
"indexes": [
{
"keyName": "user_settings_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
}
]
}

View file

@ -1,42 +0,0 @@
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"));',
);
}
}

View file

@ -1,14 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20231105150807 extends Migration {
async up(): Promise<void> {
this.addSql(
'alter table "progress_items" add column "season_number" int null, add column "episode_number" int null;',
);
}
async down(): Promise<void> {
this.addSql('alter table "progress_items" drop column "season_number";');
this.addSql('alter table "progress_items" drop column "episode_number";');
}
}

View file

@ -1,25 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20231111160045 extends Migration {
async up(): Promise<void> {
this.addSql(
'alter table "provider_metrics" add column "hostname" varchar(255) not null;',
);
this.addSql(
'alter table "provider_metrics" alter column "error_message" type text using ("error_message"::text);',
);
this.addSql(
'alter table "provider_metrics" alter column "full_error" type text using ("full_error"::text);',
);
}
async down(): Promise<void> {
this.addSql(
'alter table "provider_metrics" alter column "error_message" type varchar(255) using ("error_message"::varchar(255));',
);
this.addSql(
'alter table "provider_metrics" alter column "full_error" type varchar(255) using ("full_error"::varchar(255));',
);
this.addSql('alter table "provider_metrics" drop column "hostname";');
}
}

View file

@ -1,14 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20231122231620 extends Migration {
async up(): Promise<void> {
this.addSql('alter table "user_settings" alter column "id" type text using ("id"::text);');
}
async down(): Promise<void> {
this.addSql('alter table "user_settings" alter column "id" drop default;');
this.addSql('alter table "user_settings" alter column "id" type uuid using ("id"::text::uuid);');
}
}

View file

@ -1,13 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20231221185725 extends Migration {
async up(): Promise<void> {
this.addSql('drop table if exists "provider_metrics" cascade;');
}
async down(): Promise<void> {
this.addSql('create table "provider_metrics" ("id" uuid not null default null, "tmdb_id" varchar not null default null, "type" varchar not null default null, "title" varchar not null default null, "season_id" varchar null default null, "episode_id" varchar null default null, "created_at" timestamptz not null default null, "status" varchar not null default null, "provider_id" varchar not null default null, "embed_id" varchar null default null, "error_message" text null default null, "full_error" text null default null, "hostname" varchar not null default null, constraint "provider_metrics_pkey" primary key ("id"));');
}
}

View file

@ -1,13 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20231229214215 extends Migration {
async up(): Promise<void> {
this.addSql('alter table "user_settings" add column "proxy_urls" text[] null;');
}
async down(): Promise<void> {
this.addSql('alter table "user_settings" drop column "proxy_urls";');
}
}

View file

@ -1,54 +0,0 @@
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

@ -1,47 +0,0 @@
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

@ -1,101 +0,0 @@
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.union([z.literal('show'), z.literal('movie')]),
});
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: 'season_number', nullable: true })
seasonNumber?: number;
@Property({ name: 'episode_number', nullable: true })
episodeNumber?: number;
@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 {
id: string;
tmdbId: string;
season: {
id?: string;
number?: number;
};
episode: {
id?: string;
number?: number;
};
meta: {
title: string;
year: number;
poster?: string;
type: string;
};
duration: number;
watched: number;
updatedAt: string;
}
export function formatProgressItem(
progressItem: ProgressItem,
): ProgressItemDTO {
return {
id: progressItem.id,
tmdbId: progressItem.tmdbId,
episode: {
id: progressItem.episodeId,
number: progressItem.episodeNumber,
},
season: {
id: progressItem.seasonId,
number: progressItem.seasonNumber,
},
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

@ -1,46 +0,0 @@
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,
};
}

View file

@ -1,66 +0,0 @@
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

@ -1,39 +0,0 @@
import { ArrayType, Entity, PrimaryKey, Property } from '@mikro-orm/core';
@Entity({ tableName: 'user_settings' })
export class UserSettings {
@PrimaryKey({ name: 'id', type: 'text' })
id!: string;
@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;
@Property({ name: 'proxy_urls', type: ArrayType, nullable: true })
proxyUrls?: string[] | null;
}
export interface UserSettingsDTO {
id: string;
applicationTheme?: string | null;
applicationLanguage?: string | null;
defaultSubtitleLanguage?: string | null;
proxyUrls?: string[] | null;
}
export function formatUserSettings(
userSettings: UserSettings,
): UserSettingsDTO {
return {
id: userSettings.id,
applicationTheme: userSettings.applicationTheme,
applicationLanguage: userSettings.applicationLanguage,
defaultSubtitleLanguage: userSettings.defaultSubtitleLanguage,
proxyUrls: userSettings.proxyUrls,
};
}

View file

@ -1,39 +0,0 @@
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');
async function bootstrap(): Promise<void> {
log.info(`App booting...`, {
evt: 'setup',
});
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',
});
log.info(`--------------------------------------`);
}
bootstrap().catch((err) => {
log.error(err, {
evt: 'setup-error',
});
process.exit(1);
});

View file

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

View file

@ -1,106 +0,0 @@
import Fastify, { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import { conf } from '@/config';
import { makeFastifyLogger, scopedLogger } from '@/services/logger';
import { setupRoutes } from './routes';
import {
serializerCompiler,
validatorCompiler,
} from 'fastify-type-provider-zod';
import { ZodError } from 'zod';
import { StatusError } from '@/services/error';
const log = scopedLogger('fastify');
export async function setupFastify(): Promise<FastifyInstance> {
log.info(`setting up fastify...`, { evt: 'setup-start' });
// create server
const app = Fastify({
logger: makeFastifyLogger(log) as any,
trustProxy: conf.server.trustProxy,
});
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.setErrorHandler((err, req, reply) => {
if (err instanceof ZodError) {
reply.status(400).send({
errorType: 'validation',
errors: err.errors,
});
return;
}
if (err instanceof StatusError) {
reply.status(err.errorStatusCode).send({
errorType: 'message',
message: err.message,
});
return;
}
log.error('unhandled exception on server:', err);
log.error(err.stack);
reply.status(500).send({
errorType: 'message',
message: 'Internal server error',
...(conf.logging.debug
? {
trace: err.stack,
errorMessage: err.toString(),
}
: {}),
});
});
// plugins
log.info(`setting up plugins`, { evt: 'setup-plugins' });
const corsDomains = conf.server.cors.split(' ').filter((v) => v.length > 0);
const corsSetting = conf.server.allowAnySite ? true : corsDomains;
await app.register(cors, {
origin: corsSetting,
credentials: true,
});
return app;
}
export function startFastify(app: FastifyInstance) {
// listen to port
log.info(`listening to port`, { evt: 'setup-listen' });
return new Promise<void>((resolve) => {
app.listen(
{
port: conf.server.port,
host: '0.0.0.0',
},
function (err) {
if (err) {
app.log.error(err);
log.error(`Failed to setup fastify`, {
evt: 'setup-error',
});
process.exit(1);
}
log.info(`fastify setup successfully`, {
evt: 'setup-success',
});
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,30 +0,0 @@
import { indexRouter } from '@/routes';
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/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) {
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);
await app.register(indexRouter.register);
}

View file

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

View file

@ -1,46 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,84 +0,0 @@
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'>;
captchaSolves: Counter<'success'>;
providerHostnames: Counter<'hostname'>;
providerStatuses: Counter<'provider_id' | 'status'>;
watchMetrics: Counter<'title' | 'tmdb_full_id' | 'provider_id' | 'success'>;
toolMetrics: Counter<'tool'>;
};
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: 'mw_user_count',
help: 'mw_user_help',
labelNames: ['namespace'],
}),
captchaSolves: new Counter({
name: 'mw_captcha_solves',
help: 'mw_captcha_solves',
labelNames: ['success'],
}),
providerHostnames: new Counter({
name: 'mw_provider_hostname_count',
help: 'mw_provider_hostname_count',
labelNames: ['hostname'],
}),
providerStatuses: new Counter({
name: 'mw_provider_status_count',
help: 'mw_provider_status_count',
labelNames: ['provider_id', 'status'],
}),
watchMetrics: new Counter({
name: 'mw_media_watch_count',
help: 'mw_media_watch_count',
labelNames: ['title', 'tmdb_full_id', 'provider_id', 'success'],
}),
toolMetrics: new Counter({
name: 'mw_provider_tool_count',
help: 'mw_provider_tool_count',
labelNames: ['tool'],
}),
};
const promClient = app.metrics.client;
promClient.register.registerMetric(metrics.user);
promClient.register.registerMetric(metrics.providerHostnames);
promClient.register.registerMetric(metrics.providerStatuses);
promClient.register.registerMetric(metrics.watchMetrics);
promClient.register.registerMetric(metrics.captchaSolves);
promClient.register.registerMetric(metrics.toolMetrics);
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

@ -1,23 +0,0 @@
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

@ -1,47 +0,0 @@
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,
conf.postgres.debugLogging,
(msg) => log.info(msg),
conf.postgres.ssl,
);
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' });
}

View file

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

View file

@ -1,24 +0,0 @@
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

@ -1,64 +0,0 @@
import Redis from 'ioredis';
import RateLimiter from 'async-ratelimiter';
import ms from 'ms';
import { StatusError } from '@/services/error';
import { IpReq, getIp } from '@/services/ip';
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: IpReq, ops: BucketOptions) {
const ip = getIp(req);
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: IpReq, ops: BucketOptions) {
const { hasBeenLimited } = await this.bump(req, ops);
if (hasBeenLimited) {
throw new StatusError('Ratelimited', 429);
}
}
}

View file

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

View file

@ -1,94 +0,0 @@
import { ChallengeCode } from '@/db/models/ChallengeCode';
import { formatSession } from '@/db/models/Session';
import { User, formatUser } 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 {
user: formatUser(user),
session: formatSession(session),
token: makeSessionToken(session),
};
}),
);
});

View file

@ -1,95 +0,0 @@
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

@ -1,35 +0,0 @@
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,14 +0,0 @@
import { version } from '@/config';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
export const indexRouter = makeRouter((app) => {
app.get(
'/',
handle(async () => {
return {
message: `Backend is working as expected (${version})`,
};
}),
);
});

View file

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

View file

@ -1,103 +0,0 @@
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
import { getMetrics } from '@/modules/metrics';
import { status } from '@/routes/statuses';
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),
tool: z.string().optional(),
});
export const metricsRouter = makeRouter((app) => {
app.post(
'/metrics/providers',
{
schema: {
body: metricsProviderInputSchema,
},
},
handle(async ({ body, req, limiter }) => {
await limiter?.assertAndBump(req, {
id: 'provider_metrics',
max: 300,
inc: body.items.length,
window: '30m',
});
const hostname = req.headers.origin?.slice(0, 255) ?? '<UNKNOWN>';
getMetrics().providerHostnames.inc({
hostname,
});
body.items.forEach((item) => {
getMetrics().providerStatuses.inc({
provider_id: item.embedId ?? item.providerId,
status: item.status,
});
});
const itemList = [...body.items];
itemList.reverse();
const lastSuccessfulItem = body.items.find(
(v) => v.status === status.success,
);
const lastItem = itemList[0];
if (lastItem) {
getMetrics().watchMetrics.inc({
tmdb_full_id: lastItem.type + '-' + lastItem.tmdbId,
provider_id: lastSuccessfulItem?.providerId ?? lastItem.providerId,
title: lastItem.title,
success: (!!lastSuccessfulItem).toString(),
});
}
if (body.tool) {
getMetrics().toolMetrics.inc({
tool: body.tool,
});
}
return true;
}),
);
app.post(
'/metrics/captcha',
{
schema: {
body: z.object({
success: z.boolean(),
}),
},
},
handle(async ({ body, req, limiter }) => {
await limiter?.assertAndBump(req, {
id: 'captcha_solves',
max: 300,
inc: 1,
window: '30m',
});
getMetrics().captchaSolves.inc({
success: body.success.toString(),
});
return true;
}),
);
});

View file

@ -1,65 +0,0 @@
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({
deviceName: 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.id !== params.sid)
throw new StatusError('Cannot edit sessions other than your own', 401);
if (body.deviceName) targetedSession.device = body.deviceName;
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

@ -1,6 +0,0 @@
export const status = {
failed: 'failed',
notfound: 'notfound',
success: 'success',
} as const;
export type Status = keyof typeof status;

View file

@ -1,137 +0,0 @@
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';
const bookmarkDataSchema = z.object({
tmdbId: z.string(),
meta: bookmarkMetaSchema,
});
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: bookmarkDataSchema,
},
},
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.put(
'/users/:uid/bookmarks',
{
schema: {
params: z.object({
uid: z.string(),
}),
body: z.array(bookmarkDataSchema),
},
},
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 bookmarks = await em.upsertMany(
Bookmark,
body.map((item) => ({
userId: params.uid,
tmdbId: item.tmdbId,
meta: item.meta,
updatedAt: new Date(),
})),
{
onConflictFields: ['tmdbId', 'userId'],
},
);
await em.flush();
return bookmarks.map(formatBookmark);
}),
);
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

@ -1,63 +0,0 @@
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,
};
}),
);
});

View file

@ -1,42 +0,0 @@
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);
}),
);
});

View file

@ -1,50 +0,0 @@
import { formatSession } from '@/db/models/Session';
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/@me',
handle(async ({ auth, em }) => {
await auth.assert();
const user = await em.findOne(User, { id: auth.user.id });
if (!user) throw new StatusError('User does not exist', 404);
const session = await auth.getSession();
if (!session) throw new StatusError('Session does not exist', 400);
return {
user: formatUser(user),
session: formatSession(session),
};
}),
);
app.get(
'/users/:uid',
{
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 users other than yourself', 403);
const user = await em.findOne(User, { id: params.uid });
if (!user) throw new StatusError('User does not exist', 404);
return {
user: formatUser(user),
};
}),
);
});

View file

@ -1,230 +0,0 @@
import {
ProgressItem,
formatProgressItem,
progressMetaSchema,
} from '@/db/models/ProgressItem';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { FilterQuery } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
import { z } from 'zod';
const progressItemSchema = z.object({
meta: progressMetaSchema,
tmdbId: z.string(),
duration: z.number().transform((n) => Math.round(n)),
watched: z.number().transform((n) => Math.round(n)),
seasonId: z.string().optional(),
episodeId: z.string().optional(),
seasonNumber: z.number().optional(),
episodeNumber: z.number().optional(),
updatedAt: z.string().datetime({ offset: true }).optional(),
});
export const userProgressRouter = makeRouter((app) => {
app.put(
'/users/:uid/progress/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
body: progressItemSchema,
},
},
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;
progressItem.episodeNumber = body.episodeNumber;
progressItem.seasonNumber = body.seasonNumber;
}
em.assign(progressItem, {
duration: body.duration,
watched: body.watched,
meta: body.meta,
updatedAt: new Date(),
});
await em.persistAndFlush(progressItem);
return formatProgressItem(progressItem);
}),
);
app.put(
'/users/:uid/progress/import',
{
schema: {
params: z.object({
uid: z.string(),
}),
body: z.array(progressItemSchema),
},
},
handle(async ({ auth, params, body, em, req, limiter }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const itemsUpserted: ProgressItem[] = [];
const newItems = [...body];
for (const existingItem of await em.find(ProgressItem, {
userId: params.uid,
})) {
const newItemIndex = newItems.findIndex(
(item) =>
item.tmdbId == existingItem.tmdbId &&
item.seasonId == existingItem.seasonId &&
item.episodeId == existingItem.episodeId,
);
if (newItemIndex > -1) {
const newItem = newItems[newItemIndex];
if (existingItem.watched < newItem.watched) {
existingItem.updatedAt = defaultAndCoerceDateTime(
newItem.updatedAt,
);
existingItem.watched = newItem.watched;
}
itemsUpserted.push(existingItem);
// Remove the item from the array, we have processed it
newItems.splice(newItemIndex, 1);
}
}
// All unprocessed items, aka all items that don't already exist
for (const newItem of newItems) {
itemsUpserted.push({
id: randomUUID(),
duration: newItem.duration,
episodeId: newItem.episodeId,
episodeNumber: newItem.episodeNumber,
meta: newItem.meta,
seasonId: newItem.seasonId,
seasonNumber: newItem.seasonNumber,
tmdbId: newItem.tmdbId,
userId: params.uid,
watched: newItem.watched,
updatedAt: defaultAndCoerceDateTime(newItem.updatedAt),
});
}
const progressItems = await em.upsertMany(ProgressItem, itemsUpserted);
await em.flush();
await limiter?.assertAndBump(req, {
id: 'progress_import',
max: 5,
window: '10m',
});
return progressItems.map(formatProgressItem);
}),
);
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 query: FilterQuery<ProgressItem> = {
userId: params.uid,
tmdbId: params.tmdbid,
};
if (body.seasonId) query.seasonId = body.seasonId;
if (body.episodeId) query.episodeId = body.episodeId;
const progressItems = await em.find(ProgressItem, query);
if (progressItems.length === 0) {
return {
count: 0,
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
};
}
progressItems.forEach((v) => em.remove(v));
await em.flush();
return {
count: progressItems.length,
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);
}),
);
});
// 13th July 2021 - movie-web epoch
const minEpoch = 1626134400000;
function defaultAndCoerceDateTime(dateTime: string | undefined) {
const epoch = dateTime ? new Date(dateTime).getTime() : Date.now();
const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now()));
return new Date(clampedEpoch);
}

View file

@ -1,30 +0,0 @@
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

@ -1,74 +0,0 @@
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().nullable().optional(),
applicationTheme: z.string().nullable().optional(),
defaultSubtitleLanguage: z.string().nullable().optional(),
proxyUrls: z.string().array().nullable().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 !== undefined)
settings.applicationLanguage = body.applicationLanguage;
if (body.defaultSubtitleLanguage !== undefined)
settings.defaultSubtitleLanguage = body.defaultSubtitleLanguage;
if (body.applicationTheme !== undefined)
settings.applicationTheme = body.applicationTheme;
if (body.proxyUrls !== undefined) settings.proxyUrls = body.proxyUrls;
await em.persistAndFlush(settings);
return formatUserSettings(settings);
}),
);
});

View file

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

View file

@ -1,62 +0,0 @@
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);
},
};
}

View file

@ -1,26 +0,0 @@
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 formData = new URLSearchParams();
formData.append('secret', conf.captcha.secret);
formData.append('response', token);
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
body: formData,
});
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);
}

View file

@ -1,55 +0,0 @@
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);
}
}

View file

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

View file

@ -1,135 +0,0 @@
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

@ -1,27 +0,0 @@
import { conf } from '@/config';
import { IncomingHttpHeaders } from 'http';
export type IpReq = {
ip: string;
headers: IncomingHttpHeaders;
};
const trustCloudflare = conf.server.trustCloudflare;
function getSingleHeader(
headers: IncomingHttpHeaders,
key: string,
): string | undefined {
const header = headers[key];
if (Array.isArray(header)) return header[0];
return header;
}
export function getIp(req: IpReq) {
const cfIp = getSingleHeader(req.headers, 'cf-connecting-ip');
if (trustCloudflare && cfIp) {
return cfIp;
}
return req.ip;
}

View file

@ -1,108 +0,0 @@
import { conf } from '@/config';
import { URLSearchParams } from 'url';
import winston from 'winston';
import { consoleFormat } from 'winston-console-format';
const appName = 'mw-backend';
function createWinstonLogger() {
let loggerObj = winston.createLogger({
levels: Object.assign(
{ fatal: 0, warn: 4, trace: 7 },
winston.config.syslog.levels,
),
level: 'debug',
format: winston.format.combine(
winston.format.colorize(),
winston.format.ms(),
winston.format.label({ label: appName }),
winston.format.simple(),
winston.format.padLevels(),
winston.format.errors({ stack: true }),
consoleFormat({
showMeta: false,
inspectOptions: {
depth: Infinity,
colors: true,
maxArrayLength: Infinity,
breakLength: 120,
compact: Infinity,
},
}),
),
defaultMeta: { svc: appName },
transports: [new winston.transports.Console()],
});
// production logger
if (conf.logging.format === 'json') {
loggerObj = winston.createLogger({
levels: Object.assign(
{ fatal: 0, warn: 4, trace: 7 },
winston.config.syslog.levels,
),
format: winston.format.combine(
winston.format.label({ label: appName }),
winston.format.errors({ stack: true }),
winston.format.json(),
),
defaultMeta: { svc: appName },
transports: [new winston.transports.Console()],
});
}
return loggerObj;
}
export function scopedLogger(service: string, meta: object = {}) {
const logger = createWinstonLogger();
logger.defaultMeta = {
...logger.defaultMeta,
svc: service,
...meta,
};
return logger;
}
const ignoredUrls = ['/healthcheck', '/metrics'];
export function makeFastifyLogger(logger: winston.Logger) {
logger.format = winston.format.combine(
winston.format((info) => {
if (typeof info.message === 'object') {
const { message } = info as any;
const { res, responseTime } = message || {};
if (!res) return false;
const { request, statusCode } = res;
if (request.method === 'OPTIONS') return false;
let url = request.url;
try {
const pathParts = (request.url as string).split('?', 2);
if (ignoredUrls.includes(pathParts[0])) return false;
if (pathParts[1]) {
const searchParams = new URLSearchParams(pathParts[1]);
pathParts[1] = searchParams.toString();
}
url = pathParts.join('?');
} catch {
// ignore error, invalid search params will just log normally
}
// create log message
info.message = `[${statusCode}] ${request.method.toUpperCase()} ${url} - ${responseTime.toFixed(
2,
)}ms`;
return info;
}
return info;
})(),
logger.format,
);
return logger;
}
export const log = createWinstonLogger();

View file

@ -1,32 +0,0 @@
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);
},
};
}

View file

@ -1,69 +0,0 @@
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

@ -1,25 +0,0 @@
{
"ts-node": {
"files": true
},
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": "src",
"experimentalDecorators": true,
"isolatedModules": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"lib": ["es6"],
"include": ["src/"]
}

2620
yarn.lock

File diff suppressed because it is too large Load diff