From ef6e0e00a0602ffc1464d4e05bafbfec5c92e13e Mon Sep 17 00:00:00 2001 From: dicedtomato <35403473+diced@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:32:50 -0800 Subject: [PATCH] feat: response validation (#1012) * feat: add response schemas (WIP, hella unstable!!) * refactor: models to zod * feat: descriptions for api routes * fix: finish up api refactor * refactor: generalized error codes * fix: responses + add descriptions * fix: more * fix: lint * fix: settings errors * fix: add errors to spec --- .../{gen-openapi.yml => openapi.yml} | 7 +- package.json | 1 + scripts/index.ts | 10 +- scripts/openapi.ts | 110 ++++++ src/client/pages/auth/login.tsx | 5 +- src/client/pages/auth/register.tsx | 3 +- src/components/pages/folders/actions.tsx | 2 +- .../pages/settings/parts/SettingsUser.tsx | 3 +- src/lib/api/errors.ts | 184 +++++++++ src/lib/db/models/export.ts | 14 + src/lib/db/models/file.ts | 57 +-- src/lib/db/models/folder.ts | 76 ++-- src/lib/db/models/incompleteFile.ts | 30 +- src/lib/db/models/invite.ts | 35 +- src/lib/db/models/metric.ts | 18 +- src/lib/db/models/tag.ts | 28 +- src/lib/db/models/url.ts | 26 +- src/lib/db/models/user.ts | 100 +++-- src/lib/fetchApi.ts | 11 +- src/server/index.ts | 15 +- src/server/middleware/administrator.ts | 9 +- src/server/middleware/user.ts | 16 +- src/server/routes/api/auth/invites/[id].ts | 18 +- src/server/routes/api/auth/invites/index.ts | 33 +- src/server/routes/api/auth/invites/web.ts | 19 +- src/server/routes/api/auth/login.ts | 19 +- src/server/routes/api/auth/logout.ts | 47 ++- src/server/routes/api/auth/oauth/index.ts | 38 +- src/server/routes/api/auth/register.ts | 26 +- src/server/routes/api/auth/webauthn.ts | 32 +- src/server/routes/api/healthcheck.ts | 36 +- src/server/routes/api/server/clear_temp.ts | 10 + src/server/routes/api/server/clear_zeros.ts | 24 ++ src/server/routes/api/server/export.ts | 30 +- src/server/routes/api/server/folder.ts | 13 +- src/server/routes/api/server/import/v3.ts | 26 +- src/server/routes/api/server/import/v4.ts | 38 +- src/server/routes/api/server/public.ts | 181 +++++---- src/server/routes/api/server/requery_size.ts | 7 + .../routes/api/server/settings/index.ts | 33 +- src/server/routes/api/server/settings/web.ts | 43 +- src/server/routes/api/server/themes.ts | 24 +- src/server/routes/api/server/thumbnails.ts | 10 +- src/server/routes/api/setup.ts | 35 +- src/server/routes/api/stats.ts | 17 +- src/server/routes/api/upload/index.ts | 339 +++++++++------- src/server/routes/api/upload/partial.ts | 374 +++++++++--------- src/server/routes/api/user/avatar.ts | 42 +- src/server/routes/api/user/export.ts | 144 ++++--- .../routes/api/user/files/[id]/index.ts | 87 ++-- .../routes/api/user/files/[id]/password.ts | 13 +- src/server/routes/api/user/files/[id]/raw.ts | 28 +- .../routes/api/user/files/incomplete.ts | 34 +- src/server/routes/api/user/files/index.ts | 29 +- .../routes/api/user/files/transaction.ts | 28 +- .../routes/api/user/folders/[id]/export.ts | 15 +- .../routes/api/user/folders/[id]/index.ts | 90 +++-- src/server/routes/api/user/folders/index.ts | 26 +- src/server/routes/api/user/index.ts | 33 +- src/server/routes/api/user/mfa/passkey.ts | 25 +- src/server/routes/api/user/mfa/totp.ts | 83 ++-- src/server/routes/api/user/recent.ts | 6 +- src/server/routes/api/user/sessions.ts | 50 ++- src/server/routes/api/user/stats.ts | 151 ++++--- src/server/routes/api/user/tags/[id].ts | 60 ++- src/server/routes/api/user/tags/index.ts | 37 +- src/server/routes/api/user/token.ts | 122 ++++-- src/server/routes/api/user/urls/[id]/index.ts | 30 +- .../routes/api/user/urls/[id]/password.ts | 8 +- src/server/routes/api/user/urls/index.ts | 24 +- src/server/routes/api/users/[id]/index.ts | 39 +- src/server/routes/api/users/[id]/tags.ts | 7 +- src/server/routes/api/users/index.ts | 16 +- src/server/routes/api/version.ts | 136 ++++--- src/server/routes/raw/[id].ts | 5 +- 75 files changed, 2428 insertions(+), 1172 deletions(-) rename .github/workflows/{gen-openapi.yml => openapi.yml} (96%) create mode 100644 scripts/openapi.ts create mode 100644 src/lib/api/errors.ts create mode 100644 src/lib/db/models/export.ts diff --git a/.github/workflows/gen-openapi.yml b/.github/workflows/openapi.yml similarity index 96% rename from .github/workflows/gen-openapi.yml rename to .github/workflows/openapi.yml index bd66644e..ea88978a 100644 --- a/.github/workflows/gen-openapi.yml +++ b/.github/workflows/openapi.yml @@ -78,13 +78,12 @@ jobs: sleep 2 done - - name: Run app + - name: Run generator env: DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline CORE_SECRET: ${{ steps.secret.outputs.secret }} - ZIPLINE_OUTPUT_OPENAPI: true - - run: pnpm start + NODE_ENV: production + run: pnpm openapi - name: Verify openapi.json exists run: | diff --git a/package.json b/package.json index 1fc1d62c..d1a976d6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server", "ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl", "validate": "tsx scripts/validate.ts", + "openapi": "tsx scripts/openapi.ts", "db:prototype": "prisma db push --skip-generate && prisma generate --no-hints", "db:migrate": "prisma migrate dev --create-only", "docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w", diff --git a/scripts/index.ts b/scripts/index.ts index 78a18304..37b83a69 100644 --- a/scripts/index.ts +++ b/scripts/index.ts @@ -1,4 +1,6 @@ -export function step(name: string, command: string, condition: () => boolean = () => true) { +type StepCommand = string | (() => void | Promise); + +export function step(name: string, command: StepCommand, condition: () => boolean = () => true) { return { name, command, @@ -35,7 +37,11 @@ export async function run(name: string, ...steps: Step[]) { try { log(`> Running step "${name}/${step.name}"...`); - execSync(step.command, { stdio: 'inherit' }); + if (typeof step.command === 'string') { + execSync(step.command, { stdio: 'inherit' }); + } else { + await step.command(); + } } catch { console.error(`x Step "${name}/${step.name}" failed.`); process.exit(1); diff --git a/scripts/openapi.ts b/scripts/openapi.ts new file mode 100644 index 00000000..e8898735 --- /dev/null +++ b/scripts/openapi.ts @@ -0,0 +1,110 @@ +import { readFile, writeFile } from 'fs/promises'; +import path from 'path'; +import { run, step } from '.'; +import { API_ERRORS, ApiError, ApiErrorCode } from '../src/lib/api/errors'; + +const ALL_METHODS = ['delete', 'get', 'head', 'patch', 'post', 'put']; +const GEN_PATH = path.resolve(__dirname, '..', 'openapi.json'); + +const ALL_ERRORS = Object.keys(API_ERRORS) + .map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON()) + .sort((a, b) => a.code - b.code); + +const ERROR_SCHEMA = { + type: 'object', + description: 'Generic error for API endpoints.', + properties: { + error: { + type: 'string', + description: + 'Message for the error. This may differ from the standard message for the error code, but the error code should be used to figure out the type of error.', + }, + code: { + type: 'integer', + format: 'int32', + description: + 'Zipline API error code. Ranges: 1xxx validation, 2xxx session, 3xxx permission, 4xxx not-found, 5xxx constraint, 6xxx internal, 9xxx generic.', + enum: ALL_ERRORS.map((entry) => entry.code), + 'x-enumDescriptions': ALL_ERRORS.map((entry) => entry.message), + }, + statusCode: { + type: 'integer', + format: 'int32', + description: 'HTTP status code returned alongside this error payload.', + }, + }, + required: ['error', 'code', 'statusCode'], + additionalProperties: true, +}; + +const ERROR_EXAMPLES = ALL_ERRORS.reduce>((examples, entry) => { + examples[`E${entry.code}`] = { + summary: `${entry.error}`, + value: entry, + }; + + return examples; +}, {}); + +const generic4xxResponse = { + description: 'API error response (4xx)', + content: { + 'application/json': { + schema: ERROR_SCHEMA, + examples: ERROR_EXAMPLES, + }, + }, +}; + +function addErrorResponse(responses: Record): void { + const response = (responses['4xx'] ??= structuredClone(generic4xxResponse)); + + response.description ??= generic4xxResponse.description; + response.content ??= {}; + + const jsonContent = (response.content['application/json'] ??= {}); + jsonContent.schema ??= structuredClone(ERROR_SCHEMA); + jsonContent.examples ??= structuredClone(generic4xxResponse.content['application/json'].examples); +} + +function filterRoutes(paths = {}): Record { + return Object.fromEntries(Object.entries(paths).filter(([route]) => route.startsWith('/api'))); +} + +async function fixSpec() { + const spec = JSON.parse(await readFile(GEN_PATH, 'utf8')); + + spec.paths = filterRoutes(spec.paths); + + for (const [, pathItem] of Object.entries(spec.paths ?? {})) { + if (!pathItem) continue; + + for (const method of ALL_METHODS) { + const operation = (pathItem)[method]; + if (!operation) continue; + + operation.responses ??= {}; + addErrorResponse(operation.responses); + } + } + + await writeFile(GEN_PATH, JSON.stringify(spec)); +} + +process.env.ZIPLINE_OUTPUT_OPENAPI = 'true'; + +run( + 'openapi', + step('run-prod', 'pnpm start', () => process.env.NODE_ENV === 'production'), + step('run-dev', 'pnpm dev', () => process.env.NODE_ENV !== 'production'), + step('check', async () => { + try { + await readFile(GEN_PATH); + } catch (e) { + console.error('\nSomething went wrong...', e); + + throw new Error('No OpenAPI spec found at ./openapi.json'); + } + }), + step('fix', fixSpec), +); diff --git a/src/client/pages/auth/login.tsx b/src/client/pages/auth/login.tsx index 877ce9fa..f15ad7aa 100644 --- a/src/client/pages/auth/login.tsx +++ b/src/client/pages/auth/login.tsx @@ -4,6 +4,7 @@ import PasskeyAuthButton from '@/components/pages/login/PasskeyAuthButton'; import SecureWarningModal from '@/components/pages/login/SecureWarningModal'; import TotpModal from '@/components/pages/login/TotpModal'; import { getWebClient } from '@/lib/api/detect'; +import { ApiError } from '@/lib/api/errors'; import { fetchApi } from '@/lib/fetchApi'; import useLogin from '@/lib/hooks/useLogin'; import useObjectState from '@/lib/hooks/useObjectState'; @@ -22,6 +23,7 @@ import { Title, } from '@mantine/core'; import { useForm } from '@mantine/form'; +import { showNotification } from '@mantine/notifications'; import { browserSupportsWebAuthn } from '@simplewebauthn/browser'; import { IconBrandDiscordFilled, @@ -34,7 +36,6 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import useSWR from 'swr'; import GenericError from '../../error/GenericError'; -import { showNotification } from '@mantine/notifications'; export default function Login() { useTitle('Login'); @@ -103,7 +104,7 @@ export default function Login() { ); if (error) { - if (error.error === 'Invalid username or password') { + if (ApiError.check(error, 1044)) { form.setFieldError('username', 'Invalid username'); form.setFieldError('password', 'Invalid password'); } else { diff --git a/src/client/pages/auth/register.tsx b/src/client/pages/auth/register.tsx index 8f5778ef..a2d9fb19 100644 --- a/src/client/pages/auth/register.tsx +++ b/src/client/pages/auth/register.tsx @@ -23,6 +23,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'; import useSWR, { mutate } from 'swr'; import GenericError from '../../error/GenericError'; import { getWebClient } from '@/lib/api/detect'; +import { ApiError } from '@/lib/api/errors'; export function Component() { useTitle('Register'); @@ -114,7 +115,7 @@ export function Component() { ); if (error) { - if (error.error === 'Username is taken') { + if (ApiError.check(error, 1039)) { form.setFieldError('username', 'Username is taken'); } else { notifications.show({ diff --git a/src/components/pages/folders/actions.tsx b/src/components/pages/folders/actions.tsx index 809087f0..095e27aa 100644 --- a/src/components/pages/folders/actions.tsx +++ b/src/components/pages/folders/actions.tsx @@ -80,7 +80,7 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) { } export async function mutateFolder(folderId?: string) { - if (!folderId) return mutate(`/api/user/folders/${folderId}`); + if (folderId) return mutate(`/api/user/folders/${folderId}`); return mutate((key) => typeof key === 'string' && key.startsWith('/api/user/folders')); } diff --git a/src/components/pages/settings/parts/SettingsUser.tsx b/src/components/pages/settings/parts/SettingsUser.tsx index 2746b3d2..8ecd4e9a 100644 --- a/src/components/pages/settings/parts/SettingsUser.tsx +++ b/src/components/pages/settings/parts/SettingsUser.tsx @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { Response } from '@/lib/api/response'; import { fetchApi } from '@/lib/fetchApi'; import { useUserStore } from '@/lib/store/user'; @@ -66,7 +67,7 @@ export default function SettingsUser() { const { data, error } = await fetchApi('/api/user', 'PATCH', send); if (!data && error) { - if (error.error === 'Username already exists') { + if (ApiError.check(error, 1039)) { form.setFieldError('username', error.error); } else { notifications.show({ diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts new file mode 100644 index 00000000..cfafc86a --- /dev/null +++ b/src/lib/api/errors.ts @@ -0,0 +1,184 @@ +export const API_ERRORS = { + // 1xxx, validation and client error + 1000: 'Invalid request schema', + 1001: 'Invalid upload options', + 1002: 'Invalid partial upload', + 1003: 'Partial upload identifier is invalid', + 1004: 'Partial upload was not detected', + 1005: 'Partial uploads only support one file field', + 1006: 'File extension is not allowed', + 1007: 'Invalid characters in filename', + 1008: 'Invalid characters in original filename', + 1009: 'Invalid filename', + 1010: 'Unrecognized file mimetype', + 1011: 'File already in folder', + 1012: 'File not in folder', + 1013: 'File ID is required', + 1014: 'File with this name already exists', + 1015: 'A folder cannot be its own parent', + 1016: 'Cannot move folder into one of its descendants', + 1019: 'Invalid action', + 1020: 'Cannot PATCH without an action', + 1021: 'Cannot delete current session, use log out instead.', + 1022: 'Invalid settings update', + 1023: 'Invalid setup, no settings found. Run the setup process again before exporting data.', + 1024: 'Export is not completed', + 1025: 'No files to export', + 1026: 'No files found for the given request', + 1027: 'No files were deleted.', + 1028: 'No files were updated.', + 1029: 'No ID provided', + 1030: 'No providers to delete', + 1031: 'Session not found in logged in sessions', + 1032: 'Invalid tag specified', + 1033: 'Cannot create tag with the same name', + 1034: 'Tag name already exists', + 1035: 'Invalid invite code', + 1036: "Invites aren't enabled", + 1037: 'User registration is disabled', + 1038: 'Username already exists', + 1039: 'Username is taken', + 1040: 'A user with this username already exists', + 1041: 'Vanity already exists', + 1042: 'Vanity already taken', + 1043: "You can't delete your last OAuth provider without a password", + 1044: 'Invalid username or password', + 1045: 'Invalid code', + 1046: 'Missing WebAuthn challenge ID', + 1047: 'Missing WebAuthn payload', + 1048: 'Passkey registration timed out, try again later', + 1049: 'Error verifying passkey registration', + 1050: 'Could not verify passkey registration', + 1051: 'Error verifying passkey authentication', + 1052: 'Could not verify passkey authentication', + 1053: "You don't have TOTP enabled", + 1054: 'TOTP is disabled', + 1055: 'Password must be a string', + 1056: "The 'maxBytes' value is required", + 1057: "The 'maxFiles' value is required", + 1058: 'From date must be before to date', + 1059: 'From date must be in the past', + 1060: 'Passkey has legacy registration data and cannot be used', + + // 2xxx, session errors + 2000: 'Invalid login session', + 2001: 'Invalid token', + 2002: 'Not logged in', + + // 3xxx, permission errors + 3000: 'Admin only', + 3001: 'Metrics are disabled', + 3002: 'Folder is not open', + 3003: 'Parent folder does not belong to you', + 3004: 'Password protected', + 3005: 'Incorrect password', + 3006: 'Target folder not found', + 3007: 'You cannot assign this role', + 3008: 'You cannot create this role', + 3009: 'You cannot delete this user', + 3010: 'You cannot delete yourself', + 3011: 'You do not own this folder', + 3012: 'Shortening this URL would exceed your quota of X URLs', + 3013: "You don't have permission to delete the selected files", + 3014: "You don't have permission to modify the selected files", + 3015: 'Not super admin', + + // 4xxx, not founds + 4000: 'File not found', + 4001: 'Folder not found', + 4002: 'Folder or file not found', + 4003: 'Folder or related records not found during deletion', + 4004: 'Invite not found', + 4005: 'Invite not found through ID or code', + 4006: 'No files were moved.', + 4007: 'Parent folder not found', + 4008: 'Target folder not found', + 4009: 'User not found', + 4010: 'No settings table found', + 4011: 'Thumbnails task not found', + + // 5xxx, constraint + 5000: 'File size exceeds the configured limit', + 5001: 'File is too large', + 5002: 'Storage quota exceeded', + + // 6xxx, internal errors + 6000: 'Failed to delete invite', + 6001: 'Failed to fetch version details', + 6002: 'Failed to rename file in datasource', + 6003: 'There was an error during a healthcheck', + + // 9xxx catch all + 9000: 'Bad request', + 9001: 'Forbidden', + 9002: 'Not found', + 9004: 'Internal server error', +} as const satisfies Record; + +export type ApiErrorCode = keyof typeof API_ERRORS; + +export type ApiErrorPayload = { + error: string; + code: ApiErrorCode; + statusCode: number; + + [key: string]: any; +}; + +export class ApiError extends Error { + public readonly code: ApiErrorCode; + public readonly status: number; + public additional: Record; + + constructor(code: ApiErrorCode, message?: string, status?: number) { + super(message ?? API_ERRORS[code] ?? 'Unknown API error'); + + this.code = code; + this.status = status ?? ApiError.codeToHttpStatus(code); + this.additional = {} as Record; + + Object.setPrototypeOf(this, new.target.prototype); + } + + add(key: string, value: any): this { + this.additional[key] = value; + + return this; + } + + toJSON(): ApiErrorPayload { + const formattedMessage = API_ERRORS[this.code] + ? `E${this.code}${this.message ? `: ${this.message}` : ''}` + : this.message; + + return { + error: formattedMessage, + code: this.code, + statusCode: this.status, + ...this.additional, + }; + } + + public static check(payload: ApiErrorPayload, code: ApiErrorCode): boolean { + return payload.code === code; + } + + public static codeToHttpStatus(code: ApiErrorCode): number { + const override = { + 9000: 400, + 9001: 403, + 9002: 404, + 9004: 500, + }[code as unknown as number]; + if (override) return override; + + if (code >= 1000 && code < 2000) return 400; + if (code >= 2000 && code < 3000) return 401; + if (code >= 3000 && code < 4000) return 403; + if (code >= 4000 && code < 5000) return 404; + if (code >= 5000 && code < 6000) return 413; + if (code >= 6000 && code < 7000) return 500; + + return 500; + } +} diff --git a/src/lib/db/models/export.ts b/src/lib/db/models/export.ts new file mode 100644 index 00000000..ba364779 --- /dev/null +++ b/src/lib/db/models/export.ts @@ -0,0 +1,14 @@ +import z from 'zod'; + +export const exportSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + + completed: z.boolean(), + path: z.string(), + files: z.number(), + size: z.string(), +}); + +export type Export = z.infer; diff --git a/src/lib/db/models/file.ts b/src/lib/db/models/file.ts index 42eb2332..aab4dc43 100644 --- a/src/lib/db/models/file.ts +++ b/src/lib/db/models/file.ts @@ -1,31 +1,7 @@ import { config } from '@/lib/config'; import { formatRootUrl } from '@/lib/url'; -import { Tag, tagSelectNoFiles } from './tag'; - -export type File = { - createdAt: Date; - updatedAt: Date; - deletesAt: Date | null; - favorite: boolean; - id: string; - originalName: string | null; - name: string; - size: number; - type: string; - views: number; - maxViews?: number | null; - password?: string | boolean | null; - folderId: string | null; - - thumbnail: { - path: string; - } | null; - - tags?: Tag[]; - - url?: string; - similarity?: number; -}; +import { z } from 'zod'; +import { tagSchema, tagSelectNoFiles } from './tag'; export const fileSelect = { createdAt: true, @@ -74,3 +50,32 @@ export function cleanFiles(files: File[], stringifyDates = false) { return files; } + +export const fileSchema = z.object({ + createdAt: z.date(), + updatedAt: z.date(), + deletesAt: z.date().nullable(), + favorite: z.boolean(), + id: z.string(), + originalName: z.string().nullable(), + name: z.string(), + size: z.number(), + type: z.string(), + views: z.number(), + maxViews: z.number().nullable().optional(), + password: z.union([z.string(), z.boolean()]).nullable().optional(), + folderId: z.string().nullable(), + + thumbnail: z + .object({ + path: z.string(), + }) + .nullable(), + + tags: z.array(tagSchema).optional(), + + url: z.string().optional(), + similarity: z.number().optional(), +}); + +export type File = z.infer; diff --git a/src/lib/db/models/folder.ts b/src/lib/db/models/folder.ts index 5b70faf2..757c5ecf 100644 --- a/src/lib/db/models/folder.ts +++ b/src/lib/db/models/folder.ts @@ -1,27 +1,6 @@ -import type { Folder as PrismaFolder } from '@/prisma/client'; import { prisma } from '@/lib/db'; -import { File, cleanFiles } from './file'; - -export type Folder = PrismaFolder & { - files?: File[]; - parent?: Partial | null; - children?: Partial[]; - _count?: { - children?: number; - files?: number; - }; -}; - -export type FolderParent = { - id: string; - name: string; - parentId: string | null; - parent?: FolderParent | null; -}; - -export type FolderParentPublic = { - public: boolean; -} & FolderParent; +import { z } from 'zod'; +import { fileSchema, cleanFiles } from './file'; export async function buildParentChain(parentId: string | null): Promise { if (!parentId) return null; @@ -59,7 +38,7 @@ export async function buildPublicParentChain(parentId: string | null): Promise, stringifyDates = false): Partial { +export function cleanFolder>(folder: T, stringifyDates = false): T { if (folder.files && Array.isArray(folder.files)) cleanFiles(folder.files as any, stringifyDates); if (stringifyDates) { @@ -80,10 +59,57 @@ export function cleanFolder(folder: Partial, stringifyDates = false): Pa return folder; } -export function cleanFolders(folders: Partial[], stringifyDates = false): Partial[] { +export function cleanFolders>(folders: T[], stringifyDates = false): T[] { for (let i = 0; i !== folders.length; ++i) { cleanFolder(folders[i], stringifyDates); } return folders; } + +export const folderSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + + name: z.string(), + public: z.boolean(), + allowUploads: z.boolean(), + + parentId: z.string().nullable(), + userId: z.string(), + + files: z.array(fileSchema).optional(), + parent: z.any().nullable().optional(), + children: z.array(z.any()).optional(), + _count: z + .object({ + children: z.number().optional(), + files: z.number().optional(), + }) + .optional(), +}); + +export type Folder = z.infer; + +export const folderParentSchema = z.object({ + id: z.string(), + name: z.string(), + parentId: z.string().nullable(), + get parent() { + return folderParentSchema.nullable().optional(); + }, +}); + +export const folderParentPublicSchema = z.object({ + public: z.boolean(), + id: z.string(), + name: z.string(), + parentId: z.string().nullable(), + get parent() { + return folderParentPublicSchema.nullable().optional(); + }, +}); + +export type FolderParent = z.infer; +export type FolderParentPublic = z.infer; diff --git a/src/lib/db/models/incompleteFile.ts b/src/lib/db/models/incompleteFile.ts index 29e6e155..9728fc23 100644 --- a/src/lib/db/models/incompleteFile.ts +++ b/src/lib/db/models/incompleteFile.ts @@ -1,20 +1,6 @@ import { IncompleteFileStatus } from '@/prisma/client'; import { z } from 'zod'; -export type IncompleteFile = { - id: string; - createdAt: Date; - updatedAt: Date; - - status: IncompleteFileStatus; - chunksTotal: number; - chunksComplete: number; - - userId: string; - - metadata: IncompleteFileMetadata; -}; - export type IncompleteFileMetadata = z.infer; export const metadataSchema = z.object({ file: z.object({ @@ -23,3 +9,19 @@ export const metadataSchema = z.object({ id: z.string(), }), }); + +export const incompleteFileSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + + status: z.enum(IncompleteFileStatus), + chunksTotal: z.number(), + chunksComplete: z.number(), + + userId: z.string(), + + metadata: metadataSchema, +}); + +export type IncompleteFile = z.infer; diff --git a/src/lib/db/models/invite.ts b/src/lib/db/models/invite.ts index 2917b4c7..10139f40 100644 --- a/src/lib/db/models/invite.ts +++ b/src/lib/db/models/invite.ts @@ -1,13 +1,5 @@ -import type { Invite as PrismaInvite } from '@/prisma/client'; -import type { User } from './user'; - -export type Invite = PrismaInvite & { - inviter?: { - username: string; - id: string; - role: User['role']; - }; -}; +import { Role } from '@/prisma/client'; +import { z } from 'zod'; export const inviteInviterSelect = { select: { @@ -16,3 +8,26 @@ export const inviteInviterSelect = { role: true, }, }; + +export const inviteSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + expiresAt: z.date().nullable(), + + code: z.string(), + uses: z.number(), + maxUses: z.number().nullable(), + + inviterId: z.string(), + + inviter: z + .object({ + username: z.string(), + id: z.string(), + role: z.enum(Role), + }) + .optional(), +}); + +export type Invite = z.infer; diff --git a/src/lib/db/models/metric.ts b/src/lib/db/models/metric.ts index 92b4eb9e..0a583c3b 100644 --- a/src/lib/db/models/metric.ts +++ b/src/lib/db/models/metric.ts @@ -1,14 +1,7 @@ import { z } from 'zod'; -export type Metric = { - id: string; - createdAt: Date; - updatedAt: Date; - - data: MetricData; -}; - export type MetricData = z.infer; + export const metricDataSchema = z.object({ users: z.number(), files: z.number(), @@ -39,3 +32,12 @@ export const metricDataSchema = z.object({ }), ), }); + +export const metricSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + data: metricDataSchema, +}); + +export type Metric = z.infer; diff --git a/src/lib/db/models/tag.ts b/src/lib/db/models/tag.ts index ae51d521..b2b01212 100644 --- a/src/lib/db/models/tag.ts +++ b/src/lib/db/models/tag.ts @@ -1,13 +1,4 @@ -export type Tag = { - id: string; - createdAt: Date; - updatedAt: Date; - name: string; - color: string; - files?: { - id: string; - }[]; -}; +import { z } from 'zod'; export const tagSelect = { id: true, @@ -29,3 +20,20 @@ export const tagSelectNoFiles = { name: true, color: true, }; + +export const tagSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + name: z.string(), + color: z.string(), + files: z + .array( + z.object({ + id: z.string(), + }), + ) + .optional(), +}); + +export type Tag = z.infer; diff --git a/src/lib/db/models/url.ts b/src/lib/db/models/url.ts index 2fe02284..1caa7cb9 100644 --- a/src/lib/db/models/url.ts +++ b/src/lib/db/models/url.ts @@ -1,8 +1,4 @@ -import type { Url as PrismaUrl } from '@/prisma/client'; - -export type Url = PrismaUrl & { - similarity?: number; -}; +import { z } from 'zod'; export function cleanUrlPasswords(urls: Url[]) { for (const url of urls) { @@ -11,3 +7,23 @@ export function cleanUrlPasswords(urls: Url[]) { return urls; } + +export const urlSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + + code: z.string(), + vanity: z.string().nullable(), + destination: z.string(), + views: z.number(), + maxViews: z.number().nullable(), + password: z.union([z.string(), z.boolean()]).nullable(), + enabled: z.boolean(), + + userId: z.string().nullable(), + + similarity: z.number().optional(), +}); + +export type Url = z.infer; diff --git a/src/lib/db/models/user.ts b/src/lib/db/models/user.ts index d55c9ad3..1e927ac9 100644 --- a/src/lib/db/models/user.ts +++ b/src/lib/db/models/user.ts @@ -1,28 +1,5 @@ -import { OAuthProvider, UserPasskey, UserQuota, UserSession } from '@/prisma/client'; import { z } from 'zod'; -export type User = { - id: string; - username: string; - createdAt: Date; - updatedAt: Date; - role: 'USER' | 'ADMIN' | 'SUPERADMIN'; - view: UserViewSettings; - - sessions: UserSession[]; - - oauthProviders: OAuthProvider[]; - - totpSecret?: string | null; - passkeys?: UserPasskey[]; - - quota?: UserQuota | null; - - avatar?: string | null; - password?: string | null; - token?: string | null; -}; - export const userSelect = { id: true, username: true, @@ -37,7 +14,6 @@ export const userSelect = { sessions: true, }; -export type UserViewSettings = z.infer; export const userViewSchema = z .object({ enabled: z.boolean().nullish(), @@ -53,3 +29,79 @@ export const userViewSchema = z embedSiteName: z.string().nullish(), }) .partial(); + +export type UserViewSettings = z.infer; + +export const userSessionSchema = z.object({ + id: z.string(), + createdAt: z.date(), + ua: z.string(), + client: z.string(), + device: z.string(), + userId: z.string(), +}); + +export type UserSession = z.infer; + +export const userQuotaSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + filesQuota: z.enum(['BY_BYTES', 'BY_FILES']), + maxBytes: z.string().nullable(), + maxFiles: z.number().nullable(), + maxUrls: z.number().nullable(), + userId: z.string().nullable(), +}); + +export type UserQuota = z.infer; + +export const userPasskeySchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + lastUsed: z.date().nullable(), + name: z.string(), + reg: z.any(), + userId: z.string(), +}); + +export type UserPasskey = z.infer; + +export const oauthProviderSchema = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + userId: z.string(), + provider: z.enum(['DISCORD', 'GOOGLE', 'GITHUB', 'OIDC']), + username: z.string(), + accessToken: z.string(), + refreshToken: z.string().nullable(), + oauthId: z.string().nullable(), +}); + +export type OAuthProvider = z.infer; +export type OAuthProviderType = OAuthProvider['provider']; + +export const userSchema = z.object({ + id: z.string(), + username: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + role: z.enum(['USER', 'ADMIN', 'SUPERADMIN']), + view: userViewSchema, + + sessions: z.array(userSessionSchema), + oauthProviders: z.array(oauthProviderSchema), + + totpSecret: z.string().nullable().optional(), + passkeys: z.array(userPasskeySchema).optional(), + + quota: userQuotaSchema.nullable().optional(), + + avatar: z.string().nullable().optional(), + password: z.string().nullable().optional(), + token: z.string().nullable().optional(), +}); + +export type User = z.infer; diff --git a/src/lib/fetchApi.ts b/src/lib/fetchApi.ts index c6a2f54e..4072b885 100644 --- a/src/lib/fetchApi.ts +++ b/src/lib/fetchApi.ts @@ -1,4 +1,4 @@ -import { ErrorBody } from './response'; +import { ApiErrorPayload } from './api/errors'; const bodyMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; @@ -9,10 +9,10 @@ export async function fetchApi( headers: Record = {}, ): Promise<{ data: Response | null; - error: ErrorBody | null; + error: ApiErrorPayload | null; }> { let data: Response | null = null; - let error: ErrorBody | null = null; + let error: ApiErrorPayload | null = null; if ((bodyMethods.includes(method) && body !== null) || (body && !Object.keys(body).length)) { headers['Content-Type'] = 'application/json'; @@ -31,9 +31,10 @@ export async function fetchApi( error = await res.json(); } else { error = { - message: await res.text(), + code: 9000, + error: await res.text(), statusCode: res.status, - } as ErrorBody; + } as ApiErrorPayload; } } diff --git a/src/server/index.ts b/src/server/index.ts index 0dc0c4a2..3f67b952 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -37,6 +37,7 @@ import loadRoutes from './routes'; import { filesRoute } from './routes/files.dy'; import { urlsRoute } from './routes/urls.dy'; import cleanThumbnails from '@/lib/tasks/run/cleanThumbnails'; +import { API_ERRORS, ApiError } from '@/lib/api/errors'; const MODE = process.env.NODE_ENV || 'production'; const logger = log('server'); @@ -241,20 +242,24 @@ async function main() { server.setErrorHandler((error: any, _, res) => { if (hasZodFastifySchemaValidationErrors(error)) { return res.status(400).send({ - error: error.message ?? 'Response Validation Error', + error: error.message ?? '1000: Invalid response schema', statusCode: 400, + code: API_ERRORS[1000], issues: error.validation, }); } + if (error instanceof ApiError) { + const apiError = error as ApiError; + return res.status(apiError.status).send(apiError.toJSON()); + } + if (error.statusCode) { - res.status(error.statusCode); - res.send({ error: error.message, statusCode: error.statusCode }); + return res.status(error.statusCode).send({ error: error.message, statusCode: error.statusCode }); } else { console.error(error); - res.status(500); - res.send({ error: 'Internal Server Error', statusCode: 500 }); + return res.status(500).send({ error: 'Internal Server Error', statusCode: 500 }); } }); diff --git a/src/server/middleware/administrator.ts b/src/server/middleware/administrator.ts index 3eb8f976..33fc3c3a 100644 --- a/src/server/middleware/administrator.ts +++ b/src/server/middleware/administrator.ts @@ -1,8 +1,9 @@ +import { ApiError } from '@/lib/api/errors'; import { isAdministrator } from '@/lib/role'; -import { FastifyReply, FastifyRequest } from 'fastify'; +import { FastifyRequest } from 'fastify'; -export async function administratorMiddleware(req: FastifyRequest, res: FastifyReply) { - if (!req.user) return res.forbidden('not logged in'); +export async function administratorMiddleware(req: FastifyRequest) { + if (!req.user) throw new ApiError(2000); - if (!isAdministrator(req.user.role)) return res.forbidden(); + if (!isAdministrator(req.user.role)) throw new ApiError(3000); } diff --git a/src/server/middleware/user.ts b/src/server/middleware/user.ts index 53a87f6b..327598c5 100644 --- a/src/server/middleware/user.ts +++ b/src/server/middleware/user.ts @@ -5,8 +5,8 @@ import { User, userSelect } from '@/lib/db/models/user'; import { FastifyReply } from 'fastify'; import { FastifyRequest } from 'fastify/types/request'; import { getSession } from '../session'; -// import cookie from 'cookie'; import * as cookie from 'cookie'; +import { ApiError } from '@/lib/api/errors'; declare module 'fastify' { export interface FastifyRequest { @@ -28,13 +28,15 @@ export function parseUserToken( const decryptedToken = decryptToken(encryptedToken, config.core.secret); if (!decryptedToken) { if (noThrow) return null; - throw { error: 'could not decrypt token' }; + // throw { error: 'could not decrypt token' }; + throw new ApiError(2001); } const [date, token] = decryptedToken; if (isNaN(new Date(date).getTime())) { if (noThrow) return null; - throw { error: 'invalid token' }; + + throw new ApiError(2001); } return token; @@ -58,7 +60,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) { // eslint-disable-next-line no-var var token = parseUserToken(authorization); } catch (e) { - return res.unauthorized((e as { error: string }).error); + throw e; } const user = await prisma.user.findFirst({ @@ -67,7 +69,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) { }, select: userSelect, }); - if (!user) return res.unauthorized('invalid authorization token'); + if (!user) throw new ApiError(2001); req.user = user; @@ -76,7 +78,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) { const session = await getSession(req, res); - if (!session.id || !session.sessionId) return res.unauthorized('not logged in'); + if (!session.id || !session.sessionId) throw new ApiError(2000); const user = await prisma.user.findFirst({ where: { @@ -88,7 +90,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) { }, select: userSelect, }); - if (!user) return res.unauthorized('invalid login session'); + if (!user) throw new ApiError(2001); req.user = user; } diff --git a/src/server/routes/api/auth/invites/[id].ts b/src/server/routes/api/auth/invites/[id].ts index a4babe62..540db3c3 100644 --- a/src/server/routes/api/auth/invites/[id].ts +++ b/src/server/routes/api/auth/invites/[id].ts @@ -1,5 +1,6 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; -import { Invite, inviteInviterSelect } from '@/lib/db/models/invite'; +import { Invite, inviteInviterSelect, inviteSchema } from '@/lib/db/models/invite'; import { log } from '@/lib/logger'; import { Prisma } from '@/prisma/client'; import { administratorMiddleware } from '@/server/middleware/administrator'; @@ -21,7 +22,12 @@ export default typedPlugin( PATH, { schema: { + description: + 'Fetch a specific invite by ID or code, including information about the inviter (admin only).', params: paramsSchema, + response: { + 200: inviteSchema, + }, }, preHandler: [userMiddleware, administratorMiddleware], }, @@ -36,7 +42,7 @@ export default typedPlugin( inviter: inviteInviterSelect, }, }); - if (!invite) return res.notFound('Invite not found through id or code'); + if (!invite) throw new ApiError(4005); return res.send(invite); }, @@ -46,7 +52,11 @@ export default typedPlugin( PATH, { schema: { + description: 'Delete a specific invite by ID (admin only).', params: paramsSchema, + response: { + 200: inviteSchema, + }, }, preHandler: [userMiddleware, administratorMiddleware], }, @@ -71,11 +81,11 @@ export default typedPlugin( return res.send(invite); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { - return res.notFound('Invite not found'); + throw new ApiError(4004); } logger.error(`Failed to delete invite with id ${id}`, { error }); - return res.internalServerError('Failed to delete invite'); + throw new ApiError(6000); } }, ); diff --git a/src/server/routes/api/auth/invites/index.ts b/src/server/routes/api/auth/invites/index.ts index 25db7875..75a75dbb 100644 --- a/src/server/routes/api/auth/invites/index.ts +++ b/src/server/routes/api/auth/invites/index.ts @@ -1,6 +1,6 @@ import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; -import { Invite, inviteInviterSelect } from '@/lib/db/models/invite'; +import { Invite, inviteInviterSelect, inviteSchema } from '@/lib/db/models/invite'; import { log } from '@/lib/logger'; import { randomCharacters } from '@/lib/random'; import { secondlyRatelimit } from '@/lib/ratelimits'; @@ -21,6 +21,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'Create a new invite code for user registration, optionally limiting uses and expiration (admin only).', body: z.object({ expiresAt: z .string() @@ -28,6 +30,9 @@ export default typedPlugin( .transform((val) => parseExpiry(val)), maxUses: z.number().min(1).optional(), }), + response: { + 200: inviteSchema, + }, }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), @@ -57,15 +62,27 @@ export default typedPlugin( }, ); - server.get(PATH, { preHandler: [userMiddleware, administratorMiddleware] }, async (_, res) => { - const invites = await prisma.invite.findMany({ - include: { - inviter: inviteInviterSelect, + server.get( + PATH, + { + schema: { + description: 'List all existing invite codes and their metadata (admin only).', + response: { + 200: z.array(inviteSchema), + }, }, - }); + preHandler: [userMiddleware, administratorMiddleware], + }, + async (_, res) => { + const invites = await prisma.invite.findMany({ + include: { + inviter: inviteInviterSelect, + }, + }); - return res.send(invites); - }); + return res.send(invites); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/invites/web.ts b/src/server/routes/api/auth/invites/web.ts index 756ef17e..875a54c8 100644 --- a/src/server/routes/api/auth/invites/web.ts +++ b/src/server/routes/api/auth/invites/web.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; import { Invite } from '@/lib/db/models/invite'; @@ -18,7 +19,21 @@ export default typedPlugin( PATH, { schema: { + description: + 'Look up a public invite by code for the web UI, returning basic information about the inviter and usage limits.', querystring: z.object({ code: z.string().optional() }), + response: { + 200: z.object({ + invite: z + .object({ + code: z.string(), + maxUses: z.number().nullable(), + uses: z.number(), + inviter: z.object({ username: z.string() }), + }) + .nullable(), + }), + }, }, ...secondlyRatelimit(10), }, @@ -26,7 +41,7 @@ export default typedPlugin( const { code } = req.query; if (!code) return res.send({ invite: null }); - if (!config.invites.enabled) return res.notFound(); + if (!config.invites.enabled) throw new ApiError(9002); const invite = await prisma.invite.findFirst({ where: { @@ -48,7 +63,7 @@ export default typedPlugin( (invite.expiresAt && new Date(invite.expiresAt) < new Date()) || (invite.maxUses && invite.uses >= invite.maxUses) ) { - return res.notFound(); + throw new ApiError(9002); } delete (invite as any).expiresAt; diff --git a/src/server/routes/api/auth/login.ts b/src/server/routes/api/auth/login.ts index 4a1f6e35..b220ab2c 100644 --- a/src/server/routes/api/auth/login.ts +++ b/src/server/routes/api/auth/login.ts @@ -1,7 +1,8 @@ +import { ApiError } from '@/lib/api/errors'; import { ziplineClientParseSchema } from '@/lib/api/detect'; import { verifyPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { User, userSelect } from '@/lib/db/models/user'; +import { User, userSchema, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { verifyTotpCode } from '@/lib/totp'; @@ -24,6 +25,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'Authenticate a user, creating a session and optionally requiring a TOTP code when multi-factor auth is enabled.', body: z.object({ username: zStringTrimmed, password: zStringTrimmed, @@ -32,6 +35,12 @@ export default typedPlugin( headers: z.object({ 'x-zipline-client': ziplineClientParseSchema.optional(), }), + response: { + 200: z.object({ + user: userSchema.optional(), + totp: z.literal(true).optional(), + }), + }, }, ...secondlyRatelimit(2), }, @@ -53,8 +62,8 @@ export default typedPlugin( token: true, }, }); - if (!user) return res.badRequest('Invalid username or password'); - if (!user.password) return res.badRequest('Invalid username or password'); + if (!user) throw new ApiError(1044); + if (!user.password) throw new ApiError(1044); const valid = await verifyPassword(password, user.password); if (!valid) { @@ -64,7 +73,7 @@ export default typedPlugin( ua: req.headers['user-agent'], }); - return res.badRequest('Invalid username or password'); + throw new ApiError(1044); } if (user.totpSecret && code) { @@ -76,7 +85,7 @@ export default typedPlugin( ua: req.headers['user-agent'], }); - return res.badRequest('Invalid code'); + throw new ApiError(1045); } } diff --git a/src/server/routes/api/auth/logout.ts b/src/server/routes/api/auth/logout.ts index 8cc7d3e9..4f29d653 100644 --- a/src/server/routes/api/auth/logout.ts +++ b/src/server/routes/api/auth/logout.ts @@ -3,6 +3,7 @@ import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; import { getSession } from '@/server/session'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiLogoutResponse = { loggedOut?: boolean; @@ -13,26 +14,40 @@ const logger = log('api').c('auth').c('logout'); export const PATH = '/api/auth/logout'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const current = await getSession(req, res); - - await prisma.userSession.deleteMany({ - where: { - id: current.sessionId!, - userId: req.user.id, + server.get( + PATH, + { + schema: { + description: 'Log out the currently authenticated user and invalidate their active session.', + response: { + 200: z.object({ + loggedOut: z.boolean().optional(), + }), + }, }, - }); + preHandler: [userMiddleware], + }, + async (req, res) => { + const current = await getSession(req, res); - current.destroy(); + await prisma.userSession.deleteMany({ + where: { + id: current.sessionId!, + userId: req.user.id, + }, + }); - logger.info('user logged out', { - user: req.user.username, - ip: req.ip ?? 'unknown', - ua: req.headers['user-agent'], - }); + current.destroy(); - return res.send({ loggedOut: true }); - }); + logger.info('user logged out', { + user: req.user.username, + ip: req.ip ?? 'unknown', + ua: req.headers['user-agent'], + }); + + return res.send({ loggedOut: true }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/oauth/index.ts b/src/server/routes/api/auth/oauth/index.ts index 52d81909..c794b67e 100644 --- a/src/server/routes/api/auth/oauth/index.ts +++ b/src/server/routes/api/auth/oauth/index.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; +import { OAuthProvider, oauthProviderSchema } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; -import { OAuthProvider, OAuthProviderType } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; @@ -12,13 +13,35 @@ const logger = log('api').c('auth').c('oauth'); export const PATH = '/api/auth/oauth'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - return res.send(req.user.oauthProviders); - }); + server.get( + PATH, + { + schema: { + description: 'List OAuth providers currently linked to the authenticated user.', + response: { + 200: z.array(oauthProviderSchema), + }, + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + return res.send(req.user.oauthProviders); + }, + ); server.delete( PATH, - { schema: { body: z.object({ provider: z.enum(OAuthProviderType) }) }, preHandler: [userMiddleware] }, + { + schema: { + description: + 'Unlink one OAuth provider from the authenticated user, enforcing that at least one login method remains.', + body: z.object({ provider: oauthProviderSchema.shape.provider }), + response: { + 200: z.array(oauthProviderSchema), + }, + }, + preHandler: [userMiddleware], + }, async (req, res) => { const { password } = (await prisma.user.findFirst({ where: { @@ -29,9 +52,8 @@ export default typedPlugin( }, }))!; - if (!req.user.oauthProviders.length) return res.badRequest('No providers to delete'); - if (req.user.oauthProviders.length === 1 && !password) - return res.badRequest("You can't delete your last oauth provider without a password"); + if (!req.user.oauthProviders.length) throw new ApiError(1030); + if (req.user.oauthProviders.length === 1 && !password) throw new ApiError(1043); const { provider } = req.body; diff --git a/src/server/routes/api/auth/register.ts b/src/server/routes/api/auth/register.ts index b7621a3b..2cabcfff 100644 --- a/src/server/routes/api/auth/register.ts +++ b/src/server/routes/api/auth/register.ts @@ -1,7 +1,9 @@ +import { ApiError } from '@/lib/api/errors'; +import { ziplineClientParseSchema } from '@/lib/api/detect'; import { config } from '@/lib/config'; import { createToken, hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { User, userSelect } from '@/lib/db/models/user'; +import { User, userSchema, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { getSession, saveSession } from '@/server/session'; @@ -9,7 +11,6 @@ import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; import { ApiLoginResponse } from './login'; import { zStringTrimmed } from '@/lib/validation'; -import { ziplineClientParseSchema } from '@/lib/api/detect'; export type ApiAuthRegisterResponse = ApiLoginResponse; @@ -22,6 +23,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'Register a new user account and immediately authenticate them, optionally consuming an invite code.', body: z.object({ username: zStringTrimmed, password: zStringTrimmed, @@ -30,6 +33,11 @@ export default typedPlugin( headers: z.object({ 'x-zipline-client': ziplineClientParseSchema.optional(), }), + response: { + 200: z.object({ + user: userSchema.optional(), + }), + }, }, ...secondlyRatelimit(5), }, @@ -38,16 +46,15 @@ export default typedPlugin( const { username, password, code } = req.body; - if (code && !config.invites.enabled) return res.badRequest("Invites aren't enabled"); - if (!code && !config.features.userRegistration) - return res.badRequest('User registration is disabled'); + if (code && !config.invites.enabled) throw new ApiError(1036); + if (!code && !config.features.userRegistration) throw new ApiError(1037); const oUser = await prisma.user.findUnique({ where: { username, }, }); - if (oUser) return res.badRequest('Username is taken'); + if (oUser) throw new ApiError(1039); if (code) { const invite = await prisma.invite.findFirst({ @@ -56,10 +63,9 @@ export default typedPlugin( }, }); - if (!invite) return res.badRequest('Invalid invite code'); - if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) - return res.badRequest('Invalid invite code'); - if (invite.maxUses && invite.uses >= invite.maxUses) return res.badRequest('Invalid invite code'); + if (!invite) throw new ApiError(1035); + if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) throw new ApiError(1035); + if (invite.maxUses && invite.uses >= invite.maxUses) throw new ApiError(1035); await prisma.invite.update({ where: { diff --git a/src/server/routes/api/auth/webauthn.ts b/src/server/routes/api/auth/webauthn.ts index 5ee5be62..c95d51c8 100644 --- a/src/server/routes/api/auth/webauthn.ts +++ b/src/server/routes/api/auth/webauthn.ts @@ -1,3 +1,5 @@ +import { ApiError } from '@/lib/api/errors'; +import { ziplineClientParseSchema } from '@/lib/api/detect'; import { config } from '@/lib/config'; import { createToken } from '@/lib/crypto'; import { prisma } from '@/lib/db'; @@ -16,7 +18,6 @@ import { } from '@simplewebauthn/server'; import z from 'zod'; import { PasskeyReg, passkeysEnabledHandler } from '../user/mfa/passkey'; -import { ziplineClientParseSchema } from '@/lib/api/detect'; export type ApiAuthWebauthnResponse = { user: User; @@ -36,7 +37,16 @@ export default typedPlugin( async (server) => { server.get( PATH + '/options', - { preHandler: [passkeysEnabledHandler], ...secondlyRatelimit(20) }, + { + schema: { + description: 'Generate WebAuthn authentication options for logging in with an existing passkey.', + response: { + 200: z.custom(), + }, + }, + preHandler: [passkeysEnabledHandler], + ...secondlyRatelimit(20), + }, async (req, res) => { if (req.cookies['webauthn-challenge-id']) { const existing = OPTIONS_CACHE.get(req.cookies['webauthn-challenge-id']); @@ -72,6 +82,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'Verify a WebAuthn authentication response and log in the user associated with the matching passkey.', body: z.object({ response: z.custom(), }), @@ -86,13 +98,13 @@ export default typedPlugin( const session = await getSession(req, res); const webauthnChallengeId = req.cookies['webauthn-challenge-id']; - if (!webauthnChallengeId) return res.badRequest('Missing webauthn challenge id'); + if (!webauthnChallengeId) throw new ApiError(1046); const { response } = req.body; - if (!response) return res.badRequest('Missing webauthn payload'); + if (!response) throw new ApiError(1047); const cachedOptions = OPTIONS_CACHE.get(webauthnChallengeId); - if (!cachedOptions) return res.badRequest(); + if (!cachedOptions) throw new ApiError(1048); const user = await prisma.user.findFirst({ where: { @@ -119,7 +131,7 @@ export default typedPlugin( request: response, }); - return res.badRequest(); + throw new ApiError(1052); } const passkey = user.passkeys.find((pk) => { @@ -128,12 +140,12 @@ export default typedPlugin( return webauthn.id === response.id; }); - if (!passkey) return res.badRequest(); + if (!passkey) throw new ApiError(1052); const reg = passkey.reg as PasskeyReg; if (!reg.webauthn) { logger.debug('invalid webauthn attempt, legacy passkey found...'); - return res.badRequest(); + throw new ApiError(1060); } OPTIONS_CACHE.delete(webauthnChallengeId); @@ -154,14 +166,14 @@ export default typedPlugin( } catch (e) { console.error(e); logger.warn('error verifying passkey authentication'); - return res.badRequest('Error verifying passkey authentication'); + throw new ApiError(1051); } if (!verification.verified) { logger.warn('failed passkey authentication attempt', { user: user.username, }); - return res.badRequest('Could not verify passkey authentication'); + throw new ApiError(1052); } const { newCounter } = verification.authenticationInfo; diff --git a/src/server/routes/api/healthcheck.ts b/src/server/routes/api/healthcheck.ts index dd02a458..87e2540c 100644 --- a/src/server/routes/api/healthcheck.ts +++ b/src/server/routes/api/healthcheck.ts @@ -1,7 +1,9 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiHealthcheckResponse = { pass: boolean; @@ -12,17 +14,31 @@ const logger = log('api').c('healthcheck'); export const PATH = '/api/healthcheck'; export default typedPlugin( async (server) => { - server.get(PATH, async (_, res) => { - if (!config.features.healthcheck) return res.notFound(); + server.get( + PATH, + { + schema: { + description: + 'Perform a simple healthcheck on the database and backend of Zipline. Returns a simple pass/fail response.', + response: { + 200: z.object({ + pass: z.boolean().describe('true if the server and db are reachable and functioning.'), + }), + }, + }, + }, + async (_, res) => { + if (!config.features.healthcheck) throw new ApiError(9002); - try { - await prisma.$queryRaw`SELECT 1;`; - return res.send({ pass: true }); - } catch (e) { - logger.error('there was an error during a healthcheck').error(e as Error); - return res.internalServerError('there was an error during a healthcheck'); - } - }); + try { + await prisma.$queryRaw`SELECT 1;`; + return res.send({ pass: true }); + } catch (e) { + logger.error('there was an error during a healthcheck').error(e as Error); + throw new ApiError(6003); + } + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/clear_temp.ts b/src/server/routes/api/server/clear_temp.ts index fa7a4557..1e8fe8e0 100644 --- a/src/server/routes/api/server/clear_temp.ts +++ b/src/server/routes/api/server/clear_temp.ts @@ -4,6 +4,7 @@ import { clearTemp } from '@/lib/server-util/clearTemp'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiServerClearTempResponse = { status?: string; @@ -17,6 +18,15 @@ export default typedPlugin( server.delete( PATH, { + schema: { + description: + 'Delete temporary files on the Zipline server and return a short status message (admin only).', + response: { + 200: z.object({ + status: z.string().optional(), + }), + }, + }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), }, diff --git a/src/server/routes/api/server/clear_zeros.ts b/src/server/routes/api/server/clear_zeros.ts index f0e694f3..43e6ce9d 100644 --- a/src/server/routes/api/server/clear_zeros.ts +++ b/src/server/routes/api/server/clear_zeros.ts @@ -4,6 +4,7 @@ import { clearZeros, clearZerosFiles } from '@/lib/server-util/clearZeros'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiServerClearZerosResponse = { status?: string; @@ -18,6 +19,20 @@ export default typedPlugin( server.get( PATH, { + schema: { + description: + 'Scan for zero-byte files on disk and return the list of candidates to delete (admin only).', + response: { + 200: z.object({ + files: z.array( + z.object({ + id: z.string(), + name: z.string(), + }), + ), + }), + }, + }, preHandler: [userMiddleware, administratorMiddleware], }, async (_, res) => { @@ -30,6 +45,15 @@ export default typedPlugin( server.delete( PATH, { + schema: { + description: + 'Delete zero-byte files previously detected on disk and return a short status message (admin only).', + response: { + 200: z.object({ + status: z.string().optional(), + }), + }, + }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), }, diff --git a/src/server/routes/api/server/export.ts b/src/server/routes/api/server/export.ts index d659037d..0e0bd9c4 100644 --- a/src/server/routes/api/server/export.ts +++ b/src/server/routes/api/server/export.ts @@ -1,4 +1,5 @@ -import { Export4 } from '@/lib/import/version4/validateExport'; +import { ApiError } from '@/lib/api/errors'; +import { Export4, export4Schema } from '@/lib/import/version4/validateExport'; import { log } from '@/lib/logger'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; @@ -9,7 +10,19 @@ import { cpus, hostname, platform, release } from 'os'; import z from 'zod'; import { version } from '../../../../../package.json'; -async function getCounts() { +const exportCountsSchema = z.object({ + users: z.number(), + files: z.number(), + urls: z.number(), + folders: z.number(), + invites: z.number(), + thumbnails: z.number(), + metrics: z.number(), +}); + +type ExportCounts = z.infer; + +async function getCounts(): Promise { const users = await prisma.user.count(); const files = await prisma.file.count(); const urls = await prisma.url.count(); @@ -40,10 +53,18 @@ export default typedPlugin( PATH, { schema: { + description: + 'Export Zipline server data as a version 4 export bundle or return aggregate counts of core resources.', querystring: z.object({ nometrics: z.string().optional(), counts: z.string().optional(), }), + response: { + 200: z.union([ + exportCountsSchema.describe('if ?counts=true'), + export4Schema.describe('if ?counts is not true or not there'), + ]), + }, }, preHandler: [userMiddleware, administratorMiddleware], }, @@ -57,10 +78,7 @@ export default typedPlugin( logger.debug('exporting server data', { format: '4', requester: req.user.username }); const settingsTable = await prisma.zipline.findFirst(); - if (!settingsTable) - return res.badRequest( - 'Invalid setup, no settings found. Run the setup process again before exporting data.', - ); + if (!settingsTable) throw new ApiError(1023); const export4: Export4 = { versions: { diff --git a/src/server/routes/api/server/folder.ts b/src/server/routes/api/server/folder.ts index 360a3d91..418183a2 100644 --- a/src/server/routes/api/server/folder.ts +++ b/src/server/routes/api/server/folder.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; import { fileSelect } from '@/lib/db/models/file'; -import { buildPublicParentChain, cleanFolder, Folder } from '@/lib/db/models/folder'; +import { buildPublicParentChain, cleanFolder, Folder, folderSchema } from '@/lib/db/models/folder'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; @@ -13,12 +14,17 @@ export default typedPlugin( PATH, { schema: { + description: + 'Fetch a public view of a folder by ID, including files, child folders, and parent chain when allowed.', params: z.object({ id: z.string(), }), querystring: z.object({ uploads: z.string().optional(), }), + response: { + 200: folderSchema.partial(), + }, }, }, async (req, res) => { @@ -60,9 +66,10 @@ export default typedPlugin( }, }); - if (!folder) return res.notFound(); + if (!folder) throw new ApiError(9002); - if ((uploads && !folder.allowUploads) || (!uploads && !folder.public)) return res.notFound(); + if (uploads && !folder.allowUploads) throw new ApiError(3002); + if (!uploads && !folder.public) throw new ApiError(9002); if (folder.parentId) { (folder as any).parent = await buildPublicParentChain(folder.parentId); diff --git a/src/server/routes/api/server/import/v3.ts b/src/server/routes/api/server/import/v3.ts index 4f8fc569..edb9024b 100644 --- a/src/server/routes/api/server/import/v3.ts +++ b/src/server/routes/api/server/import/v3.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { createToken } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { export3Schema } from '@/lib/import/version3/validateExport'; @@ -8,13 +9,15 @@ import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; -export type ApiServerImportV3 = { - users: Record; - files: Record; - folders: Record; - urls: Record; - settings: string[]; -}; +export type ApiServerImportV3 = z.infer; + +const serverImportSchema = z.object({ + users: z.record(z.string(), z.string()), + files: z.record(z.string(), z.string()), + folders: z.record(z.string(), z.string()), + urls: z.record(z.string(), z.string()), +}); + const parseDate = (date: string) => (isNaN(Date.parse(date)) ? new Date() : new Date(date)); const logger = log('api').c('server').c('import').c('v3'); @@ -26,10 +29,15 @@ export default typedPlugin( PATH, { schema: { + description: + 'Import data from a legacy Zipline v3 export file, creating users, files, folders and URLs and returning a mapping of old IDs to new IDs.', body: z.object({ export3: export3Schema.required(), importFromUser: z.string().optional(), }), + response: { + 200: serverImportSchema, + }, }, preHandler: [userMiddleware, administratorMiddleware], // 24gb, just in case @@ -37,7 +45,7 @@ export default typedPlugin( ...secondlyRatelimit(5), }, async (req, res) => { - if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin'); + if (req.user.role !== 'SUPERADMIN') throw new ApiError(3015); const { export3 } = req.body; @@ -288,7 +296,7 @@ export default typedPlugin( files: filesImportedToId, folders: foldersImportedToId, urls: urlsImportedToId, - }); + } satisfies ApiServerImportV3); }, ); }, diff --git a/src/server/routes/api/server/import/v4.ts b/src/server/routes/api/server/import/v4.ts index bda702e9..c19a9032 100644 --- a/src/server/routes/api/server/import/v4.ts +++ b/src/server/routes/api/server/import/v4.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { createToken } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { export4Schema } from '@/lib/import/version4/validateExport'; @@ -8,20 +9,22 @@ import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; -export type ApiServerImportV4 = { - imported: { - users: number; - oauthProviders: number; - quotas: number; - passkeys: number; - folders: number; - files: number; - tags: number; - urls: number; - invites: number; - metrics: number; - }; -}; +export type ApiServerImportV4 = z.infer; + +const serverImportSchema = z.object({ + imported: z.object({ + users: z.number(), + oauthProviders: z.number(), + quotas: z.number(), + passkeys: z.number(), + folders: z.number(), + files: z.number(), + tags: z.number(), + urls: z.number(), + invites: z.number(), + metrics: z.number(), + }), +}); const logger = log('api').c('server').c('import').c('v4'); @@ -32,6 +35,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'Import data from a Zipline v4 export file, optionally merging into the current user and returning counts of imported records.', body: z.object({ export4: export4Schema.required(), config: z.object({ @@ -39,6 +44,9 @@ export default typedPlugin( mergeCurrentUser: z.string().nullish().default(null), }), }), + response: { + 200: serverImportSchema, + }, }, preHandler: [userMiddleware, administratorMiddleware], // 24gb, just in case @@ -46,7 +54,7 @@ export default typedPlugin( ...secondlyRatelimit(5), }, async (req, res) => { - if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin'); + if (req.user.role !== 'SUPERADMIN') throw new ApiError(3015); const { export4, config: importConfig } = req.body; diff --git a/src/server/routes/api/server/public.ts b/src/server/routes/api/server/public.ts index 2712dc2c..d919302f 100644 --- a/src/server/routes/api/server/public.ts +++ b/src/server/routes/api/server/public.ts @@ -1,99 +1,116 @@ import { config } from '@/lib/config'; -import { Config } from '@/lib/config/validate'; +import { schema as configSchema } from '@/lib/config/validate'; import { getZipline } from '@/lib/db/models/zipline'; import enabled from '@/lib/oauth/enabled'; import { isTruthy } from '@/lib/primitive'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; -export type ApiServerPublicResponse = { - oauth: { - bypassLocalLogin: boolean; - loginOnly: boolean; - }; - oauthEnabled: { - discord: boolean; - github: boolean; - google: boolean; - oidc: boolean; - }; - website: { - loginBackground?: string | null; - loginBackgroundBlur?: boolean; - title?: string; - tos: boolean; - }; - features: { - oauthRegistration: boolean; - userRegistration: boolean; - metrics?: { - adminOnly?: boolean; - }; - }; - mfa: { - passkeys: boolean; - }; - tos?: string | null; - files: { - maxFileSize: string; - defaultFormat: Config['files']['defaultFormat']; - maxExpiration?: string | null; - }; - chunks: Config['chunks']; - firstSetup: boolean; - domains?: string[]; - returnHttps: boolean; -}; +export type ApiServerPublicResponse = z.infer; + +const publicConfigSchema = z.object({ + oauth: z.object({ + bypassLocalLogin: z.boolean(), + loginOnly: z.boolean(), + }), + oauthEnabled: z.object({ + discord: z.boolean(), + github: z.boolean(), + google: z.boolean(), + oidc: z.boolean(), + }), + website: z.object({ + loginBackground: z.string().nullable().optional(), + loginBackgroundBlur: z.boolean().optional(), + title: z.string().optional(), + tos: z.boolean(), + }), + features: z.object({ + oauthRegistration: z.boolean(), + userRegistration: z.boolean(), + metrics: z + .object({ + adminOnly: z.boolean().optional(), + }) + .optional(), + }), + mfa: z.object({ + passkeys: z.boolean(), + }), + tos: z.string().nullable().optional(), + files: z.object({ + maxFileSize: z.string(), + defaultFormat: configSchema.shape.files.shape.defaultFormat, + maxExpiration: z.string().nullable().optional(), + }), + chunks: configSchema.shape.chunks, + firstSetup: z.boolean(), + domains: z.array(z.string()).optional(), + returnHttps: z.boolean(), +}); export const PATH = '/api/server/public'; export default typedPlugin( async (server) => { - server.get<{ Body: Body }>(PATH, async (_, res) => { - const zipline = await getZipline(); + server.get<{ Body: Body }>( + PATH, + { + schema: { + description: + 'Return the public Zipline configuration used by the client, including OAuth, website, feature, file and chunk settings.', + response: { + 200: publicConfigSchema.describe('the public configuration for the Zipline instance'), + }, + }, + }, + async (_, res) => { + const zipline = await getZipline(); - const response: ApiServerPublicResponse = { - oauth: { - bypassLocalLogin: config.oauth.bypassLocalLogin, - loginOnly: config.oauth.loginOnly, - }, - oauthEnabled: enabled(config), - website: { - loginBackground: config.website.loginBackground, - loginBackgroundBlur: config.website.loginBackgroundBlur, - title: config.website.title, - tos: config.website.tos !== undefined, - }, - features: { - oauthRegistration: config.features.oauthRegistration, - userRegistration: config.features.userRegistration, - }, - mfa: { - passkeys: isTruthy( - config.mfa.passkeys.enabled, - config.mfa.passkeys.rpID, - config.mfa.passkeys.origin, - ), - }, - files: { - maxFileSize: config.files.maxFileSize, - defaultFormat: config.files.defaultFormat, - maxExpiration: config.files.maxExpiration, - }, - chunks: config.chunks, - firstSetup: zipline.firstSetup, - domains: config.domains, - returnHttps: config.core.returnHttpsUrls, - }; + const response: ApiServerPublicResponse = { + oauth: { + bypassLocalLogin: config.oauth.bypassLocalLogin, + loginOnly: config.oauth.loginOnly, + }, + oauthEnabled: enabled(config), + website: { + loginBackground: config.website.loginBackground, + loginBackgroundBlur: config.website.loginBackgroundBlur, + title: config.website.title, + tos: config.website.tos !== undefined, + }, + features: { + oauthRegistration: config.features.oauthRegistration, + userRegistration: config.features.userRegistration, + }, + mfa: { + passkeys: isTruthy( + config.mfa.passkeys.enabled, + config.mfa.passkeys.rpID, + config.mfa.passkeys.origin, + ), + }, + files: { + maxFileSize: config.files.maxFileSize, + defaultFormat: config.files.defaultFormat, + maxExpiration: config.files.maxExpiration, + }, + chunks: config.chunks, + firstSetup: zipline.firstSetup, + domains: config.domains, + returnHttps: config.core.returnHttpsUrls, + }; - if (config.features.metrics.adminOnly) { - response.features.metrics = { adminOnly: true }; - } + if (config.features.metrics.adminOnly) { + response.features.metrics = { adminOnly: true }; + } - if (config.website.tos) { - response.tos = global.__cachedConfigValues__.tos!; - } + if (config.website.tos) { + response.tos = global.__cachedConfigValues__.tos!; + } - return res.send(response); - }); + return res.send(response); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/requery_size.ts b/src/server/routes/api/server/requery_size.ts index 6aa99f7b..2493a0ad 100644 --- a/src/server/routes/api/server/requery_size.ts +++ b/src/server/routes/api/server/requery_size.ts @@ -19,10 +19,17 @@ export default typedPlugin( PATH, { schema: { + description: + 'Re-scan stored files to update their sizes and optionally delete missing ones, returning a short status message (admin only).', body: z.object({ forceDelete: z.boolean().default(false), forceUpdate: z.boolean().default(false), }), + response: { + 200: z.object({ + status: z.string().optional(), + }), + }, }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), diff --git a/src/server/routes/api/server/settings/index.ts b/src/server/routes/api/server/settings/index.ts index 41ebc374..0d6f29b4 100644 --- a/src/server/routes/api/server/settings/index.ts +++ b/src/server/routes/api/server/settings/index.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { bytes } from '@/lib/bytes'; import { checkOutput, COMPRESS_TYPES } from '@/lib/compress'; import { reloadSettings } from '@/lib/config'; @@ -8,6 +9,7 @@ import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { readThemes } from '@/lib/theme/file'; +import { zStringTrimmed } from '@/lib/validation'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; @@ -47,8 +49,11 @@ const jsonTransform = (value: any, ctx: z.RefinementCtx) => { } }; -const zMs = z.string().refine((value) => ms(value as StringValue) > 0, 'Value must be greater than 0'); -const zBytes = z.string().refine((value) => bytes(value) > 0, 'Value must be greater than 0'); +const zMs = zStringTrimmed.refine( + (value) => ms((value ?? '0') as StringValue) > 0, + 'Value must be greater than 0', +); +const zBytes = zStringTrimmed.refine((value) => bytes(value) > 0, 'Value must be greater than 0'); const zIntervalMs = zMs.refine( (value) => ms(value as StringValue) <= MAX_SAFE_TIMEOUT_MS, @@ -89,6 +94,16 @@ export default typedPlugin( server.get( PATH, { + schema: { + description: + 'Fetch the full Zipline server settings row along with a list of configuration keys that were overridden at runtime (admin only).', + response: { + 200: z.object({ + settings: z.custom(), + tampered: z.array(z.string()), + }), + }, + }, preHandler: [userMiddleware, administratorMiddleware], }, async (_, res) => { @@ -101,7 +116,7 @@ export default typedPlugin( }, }); - if (!settings) return res.notFound('no settings table found'); + if (!settings) throw new ApiError(4010); return res.send({ settings, tampered: global.__tamperedConfig__ || [] }); }, @@ -111,14 +126,19 @@ export default typedPlugin( PATH, { schema: { + description: + 'Partially update Zipline server settings using a validated subset of configuration keys (admin only).', body: z.custom>(), + response: { + 200: z.custom(), + }, }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), }, async (req, res) => { const settings = await prisma.zipline.findFirst(); - if (!settings) return res.notFound('no settings table found'); + if (!settings) throw new ApiError(4010); const themes = (await readThemes()).map((x) => x.id); @@ -459,10 +479,7 @@ export default typedPlugin( issues: result.error.issues, }); - return res.status(400).send({ - statusCode: 400, - issues: result.error.issues, - }); + throw new ApiError(1022).add('issues', result.error.issues); } const newSettings = await prisma.zipline.update({ diff --git a/src/server/routes/api/server/settings/web.ts b/src/server/routes/api/server/settings/web.ts index 95fef0e4..bcc14eed 100644 --- a/src/server/routes/api/server/settings/web.ts +++ b/src/server/routes/api/server/settings/web.ts @@ -5,6 +5,7 @@ import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import { readFile } from 'fs/promises'; import { join } from 'path'; +import z from 'zod'; export type ApiServerSettingsWebResponse = { config: ReturnType; @@ -19,24 +20,36 @@ let codeMap: ApiServerSettingsWebResponse['codeMap'] = []; export const PATH = '/api/server/settings/web'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (_, res) => { - const webConfig = safeConfig(config); + server.get( + PATH, + { + schema: { + description: 'Return the safe dashboard configuration and MIME type code map used by the web UI.', + response: { + 200: z.custom(), + }, + }, + preHandler: [userMiddleware], + }, + async (_, res) => { + const webConfig = safeConfig(config); - if (codeMap.length === 0) { - try { - const codeJson = await readFile(codeJsonPath, 'utf8'); - codeMap = JSON.parse(codeJson); - } catch (error) { - logger.error('failed to read code.json', { error }); - codeMap = []; + if (codeMap.length === 0) { + try { + const codeJson = await readFile(codeJsonPath, 'utf8'); + codeMap = JSON.parse(codeJson); + } catch (error) { + logger.error('failed to read code.json', { error }); + codeMap = []; + } } - } - return res.send({ - config: webConfig, - codeMap: codeMap, - } satisfies ApiServerSettingsWebResponse); - }); + return res.send({ + config: webConfig, + codeMap: codeMap, + } satisfies ApiServerSettingsWebResponse); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/themes.ts b/src/server/routes/api/server/themes.ts index 01ca027d..9cbb47d9 100644 --- a/src/server/routes/api/server/themes.ts +++ b/src/server/routes/api/server/themes.ts @@ -3,6 +3,7 @@ import { Config } from '@/lib/config/validate'; import { ZiplineTheme } from '@/lib/theme'; import { readThemes } from '@/lib/theme/file'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiServerThemesResponse = { themes: ZiplineTheme[]; @@ -12,11 +13,26 @@ export type ApiServerThemesResponse = { export const PATH = '/api/server/themes'; export default typedPlugin( async (server) => { - server.get(PATH, async (_, res) => { - const themes = await readThemes(); + server.get( + PATH, + { + schema: { + description: + 'List all available themes and indicate which theme is currently configured as the default.', + response: { + 200: z.object({ + themes: z.array(z.custom()), + defaultTheme: z.custom(), + }), + }, + }, + }, + async (_, res) => { + const themes = await readThemes(); - return res.send({ themes, defaultTheme: config.website.theme }); - }); + return res.send({ themes, defaultTheme: config.website.theme }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/thumbnails.ts b/src/server/routes/api/server/thumbnails.ts index 89981e1a..1a195a03 100644 --- a/src/server/routes/api/server/thumbnails.ts +++ b/src/server/routes/api/server/thumbnails.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { administratorMiddleware } from '@/server/middleware/administrator'; @@ -18,16 +19,23 @@ export default typedPlugin( PATH, { schema: { + description: + 'Manually trigger the thumbnails background task, optionally rerunning it for existing files (admin only).', body: z.object({ rerun: z.boolean().default(false), }), + response: { + 200: z.object({ + status: z.string(), + }), + }, }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), }, async (req, res) => { const thumbnailTask = server.tasks.tasks.find((x) => x.id === 'thumbnails'); - if (!thumbnailTask) return res.notFound('thumbnails task not found'); + if (!thumbnailTask) throw new ApiError(4011); thumbnailTask.logger.debug('manually running thumbnails task'); diff --git a/src/server/routes/api/setup.ts b/src/server/routes/api/setup.ts index 30cdf825..29ea6845 100644 --- a/src/server/routes/api/setup.ts +++ b/src/server/routes/api/setup.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { createToken, hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { User, userSelect } from '@/lib/db/models/user'; +import { User, userSchema, userSelect } from '@/lib/db/models/user'; import { getZipline } from '@/lib/db/models/zipline'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; @@ -18,28 +19,48 @@ const logger = log('api').c('setup'); export const PATH = '/api/setup'; export default typedPlugin( async (server) => { - server.get(PATH, async (_, res) => { - const { firstSetup } = await getZipline(); - if (!firstSetup) return res.forbidden(); + server.get( + PATH, + { + schema: { + description: 'Return whether Zipline is in first-time setup mode, used by the initial setup flow.', + response: { + 200: z.object({ + firstSetup: z.boolean(), + }), + }, + }, + }, + async (_, res) => { + const { firstSetup } = await getZipline(); + if (!firstSetup) throw new ApiError(9001); - return res.send({ firstSetup }); - }); + return res.send({ firstSetup }); + }, + ); server.post( PATH, { schema: { + description: 'Perform the first-time setup by creating the initial SUPERADMIN user.', body: z.object({ username: zStringTrimmed, password: zStringTrimmed, }), + response: { + 200: z.object({ + firstSetup: z.boolean(), + user: userSchema, + }), + }, }, ...secondlyRatelimit(5), }, async (req, res) => { const { firstSetup, id } = await getZipline(); - if (!firstSetup) return res.forbidden(); + if (!firstSetup) throw new ApiError(9001); logger.info('first setup running'); diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index f3a6a888..082acb45 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; -import { Metric } from '@/lib/db/models/metric'; +import { Metric, metricSchema } from '@/lib/db/models/metric'; import { isAdministrator } from '@/lib/role'; import { zQsBoolean } from '@/lib/validation'; import { userMiddleware } from '@/server/middleware/user'; @@ -16,6 +17,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'Get instance-wide metrics and statistics for Zipline over a given date range or for all time.', querystring: z.object({ from: z .string() @@ -35,14 +38,16 @@ export default typedPlugin( }, 'Invalid date'), all: zQsBoolean.default(false), }), + response: { + 200: z.array(metricSchema), + }, }, preHandler: [userMiddleware], }, async (req, res) => { - if (!config.features.metrics) return res.forbidden('metrics are disabled'); + if (!config.features.metrics) throw new ApiError(3001); - if (config.features.metrics.adminOnly && !isAdministrator(req.user.role)) - return res.forbidden('admin only'); + if (config.features.metrics.adminOnly && !isAdministrator(req.user.role)) throw new ApiError(3000); const { from, to, all } = req.query; @@ -50,8 +55,8 @@ export default typedPlugin( const toDate = to ? new Date(to) : new Date(); if (!all) { - if (fromDate > toDate) return res.badRequest('from date must be before to date'); - if (fromDate > new Date()) return res.badRequest('from date must be in the past'); + if (fromDate > toDate) throw new ApiError(1058); + if (fromDate > new Date()) throw new ApiError(1059); } const stats = await prisma.metric.findMany({ diff --git a/src/server/routes/api/upload/index.ts b/src/server/routes/api/upload/index.ts index dcd13694..ae6a0dff 100644 --- a/src/server/routes/api/upload/index.ts +++ b/src/server/routes/api/upload/index.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { checkQuota, getDomain, getExtension, getFilename, getMimetype } from '@/lib/api/upload'; import { bytes } from '@/lib/bytes'; -import { compressFile, CompressResult } from '@/lib/compress'; +import { COMPRESS_TYPES, compressFile, CompressResult } from '@/lib/compress'; import { config } from '@/lib/config'; import { hashPassword } from '@/lib/crypto'; import { datasource } from '@/lib/datasource'; @@ -15,6 +16,7 @@ import { Prisma } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import { stat } from 'fs/promises'; +import { z } from 'zod'; export type ApiUploadResponse = { files: { @@ -42,166 +44,209 @@ export default typedPlugin( server.post<{ Headers: UploadHeaders; - }>(PATH, { preHandler: [userMiddleware, rateLimit] }, async (req, res) => { - const options = parseHeaders(req.headers, config.files); - if (options.header) return res.badRequest(`bad options: ${options.message}`); - - if (options.partial) return res.badRequest('bad options, receieved: partial upload'); - - let folder = null; - if (options.folder) { - folder = await prisma.folder.findFirst({ - where: { - id: options.folder, + }>( + PATH, + { + preHandler: [userMiddleware, rateLimit], + schema: { + description: + 'Upload one or more files for the authenticated user, applying quota, folder, and upload option restrictions.', + consumes: ['multipart/form-data'], + response: { + 200: z.union([ + z.string().describe('if the noJson option is true, returns a comma-separated list of URLs'), + z.object({ + files: z.array( + z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + url: z.string(), + pending: z.boolean().optional(), + removedGps: z.boolean().optional(), + compressed: z + .object({ + mimetype: z.string(), + ext: z.enum(COMPRESS_TYPES), + failed: z.boolean().optional(), + }) + .optional(), + }), + ), + deletesAt: z.string().optional(), + assumedMimetypes: z.array(z.boolean()).optional(), + }), + ]), }, - }); - if (!folder) return res.badRequest('folder not found'); - if (!req.user && !folder.allowUploads) return res.forbidden('folder is not open'); - } + }, + }, + async (req, res) => { + const options = parseHeaders(req.headers, config.files); + if (options.header) throw new ApiError(1001, `bad options: ${options.message}`); - const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory }); + if (options.partial) throw new ApiError(1001, 'bad options, receieved: partial upload'); - const totalFileSize = files.reduce((acc, x) => acc + x.file.bytesRead, 0); - const quotaCheck = await checkQuota(req.user, totalFileSize, files.length); - if (quotaCheck !== true) return res.payloadTooLarge(quotaCheck); - - const response: ApiUploadResponse = { - files: [], - ...(options.deletesAt && { - deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(), - }), - ...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }), - }; - - const domain = getDomain(options.overrides?.returnDomain, config.core.defaultDomain, req.headers.host); - - logger.debug('uploading files', { files: files.map((x) => x.filename) }); - - for (let i = 0; i !== files.length; ++i) { - const file = files[i]; - const extension = getExtension(file.filename, options.overrides?.extension); - - if (config.files.disabledExtensions.includes(extension)) - return res.badRequest(`file[${i}]: File extension ${extension} is not allowed`); - if (file.file.bytesRead > bytes(config.files.maxFileSize)) - return res.payloadTooLarge( - `file[${i}]: File size is too large. Maximum file size is ${bytes(config.files.maxFileSize)} bytes`, - ); - - // determine filename - const format = options.format || config.files.defaultFormat; - const nameResult = await getFilename(format, file.filename, extension, options.overrides?.filename); - if ('error' in nameResult) return res.badRequest(`file[${i}]: ${nameResult.error}`); - - const { fileName } = nameResult; - - // determine mimetype - const { mimetype, assumed } = await getMimetype(file.mimetype, extension); - if (!assumed && config.files.assumeMimetypes) { - logger.warn( - `file[${i}]: mimetype ${file.mimetype} was not recognized, to ignore this warning, turn off assume mimetypes.`, - ); - - return res.badRequest( - `file[${i}]: mimetype ${file.mimetype} was not recognized, supply a valid mimetype`, - ); - } - - // compress the image if requested - let compressed; - if (mimetype.startsWith('image/') && options.imageCompression) { - compressed = await compressFile(file.filepath, { - quality: options.imageCompression.percent, - type: options.imageCompression.type, + let folder = null; + if (options.folder) { + folder = await prisma.folder.findFirst({ + where: { + id: options.folder, + }, }); - - if (compressed.failed) { - compressed = undefined; - logger.warn('failed to compress file, using original.'); - } else { - logger.c('compress').debug(`compressed file ${file.filename}`); - } + if (!folder) throw new ApiError(4001); + if (!req.user && !folder.allowUploads) throw new ApiError(3002); } - // remove gps metadata if requested - let removedGps = false; - if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) { - const removed = removeGps(file.filepath); - if (removed) logger.c('gps').debug(`removed gps metadata from ${file.filename}`); + const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory }); - removedGps = removed; - } + const totalFileSize = files.reduce((acc, x) => acc + x.file.bytesRead, 0); + const quotaCheck = await checkQuota(req.user, totalFileSize, files.length); + if (quotaCheck !== true) + throw new ApiError(5002, typeof quotaCheck === 'string' ? quotaCheck : undefined); - const tempFileStats = await stat(file.filepath); - - const data: Prisma.FileCreateInput = { - name: `${fileName}${compressed ? '.' + compressed.ext : extension}`, - size: compressed?.buffer?.length ?? tempFileStats.size, - type: compressed?.mimetype ?? mimetype, - User: { connect: { id: req.user ? req.user.id : options.folder ? folder?.userId : undefined } }, + const response: ApiUploadResponse = { + files: [], + ...(options.deletesAt && { + deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(), + }), + ...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }), }; - if (options.maxViews) data.maxViews = options.maxViews; - if (options.password) data.password = await hashPassword(options.password); - if (folder) data.Folder = { connect: { id: folder.id } }; - if (options.addOriginalName) { - const sanitizedOG = sanitizeFilename(file.filename); - if (!sanitizedOG) return res.badRequest(`file[${i}]: Invalid characters in original filename`); - - data.originalName = sanitizedOG; - } - - data.deletesAt = options.deletesAt && options.deletesAt !== 'never' ? options.deletesAt : null; - - const fileUpload = await prisma.file.create({ - data, - select: fileSelect, - }); - - await datasource.put(fileUpload.name, compressed?.buffer ?? file.filepath, { - mimetype: fileUpload.type, - }); - - const responseUrl = `${domain}${config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`}/${fileUpload.name}`; - - response.files.push({ - id: fileUpload.id, - name: fileUpload.name, - type: fileUpload.type, - url: encodeURI(responseUrl), - removedGps: removedGps || undefined, - compressed: compressed || undefined, - }); - - logger.info( - `${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`, - { size: bytes(compressed?.buffer?.length ?? fileUpload.size), ip: req.ip }, + const domain = getDomain( + options.overrides?.returnDomain, + config.core.defaultDomain, + req.headers.host, ); - await onUpload(config, { - user: req.user ?? { - id: 'anonymous', - username: 'anonymous', - createdAt: new Date(), - updatedAt: new Date(), - role: 'USER', - }, - file: fileUpload, - link: { - raw: `${domain}/raw/${encodeURIComponent(fileUpload.name)}`, - returned: encodeURI(responseUrl), - }, - }); - } + logger.debug('uploading files', { files: files.map((x) => x.filename) }); - if (options.noJson) - return res - .status(200) - .type('text/plain') - .send(response.files.map((x) => x.url).join(',')); + for (let i = 0; i !== files.length; ++i) { + const file = files[i]; + const extension = getExtension(file.filename, options.overrides?.extension); - return res.send(response); - }); + if (config.files.disabledExtensions.includes(extension)) + throw new ApiError(1006, `file[${i}]: File extension ${extension} is not allowed`); + if (file.file.bytesRead > bytes(config.files.maxFileSize)) + throw new ApiError( + 5001, + `file[${i}]: File size is too large. Maximum file size is ${bytes(config.files.maxFileSize)} bytes`, + ); + + // determine filename + const format = options.format || config.files.defaultFormat; + const nameResult = await getFilename(format, file.filename, extension, options.overrides?.filename); + if ('error' in nameResult) throw new ApiError(1009, `file[${i}]: ${nameResult.error}`); + + const { fileName } = nameResult; + + // determine mimetype + const { mimetype, assumed } = await getMimetype(file.mimetype, extension); + if (!assumed && config.files.assumeMimetypes) { + logger.warn( + `file[${i}]: mimetype ${file.mimetype} was not recognized, to ignore this warning, turn off assume mimetypes.`, + ); + throw new ApiError( + 1010, + `file[${i}]: mimetype ${file.mimetype} was not recognized, supply a valid mimetype`, + ); + } + + // compress the image if requested + let compressed; + if (mimetype.startsWith('image/') && options.imageCompression) { + compressed = await compressFile(file.filepath, { + quality: options.imageCompression.percent, + type: options.imageCompression.type, + }); + + if (compressed.failed) { + compressed = undefined; + logger.warn('failed to compress file, using original.'); + } else { + logger.c('compress').debug(`compressed file ${file.filename}`); + } + } + + // remove gps metadata if requested + let removedGps = false; + if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) { + const removed = removeGps(file.filepath); + if (removed) logger.c('gps').debug(`removed gps metadata from ${file.filename}`); + + removedGps = removed; + } + + const tempFileStats = await stat(file.filepath); + + const data: Prisma.FileCreateInput = { + name: `${fileName}${compressed ? '.' + compressed.ext : extension}`, + size: compressed?.buffer?.length ?? tempFileStats.size, + type: compressed?.mimetype ?? mimetype, + User: { connect: { id: req.user ? req.user.id : options.folder ? folder?.userId : undefined } }, + }; + + if (options.maxViews) data.maxViews = options.maxViews; + if (options.password) data.password = await hashPassword(options.password); + if (folder) data.Folder = { connect: { id: folder.id } }; + if (options.addOriginalName) { + const sanitizedOG = sanitizeFilename(file.filename); + if (!sanitizedOG) throw new ApiError(1008, `file[${i}]: Invalid characters in original filename`); + + data.originalName = sanitizedOG; + } + + data.deletesAt = options.deletesAt && options.deletesAt !== 'never' ? options.deletesAt : null; + + const fileUpload = await prisma.file.create({ + data, + select: fileSelect, + }); + + await datasource.put(fileUpload.name, compressed?.buffer ?? file.filepath, { + mimetype: fileUpload.type, + }); + + const responseUrl = `${domain}${config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`}/${fileUpload.name}`; + + response.files.push({ + id: fileUpload.id, + name: fileUpload.name, + type: fileUpload.type, + url: encodeURI(responseUrl), + removedGps: removedGps || undefined, + compressed: compressed || undefined, + }); + + logger.info( + `${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`, + { size: bytes(compressed?.buffer?.length ?? fileUpload.size), ip: req.ip }, + ); + + await onUpload(config, { + user: req.user ?? { + id: 'anonymous', + username: 'anonymous', + createdAt: new Date(), + updatedAt: new Date(), + role: 'USER', + }, + file: fileUpload, + link: { + raw: `${domain}/raw/${encodeURIComponent(fileUpload.name)}`, + returned: encodeURI(responseUrl), + }, + }); + } + + if (options.noJson) + return res + .status(200) + .type('text/plain') + .send(response.files.map((x) => x.url).join(',')); + + return res.send(response); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/upload/partial.ts b/src/server/routes/api/upload/partial.ts index a03e281c..0ac625a2 100644 --- a/src/server/routes/api/upload/partial.ts +++ b/src/server/routes/api/upload/partial.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { checkQuota, getDomain, getExtension, getFilename } from '@/lib/api/upload'; import { bytes } from '@/lib/bytes'; import { config } from '@/lib/config'; @@ -11,6 +12,7 @@ import { UploadHeaders, UploadOptions, parseHeaders } from '@/lib/uploader/parse import { Prisma } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; +import { z } from 'zod'; import { readdir, rename, rm } from 'fs/promises'; import { join } from 'path'; import { Worker } from 'worker_threads'; @@ -57,204 +59,220 @@ export default typedPlugin( server.post<{ Headers: UploadHeaders; - }>(PATH, { preHandler: [userMiddleware, rateLimit] }, async (req, res) => { - const options = parseHeaders(req.headers, config.files); - if (options.header) return res.badRequest('bad options, receieved: ' + JSON.stringify(options)); - if (!options.partial) return res.badRequest('partial upload was not detected'); - if (!options.partial.range || options.partial.range.length !== 3) - return res.badRequest('Invalid partial upload'); - - let folder = null; - if (options.folder) { - folder = await prisma.folder.findFirst({ - where: { - id: options.folder, + }>( + PATH, + { + schema: { + description: + 'Upload a single file in chunks as a partial upload session, using headers to control chunking and resumption.', + response: { + 200: z.custom(), }, - }); - if (!folder) return res.badRequest('folder not found'); - if (!req.user && !folder.allowUploads) return res.forbidden('folder is not open'); - } + }, + preHandler: [userMiddleware, rateLimit], + }, + async (req, res) => { + const options = parseHeaders(req.headers, config.files); + if (options.header) throw new ApiError(1001, 'bad options, receieved: ' + JSON.stringify(options)); + if (!options.partial) throw new ApiError(1004); + if (!options.partial.range || options.partial.range.length !== 3) throw new ApiError(1002); - const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory }); - - const response: ApiUploadPartialResponse = { - files: [], - ...(options.deletesAt && { - deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(), - }), - ...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }), - }; - - const domain = getDomain(options.overrides?.returnDomain, config.core.defaultDomain, req.headers.host); - - logger.debug('saving partial files', { partial: options.partial, files: files.map((x) => x.filename) }); - - if (files.length > 1) return res.badRequest('partial uploads only support one file field'); - const file = files[0]; - const fileSize = file.file.bytesRead; - - // caching for partial uploads server side checks and performance - if (options.partial.range[0] === 0) { - options.partial.identifier = createPartial(fileSize, options); - } else { - if (!options.partial.identifier || !partialsCache.has(options.partial.identifier)) - return res.badRequest('No/Invalid partial upload identifier provided'); - } - - const cache = partialsCache.get(options.partial.identifier); - if (!cache) return res.badRequest('No/Invalid partial upload identifier provided'); - - // check quota, using the current added length, and only just adding one file - const quotaCheck = await checkQuota(req.user, cache.length + fileSize, 1); - if (quotaCheck !== true) { - await deletePartial(options.partial.identifier); - - return res.payloadTooLarge(quotaCheck); - } - - // file is too large so we delete everything - if (cache.length + fileSize > bytes(config.files.maxFileSize)) { - await deletePartial(options.partial.identifier!); - - return res.payloadTooLarge('File is too large'); - } - - cache.length += fileSize; - - // handle partial stuff - const sanitized = sanitizeFilename( - `${cache.prefix}${options.partial.range[0]}_${options.partial.range[1]}`, - ); - if (!sanitized) return res.badRequest('Invalid characters in filename'); - - const tempFile = join(config.core.tempDirectory, sanitized); - await rename(file.filepath, tempFile); - - if (options.partial.lastchunk) { - const extension = getExtension(options.partial.filename, options.overrides?.extension); - if (config.files.disabledExtensions.includes(extension)) - return res.badRequest(`File extension ${extension} is not allowed`); - - // determine filename - const format = options.format || config.files.defaultFormat; - const nameResult = await getFilename( - format, - options.partial.filename, - extension, - options.overrides?.filename, - ); - if ('error' in nameResult) return res.badRequest(nameResult.error); - - const { fileName } = nameResult; - - // determine mimetype - let mimetype = options.partial.contentType; - if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) { - const mime = await guess(extension.substring(1)); - - if (!mime) response.assumedMimetypes![0] = false; - else { - response.assumedMimetypes![0] = true; - mimetype = mime; - } + let folder = null; + if (options.folder) { + folder = await prisma.folder.findFirst({ + where: { + id: options.folder, + }, + }); + if (!folder) throw new ApiError(4001); + if (!req.user && !folder.allowUploads) throw new ApiError(3002); } - const data: Prisma.FileCreateInput = { - name: `${fileName}${extension}`, - size: 0, - type: mimetype, - User: { - connect: { - id: req.user ? req.user.id : options.folder ? folder?.userId : undefined, - }, - }, + const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory }); + + const response: ApiUploadPartialResponse = { + files: [], + ...(options.deletesAt && { + deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(), + }), + ...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }), }; - if (options.password) data.password = await hashPassword(options.password); - if (options.maxViews) data.maxViews = options.maxViews; - if (folder) data.Folder = { connect: { id: folder.id } }; - if (options.addOriginalName) { - const sanitizedOG = sanitizeFilename(options.partial.filename); - if (!sanitizedOG) return res.badRequest('Invalid characters in original filename'); + const domain = getDomain( + options.overrides?.returnDomain, + config.core.defaultDomain, + req.headers.host, + ); - data.originalName = sanitizedOG || file.filename; // this will prolly be "blob" but should hopefully never happen + logger.debug('saving partial files', { + partial: options.partial, + files: files.map((x) => x.filename), + }); + + if (files.length > 1) throw new ApiError(1005); + const file = files[0]; + const fileSize = file.file.bytesRead; + + // caching for partial uploads server side checks and performance + if (options.partial.range[0] === 0) { + options.partial.identifier = createPartial(fileSize, options); + } else { + if (!options.partial.identifier || !partialsCache.has(options.partial.identifier)) + throw new ApiError(1003); } - const fileUpload = await prisma.file.create({ - data, - }); + const cache = partialsCache.get(options.partial.identifier); + if (!cache) throw new ApiError(1003); - const responseUrl = `${domain}${ - config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}` - }/${fileUpload.name}`; + // check quota, using the current added length, and only just adding one file + const quotaCheck = await checkQuota(req.user, cache.length + fileSize, 1); + if (quotaCheck !== true) { + await deletePartial(options.partial.identifier); + throw new ApiError(5002, typeof quotaCheck === 'string' ? quotaCheck : undefined); + } - const worker = new Worker('./build/offload/partial.js', { - workerData: { - user: { - id: req.user ? req.user.id : options.folder ? folder?.userId : undefined, - }, - file: { - id: fileUpload.id, - filename: fileUpload.name, - type: fileUpload.type, - }, - options, - domain, - responseUrl, - config, - }, - }); + // file is too large so we delete everything + if (cache.length + fileSize > bytes(config.files.maxFileSize)) { + await deletePartial(options.partial.identifier!); + throw new ApiError(5001); + } - worker.on('message', async (msg) => { - if (msg.type === 'query') { - let result; + cache.length += fileSize; - switch (msg.query) { - case 'incompleteFile.create': - result = await prisma.incompleteFile.create(msg.data); - break; - case 'incompleteFile.update': - result = await prisma.incompleteFile.update(msg.data); - break; - case 'file.update': - result = await prisma.file.update(msg.data); - break; - case 'user.findUnique': - result = await prisma.user.findUnique(msg.data); - break; - default: - console.error(`Unknown query type: ${msg.query}`); - result = null; + // handle partial stuff + const sanitized = sanitizeFilename( + `${cache.prefix}${options.partial.range[0]}_${options.partial.range[1]}`, + ); + if (!sanitized) throw new ApiError(1007); + + const tempFile = join(config.core.tempDirectory, sanitized); + await rename(file.filepath, tempFile); + + if (options.partial.lastchunk) { + const extension = getExtension(options.partial.filename, options.overrides?.extension); + if (config.files.disabledExtensions.includes(extension)) throw new ApiError(1006); + + // determine filename + const format = options.format || config.files.defaultFormat; + const nameResult = await getFilename( + format, + options.partial.filename, + extension, + options.overrides?.filename, + ); + if ('error' in nameResult) throw new ApiError(1009, nameResult.error); + + const { fileName } = nameResult; + + // determine mimetype + let mimetype = options.partial.contentType; + if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) { + const mime = await guess(extension.substring(1)); + + if (!mime) response.assumedMimetypes![0] = false; + else { + response.assumedMimetypes![0] = true; + mimetype = mime; } - - worker.postMessage({ - type: 'response', - id: msg.id, - result: JSON.stringify(result), - }); } - }); - response.files.push({ - id: fileUpload.id, - name: fileUpload.name, - type: fileUpload.type, - url: responseUrl, - pending: true, - }); + const data: Prisma.FileCreateInput = { + name: `${fileName}${extension}`, + size: 0, + type: mimetype, + User: { + connect: { + id: req.user ? req.user.id : options.folder ? folder?.userId : undefined, + }, + }, + }; - await deletePartial(options.partial.identifier, false); - } + if (options.password) data.password = await hashPassword(options.password); + if (options.maxViews) data.maxViews = options.maxViews; + if (folder) data.Folder = { connect: { id: folder.id } }; + if (options.addOriginalName) { + const sanitizedOG = sanitizeFilename(options.partial.filename); + if (!sanitizedOG) throw new ApiError(1008); - response.partialSuccess = true; + data.originalName = sanitizedOG || file.filename; // this will prolly be "blob" but should hopefully never happen + } - // send an identifier if this is the first chunk for server-side checks - if (options.partial.range[0] === 0) { - response.partialIdentifier = options.partial.identifier; - } + const fileUpload = await prisma.file.create({ + data, + }); - return res.send(response); - }); + const responseUrl = `${domain}${ + config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}` + }/${fileUpload.name}`; + + const worker = new Worker('./build/offload/partial.js', { + workerData: { + user: { + id: req.user ? req.user.id : options.folder ? folder?.userId : undefined, + }, + file: { + id: fileUpload.id, + filename: fileUpload.name, + type: fileUpload.type, + }, + options, + domain, + responseUrl, + config, + }, + }); + + worker.on('message', async (msg) => { + if (msg.type === 'query') { + let result; + + switch (msg.query) { + case 'incompleteFile.create': + result = await prisma.incompleteFile.create(msg.data); + break; + case 'incompleteFile.update': + result = await prisma.incompleteFile.update(msg.data); + break; + case 'file.update': + result = await prisma.file.update(msg.data); + break; + case 'user.findUnique': + result = await prisma.user.findUnique(msg.data); + break; + default: + console.error(`Unknown query type: ${msg.query}`); + result = null; + } + + worker.postMessage({ + type: 'response', + id: msg.id, + result: JSON.stringify(result), + }); + } + }); + + response.files.push({ + id: fileUpload.id, + name: fileUpload.name, + type: fileUpload.type, + url: responseUrl, + pending: true, + }); + + await deletePartial(options.partial.identifier, false); + } + + response.partialSuccess = true; + + // send an identifier if this is the first chunk for server-side checks + if (options.partial.range[0] === 0) { + response.partialIdentifier = options.partial.identifier; + } + + return res.send(response); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/avatar.ts b/src/server/routes/api/user/avatar.ts index ce09bec0..cd69b27a 100644 --- a/src/server/routes/api/user/avatar.ts +++ b/src/server/routes/api/user/avatar.ts @@ -1,30 +1,40 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; -import { User } from '@/lib/db/models/user'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; -export type ApiUserTokenResponse = { - user?: User; - token?: string; -}; +export type ApiUserAvatarResponse = string; export const PATH = '/api/user/avatar'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const u = await prisma.user.findFirstOrThrow({ - where: { - id: req.user.id, + server.get( + PATH, + { + schema: { + description: "Return the current user's avatar as a base64 data URL.", + response: { + 200: z.string().describe('data URL with base64'), + }, }, - select: { - avatar: true, - }, - }); + preHandler: [userMiddleware], + }, + async (req, res) => { + const u = await prisma.user.findFirstOrThrow({ + where: { + id: req.user.id, + }, + select: { + avatar: true, + }, + }); - if (!u.avatar) return res.notFound(); + if (!u.avatar) throw new ApiError(9002); - return res.send(u.avatar); - }); + return res.send(u.avatar); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/export.ts b/src/server/routes/api/user/export.ts index 3ff67dee..18fc86db 100644 --- a/src/server/routes/api/user/export.ts +++ b/src/server/routes/api/user/export.ts @@ -1,7 +1,9 @@ +import { ApiError } from '@/lib/api/errors'; import { bytes } from '@/lib/bytes'; import { config } from '@/lib/config'; import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; +import { exportSchema } from '@/lib/db/models/export'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { Export } from '@/prisma/client'; @@ -32,7 +34,12 @@ export default typedPlugin( PATH, { schema: { + description: 'List your exports or download a specific completed export archive by ID.', querystring: querySchema, + response: { + 200: z.array(exportSchema), + }, + produces: ['application/json', 'application/zip'], }, preHandler: [userMiddleware], }, @@ -43,9 +50,9 @@ export default typedPlugin( if (req.query.id) { const file = exports.find((x) => x.id === req.query.id); - if (!file) return res.notFound(); + if (!file) throw new ApiError(9002); - if (!file.completed) return res.badRequest('Export is not completed'); + if (!file.completed) throw new ApiError(1024); return res.sendFile(file.path); } @@ -57,11 +64,19 @@ export default typedPlugin( server.delete( PATH, { - schema: { querystring: querySchema }, + schema: { + description: 'Delete a specific export and remove its archive file from storage.', + querystring: querySchema, + response: { + 200: z.object({ + deleted: z.boolean(), + }), + }, + }, preHandler: [userMiddleware], }, async (req, res) => { - if (!req.query.id) return res.badRequest('No id provided'); + if (!req.query.id) throw new ApiError(1029); const exportDb = await prisma.export.findFirst({ where: { @@ -69,7 +84,7 @@ export default typedPlugin( id: req.query.id, }, }); - if (!exportDb) return res.notFound(); + if (!exportDb) throw new ApiError(9002); const path = join(config.core.tempDirectory, exportDb.path); @@ -90,70 +105,85 @@ export default typedPlugin( }, ); - server.post(PATH, { preHandler: [userMiddleware], ...secondlyRatelimit(5) }, async (req, res) => { - const files = await prisma.file.findMany({ - where: { userId: req.user.id }, - }); - - if (!files.length) return res.badRequest('No files to export'); - - const exportFileName = `zexport_${req.user.id}_${Date.now()}_${files.length}.zip`; - const exportPath = join(config.core.tempDirectory, exportFileName); - - logger.debug(`exporting ${req.user.id}`, { exportPath, files: files.length }); - - const exportDb = await prisma.export.create({ - data: { - userId: req.user.id, - path: exportFileName, - files: files.length, - size: '0', + server.post( + PATH, + { + schema: { + description: 'Start an export job that zips all of your files into a downloadable archive.', + response: { + 200: z.object({ + running: z.boolean(), + }), + }, }, - }); - const writeStream = createWriteStream(exportPath); + preHandler: [userMiddleware], + ...secondlyRatelimit(5), + }, + async (req, res) => { + const files = await prisma.file.findMany({ + where: { userId: req.user.id }, + }); - const zip = archiver('zip', { - zlib: { level: 9 }, - }); + if (!files.length) throw new ApiError(1025); - zip.pipe(writeStream); + const exportFileName = `zexport_${req.user.id}_${Date.now()}_${files.length}.zip`; + const exportPath = join(config.core.tempDirectory, exportFileName); - let totalSize = 0; - for (const file of files) { - const stream = await datasource.get(file.name); - if (!stream) { - logger.warn(`failed to get file ${file.name}`); - continue; - } + logger.debug(`exporting ${req.user.id}`, { exportPath, files: files.length }); - zip.append(stream, { name: file.name }); - totalSize += file.size; - logger.debug('file added to zip', { name: file.name, size: file.size }); - } - - writeStream.on('close', async () => { - logger.debug('exported', { path: exportPath, bytes: zip.pointer() }); - logger.info(`export for ${req.user.id} finished at ${exportPath}`); - - await prisma.export.update({ - where: { id: exportDb.id }, + const exportDb = await prisma.export.create({ data: { - completed: true, - size: (await stat(exportPath)).size.toString(), + userId: req.user.id, + path: exportFileName, + files: files.length, + size: '0', }, }); - }); + const writeStream = createWriteStream(exportPath); - zip.on('error', (err) => { - logger.error('export zip error', { err, exportId: exportDb.id }); - }); + const zip = archiver('zip', { + zlib: { level: 9 }, + }); - zip.finalize(); + zip.pipe(writeStream); - logger.info(`export for ${req.user.id} started`, { totalSize: bytes(totalSize) }); + let totalSize = 0; + for (const file of files) { + const stream = await datasource.get(file.name); + if (!stream) { + logger.warn(`failed to get file ${file.name}`); + continue; + } - return res.send({ running: true }); - }); + zip.append(stream, { name: file.name }); + totalSize += file.size; + logger.debug('file added to zip', { name: file.name, size: file.size }); + } + + writeStream.on('close', async () => { + logger.debug('exported', { path: exportPath, bytes: zip.pointer() }); + logger.info(`export for ${req.user.id} finished at ${exportPath}`); + + await prisma.export.update({ + where: { id: exportDb.id }, + data: { + completed: true, + size: (await stat(exportPath)).size.toString(), + }, + }); + }); + + zip.on('error', (err) => { + logger.error('export zip error', { err, exportId: exportDb.id }); + }); + + zip.finalize(); + + logger.info(`export for ${req.user.id} started`, { totalSize: bytes(totalSize) }); + + return res.send({ running: true }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/files/[id]/index.ts b/src/server/routes/api/user/files/[id]/index.ts index 046f8688..2bb8b984 100644 --- a/src/server/routes/api/user/files/[id]/index.ts +++ b/src/server/routes/api/user/files/[id]/index.ts @@ -1,8 +1,9 @@ +import { ApiError } from '@/lib/api/errors'; import { bytes } from '@/lib/bytes'; import { hashPassword } from '@/lib/crypto'; import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; -import { File, fileSelect } from '@/lib/db/models/file'; +import { File, fileSchema, fileSelect } from '@/lib/db/models/file'; import { log } from '@/lib/logger'; import { canInteract } from '@/lib/role'; import { zValidatePath } from '@/lib/validation'; @@ -22,35 +23,16 @@ const paramsSchema = z.object({ export const PATH = '/api/user/files/:id'; export default typedPlugin( async (server) => { - server.get(PATH, { schema: { params: paramsSchema }, preHandler: [userMiddleware] }, async (req, res) => { - const file = await prisma.file.findFirst({ - where: { - OR: [{ id: req.params.id }, { name: req.params.id }], - }, - select: { User: true, ...fileSelect }, - }); - if (!file) return res.notFound(); - - if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER')) - return res.notFound(); - - return res.send(file); - }); - - server.patch( + server.get( PATH, { schema: { + description: + 'Fetch a single file owned by the authenticated user (or another user if permitted) by ID or short name.', params: paramsSchema, - body: z.object({ - favorite: z.boolean().optional(), - maxViews: z.number().min(0).optional(), - password: z.string().nullish(), - originalName: z.string().trim().min(1).optional().transform(zValidatePath), - type: z.string().min(1).optional(), - tags: z.array(z.string()).optional(), - name: z.string().trim().min(1).optional().transform(zValidatePath), - }), + response: { + 200: fileSchema, + }, }, preHandler: [userMiddleware], }, @@ -61,10 +43,48 @@ export default typedPlugin( }, select: { User: true, ...fileSelect }, }); - if (!file) return res.notFound(); + if (!file) throw new ApiError(4000); if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER')) - return res.notFound(); + throw new ApiError(4000); + + return res.send(file); + }, + ); + + server.patch( + PATH, + { + schema: { + description: + 'Update metadata for a single file, including favorite, name, tags, password, and view limits.', + params: paramsSchema, + body: z.object({ + favorite: z.boolean().optional(), + maxViews: z.number().min(0).optional(), + password: z.string().nullish(), + originalName: z.string().trim().min(1).optional().transform(zValidatePath), + type: z.string().min(1).optional(), + tags: z.array(z.string()).optional(), + name: z.string().trim().min(1).optional().transform(zValidatePath), + }), + response: { + 200: fileSchema, + }, + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const file = await prisma.file.findFirst({ + where: { + OR: [{ id: req.params.id }, { name: req.params.id }], + }, + select: { User: true, ...fileSelect }, + }); + if (!file) throw new ApiError(4000); + + if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER')) + throw new ApiError(4000); const data: Prisma.FileUpdateInput = {}; @@ -94,7 +114,7 @@ export default typedPlugin( }, }); - if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere'); + if (tags.length !== req.body.tags.length) throw new ApiError(1032); data.tags = { set: req.body.tags.map((tag) => ({ id: tag })), @@ -109,8 +129,7 @@ export default typedPlugin( }, }); - if (existingFile && existingFile.id !== file.id) - return res.badRequest('File with this name already exists'); + if (existingFile && existingFile.id !== file.id) throw new ApiError(1014); data.name = name; @@ -118,7 +137,7 @@ export default typedPlugin( await datasource.rename(file.name, data.name); } catch (error) { logger.error('Failed to rename file in datasource', { error }); - return res.internalServerError('Failed to rename file in datasource'); + throw new ApiError(6002); } } @@ -155,10 +174,10 @@ export default typedPlugin( User: true, }, }); - if (!file) return res.notFound(); + if (!file) throw new ApiError(4000); if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER')) - return res.notFound(); + throw new ApiError(4000); const deletedFile = await prisma.file.delete({ where: { diff --git a/src/server/routes/api/user/files/[id]/password.ts b/src/server/routes/api/user/files/[id]/password.ts index 243b1391..c70c9d51 100644 --- a/src/server/routes/api/user/files/[id]/password.ts +++ b/src/server/routes/api/user/files/[id]/password.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { verifyPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; @@ -19,12 +20,18 @@ export default typedPlugin( PATH, { schema: { + description: 'Verify the password for a password-protected file by ID or name.', body: z.object({ password: zStringTrimmed, }), params: z.object({ id: z.string(), }), + response: { + 200: z.object({ + success: z.boolean(), + }), + }, }, ...secondlyRatelimit(2), }, @@ -39,8 +46,8 @@ export default typedPlugin( id: true, }, }); - if (!file) return res.notFound(); - if (!file.password) return res.notFound(); + if (!file) throw new ApiError(4000); + if (!file.password) throw new ApiError(4000); const verified = await verifyPassword(req.body.password, file.password); if (!verified) { @@ -50,7 +57,7 @@ export default typedPlugin( ua: req.headers['user-agent'], }); - return res.forbidden('Incorrect password'); + throw new ApiError(3005); } logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] }); diff --git a/src/server/routes/api/user/files/[id]/raw.ts b/src/server/routes/api/user/files/[id]/raw.ts index ff0eee6e..7f6cb2dc 100644 --- a/src/server/routes/api/user/files/[id]/raw.ts +++ b/src/server/routes/api/user/files/[id]/raw.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { parseRange } from '@/lib/api/range'; import { config } from '@/lib/config'; import { verifyPassword } from '@/lib/crypto'; @@ -20,6 +21,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'Stream a file or thumbnail owned by the authenticated user by ID, with optional password and download handling.', params: z.object({ id: z.string(), }), @@ -34,7 +37,7 @@ export default typedPlugin( const { pw, download } = req.query; const id = sanitizeFilename(req.params.id); - if (!id) return res.callNotFound(); + if (!id) throw new ApiError(9002); if (id.startsWith('.thumbnail')) { const thumbnail = await prisma.thumbnail.findFirst({ @@ -50,9 +53,9 @@ export default typedPlugin( }, }); - if (!thumbnail) return res.callNotFound(); + if (!thumbnail) throw new ApiError(9002); if (thumbnail.file && thumbnail.file.userId !== req.user.id) { - if (!canInteract(req.user.role, thumbnail.file.User?.role)) return res.callNotFound(); + if (!canInteract(req.user.role, thumbnail.file.User?.role)) throw new ApiError(9002); } } @@ -66,7 +69,7 @@ export default typedPlugin( }); if (file && file.userId !== req.user.id) { - if (!canInteract(req.user.role, file.User?.role)) return res.callNotFound(); + if (!canInteract(req.user.role, file.User?.role)) throw new ApiError(9002); } if (file?.deletesAt && file.deletesAt <= new Date()) { @@ -85,11 +88,11 @@ export default typedPlugin( .error(e as Error); } - return res.callNotFound(); + throw new ApiError(9002); } if (file?.maxViews && file.views >= file.maxViews) { - if (!config.features.deleteOnMaxViews) return res.callNotFound(); + if (!config.features.deleteOnMaxViews) throw new ApiError(9002); try { await datasource.delete(file.name); @@ -106,14 +109,13 @@ export default typedPlugin( .error(e as Error); } - return res.callNotFound(); + throw new ApiError(9002); } if (file?.password) { - if (!pw) return res.forbidden('Password protected.'); + if (!pw) throw new ApiError(3004); const verified = await verifyPassword(pw, file.password!); - - if (!verified) return res.forbidden('Incorrect password.'); + if (!verified) throw new ApiError(3005); } const size = file?.size || (await datasource.size(file?.name ?? id)); @@ -124,7 +126,7 @@ export default typedPlugin( const [start, end] = parseRange(req.headers.range, size); if (start >= size || end >= size) { const buf = await datasource.get(file?.name ?? id); - if (!buf) return res.callNotFound(); + if (!buf) throw new ApiError(9002); return res .type(contentType) @@ -143,7 +145,7 @@ export default typedPlugin( } const buf = await datasource.range(file?.name ?? id, start || 0, end); - if (!buf) return res.callNotFound(); + if (!buf) throw new ApiError(9002); return res .type(contentType) @@ -164,7 +166,7 @@ export default typedPlugin( } const buf = await datasource.get(file?.name ?? id); - if (!buf) return res.callNotFound(); + if (!buf) throw new ApiError(9002); return res .type(contentType) diff --git a/src/server/routes/api/user/files/incomplete.ts b/src/server/routes/api/user/files/incomplete.ts index dc48a561..c46b5f71 100644 --- a/src/server/routes/api/user/files/incomplete.ts +++ b/src/server/routes/api/user/files/incomplete.ts @@ -1,5 +1,5 @@ import { prisma } from '@/lib/db'; -import { IncompleteFile } from '@/lib/db/models/incompleteFile'; +import { IncompleteFile, incompleteFileSchema } from '@/lib/db/models/incompleteFile'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { userMiddleware } from '@/server/middleware/user'; @@ -13,23 +13,41 @@ const logger = log('api').c('user').c('files').c('incomplete'); export const PATH = '/api/user/files/incomplete'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const incompleteFiles = await prisma.incompleteFile.findMany({ - where: { - userId: req.user.id, + server.get( + PATH, + { + schema: { + description: 'List incomplete or still-processing file uploads for the authenticated user.', + response: { + 200: z.array(incompleteFileSchema), + }, }, - }); + preHandler: [userMiddleware], + }, + async (req, res) => { + const incompleteFiles = await prisma.incompleteFile.findMany({ + where: { + userId: req.user.id, + }, + }); - return res.send(incompleteFiles); - }); + return res.send(incompleteFiles); + }, + ); server.delete( PATH, { schema: { + description: 'Delete one or more incomplete file records owned by the authenticated user.', body: z.object({ id: z.array(z.string()), }), + response: { + 200: z.object({ + count: z.number(), + }), + }, }, preHandler: [userMiddleware], ...secondlyRatelimit(1), diff --git a/src/server/routes/api/user/files/index.ts b/src/server/routes/api/user/files/index.ts index b888f623..5e1726ea 100644 --- a/src/server/routes/api/user/files/index.ts +++ b/src/server/routes/api/user/files/index.ts @@ -1,5 +1,6 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; -import { File, cleanFiles, fileSelect } from '@/lib/db/models/file'; +import { File, cleanFiles, fileSchema, fileSelect } from '@/lib/db/models/file'; import { canInteract } from '@/lib/role'; import { zQsBoolean } from '@/lib/validation'; import { userMiddleware } from '@/server/middleware/user'; @@ -26,6 +27,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'List, filter, and search files for the authenticated user (or another user if permitted).', querystring: z.object({ page: z.coerce.number(), perpage: z.coerce.number().default(15), @@ -52,6 +55,19 @@ export default typedPlugin( id: z.string().optional(), folder: z.string().optional(), }), + response: { + 200: z.object({ + page: z.array(fileSchema), + search: z + .object({ + field: z.enum(['name', 'originalName', 'type', 'tags', 'id']), + query: z.union([z.string(), z.array(z.string())]), + }) + .optional(), + total: z.number().optional(), + pages: z.number().optional(), + }), + }, }, preHandler: [userMiddleware], }, @@ -62,8 +78,9 @@ export default typedPlugin( }, }); - if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role)) return res.notFound(); - if (!user) return res.notFound(); + if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role)) + throw new ApiError(9002); + if (!user) throw new ApiError(9002); const { perpage, searchQuery, searchField, page, filter, favorite, sortBy, order, folder } = req.query; @@ -78,8 +95,8 @@ export default typedPlugin( User: true, }, }); - if (!f) return res.notFound(); - if (!checkInteraction(req.user, f?.User)) return res.notFound(); + if (!f) throw new ApiError(9002); + if (!checkInteraction(req.user, f?.User)) throw new ApiError(9002); folderId = f.id; } @@ -121,7 +138,7 @@ export default typedPlugin( }, }); - if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere'); + if (foundTags.length !== parsedTags.length) throw new ApiError(1032); tagFiles = foundTags .map((tag) => tag.files.map((file) => file.id)) diff --git a/src/server/routes/api/user/files/transaction.ts b/src/server/routes/api/user/files/transaction.ts index df062a8c..4c8a7580 100644 --- a/src/server/routes/api/user/files/transaction.ts +++ b/src/server/routes/api/user/files/transaction.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; @@ -39,11 +40,18 @@ export default typedPlugin( PATH, { schema: { + description: 'Bulk update files owned by the user: favorite/unfavorite or move them into a folder.', body: z.object({ files: z.array(z.string()).min(1), favorite: z.boolean().optional(), folder: z.string().optional(), }), + response: { + 200: z.object({ + count: z.number(), + name: z.string().optional(), + }), + }, }, preHandler: [userMiddleware], ...secondlyRatelimit(2), @@ -66,7 +74,7 @@ export default typedPlugin( toFavoriteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })), ); if (invalids.length > 0) - return res.forbidden(`You don't have the permission to modify files[${invalids.join(', ')}]`); + throw new ApiError(3014, `You don't have the permission to modify files[${invalids.join(', ')}]`); const resp = await prisma.file.updateMany({ where: { @@ -79,7 +87,7 @@ export default typedPlugin( }, }); - if (resp.count === 0) return res.badRequest('No files were updated.'); + if (resp.count === 0) throw new ApiError(1028); logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, { user: req.user.id, @@ -89,7 +97,7 @@ export default typedPlugin( return res.send(resp); } - if (!folder) return res.badRequest("can't PATCH without an action"); + if (!folder) throw new ApiError(1020); const f = await prisma.folder.findUnique({ where: { @@ -97,7 +105,7 @@ export default typedPlugin( userId: req.user.id, }, }); - if (!f) return res.notFound('folder not found'); + if (!f) throw new ApiError(4001); const resp = await prisma.file.updateMany({ where: { @@ -112,7 +120,7 @@ export default typedPlugin( }, }); - if (resp.count === 0) return res.notFound('No files were moved.'); + if (resp.count === 0) throw new ApiError(4006); logger.info(`${req.user.username} moved ${resp.count} files to ${f.name}`, { user: req.user.id, @@ -130,10 +138,16 @@ export default typedPlugin( PATH, { schema: { + description: 'Bulk delete files (and optionally delete the underlying datasource objects).', body: z.object({ files: z.array(z.string()).min(1), delete_datasourceFiles: z.boolean().optional(), }), + response: { + 200: z.object({ + count: z.number(), + }), + }, }, preHandler: [userMiddleware], ...secondlyRatelimit(2), @@ -162,7 +176,7 @@ export default typedPlugin( toDeleteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })), ); if (invalids.length > 0) - return res.forbidden(`You don't have the permission to delete files[${invalids.join(', ')}]`); + throw new ApiError(3013, `You don't have the permission to delete files[${invalids.join(', ')}]`); if (delete_datasourceFiles) { for (let i = 0; i !== toDeleteFiles.length; ++i) { @@ -182,7 +196,7 @@ export default typedPlugin( }, }); - if (resp.count === 0) return res.badRequest('No files were deleted.'); + if (resp.count === 0) throw new ApiError(1027); logger.info(`${req.user.username} deleted ${resp.count} files`, { user: req.user.id, diff --git a/src/server/routes/api/user/folders/[id]/export.ts b/src/server/routes/api/user/folders/[id]/export.ts index 97d912b5..e2a8a89e 100644 --- a/src/server/routes/api/user/folders/[id]/export.ts +++ b/src/server/routes/api/user/folders/[id]/export.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; @@ -77,7 +78,13 @@ export default typedPlugin( async (server) => { server.get( PATH, - { schema: { params: z.object({ id: z.string() }) }, preHandler: [userMiddleware] }, + { + schema: { + description: 'Download a ZIP archive of all files contained in a folder and its subfolders.', + params: z.object({ id: z.string() }), + }, + preHandler: [userMiddleware], + }, async (req, res) => { const { id } = req.params; @@ -86,11 +93,11 @@ export default typedPlugin( select: { id: true, name: true, userId: true }, }); - if (!folder) return res.notFound('Folder not found'); - if (req.user.id !== folder.userId) return res.forbidden('You do not own this folder'); + if (!folder) throw new ApiError(4001); + if (req.user.id !== folder.userId) throw new ApiError(3011); const folderTree = await getFolderTree(id, req.user.id); - if (!folderTree) return res.notFound('Folder not found'); + if (!folderTree) throw new ApiError(4001); logger.info(`folder export requested: ${folder.name}`, { user: req.user.id, folder: folder.id }); diff --git a/src/server/routes/api/user/folders/[id]/index.ts b/src/server/routes/api/user/folders/[id]/index.ts index d475506c..87c7bf26 100644 --- a/src/server/routes/api/user/folders/[id]/index.ts +++ b/src/server/routes/api/user/folders/[id]/index.ts @@ -1,13 +1,14 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; import { fileSelect } from '@/lib/db/models/file'; -import { buildParentChain, Folder, cleanFolder } from '@/lib/db/models/folder'; +import { buildParentChain, Folder, cleanFolder, folderSchema } from '@/lib/db/models/folder'; import { User } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { canInteract } from '@/lib/role'; import { zStringTrimmed } from '@/lib/validation'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; -import { FastifyReply, FastifyRequest } from 'fastify'; +import { FastifyRequest } from 'fastify'; import z from 'zod'; export type ApiUserFoldersIdResponse = Folder; @@ -28,7 +29,7 @@ const paramsSchema = z.object({ id: z.string(), }); -const folderExistsAndEditable = async (req: FastifyRequest, res: FastifyReply) => { +const folderExistsAndEditable = async (req: FastifyRequest) => { const { id } = req.params as z.infer; const folder = await prisma.folder.findUnique({ @@ -40,8 +41,8 @@ const folderExistsAndEditable = async (req: FastifyRequest, res: FastifyReply) = }, }); - if (!folder) return res.notFound('Folder not found'); - if (!checkInteraction(req.user, folder.User)) return res.notFound('Folder not found'); + if (!folder) throw new ApiError(4001); + if (!checkInteraction(req.user, folder.User)) throw new ApiError(4001); }; export const PATH = '/api/user/folders/:id'; @@ -49,7 +50,16 @@ export default typedPlugin( async (server) => { server.get( PATH, - { schema: { params: paramsSchema }, preHandler: [userMiddleware, folderExistsAndEditable] }, + { + schema: { + description: 'Fetch a specific folder by ID, including files, children, and its parent chain.', + params: paramsSchema, + response: { + 200: folderSchema.partial(), + }, + }, + preHandler: [userMiddleware, folderExistsAndEditable], + }, async (req, res) => { const { id } = req.params; @@ -81,7 +91,7 @@ export default typedPlugin( }, }, }); - if (!folder) return res.notFound('Folder not found'); + if (!folder) throw new ApiError(4001); if (folder.parentId) { (folder as any).parent = await buildParentChain(folder.parentId); @@ -95,10 +105,14 @@ export default typedPlugin( PATH, { schema: { + description: 'Add a file to a specific folder owned by the user.', body: z.object({ id: z.string(), }), params: paramsSchema, + response: { + 200: folderSchema.partial(), + }, }, preHandler: [userMiddleware, folderExistsAndEditable], }, @@ -114,8 +128,8 @@ export default typedPlugin( User: true, }, }); - if (!file) return res.notFound('File not found'); - if (!checkInteraction(req.user, file.User)) return res.notFound('File not found'); + if (!file) throw new ApiError(4000); + if (!checkInteraction(req.user, file.User)) throw new ApiError(4000); const fileInFolder = await prisma.file.findFirst({ where: { @@ -125,7 +139,7 @@ export default typedPlugin( }, }, }); - if (fileInFolder) return res.badRequest('File already in folder'); + if (fileInFolder) throw new ApiError(1011); try { const nFolder = await prisma.folder.update({ @@ -147,7 +161,7 @@ export default typedPlugin( logger.info('file added to folder', { folder: folderId, file: id }); return res.send(cleanFolder(nFolder)); } catch (error: any) { - if (error.code === 'P2025') return res.notFound('Folder or File not found'); + if (error.code === 'P2025') throw new ApiError(4002); throw error; } }, @@ -157,6 +171,7 @@ export default typedPlugin( PATH, { schema: { + description: "Update a folder's visibility, name, upload permissions, or parent.", body: z.object({ isPublic: z.boolean().optional(), name: zStringTrimmed.optional(), @@ -164,6 +179,9 @@ export default typedPlugin( parentId: z.string().nullish(), }), params: paramsSchema, + response: { + 200: folderSchema.partial(), + }, }, preHandler: [userMiddleware, folderExistsAndEditable], }, @@ -172,7 +190,7 @@ export default typedPlugin( const { isPublic, name, allowUploads, parentId } = req.body; if (parentId !== undefined) { - if (parentId === folderId) return res.badRequest('A folder cannot be its own parent'); + if (parentId === folderId) throw new ApiError(1015); if (parentId !== null) { const newParent = await prisma.folder.findUnique({ @@ -180,14 +198,13 @@ export default typedPlugin( select: { id: true, userId: true, parentId: true }, }); - if (!newParent) return res.notFound('Parent folder not found'); - if (newParent.userId !== req.user.id) - return res.forbidden('Parent folder does not belong to you'); + if (!newParent) throw new ApiError(4007); + if (newParent.userId !== req.user.id) throw new ApiError(3003); let currentParentId: string | null = newParent.parentId; while (currentParentId) { if (currentParentId === folderId) { - return res.badRequest('Cannot move folder into one of its descendants'); + throw new ApiError(1016); } const parent = await prisma.folder.findUnique({ where: { id: currentParentId }, @@ -233,7 +250,7 @@ export default typedPlugin( return res.send(cleanFolder(nFolder)); } catch (error: any) { - if (error.code === 'P2025') return res.notFound('Folder not found'); + if (error.code === 'P2025') throw new ApiError(4001); throw error; } }, @@ -251,6 +268,18 @@ export default typedPlugin( targetFolderId: z.string().optional(), }), params: paramsSchema, + response: { + 200: z.union([ + folderSchema + .partial() + .describe('if deleting a file from the folder, returns the updated folder'), + z + .object({ + success: z.boolean(), + }) + .describe('if deleting the folder, returns success status'), + ]), + }, }, preHandler: [userMiddleware, folderExistsAndEditable], }, @@ -264,17 +293,15 @@ export default typedPlugin( where: { id: targetFolderId }, select: { id: true, User: true }, }); - if (!targetFolder) return res.notFound('Target folder not found'); - if (!checkInteraction(req.user, targetFolder.User)) - return res.forbidden('Target folder not found'); + if (!targetFolder) throw new ApiError(4008); + if (!checkInteraction(req.user, targetFolder.User)) throw new ApiError(4008, undefined, 403); } try { const result = await prisma.$transaction(async (tx) => { - if (!childrenAction) - return { - success: false, - }; + if (!childrenAction) { + return { success: true }; + } if (childrenAction === 'root') { await tx.folder.updateMany({ where: { parentId: folderId }, data: { parentId: null } }); @@ -310,7 +337,7 @@ export default typedPlugin( } }); - if (!result?.success) return res.badRequest('Invalid action'); + if (!result?.success) throw new ApiError(1019); if (result?.isCascade) { logger.info('folder cascade deleted', { folder: folderId }); @@ -322,21 +349,20 @@ export default typedPlugin( logger.info('folder deleted', { folder: folderId, childrenAction, targetFolderId }); return res.send({ success: true }); } catch (error: any) { - if (error.code === 'P2025') - return res.notFound('Folder or related records not found during deletion'); + if (error.code === 'P2025') throw new ApiError(4003); throw error; } } else if (del === 'file') { const { id } = req.body; - if (!id) return res.badRequest('File id is required'); + if (!id) throw new ApiError(1013); const file = await prisma.file.findUnique({ where: { id }, include: { User: true }, }); - if (!file) return res.notFound('File not found'); - if (!checkInteraction(req.user, file.User)) return res.notFound('File not found'); + if (!file) throw new ApiError(4000); + if (!checkInteraction(req.user, file.User)) throw new ApiError(4000); const fileInFolder = await prisma.file.findFirst({ where: { @@ -344,7 +370,7 @@ export default typedPlugin( Folder: { id: folderId }, }, }); - if (!fileInFolder) return res.badRequest('File not in folder'); + if (!fileInFolder) throw new ApiError(1012); try { const nFolder = await prisma.folder.update({ @@ -365,7 +391,7 @@ export default typedPlugin( logger.info('file removed from folder', { folder: nFolder.id, file: id }); return res.send(cleanFolder(nFolder)); } catch (error: any) { - if (error.code === 'P2025') return res.notFound('Folder or file not found'); + if (error.code === 'P2025') throw new ApiError(4002); throw error; } } diff --git a/src/server/routes/api/user/folders/index.ts b/src/server/routes/api/user/folders/index.ts index 5dddc061..07e88bf1 100644 --- a/src/server/routes/api/user/folders/index.ts +++ b/src/server/routes/api/user/folders/index.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; import { fileSelect } from '@/lib/db/models/file'; -import { Folder, cleanFolder, cleanFolders } from '@/lib/db/models/folder'; +import { Folder, cleanFolder, cleanFolders, folderSchema } from '@/lib/db/models/folder'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { canInteract } from '@/lib/role'; @@ -20,12 +21,17 @@ export default typedPlugin( PATH, { schema: { + description: + 'List folders for the authenticated user, optionally including files or filtering by parent/root.', querystring: z.object({ noincl: zQsBoolean.optional(), user: z.string().optional(), parentId: z.string().optional(), root: zQsBoolean.optional(), }), + response: { + 200: z.array(folderSchema), + }, }, preHandler: [userMiddleware], }, @@ -39,9 +45,9 @@ export default typedPlugin( }, }); - if (!user) return res.notFound(); + if (!user) throw new ApiError(4009); if (req.user.id !== user.id) { - if (!canInteract(req.user.role, user.role)) return res.notFound(); + if (!canInteract(req.user.role, user.role)) throw new ApiError(4009); } } @@ -82,7 +88,7 @@ export default typedPlugin( }, }); - return res.send(cleanFolders(folders as unknown as Partial[])); + return res.send(cleanFolders(folders as unknown as Folder[])); }, ); @@ -90,12 +96,17 @@ export default typedPlugin( PATH, { schema: { + description: + 'Create a new folder for the authenticated user, optionally public and/or seeded with files.', body: z.object({ name: z.string().trim().min(1), isPublic: z.boolean().optional(), files: z.array(z.string()).optional(), parentId: z.string().optional(), }), + response: { + 200: folderSchema, + }, }, preHandler: [userMiddleware], ...secondlyRatelimit(2), @@ -110,9 +121,8 @@ export default typedPlugin( select: { id: true, userId: true }, }); - if (!parentFolder) return res.notFound('Parent folder not found'); - if (parentFolder.userId !== req.user.id) - return res.forbidden('Parent folder does not belong to you'); + if (!parentFolder) throw new ApiError(4007); + if (parentFolder.userId !== req.user.id) throw new ApiError(3003); } if (files) { @@ -127,7 +137,7 @@ export default typedPlugin( }, }); - if (!filesAdd.length) return res.badRequest('No files found, with given request'); + if (!filesAdd.length) throw new ApiError(1026); files = filesAdd.map((f) => f.id); } diff --git a/src/server/routes/api/user/index.ts b/src/server/routes/api/user/index.ts index 381f1777..32b1ee60 100644 --- a/src/server/routes/api/user/index.ts +++ b/src/server/routes/api/user/index.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { User, userSelect } from '@/lib/db/models/user'; +import { User, userSchema, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { zStringTrimmed } from '@/lib/validation'; @@ -18,14 +19,30 @@ const logger = log('api').c('user'); export const PATH = '/api/user'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - return res.send({ user: req.user, token: req.cookies.zipline_token }); - }); + server.get( + PATH, + { + schema: { + description: 'Get the currently authenticated user and their token.', + response: { + 200: z.object({ + user: userSchema.optional(), + token: z.string().optional(), + }), + }, + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + return res.send({ user: req.user, token: req.cookies.zipline_token }); + }, + ); server.patch( PATH, { schema: { + description: "Update the current user's profile, credentials, avatar, and view settings.", body: z.object({ username: zStringTrimmed.optional(), password: zStringTrimmed.optional(), @@ -47,6 +64,12 @@ export default typedPlugin( .partial() .optional(), }), + response: { + 200: z.object({ + user: userSchema.optional(), + token: z.string().optional(), + }), + }, }, preHandler: [userMiddleware], ...secondlyRatelimit(1), @@ -59,7 +82,7 @@ export default typedPlugin( }, }); - if (existing) return res.badRequest('Username already exists'); + if (existing) throw new ApiError(1038); } const user = await prisma.user.update({ diff --git a/src/server/routes/api/user/mfa/passkey.ts b/src/server/routes/api/user/mfa/passkey.ts index b9d4b68e..8eb62dad 100644 --- a/src/server/routes/api/user/mfa/passkey.ts +++ b/src/server/routes/api/user/mfa/passkey.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; import { User } from '@/lib/db/models/user'; @@ -27,8 +28,8 @@ const logger = log('api').c('user').c('mfa').c('passkey'); const passkeysEnabled = (): boolean => isTruthy(config.mfa.passkeys.enabled, config.mfa.passkeys.rpID, config.mfa.passkeys.origin); -export const passkeysEnabledHandler = async (_: FastifyRequest, res: FastifyReply) => { - if (!passkeysEnabled()) return res.notFound(); +export const passkeysEnabledHandler = async (_: FastifyRequest, __: FastifyReply) => { + if (!passkeysEnabled()) throw new ApiError(9002); }; export type PasskeyReg = { @@ -63,7 +64,13 @@ export default typedPlugin( server.get( PATH + '/options', - { preHandler: [userMiddleware, passkeysEnabledHandler], ...secondlyRatelimit(1) }, + { + schema: { + description: 'Generate WebAuthn registration options for creating a new passkey.', + }, + preHandler: [userMiddleware, passkeysEnabledHandler], + ...secondlyRatelimit(1), + }, async (req, res) => { if (OPTIONS_CACHE.has(req.user.id)) return res.send(OPTIONS_CACHE.get(req.user.id)!); @@ -108,8 +115,11 @@ export default typedPlugin( PATH, { schema: { + description: 'Register a new WebAuthn passkey for the authenticated user.', body: z.object({ - response: z.custom(), + response: z + .custom() + .describe('The registration response from the client, containing the new passkey credential.'), name: zStringTrimmed, }), }, @@ -120,7 +130,7 @@ export default typedPlugin( const { response, name } = req.body; const optionsCached = OPTIONS_CACHE.get(req.user.id); - if (!optionsCached) return res.badRequest('passkey registration timed out, try again later'); + if (!optionsCached) throw new ApiError(1048); OPTIONS_CACHE.delete(req.user.id); @@ -135,10 +145,10 @@ export default typedPlugin( } catch (e) { console.error(e); logger.warn('error verifying passkey registration'); - return res.badRequest('Error verifying passkey registration'); + throw new ApiError(1049); } - if (!verification.verified) return res.badRequest('Could not verify passkey registration'); + if (!verification.verified) throw new ApiError(1050); const user = await prisma.user.update({ where: { id: req.user.id }, @@ -176,6 +186,7 @@ export default typedPlugin( PATH, { schema: { + description: 'Remove an existing passkey credential from your account.', body: z.object({ id: z.string(), }), diff --git a/src/server/routes/api/user/mfa/totp.ts b/src/server/routes/api/user/mfa/totp.ts index c771506b..ef819542 100644 --- a/src/server/routes/api/user/mfa/totp.ts +++ b/src/server/routes/api/user/mfa/totp.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; -import { User, userSelect } from '@/lib/db/models/user'; +import { User, userSchema, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { generateKey, totpQrcode, verifyTotpCode } from '@/lib/totp'; import { userMiddleware } from '@/server/middleware/user'; @@ -12,8 +13,8 @@ export type ApiUserMfaTotpResponse = User | { secret: string } | { secret: strin const logger = log('api').c('user').c('mfa').c('totp'); -const totpEnabledMiddleware = (_: FastifyRequest, res: FastifyReply, next: () => void) => { - if (!config.mfa.totp.enabled) return res.badRequest('TOTP is disabled'); +const totpEnabledMiddleware = (_: FastifyRequest, __: FastifyReply, next: () => void) => { + if (!config.mfa.totp.enabled) throw new ApiError(1054); next(); }; @@ -21,38 +22,66 @@ const totpEnabledMiddleware = (_: FastifyRequest, res: FastifyReply, next: () => export const PATH = '/api/user/mfa/totp'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware, totpEnabledMiddleware] }, async (req, res) => { - if (!req.user.totpSecret) { - const secret = generateKey(); - const qrcode = await totpQrcode({ - issuer: config.mfa.totp.issuer, - username: req.user.username, - secret, - }); + server.get( + PATH, + { + schema: { + description: 'Get your current TOTP secret, generating one (and a QR code) if not yet enabled.', + response: { + 200: z.union([ + z + .object({ + secret: z.string(), + }) + .describe('TOTP is enabled, returning the existing secret'), + z + .object({ + secret: z.string(), + qrcode: z.string(), + }) + .describe('TOTP is not yet enabled, returning a new secret and QR code data URL'), + ]), + }, + }, + preHandler: [userMiddleware, totpEnabledMiddleware], + }, + async (req, res) => { + if (!req.user.totpSecret) { + const secret = generateKey(); + const qrcode = await totpQrcode({ + issuer: config.mfa.totp.issuer, + username: req.user.username, + secret, + }); - logger.info('user generated TOTP secret', { - user: req.user.username, - }); + logger.info('user generated TOTP secret', { + user: req.user.username, + }); + + return res.send({ + secret, + qrcode, + }); + } return res.send({ - secret, - qrcode, + secret: req.user.totpSecret, }); - } - - return res.send({ - secret: req.user.totpSecret, - }); - }); + }, + ); server.post( PATH, { schema: { + description: 'Enable TOTP for your account by verifying a code for the provided secret.', body: z.object({ code: z.string().min(6).max(6), secret: z.string(), }), + response: { + 200: userSchema, + }, }, preHandler: [userMiddleware, totpEnabledMiddleware], }, @@ -60,7 +89,7 @@ export default typedPlugin( const { code, secret } = req.body; const valid = verifyTotpCode(code, secret); - if (!valid) return res.badRequest('Invalid code'); + if (!valid) throw new ApiError(1045); const user = await prisma.user.update({ where: { id: req.user.id }, @@ -80,19 +109,23 @@ export default typedPlugin( PATH, { schema: { + description: 'Disable TOTP for your account after confirming a valid TOTP code.', body: z.object({ code: z.string().min(6).max(6), }), + response: { + 200: userSchema, + }, }, preHandler: [userMiddleware, totpEnabledMiddleware], }, async (req, res) => { - if (!req.user.totpSecret) return res.badRequest("You don't have TOTP enabled"); + if (!req.user.totpSecret) throw new ApiError(1053); const { code } = req.body; const valid = verifyTotpCode(code, req.user.totpSecret); - if (!valid) return res.badRequest('Invalid code'); + if (!valid) throw new ApiError(1045); const user = await prisma.user.update({ where: { id: req.user.id }, diff --git a/src/server/routes/api/user/recent.ts b/src/server/routes/api/user/recent.ts index b149c07a..ec86512c 100644 --- a/src/server/routes/api/user/recent.ts +++ b/src/server/routes/api/user/recent.ts @@ -1,5 +1,5 @@ import { prisma } from '@/lib/db'; -import { File, cleanFiles, fileSelect } from '@/lib/db/models/file'; +import { File, cleanFiles, fileSchema, fileSelect } from '@/lib/db/models/file'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; @@ -13,9 +13,13 @@ export default typedPlugin( PATH, { schema: { + description: 'Get the most recently uploaded files for the authenticated user.', querystring: z.object({ take: z.coerce.number().min(1).max(100).default(3), }), + response: { + 200: z.array(fileSchema), + }, }, preHandler: [userMiddleware], }, diff --git a/src/server/routes/api/user/sessions.ts b/src/server/routes/api/user/sessions.ts index 54904188..ce56c311 100644 --- a/src/server/routes/api/user/sessions.ts +++ b/src/server/routes/api/user/sessions.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; -import type { UserSession } from '@/prisma/client'; +import { UserSession, userSessionSchema } from '@/lib/db/models/user'; import { userMiddleware } from '@/server/middleware/user'; import { getSession } from '@/server/session'; import typedPlugin from '@/server/typedPlugin'; @@ -15,27 +16,50 @@ const logger = log('api').c('user').c('sessions'); export const PATH = '/api/user/sessions'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const currentSession = await getSession(req, res); + server.get( + PATH, + { + schema: { + description: + 'List the current browser session and other active sessions for the authenticated user.', + response: { + 200: z.object({ + current: userSessionSchema, + other: z.array(userSessionSchema), + }), + }, + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const currentSession = await getSession(req, res); - const currentDbSession = req.user.sessions.find((session) => session.id === currentSession.sessionId); + const currentDbSession = req.user.sessions.find((session) => session.id === currentSession.sessionId); - if (!currentDbSession) return res.unauthorized('invalid login session'); + if (!currentDbSession) throw new ApiError(2000); - return res.send({ - current: currentDbSession, - other: req.user.sessions.filter((session) => session.id !== currentSession.sessionId), - }); - }); + return res.send({ + current: currentDbSession, + other: req.user.sessions.filter((session) => session.id !== currentSession.sessionId), + }); + }, + ); server.delete( PATH, { schema: { + description: 'Invalidate one or all other sessions for the authenticated user.', body: z.object({ sessionId: z.string().optional(), all: z.boolean().optional(), }), + response: { + 200: z.object({ + current: userSessionSchema, + other: z.array(userSessionSchema), + }), + }, }, preHandler: [userMiddleware], }, @@ -71,10 +95,8 @@ export default typedPlugin( }); } - if (req.body.sessionId === currentSession.sessionId) - return res.badRequest('Cannot delete current session, use log out instead.'); - if (!req.user.sessions.find((session) => session.id === req.body.sessionId)) - return res.badRequest('Session not found in logged in sessions'); + if (req.body.sessionId === currentSession.sessionId) throw new ApiError(1021); + if (!req.user.sessions.find((session) => session.id === req.body.sessionId)) throw new ApiError(1031); const user = await prisma.user.update({ where: { diff --git a/src/server/routes/api/user/stats.ts b/src/server/routes/api/user/stats.ts index acdb9702..899e2c02 100644 --- a/src/server/routes/api/user/stats.ts +++ b/src/server/routes/api/user/stats.ts @@ -1,6 +1,7 @@ import { prisma } from '@/lib/db'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserStatsResponse = { filesUploaded: number; @@ -19,78 +20,100 @@ export const PATH = '/api/user/stats'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const aggFile = await prisma.file.aggregate({ - where: { - userId: req.user.id, + server.get( + PATH, + { + schema: { + description: "View aggregate statistics for the authenticated user's files and URLs.", + response: { + 200: z.object({ + filesUploaded: z.number(), + favoriteFiles: z.number(), + views: z.number(), + avgViews: z.number(), + storageUsed: z.number(), + avgStorageUsed: z.number(), + urlsCreated: z.number(), + urlViews: z.number(), + sortTypeCount: z.record(z.string(), z.number()), + }), + }, }, - _count: { - _all: true, - }, - _sum: { - views: true, - size: true, - }, - _avg: { - views: true, - size: true, - }, - }); + preHandler: [userMiddleware], + }, + async (req, res) => { + const aggFile = await prisma.file.aggregate({ + where: { + userId: req.user.id, + }, + _count: { + _all: true, + }, + _sum: { + views: true, + size: true, + }, + _avg: { + views: true, + size: true, + }, + }); - const favCount = await prisma.file.count({ - where: { - userId: req.user.id, - favorite: true, - }, - }); + const favCount = await prisma.file.count({ + where: { + userId: req.user.id, + favorite: true, + }, + }); - const aggUrl = await prisma.url.aggregate({ - where: { - userId: req.user.id, - }, - _count: { - _all: true, - }, - _avg: { - views: true, - }, - _sum: { - views: true, - }, - }); + const aggUrl = await prisma.url.aggregate({ + where: { + userId: req.user.id, + }, + _count: { + _all: true, + }, + _avg: { + views: true, + }, + _sum: { + views: true, + }, + }); - const sortType = await prisma.file.findMany({ - where: { - userId: req.user.id, - }, - select: { - type: true, - }, - }); + const sortType = await prisma.file.findMany({ + where: { + userId: req.user.id, + }, + select: { + type: true, + }, + }); - const sortTypeCount = sortType.reduce( - (acc, cur) => { - if (acc[cur.type]) acc[cur.type] += 1; - else acc[cur.type] = 1; + const sortTypeCount = sortType.reduce( + (acc, cur) => { + if (acc[cur.type]) acc[cur.type] += 1; + else acc[cur.type] = 1; - return acc; - }, - {} as { [type: string]: number }, - ); + return acc; + }, + {} as { [type: string]: number }, + ); - return res.send({ - filesUploaded: aggFile._count._all ?? 0, - favoriteFiles: favCount ?? 0, - views: aggFile._sum.views ?? 0, - avgViews: aggFile._avg.views ?? 0, - storageUsed: Number(aggFile._sum.size ?? 0), - avgStorageUsed: Number(aggFile._avg.size ?? 0), - urlsCreated: aggUrl._count._all ?? 0, - urlViews: aggUrl._sum.views ?? 0, + return res.send({ + filesUploaded: aggFile._count._all ?? 0, + favoriteFiles: favCount ?? 0, + views: aggFile._sum.views ?? 0, + avgViews: aggFile._avg.views ?? 0, + storageUsed: Number(aggFile._sum.size ?? 0), + avgStorageUsed: Number(aggFile._avg.size ?? 0), + urlsCreated: aggUrl._count._all ?? 0, + urlViews: aggUrl._sum.views ?? 0, - sortTypeCount, - }); - }); + sortTypeCount, + }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/tags/[id].ts b/src/server/routes/api/user/tags/[id].ts index 08061c84..2f0d4e39 100644 --- a/src/server/routes/api/user/tags/[id].ts +++ b/src/server/routes/api/user/tags/[id].ts @@ -1,5 +1,6 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; -import { Tag, tagSelect } from '@/lib/db/models/tag'; +import { Tag, tagSchema, tagSelect } from '@/lib/db/models/tag'; import { log } from '@/lib/logger'; import { zStringTrimmed } from '@/lib/validation'; import { userMiddleware } from '@/server/middleware/user'; @@ -17,25 +18,46 @@ const paramsSchema = z.object({ export const PATH = '/api/user/tags/:id'; export default typedPlugin( async (server) => { - server.get(PATH, { schema: { params: paramsSchema }, preHandler: [userMiddleware] }, async (req, res) => { - const { id } = req.params; - - const tag = await prisma.tag.findFirst({ - where: { - userId: req.user.id, - id, + server.get( + PATH, + { + schema: { + description: 'Fetch a specific tag by ID, ensuring it is owned by the authenticated user.', + params: paramsSchema, + response: { + 200: tagSchema, + }, }, - select: tagSelect, - }); - if (!tag) return res.notFound(); + preHandler: [userMiddleware], + }, + async (req, res) => { + const { id } = req.params; - return res.send(tag); - }); + const tag = await prisma.tag.findFirst({ + where: { + userId: req.user.id, + id, + }, + select: tagSelect, + }); + if (!tag) throw new ApiError(9002); + + return res.send(tag); + }, + ); server.delete( PATH, { - schema: { params: paramsSchema }, + schema: { + description: 'Delete a specific tag owned by the authenticated user.', + params: paramsSchema, + response: { + 200: z.object({ + success: z.boolean(), + }), + }, + }, preHandler: [userMiddleware], }, async (req, res) => { @@ -48,7 +70,7 @@ export default typedPlugin( }, }); - if (tag.count === 0) return res.notFound(); + if (tag.count === 0) throw new ApiError(9002); logger.info('tag deleted', { id, @@ -63,6 +85,7 @@ export default typedPlugin( PATH, { schema: { + description: 'Update the name and/or color of a specific tag.', params: paramsSchema, body: z.object({ name: zStringTrimmed.optional(), @@ -71,6 +94,9 @@ export default typedPlugin( .regex(/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/) .optional(), }), + response: { + 200: tagSchema, + }, }, preHandler: [userMiddleware], }, @@ -84,7 +110,7 @@ export default typedPlugin( id, }, }); - if (!existingTag) return res.notFound(); + if (!existingTag) throw new ApiError(9002); if (name) { const existing = await prisma.tag.findFirst({ @@ -93,7 +119,7 @@ export default typedPlugin( }, }); - if (existing) return res.badRequest('tag name already exists'); + if (existing) throw new ApiError(1034); } const tag = await prisma.tag.update({ diff --git a/src/server/routes/api/user/tags/index.ts b/src/server/routes/api/user/tags/index.ts index 6360ac53..417fb22d 100644 --- a/src/server/routes/api/user/tags/index.ts +++ b/src/server/routes/api/user/tags/index.ts @@ -1,5 +1,6 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; -import { Tag, tagSelect } from '@/lib/db/models/tag'; +import { Tag, tagSchema, tagSelect } from '@/lib/db/models/tag'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { zStringTrimmed } from '@/lib/validation'; @@ -14,25 +15,41 @@ const logger = log('api').c('user').c('tags'); export const PATH = '/api/user/tags'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const tags = await prisma.tag.findMany({ - where: { - userId: req.user.id, + server.get( + PATH, + { + schema: { + description: 'List all tags created by the authenticated user.', + response: { + 200: z.array(tagSchema), + }, }, - select: tagSelect, - }); + preHandler: [userMiddleware], + }, + async (req, res) => { + const tags = await prisma.tag.findMany({ + where: { + userId: req.user.id, + }, + select: tagSelect, + }); - return res.send(tags); - }); + return res.send(tags); + }, + ); server.post( PATH, { schema: { + description: 'Create a new tag with a name and color for organizing files.', body: z.object({ name: zStringTrimmed, color: z.string().regex(/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/), }), + response: { + 200: tagSchema, + }, }, preHandler: [userMiddleware], ...secondlyRatelimit(1), @@ -47,7 +64,7 @@ export default typedPlugin( }, }); - if (existingTag) return res.badRequest('Cannot create tag with the same name'); + if (existingTag) throw new ApiError(1033); const tag = await prisma.tag.create({ data: { diff --git a/src/server/routes/api/user/token.ts b/src/server/routes/api/user/token.ts index 0b85adb8..53bdd354 100644 --- a/src/server/routes/api/user/token.ts +++ b/src/server/routes/api/user/token.ts @@ -1,11 +1,13 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { createToken, encryptToken } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { User, userSelect } from '@/lib/db/models/user'; +import { User, userSchema, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserTokenResponse = { user?: User; @@ -17,53 +19,87 @@ const logger = log('api').c('user').c('token'); export const PATH = '/api/user/token'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const user = await prisma.user.findUnique({ - where: { - id: req.user.id, + server.get( + PATH, + { + schema: { + description: 'Return an encrypted API token for the authenticated user.', + response: { + 200: z.object({ + token: z.string().optional(), + }), + }, }, - select: { - token: true, + preHandler: [userMiddleware], + }, + async (req, res) => { + const user = await prisma.user.findUnique({ + where: { + id: req.user.id, + }, + select: { + token: true, + }, + }); + + if (!user || !user.token) { + logger.warn('something went very wrong! user not found or token not found', { + userId: req.user.id, + }); + + throw new ApiError(9004); + } + + const token = encryptToken(user!.token, config.core.secret); + + return res.send({ + token, + }); + }, + ); + + server.patch( + PATH, + { + preHandler: [userMiddleware], + ...secondlyRatelimit(1), + schema: { + description: + "Refresh the user's underlying token secret and return an updated token and user object.", + response: { + 200: z.object({ + user: userSchema.optional(), + token: z.string().optional(), + }), + }, }, - }); + }, + async (req, res) => { + const user = await prisma.user.update({ + where: { + id: req.user.id, + }, + data: { + token: createToken(), + }, + select: { + ...userSelect, + token: true, + }, + }); - if (!user || !user.token) { - logger.warn('something went very wrong! user not found or token not found', { userId: req.user.id }); - return res.internalServerError(); - } + delete (user as any).password; - const token = encryptToken(user!.token, config.core.secret); + logger.info('user reset their token', { + user: user.username, + }); - return res.send({ - token, - }); - }); - - server.patch(PATH, { preHandler: [userMiddleware], ...secondlyRatelimit(1) }, async (req, res) => { - const user = await prisma.user.update({ - where: { - id: req.user.id, - }, - data: { - token: createToken(), - }, - select: { - ...userSelect, - token: true, - }, - }); - - delete (user as any).password; - - logger.info('user reset their token', { - user: user.username, - }); - - return res.send({ - user, - token: encryptToken(user.token, config.core.secret), - }); - }); + return res.send({ + user, + token: encryptToken(user.token, config.core.secret), + }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/urls/[id]/index.ts b/src/server/routes/api/user/urls/[id]/index.ts index 12cd4d06..e7fc7193 100644 --- a/src/server/routes/api/user/urls/[id]/index.ts +++ b/src/server/routes/api/user/urls/[id]/index.ts @@ -1,6 +1,7 @@ +import { ApiError } from '@/lib/api/errors'; import { hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { Url } from '@/lib/db/models/url'; +import { Url, urlSchema } from '@/lib/db/models/url'; import { log } from '@/lib/logger'; import { zStringTrimmed } from '@/lib/validation'; import { userMiddleware } from '@/server/middleware/user'; @@ -21,7 +22,12 @@ export default typedPlugin( server.get( PATH, { - schema: { params: paramsSchema }, + schema: { + params: paramsSchema, + response: { + 200: urlSchema.omit({ password: true }), + }, + }, preHandler: [userMiddleware], }, async (req, res) => { @@ -36,7 +42,7 @@ export default typedPlugin( password: true, }, }); - if (!url) return res.notFound(); + if (!url) throw new ApiError(9002); return res.send(url); }, @@ -54,6 +60,9 @@ export default typedPlugin( destination: z.httpUrl().optional(), enabled: z.boolean().optional(), }), + response: { + 200: urlSchema.omit({ password: true }), + }, }, preHandler: [userMiddleware], }, @@ -67,7 +76,7 @@ export default typedPlugin( }, }); - if (!url) return res.notFound(); + if (!url) throw new ApiError(9002); let password: string | null | undefined = undefined; if (req.body.password !== undefined) { @@ -76,7 +85,7 @@ export default typedPlugin( } else if (typeof req.body.password === 'string') { password = await hashPassword(req.body.password); } else { - return res.badRequest('password must be a string'); + throw new ApiError(1055); } } @@ -87,7 +96,7 @@ export default typedPlugin( }, }); - if (existingUrl) return res.badRequest('vanity already exists'); + if (existingUrl) throw new ApiError(1041); } const updatedUrl = await prisma.url.update({ @@ -117,7 +126,12 @@ export default typedPlugin( server.delete( PATH, { - schema: { params: paramsSchema }, + schema: { + params: paramsSchema, + response: { + 200: urlSchema.omit({ password: true }), + }, + }, preHandler: [userMiddleware], }, async (req, res) => { @@ -130,7 +144,7 @@ export default typedPlugin( }, }); - if (!url) return res.notFound(); + if (!url) throw new ApiError(9002); const deletedUrl = await prisma.url.delete({ where: { diff --git a/src/server/routes/api/user/urls/[id]/password.ts b/src/server/routes/api/user/urls/[id]/password.ts index bcf6ff88..69fc49dc 100644 --- a/src/server/routes/api/user/urls/[id]/password.ts +++ b/src/server/routes/api/user/urls/[id]/password.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { verifyPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; @@ -19,6 +20,7 @@ export default typedPlugin( PATH, { schema: { + description: 'Verify the password for a password-protected short URL by ID, code, or vanity.', params: z.object({ id: z.string(), }), @@ -38,8 +40,8 @@ export default typedPlugin( id: true, }, }); - if (!url) return res.notFound(); - if (!url.password) return res.notFound(); + if (!url) throw new ApiError(9002); + if (!url.password) throw new ApiError(9002); const verified = await verifyPassword(req.body.password, url.password); if (!verified) { @@ -49,7 +51,7 @@ export default typedPlugin( ua: req.headers['user-agent'], }); - return res.notFound(); + throw new ApiError(9002); } logger.info(`url ${url.id} was accessed with the correct password`, { diff --git a/src/server/routes/api/user/urls/index.ts b/src/server/routes/api/user/urls/index.ts index 5183d682..4c2e8597 100644 --- a/src/server/routes/api/user/urls/index.ts +++ b/src/server/routes/api/user/urls/index.ts @@ -1,7 +1,8 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { cleanUrlPasswords, Url } from '@/lib/db/models/url'; +import { cleanUrlPasswords, Url, urlSchema } from '@/lib/db/models/url'; import { log } from '@/lib/logger'; import { randomCharacters } from '@/lib/random'; import { zStringTrimmed } from '@/lib/validation'; @@ -29,6 +30,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'Create a new shortened URL for the authenticated user, with optional vanity, password, and max-views settings.', body: z.object({ vanity: zStringTrimmed.max(100).nullish(), destination: z.string().min(1), @@ -43,6 +46,14 @@ export default typedPlugin( 'x-zipline-domain': z.string().optional(), 'x-zipline-password': z.string().optional(), }), + response: { + 200: z.union([ + z.string(), + urlSchema.omit({ password: true }).extend({ + url: z.string(), + }), + ]), + }, }, preHandler: [userMiddleware, rateLimit], }, @@ -56,7 +67,8 @@ export default typedPlugin( }, }); if (req.user.quota && req.user.quota.maxUrls && countUrls + 1 > req.user.quota.maxUrls) - return res.forbidden( + throw new ApiError( + 3012, `Shortening this URL would exceed your quota of ${req.user.quota.maxUrls} URLs.`, ); @@ -73,8 +85,6 @@ export default typedPlugin( ? await hashPassword(req.headers['x-zipline-password']) : undefined; - if (!destination) return res.badRequest('Destination is required'); - if (vanity) { const existingVanity = await prisma.url.findFirst({ where: { @@ -82,7 +92,7 @@ export default typedPlugin( }, }); - if (existingVanity) return res.badRequest('Vanity already taken'); + if (existingVanity) throw new ApiError(1042); } let code, existingCode; @@ -146,10 +156,14 @@ export default typedPlugin( PATH, { schema: { + description: 'List or search shortened URLs owned by the authenticated user.', querystring: z.object({ searchField: z.enum(['destination', 'vanity', 'code']).default('destination'), searchQuery: z.string().min(1).optional(), }), + response: { + 200: z.array(urlSchema.omit({ password: true })), + }, }, preHandler: [userMiddleware], }, diff --git a/src/server/routes/api/users/[id]/index.ts b/src/server/routes/api/users/[id]/index.ts index b5c90b08..ba9165d6 100644 --- a/src/server/routes/api/users/[id]/index.ts +++ b/src/server/routes/api/users/[id]/index.ts @@ -1,8 +1,9 @@ +import { ApiError } from '@/lib/api/errors'; import { bytes } from '@/lib/bytes'; import { hashPassword } from '@/lib/crypto'; import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; -import { User, userSelect } from '@/lib/db/models/user'; +import { User, userSchema, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { canInteract } from '@/lib/role'; import { zStringTrimmed } from '@/lib/validation'; @@ -26,7 +27,13 @@ export default typedPlugin( server.get( PATH, { - schema: { params: paramsSchema }, + schema: { + description: 'Fetch a specific user by ID, including their profile and role (admin only).', + params: paramsSchema, + response: { + 200: userSchema, + }, + }, preHandler: [userMiddleware, administratorMiddleware], }, async (req, res) => { @@ -37,7 +44,7 @@ export default typedPlugin( select: userSelect, }); - if (!user) return res.notFound('User not found'); + if (!user) throw new ApiError(4009); return res.send(user); }, @@ -47,6 +54,8 @@ export default typedPlugin( PATH, { schema: { + description: + "Update another user's profile, credentials, role, and optional file quota limits (admin only).", params: paramsSchema, body: z.object({ username: zStringTrimmed.optional(), @@ -62,6 +71,9 @@ export default typedPlugin( }) .optional(), }), + response: { + 200: userSchema, + }, }, preHandler: [userMiddleware, administratorMiddleware], }, @@ -72,10 +84,10 @@ export default typedPlugin( }, select: userSelect, }); - if (!user) return res.notFound('User not found'); + if (!user) throw new ApiError(4009); const { username, password, avatar, role, quota } = req.body; - if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot assign this role'); + if (role && !canInteract(req.user.role, role)) throw new ApiError(3007); let finalQuota: | { @@ -86,10 +98,8 @@ export default typedPlugin( } | undefined = undefined; if (quota) { - if (quota.filesType === 'BY_BYTES' && quota.maxBytes === undefined) - return res.badRequest('maxBytes is required'); - if (quota.filesType === 'BY_FILES' && quota.maxFiles === undefined) - return res.badRequest('maxFiles is required'); + if (quota.filesType === 'BY_BYTES' && quota.maxBytes === undefined) throw new ApiError(1056); + if (quota.filesType === 'BY_FILES' && quota.maxFiles === undefined) throw new ApiError(1057); finalQuota = { ...(quota.filesType === 'BY_BYTES' && { @@ -157,10 +167,15 @@ export default typedPlugin( PATH, { schema: { + description: + 'Delete another user by ID, optionally cascading deletion of their files and URLs (admin only).', params: paramsSchema, body: z.object({ delete: z.boolean().optional(), }), + response: { + 200: userSchema, + }, }, preHandler: [userMiddleware, administratorMiddleware], }, @@ -172,9 +187,9 @@ export default typedPlugin( select: userSelect, }); - if (!user) return res.notFound('User not found'); - if (user.id === req.user.id) return res.forbidden('You cannot delete yourself'); - if (!canInteract(req.user.role, user.role)) return res.forbidden('You cannot delete this user'); + if (!user) throw new ApiError(4009); + if (user.id === req.user.id) throw new ApiError(3010); + if (!canInteract(req.user.role, user.role)) throw new ApiError(3009); if (req.body.delete) { const files = await prisma.file.findMany({ diff --git a/src/server/routes/api/users/[id]/tags.ts b/src/server/routes/api/users/[id]/tags.ts index b918f5c0..0883e194 100644 --- a/src/server/routes/api/users/[id]/tags.ts +++ b/src/server/routes/api/users/[id]/tags.ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { prisma } from '@/lib/db'; import { Tag, tagSelect } from '@/lib/db/models/tag'; import { canInteract } from '@/lib/role'; @@ -17,6 +18,8 @@ export default typedPlugin( PATH, { schema: { + description: + 'List tags owned by the specified user, enforcing role-based interaction rules (admin only).', params: z.object({ id: z.string(), }), @@ -32,8 +35,8 @@ export default typedPlugin( }, }); - if (!user) return res.notFound(); - if (!canInteract(req.user.role, user.role)) return res.notFound(); + if (!user) throw new ApiError(9002); + if (!canInteract(req.user.role, user.role)) throw new ApiError(9002); const tags = await prisma.tag.findMany({ where: { diff --git a/src/server/routes/api/users/index.ts b/src/server/routes/api/users/index.ts index 653760ac..2828ce66 100644 --- a/src/server/routes/api/users/index.ts +++ b/src/server/routes/api/users/index.ts @@ -1,7 +1,8 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { createToken, hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { User, userSelect } from '@/lib/db/models/user'; +import { User, userSchema, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { canInteract } from '@/lib/role'; @@ -28,7 +29,12 @@ export default typedPlugin( PATH, { schema: { + description: + 'List users in the instance, optionally excluding the current admin from the results (admin only).', querystring: querySchema, + response: { + 200: z.array(userSchema), + }, }, preHandler: [userMiddleware, administratorMiddleware], }, @@ -51,6 +57,7 @@ export default typedPlugin( PATH, { schema: { + description: 'Create a new user with the given username, password, avatar, and role (admin only).', querystring: querySchema, body: z.object({ username: zStringTrimmed, @@ -58,6 +65,9 @@ export default typedPlugin( avatar: z.string().optional(), role: z.enum(Role).default('USER').optional(), }), + response: { + 200: userSchema, + }, }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), @@ -70,7 +80,7 @@ export default typedPlugin( username, }, }); - if (existing) return res.badRequest('a user with this username already exists'); + if (existing) throw new ApiError(1040); let avatar64 = null; @@ -84,7 +94,7 @@ export default typedPlugin( logger.debug('failed to read default avatar', { path: config.website.defaultAvatar }); } - if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot create this role'); + if (role && !canInteract(req.user.role, role)) throw new ApiError(3008); const user = await prisma.user.create({ data: { diff --git a/src/server/routes/api/version.ts b/src/server/routes/api/version.ts index 51bd7f9b..617aa36b 100644 --- a/src/server/routes/api/version.ts +++ b/src/server/routes/api/version.ts @@ -1,8 +1,10 @@ +import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { log } from '@/lib/logger'; import { getVersion } from '@/lib/version'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiVersionResponse = { details: ReturnType; @@ -10,25 +12,29 @@ export type ApiVersionResponse = { cached: true; }; -type VersionAPI = { - isUpstream: boolean; - isRelease: boolean; - isLatest: boolean; - version: { - tag: string; - sha: string; - url: string; - }; - latest: { - tag: string; - url: string; - commit?: { - sha: string; - url: string; - pull: boolean; - }; - }; -}; +const versionApiSchema = z.object({ + isUpstream: z.boolean(), + isRelease: z.boolean(), + isLatest: z.boolean(), + version: z.object({ + tag: z.string(), + sha: z.string(), + url: z.string(), + }), + latest: z.object({ + tag: z.string(), + url: z.string(), + commit: z + .object({ + sha: z.string(), + url: z.string(), + pull: z.boolean(), + }) + .optional(), + }), +}); + +type VersionAPI = z.infer; const logger = log('api').c('version'); @@ -38,48 +44,68 @@ let cachedAt = 0; export const PATH = '/api/version'; export default typedPlugin( async (server) => { - server.get(PATH, { preHandler: [userMiddleware] }, async (_, res) => { - if (!config.features.versionChecking) return res.notFound(); + server.get( + PATH, + { + schema: { + description: + 'Return backend version information, including current build details and upstream/latest version metadata.', + response: { + 200: z.object({ + data: versionApiSchema.describe('version information from the version checking API'), + details: z.object({ + version: z.string(), + sha: z.string().nullable(), + }), + cached: z.boolean(), + }), + }, + }, + preHandler: [userMiddleware], + }, + async (_, res) => { + if (!config.features.versionChecking) throw new ApiError(9002); - const details = getVersion(); + const details = getVersion(); - // 6 hrs cache - if (cachedData && Date.now() - cachedAt < 6 * 60 * 60 * 1000) { - return res.send({ data: cachedData, details, cached: true }); - } - - const url = new URL(config.features.versionAPI); - url.pathname = '/'; - url.searchParams.set('details', JSON.stringify(details)); - - try { - const resp = await fetch(url); - - if (!resp.ok) { - logger.error('failed to fetch version details', { - status: resp.status, - statusText: resp.statusText, - text: await resp.text(), - }); - - return res.internalServerError('failed to fetch version details'); + // 6 hrs cache + if (cachedData && Date.now() - cachedAt < 6 * 60 * 60 * 1000) { + return res.send({ data: cachedData, details, cached: true }); } - const data: VersionAPI = await resp.json(); + const url = new URL(config.features.versionAPI); + url.pathname = '/'; + url.searchParams.set('details', JSON.stringify(details)); - cachedData = data; - cachedAt = Date.now(); + try { + const resp = await fetch(url); - return res.send({ - data, - details, - cached: false, - }); - } catch (e) { - logger.error('failed to fetch version details').error(e as Error); - return res.internalServerError('failed to fetch version details'); - } - }); + if (!resp.ok) { + logger.error('failed to fetch version details', { + status: resp.status, + statusText: resp.statusText, + text: await resp.text(), + }); + + throw new ApiError(6001); + } + + const data: VersionAPI = await resp.json(); + + cachedData = data; + cachedAt = Date.now(); + + return res.send({ + data, + details, + cached: false, + }); + } catch (e) { + logger.error('failed to fetch version details').error(e as Error); + throw new ApiError(6001); + } + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/raw/[id].ts b/src/server/routes/raw/[id].ts index 4aaa8442..53f9190a 100644 --- a/src/server/routes/raw/[id].ts +++ b/src/server/routes/raw/[id].ts @@ -1,3 +1,4 @@ +import { ApiError } from '@/lib/api/errors'; import { parseRange } from '@/lib/api/range'; import { config } from '@/lib/config'; import { verifyPassword } from '@/lib/crypto'; @@ -78,10 +79,10 @@ export const rawFileHandler = async ( } if (file?.password) { - if (!pw) return res.forbidden('Password protected.'); + if (!pw) throw new ApiError(3004); const verified = await verifyPassword(pw, file.password!); - if (!verified) return res.forbidden('Incorrect password.'); + if (!verified) throw new ApiError(3005); } const size = file?.size || (await datasource.size(file?.name ?? id));