mirror of
https://github.com/p-stream/backend.git
synced 2026-03-11 17:55:35 +00:00
Fresh Start
This commit is contained in:
parent
49452e8dea
commit
e9fa3b2d58
68 changed files with 0 additions and 9807 deletions
23
Dockerfile
23
Dockerfile
|
|
@ -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
21
LICENSE
|
|
@ -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.
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# backend
|
||||
Backend for sudo-flix
|
||||
|
|
@ -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
|
||||
68
package.json
68
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
3663
pnpm-lock.yaml
3663
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -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>>;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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({}),
|
||||
});
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"));',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";');
|
||||
}
|
||||
}
|
||||
|
|
@ -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";');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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"));');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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";');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
39
src/main.ts
39
src/main.ts
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { ormConf } from '@/config/orm';
|
||||
import { makeOrmConfig } from '@/modules/mikro/orm';
|
||||
|
||||
export default makeOrmConfig(ormConf.postgres.connection, ormConf.postgres.ssl);
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
|
|
@ -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`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -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`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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!');
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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})`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export const status = {
|
||||
failed: 'failed',
|
||||
notfound: 'notfound',
|
||||
success: 'success',
|
||||
} as const;
|
||||
export type Status = keyof typeof status;
|
||||
|
|
@ -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 };
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export const roles = {
|
||||
ADMIN: 'ADMIN', // has access to admin endpoints
|
||||
} as const;
|
||||
|
||||
export type Roles = (typeof roles)[keyof typeof roles];
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export class StatusError extends Error {
|
||||
errorStatusCode: number;
|
||||
|
||||
constructor(message: string, code: number) {
|
||||
super(message);
|
||||
this.errorStatusCode = code;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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/"]
|
||||
}
|
||||
Loading…
Reference in a new issue