diff --git a/README.md b/README.md index 90ada07..35b50df 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,28 @@ Backend for movie-web - [X] edit account name and PFP - [X] delete logged in user - [X] backend meta (name and description) - - [ ] upsert settings - - [ ] upsert watched items - - [ ] upsert bookmarks + - [X] upsert settings + - [X] upsert progress items + - [X] upsert bookmarks + - [X] GET bookmarks + - [X] GET settings + - [X] GET progress items + - [X] DELETE progress items - [ ] consume provider metrics + - [ ] DELETE user - should delete all associated data - [ ] prometheus metrics - [ ] requests - [ ] user count - [ ] provider metrics - [ ] ratelimits (stored in redis) - [X] switch to pnpm + - [ ] catpcha support + - [ ] global namespacing (accounts are stored on a namespace) + - [ ] cleanup jobs + - [ ] cleanup expired sessions + - [ ] cleanup old metrics + +## Second todo list - [ ] think of privacy centric method of auth - [ ] Register - [ ] Login - - [ ] global namespacing (accounts are stored on a namespace) diff --git a/package.json b/package.json index a13b4b7..942664f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@mikro-orm/core": "^5.9.0", "@mikro-orm/postgresql": "^5.9.0", "fastify": "^4.21.0", + "fastify-metrics": "^10.3.2", "fastify-type-provider-zod": "^1.1.9", "jsonwebtoken": "^9.0.2", "neat-config": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da6a8d9..fa5589a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ dependencies: fastify: specifier: ^4.21.0 version: 4.24.3 + fastify-metrics: + specifier: ^10.3.2 + version: 10.3.2(fastify@4.24.3) fastify-type-provider-zod: specifier: ^1.1.9 version: 1.1.9(fastify@4.24.3)(zod@3.22.4) @@ -735,6 +738,10 @@ packages: engines: {node: '>=8'} dev: true + /bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + dev: false + /bplist-parser@0.2.0: resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} engines: {node: '>= 5.10.0'} @@ -1226,6 +1233,16 @@ packages: resolution: {integrity: sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg==} dev: false + /fastify-metrics@10.3.2(fastify@4.24.3): + resolution: {integrity: sha512-02SEIGH02zfguqRMho0LB8L7YVAj5cIgWM0iqZslIErqaUWc1iHVAOC+YXYG3S2DZU6VHdFaMyuxjEOCQHAETA==} + peerDependencies: + fastify: '>=4' + dependencies: + fastify: 4.24.3 + fastify-plugin: 4.5.1 + prom-client: 14.2.0 + dev: false + /fastify-plugin@4.5.1: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} dev: false @@ -2218,6 +2235,13 @@ packages: engines: {node: '>= 0.6.0'} dev: false + /prom-client@14.2.0: + resolution: {integrity: sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==} + engines: {node: '>=10'} + dependencies: + tdigest: 0.1.2 + dev: false + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2531,6 +2555,12 @@ packages: engines: {node: '>=8.0.0'} dev: false + /tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + dependencies: + bintrees: 1.0.2 + dev: false + /text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} dev: false diff --git a/src/db/models/Bookmark.ts b/src/db/models/Bookmark.ts new file mode 100644 index 0000000..3283263 --- /dev/null +++ b/src/db/models/Bookmark.ts @@ -0,0 +1,54 @@ +import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core'; +import { z } from 'zod'; + +export const bookmarkMetaSchema = z.object({ + title: z.string(), + year: z.number(), + poster: z.string().optional(), + type: z.string(), +}); + +export type BookmarkMeta = z.infer; + +@Entity({ tableName: 'bookmark' }) +@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(), + }; +} diff --git a/src/db/models/ProgressItem.ts b/src/db/models/ProgressItem.ts new file mode 100644 index 0000000..e08c6e3 --- /dev/null +++ b/src/db/models/ProgressItem.ts @@ -0,0 +1,81 @@ +import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core'; +import { randomUUID } from 'crypto'; +import { z } from 'zod'; + +export const progressMetaSchema = z.object({ + title: z.string(), + year: z.number(), + poster: z.string().optional(), + type: z.string(), +}); + +export type ProgressMeta = z.infer; + +@Entity({ tableName: 'progress_item' }) +@Unique({ properties: ['tmdbId', 'userId', 'seasonId', 'episodeId'] }) +export class ProgressItem { + @PrimaryKey({ name: 'id', type: 'uuid' }) + id: string = randomUUID(); + + @Property({ name: 'tmdb_id' }) + tmdbId!: string; + + @Property({ name: 'user_id' }) + userId!: string; + + @Property({ name: 'season_id', nullable: true }) + seasonId?: string; + + @Property({ name: 'episode_id', nullable: true }) + episodeId?: string; + + @Property({ + name: 'meta', + type: types.json, + }) + meta!: ProgressMeta; + + @Property({ name: 'updated_at', type: 'date' }) + updatedAt!: Date; + + /* progress */ + @Property({ name: 'duration', type: 'bigint' }) + duration!: number; + + @Property({ name: 'watched', type: 'bigint' }) + watched!: number; +} + +export interface ProgressItemDTO { + tmdbId: string; + seasonId?: string; + episodeId?: string; + meta: { + title: string; + year: number; + poster?: string; + type: string; + }; + duration: number; + watched: number; + updatedAt: string; +} + +export function formatProgressItem( + progressItem: ProgressItem, +): ProgressItemDTO { + return { + tmdbId: progressItem.tmdbId, + seasonId: progressItem.seasonId, + episodeId: progressItem.episodeId, + meta: { + title: progressItem.meta.title, + year: progressItem.meta.year, + poster: progressItem.meta.poster, + type: progressItem.meta.type, + }, + duration: progressItem.duration, + watched: progressItem.watched, + updatedAt: progressItem.updatedAt.toISOString(), + }; +} diff --git a/src/db/models/UserSettings.ts b/src/db/models/UserSettings.ts new file mode 100644 index 0000000..22fbfa3 --- /dev/null +++ b/src/db/models/UserSettings.ts @@ -0,0 +1,35 @@ +import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; +import { randomUUID } from 'crypto'; + +@Entity({ tableName: 'user_settings' }) +export class UserSettings { + @PrimaryKey({ name: 'id', type: 'uuid' }) + id: string = randomUUID(); + + @Property({ name: 'application_theme', nullable: true }) + applicationTheme?: string | null; + + @Property({ name: 'application_language', nullable: true }) + applicationLanguage?: string | null; + + @Property({ name: 'default_subtitle_language', nullable: true }) + defaultSubtitleLanguage?: string | null; +} + +export interface UserSettingsDTO { + id: string; + applicationTheme?: string | null; + applicationLanguage?: string | null; + defaultSubtitleLanguage?: string | null; +} + +export function formatUserSettings( + userSettings: UserSettings, +): UserSettingsDTO { + return { + id: userSettings.id, + applicationTheme: userSettings.applicationTheme, + applicationLanguage: userSettings.applicationLanguage, + defaultSubtitleLanguage: userSettings.defaultSubtitleLanguage, + }; +} diff --git a/src/modules/fastify/routes.ts b/src/modules/fastify/routes.ts index 364fc6f..00487f1 100644 --- a/src/modules/fastify/routes.ts +++ b/src/modules/fastify/routes.ts @@ -2,17 +2,26 @@ import { loginAuthRouter } from '@/routes/auth/login'; import { manageAuthRouter } from '@/routes/auth/manage'; import { metaRouter } from '@/routes/meta'; import { sessionsRouter } from '@/routes/sessions'; +import { userBookmarkRouter } from '@/routes/users/bookmark'; import { userDeleteRouter } from '@/routes/users/delete'; import { userEditRouter } from '@/routes/users/edit'; +import { userProgressRouter } from '@/routes/users/progress'; import { userSessionsRouter } from '@/routes/users/sessions'; +import { userSettingsRouter } from '@/routes/users/settings'; import { FastifyInstance } from 'fastify'; +import metricsPlugin from 'fastify-metrics'; export async function setupRoutes(app: FastifyInstance) { - app.register(manageAuthRouter.register); - app.register(loginAuthRouter.register); - app.register(userSessionsRouter.register); - app.register(sessionsRouter.register); - app.register(userEditRouter.register); - app.register(userDeleteRouter.register); - app.register(metaRouter.register); + await app.register(metricsPlugin, { endpoint: '/metrics' }); + + 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); } diff --git a/src/routes/users/bookmark.ts b/src/routes/users/bookmark.ts new file mode 100644 index 0000000..451cae2 --- /dev/null +++ b/src/routes/users/bookmark.ts @@ -0,0 +1,100 @@ +import { + Bookmark, + bookmarkMetaSchema, + formatBookmark, +} from '@/db/models/Bookmark'; +import { StatusError } from '@/services/error'; +import { handle } from '@/services/handler'; +import { makeRouter } from '@/services/router'; +import { z } from 'zod'; + +export const userBookmarkRouter = makeRouter((app) => { + app.get( + '/users/:uid/bookmarks', + { + schema: { + params: z.object({ + uid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot access other user information', 403); + + const bookmarks = await em.find(Bookmark, { + userId: params.uid, + }); + + return bookmarks.map(formatBookmark); + }), + ); + + app.post( + '/users/:uid/bookmarks/:tmdbid', + { + schema: { + params: z.object({ + uid: z.string(), + tmdbid: z.string(), + }), + body: z.object({ + meta: bookmarkMetaSchema, + }), + }, + }, + handle(async ({ auth, params, body, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot modify user other than yourself', 403); + + const oldBookmark = await em.findOne(Bookmark, { + userId: params.uid, + tmdbId: params.tmdbid, + }); + if (oldBookmark) throw new StatusError('Already bookmarked', 400); + + const bookmark = new Bookmark(); + em.assign(bookmark, { + userId: params.uid, + tmdbId: params.tmdbid, + meta: body.meta, + updatedAt: new Date(), + }); + + await em.persistAndFlush(bookmark); + return formatBookmark(bookmark); + }), + ); + + app.delete( + '/users/:uid/bookmarks/:tmdbid', + { + schema: { + params: z.object({ + uid: z.string(), + tmdbid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot modify user other than yourself', 403); + + const bookmark = await em.findOne(Bookmark, { + userId: params.uid, + tmdbId: params.tmdbid, + }); + + if (!bookmark) return { tmdbId: params.tmdbid }; + + await em.removeAndFlush(bookmark); + return { tmdbId: params.tmdbid }; + }), + ); +}); diff --git a/src/routes/users/progress.ts b/src/routes/users/progress.ts new file mode 100644 index 0000000..3ad6e1b --- /dev/null +++ b/src/routes/users/progress.ts @@ -0,0 +1,126 @@ +import { + ProgressItem, + formatProgressItem, + progressMetaSchema, +} from '@/db/models/ProgressItem'; +import { StatusError } from '@/services/error'; +import { handle } from '@/services/handler'; +import { makeRouter } from '@/services/router'; +import { z } from 'zod'; + +export const userProgressRouter = makeRouter((app) => { + app.put( + '/users/:uid/progress/:tmdbid', + { + schema: { + params: z.object({ + uid: z.string(), + tmdbid: z.string(), + }), + body: z.object({ + meta: progressMetaSchema, + seasonId: z.string().optional(), + episodeId: z.string().optional(), + duration: z.number(), + watched: z.number(), + }), + }, + }, + handle(async ({ auth, params, body, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot modify user other than yourself', 403); + + let progressItem = await em.findOne(ProgressItem, { + userId: params.uid, + tmdbId: params.tmdbid, + episodeId: body.episodeId, + seasonId: body.seasonId, + }); + if (!progressItem) { + progressItem = new ProgressItem(); + progressItem.tmdbId = params.tmdbid; + progressItem.userId = params.uid; + progressItem.episodeId = body.episodeId; + progressItem.seasonId = body.seasonId; + } + + em.assign(progressItem, { + duration: body.duration, + watched: body.watched, + meta: body.meta, + updatedAt: new Date(), + }); + + await em.persistAndFlush(progressItem); + return formatProgressItem(progressItem); + }), + ); + + app.delete( + '/users/:uid/progress/:tmdbid', + { + schema: { + params: z.object({ + uid: z.string(), + tmdbid: z.string(), + }), + body: z.object({ + seasonId: z.string().optional(), + episodeId: z.string().optional(), + }), + }, + }, + handle(async ({ auth, params, body, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot modify user other than yourself', 403); + + const progressItem = await em.findOne(ProgressItem, { + userId: params.uid, + tmdbId: params.tmdbid, + episodeId: body.episodeId, + seasonId: body.seasonId, + }); + if (!progressItem) { + return { + tmdbId: params.tmdbid, + episodeId: body.episodeId, + seasonId: body.seasonId, + }; + } + + await em.removeAndFlush(progressItem); + return { + tmdbId: params.tmdbid, + episodeId: body.episodeId, + seasonId: body.seasonId, + }; + }), + ); + + app.get( + '/users/:uid/progress', + { + schema: { + params: z.object({ + uid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot modify user other than yourself', 403); + + const items = await em.find(ProgressItem, { + userId: params.uid, + }); + + return items.map(formatProgressItem); + }), + ); +}); diff --git a/src/routes/users/settings.ts b/src/routes/users/settings.ts new file mode 100644 index 0000000..e905412 --- /dev/null +++ b/src/routes/users/settings.ts @@ -0,0 +1,72 @@ +import { UserSettings, formatUserSettings } from '@/db/models/UserSettings'; +import { StatusError } from '@/services/error'; +import { handle } from '@/services/handler'; +import { makeRouter } from '@/services/router'; +import { z } from 'zod'; + +export const userSettingsRouter = makeRouter((app) => { + app.get( + '/users/:uid/settings', + { + schema: { + params: z.object({ + uid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot get other user information', 403); + + const settings = await em.findOne(UserSettings, { + id: params.uid, + }); + + if (!settings) return { id: params.uid }; + + return formatUserSettings(settings); + }), + ); + + app.put( + '/users/:uid/settings', + { + schema: { + params: z.object({ + uid: z.string(), + }), + body: z.object({ + applicationLanguage: z.string().optional(), + applicationTheme: z.string().optional(), + defaultSubtitleLanguage: z.string().optional(), + }), + }, + }, + handle(async ({ auth, params, body, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot modify user other than yourself', 403); + + let settings = await em.findOne(UserSettings, { + id: params.uid, + }); + if (!settings) { + settings = new UserSettings(); + settings.id = params.uid; + } + + if (body.applicationLanguage) + settings.applicationLanguage = body.applicationLanguage; + if (body.applicationTheme) + settings.applicationTheme = body.applicationTheme; + if (body.defaultSubtitleLanguage) + settings.defaultSubtitleLanguage = body.defaultSubtitleLanguage; + + await em.persistAndFlush(settings); + return formatUserSettings(settings); + }), + ); +});