mirror of
https://github.com/p-stream/backend.git
synced 2026-04-21 08:12:18 +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