From 260c283872bacccfe9fbe6ccabae693e17e032e7 Mon Sep 17 00:00:00 2001 From: diced Date: Sat, 10 Jan 2026 23:32:59 -0800 Subject: [PATCH] feat: input validation schemas (very wip) --- eslint.config.mjs | 4 - package.json | 2 + pnpm-lock.yaml | 79 +++- .../file/DashboardFile/FileModal.tsx | 14 +- .../pages/files/views/FileTable.tsx | 66 +-- src/components/pages/files/views/Files.tsx | 16 +- .../pages/folders/views/FolderTableView.tsx | 37 +- .../pages/invites/views/InviteTableView.tsx | 30 +- src/components/pages/metrics/index.tsx | 15 +- .../pages/settings/parts/SettingsFileView.tsx | 19 + .../parts/SettingsMfa/PasskeyButton.tsx | 2 - src/components/pages/urls/EditUrlModal.tsx | 12 +- .../pages/urls/views/UrlTableView.tsx | 28 +- .../pages/users/views/UserTableView.tsx | 43 +- src/lib/store/fileTableSettings.ts | 4 +- src/lib/validation.ts | 19 + src/server/index.ts | 39 +- src/server/routes/api/auth/invites/[id].ts | 38 +- src/server/routes/api/auth/invites/index.ts | 32 +- src/server/routes/api/auth/invites/web.ts | 71 ++- src/server/routes/api/auth/login.ts | 131 +++--- src/server/routes/api/auth/logout.ts | 8 +- src/server/routes/api/auth/oauth/discord.ts | 8 +- src/server/routes/api/auth/oauth/github.ts | 8 +- src/server/routes/api/auth/oauth/google.ts | 8 +- src/server/routes/api/auth/oauth/index.ts | 84 ++-- src/server/routes/api/auth/oauth/oidc.ts | 8 +- src/server/routes/api/auth/register.ts | 144 +++--- src/server/routes/api/auth/webauthn.ts | 25 +- src/server/routes/api/healthcheck.ts | 10 +- src/server/routes/api/server/clear_temp.ts | 8 +- src/server/routes/api/server/clear_zeros.ts | 8 +- src/server/routes/api/server/export.ts | 22 +- src/server/routes/api/server/folder.ts | 74 ++-- src/server/routes/api/server/import/v3.ts | 24 +- src/server/routes/api/server/import/v4.ts | 29 +- src/server/routes/api/server/public.ts | 8 +- src/server/routes/api/server/requery_size.ts | 31 +- .../routes/api/server/settings/index.ts | 17 +- src/server/routes/api/server/settings/web.ts | 8 +- src/server/routes/api/server/themes.ts | 8 +- src/server/routes/api/server/thumbnails.ts | 20 +- src/server/routes/api/setup.ts | 88 ++-- src/server/routes/api/stats.ts | 112 +++-- src/server/routes/api/upload/index.ts | 14 +- src/server/routes/api/upload/partial.ts | 10 +- src/server/routes/api/user/avatar.ts | 8 +- src/server/routes/api/user/export.ts | 115 ++--- .../routes/api/user/files/[id]/index.ts | 268 ++++++------ .../routes/api/user/files/[id]/password.ts | 95 ++-- src/server/routes/api/user/files/[id]/raw.ts | 242 ++++++----- .../routes/api/user/files/incomplete.ts | 25 +- src/server/routes/api/user/files/index.ts | 410 +++++++++--------- .../routes/api/user/files/transaction.ts | 50 ++- .../routes/api/user/folders/[id]/export.ts | 86 ++-- .../routes/api/user/folders/[id]/index.ts | 343 ++++++++------- src/server/routes/api/user/folders/index.ts | 114 ++--- src/server/routes/api/user/index.ts | 53 +-- src/server/routes/api/user/mfa/passkey.ts | 46 +- src/server/routes/api/user/mfa/totp.ts | 48 +- src/server/routes/api/user/recent.ts | 63 +-- src/server/routes/api/user/sessions.ts | 100 +++-- src/server/routes/api/user/stats.ts | 8 +- src/server/routes/api/user/tags/[id].ts | 169 ++++---- src/server/routes/api/user/tags/index.ts | 30 +- src/server/routes/api/user/token.ts | 8 +- src/server/routes/api/user/urls/[id]/index.ts | 131 +++--- .../routes/api/user/urls/[id]/password.ts | 94 ++-- src/server/routes/api/user/urls/index.ts | 123 +++--- src/server/routes/api/users/[id]/index.ts | 90 ++-- src/server/routes/api/users/[id]/tags.ts | 24 +- src/server/routes/api/users/index.ts | 60 +-- src/server/routes/api/version.ts | 8 +- src/server/routes/favicon.ts | 8 +- src/server/routes/invites.ts | 8 +- src/server/routes/manifest.json.ts | 8 +- src/server/routes/raw/[id].ts | 8 +- src/server/routes/robots.txt.ts | 10 +- src/server/typedPlugin.ts | 9 + 79 files changed, 2277 insertions(+), 2070 deletions(-) create mode 100644 src/lib/validation.ts create mode 100644 src/server/typedPlugin.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 683585ec..e5f0d54a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,10 +22,6 @@ const gitignorePatterns = gitignoreContent .filter((line) => line.trim() && !line.startsWith('#')) .map((pattern) => pattern.trim()); -const reactRecommendedRules = reactPlugin.configs.recommended.rules; -const reactHooksRecommendedRules = reactHooksPlugin.configs['recommended-latest'].rules; -const reactRefreshRules = reactRefreshPlugin.configs.vite.rules; - import { defineConfig } from 'eslint/config'; export default defineConfig( diff --git a/package.json b/package.json index 400cdc6c..77e9b399 100755 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.4", "@fastify/static": "^8.3.0", + "@fastify/swagger": "^9.6.1", "@mantine/charts": "^8.3.9", "@mantine/code-highlight": "^8.3.9", "@mantine/core": "^8.3.9", @@ -64,6 +65,7 @@ "fast-glob": "^3.3.3", "fastify": "^5.6.2", "fastify-plugin": "^5.1.0", + "fastify-type-provider-zod": "^6.1.0", "fluent-ffmpeg": "^2.1.3", "highlight.js": "^11.11.1", "iron-session": "^8.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 822ee8fc..6cd508bb 100755 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@fastify/static': specifier: ^8.3.0 version: 8.3.0 + '@fastify/swagger': + specifier: ^9.6.1 + version: 9.6.1 '@mantine/charts': specifier: ^8.3.9 version: 8.3.9(@mantine/core@8.3.9(@mantine/hooks@8.3.9(react@19.2.1))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@mantine/hooks@8.3.9(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(recharts@2.15.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) @@ -137,6 +140,9 @@ importers: fastify-plugin: specifier: ^5.1.0 version: 5.1.0 + fastify-type-provider-zod: + specifier: ^6.1.0 + version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.1.13) fluent-ffmpeg: specifier: ^2.1.3 version: 2.1.3 @@ -199,7 +205,7 @@ importers: version: 8.48.1(eslint@9.39.1(jiti@2.5.1))(typescript@5.9.3) vite: specifier: ^7.2.7 - version: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0) + version: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2) zod: specifier: ^4.1.13 version: 4.1.13 @@ -242,7 +248,7 @@ importers: version: 1.8.8 '@vitejs/plugin-react': specifier: ^5.1.1 - version: 5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)) + version: 5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^9.39.1 version: 9.39.1(jiti@2.5.1) @@ -287,7 +293,7 @@ importers: version: 1.8.16 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -1041,6 +1047,9 @@ packages: '@fastify/static@8.3.0': resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} + '@fastify/swagger@9.6.1': + resolution: {integrity: sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -2996,6 +3005,14 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-type-provider-zod@6.1.0: + resolution: {integrity: sha512-Sl19VZFSX4W/+AFl3hkL5YgWk3eDXZ4XYOdrq94HunK+o7GQBCAqgk7+3gPXoWkF0bNxOiIgfnFGJJ3i9a2BtQ==} + peerDependencies: + '@fastify/swagger': '>=9.5.1' + fastify: ^5.5.0 + openapi-types: ^12.1.3 + zod: '>=4.1.5' + fastify@5.6.2: resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} @@ -3487,6 +3504,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -3894,6 +3915,9 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5149,6 +5173,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -6198,6 +6227,16 @@ snapshots: fastq: 1.19.1 glob: 11.1.0 + '@fastify/swagger@9.6.1': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.2 + transitivePeerDependencies: + - supports-color + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -7548,7 +7587,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0))': + '@vitejs/plugin-react@5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -7556,7 +7595,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.47 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -8525,6 +8564,14 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-type-provider-zod@6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.1.13): + dependencies: + '@fastify/error': 4.2.0 + '@fastify/swagger': 9.6.1 + fastify: 5.6.2 + openapi-types: 12.1.3 + zod: 4.1.13 + fastify@5.6.2: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -9082,6 +9129,14 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.0 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -9684,6 +9739,8 @@ snapshots: on-exit-leak-free@2.1.2: {} + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -9873,13 +9930,14 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0): + postcss-load-config@6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.5.1 postcss: 8.5.6 tsx: 4.21.0 + yaml: 2.8.2 postcss-mixins@12.1.2(postcss@8.5.6): dependencies: @@ -10731,7 +10789,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + tsup@8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.1) cac: 6.7.14 @@ -10742,7 +10800,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0) + postcss-load-config: 6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) resolve-from: 5.0.0 rollup: 4.53.3 source-map: 0.7.6 @@ -10971,7 +11029,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0): + vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -10986,6 +11044,7 @@ snapshots: sass: 1.94.2 sugarss: 5.0.1(postcss@8.5.6) tsx: 4.21.0 + yaml: 2.8.2 w3c-xmlserializer@5.0.0: dependencies: @@ -11087,6 +11146,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.8.2: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 diff --git a/src/components/file/DashboardFile/FileModal.tsx b/src/components/file/DashboardFile/FileModal.tsx index b39469c1..1aaff0b4 100755 --- a/src/components/file/DashboardFile/FileModal.tsx +++ b/src/components/file/DashboardFile/FileModal.tsx @@ -47,7 +47,7 @@ import { IconTrashFilled, IconUpload, } from '@tabler/icons-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import useSWR, { mutate } from 'swr'; import DashboardFileType from '../DashboardFileType'; import { @@ -122,7 +122,9 @@ export default function FileModal({ ); const tagsCombobox = useCombobox(); - const [value, setValue] = useState(file?.tags?.map((x) => x.id) ?? []); + + const [value, setValue] = useState(() => file?.tags?.map((x) => x.id) ?? []); + const handleValueSelect = (val: string) => { setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val])); }; @@ -172,14 +174,6 @@ export default function FileModal({ const values = value.map((tag) => t.id === tag) || null} />); - useEffect(() => { - if (file) { - setValue(file.tags?.map((x) => x.id) ?? []); - } else { - setValue([]); - } - }, [file]); - return ( <> setEditFileOpen(false)} file={file!} /> diff --git a/src/components/pages/files/views/FileTable.tsx b/src/components/pages/files/views/FileTable.tsx index 5bf5e5e7..89cc0d5f 100755 --- a/src/components/pages/files/views/FileTable.tsx +++ b/src/components/pages/files/views/FileTable.tsx @@ -212,35 +212,23 @@ export default function FileTable({ | 'favorite' >('createdAt'); const [order, setOrder] = useState<'asc' | 'desc'>('desc'); - const [selectedFile, setSelectedFile] = useState(null); const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name'); const [searchQuery, setSearchQuery] = useReducer( - (state: ReducerQuery['state'], action: ReducerQuery['action']) => { - return { - ...state, - [action.field]: action.query, - }; - }, + ( + _state: { name: string; originalName: string; type: string; tags: string; id: string }, + action: { field: keyof ReducerQuery['state']; query: string }, + ) => ({ + name: action.field === 'name' ? action.query : '', + originalName: action.field === 'originalName' ? action.query : '', + type: action.field === 'type' ? action.query : '', + tags: action.field === 'tags' ? action.query : '', + id: action.field === 'id' ? action.query : '', + }), { name: '', originalName: '', type: '', tags: '', id: '' }, ); const [debouncedQuery, setDebouncedQuery] = useState(searchQuery); - useEffect(() => { - if (idSearch.open) return; - - setSearchQuery({ - field: 'id', - query: '', - }); - }, [idSearch.open]); - - useEffect(() => { - const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300); - - return () => clearTimeout(handler); - }, [searchQuery]); - const [selectedFiles, setSelectedFiles] = useState([]); const combobox = useCombobox(); @@ -273,6 +261,11 @@ export default function FileTable({ }), }); + const [selectedFileId, setSelectedFile] = useState(null); + const selectedFile = selectedFileId + ? (data?.page.find((file) => file.id === selectedFileId) ?? null) + : null; + const FIELDS = [ { accessor: 'name', @@ -367,29 +360,14 @@ export default function FileTable({ return aIndex - bIndex; }); - useEffect(() => { - if (data && selectedFile) { - const file = data.page.find((x) => x.id === selectedFile.id); - - if (file) { - setSelectedFile(file); - } - } - }, [data]); - - useEffect(() => { - for (const field of ['name', 'originalName', 'type', 'tags', 'id'] as const) { - if (field !== searchField) { - setSearchQuery({ - field, - query: '', - }); - } - } - }, [searchField]); - const unfavoriteAll = selectedFiles.every((file) => file.favorite); + useEffect(() => { + const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300); + + return () => clearTimeout(handler); + }, [searchQuery]); + return ( <> setSelectedFile(record)} + onCellClick={({ record }) => setSelectedFile(record.id)} selectedRecords={selectedFiles} onSelectedRecordsChange={setSelectedFiles} paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`} diff --git a/src/components/pages/files/views/Files.tsx b/src/components/pages/files/views/Files.tsx index 84e967a1..9c4947a4 100755 --- a/src/components/pages/files/views/Files.tsx +++ b/src/components/pages/files/views/Files.tsx @@ -1,3 +1,4 @@ +import { useQueryState } from '@/lib/hooks/useQueryState'; import { Button, Center, @@ -11,11 +12,10 @@ import { Text, Title, } from '@mantine/core'; -import { IconFileUpload, IconFilesOff } from '@tabler/icons-react'; -import { lazy, Suspense, useEffect, useState } from 'react'; -import { useApiPagination } from '../useApiPagination'; +import { IconFilesOff, IconFileUpload } from '@tabler/icons-react'; +import { lazy, Suspense, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useQueryState } from '@/lib/hooks/useQueryState'; +import { useApiPagination } from '../useApiPagination'; const DashboardFile = lazy(() => import('@/components/file/DashboardFile')); @@ -24,7 +24,6 @@ const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45]; export default function Files({ id }: { id?: string }) { const [page, setPage] = useQueryState('page', 1); const [perpage, setPerpage] = useState(15); - const [cachedPages, setCachedPages] = useState(1); const { data, isLoading } = useApiPagination({ page, @@ -32,15 +31,10 @@ export default function Files({ id }: { id?: string }) { id, }); - useEffect(() => { - if (data?.pages) { - setCachedPages(data.pages); - } - }, [data?.pages]); - const from = (page - 1) * perpage + 1; const to = Math.min(page * perpage, data?.total ?? 0); const totalRecords = data?.total ?? 0; + const cachedPages = data?.pages ?? 1; return ( <> diff --git a/src/components/pages/folders/views/FolderTableView.tsx b/src/components/pages/folders/views/FolderTableView.tsx index e0901286..bb7bd019 100755 --- a/src/components/pages/folders/views/FolderTableView.tsx +++ b/src/components/pages/folders/views/FolderTableView.tsx @@ -3,10 +3,6 @@ import { Response } from '@/lib/api/response'; import { Folder } from '@/lib/db/models/folder'; import { ActionIcon, Anchor, Box, Checkbox, Group, Tooltip } from '@mantine/core'; import { useClipboard } from '@mantine/hooks'; -import { DataTable, DataTableSortStatus } from 'mantine-datatable'; -import { useEffect, useState } from 'react'; -import useSWR from 'swr'; -import { copyFolderUrl, deleteFolder, editFolderVisibility, editFolderUploads } from '../actions'; import { IconCopy, IconFiles, @@ -18,8 +14,12 @@ import { IconTrashFilled, IconZip, } from '@tabler/icons-react'; -import ViewFilesModal from '../ViewFilesModal'; +import { DataTable, DataTableSortStatus } from 'mantine-datatable'; +import { useMemo, useState } from 'react'; +import useSWR from 'swr'; +import { copyFolderUrl, deleteFolder, editFolderUploads, editFolderVisibility } from '../actions'; import EditFolderNameModal from '../EditFolderNameModal'; +import ViewFilesModal from '../ViewFilesModal'; export default function FolderTableView() { const clipboard = useClipboard(); @@ -30,28 +30,23 @@ export default function FolderTableView() { columnAccessor: 'createdAt', direction: 'desc', }); - const [sorted, setSorted] = useState(data ?? []); const [selectedFolder, setSelectedFolder] = useState(null); - const [editNameOpen, setEditNameOpen] = useState(null); - useEffect(() => { - if (data) { - const sorted = data.sort((a, b) => { - const cl = sortStatus.columnAccessor as keyof Folder; + const sorted = useMemo(() => { + if (!data) return []; - return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1; - }); + const { columnAccessor, direction } = sortStatus; + const key = columnAccessor as keyof Folder; - setSorted(sorted); - } - }, [sortStatus]); + return [...data].sort((a, b) => { + const av = a[key]!; + const bv = b[key]!; - useEffect(() => { - if (data) { - setSorted(data); - } - }, [data]); + if (av === bv) return 0; + return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1; + }); + }, [data, sortStatus]); return ( <> diff --git a/src/components/pages/invites/views/InviteTableView.tsx b/src/components/pages/invites/views/InviteTableView.tsx index 27416c64..14e3f714 100755 --- a/src/components/pages/invites/views/InviteTableView.tsx +++ b/src/components/pages/invites/views/InviteTableView.tsx @@ -1,14 +1,14 @@ import RelativeDate from '@/components/RelativeDate'; import { Response } from '@/lib/api/response'; import { Invite } from '@/lib/db/models/invite'; +import { useSettingsStore } from '@/lib/store/settings'; import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core'; import { useClipboard } from '@mantine/hooks'; import { IconCopy, IconTrashFilled } from '@tabler/icons-react'; import { DataTable, DataTableSortStatus } from 'mantine-datatable'; -import { useEffect, useState } from 'react'; +import { useMemo, useState } from 'react'; import useSWR from 'swr'; import { copyInviteUrl, deleteInvite } from '../actions'; -import { useSettingsStore } from '@/lib/store/settings'; export default function InviteTableView() { const clipboard = useClipboard(); @@ -20,25 +20,21 @@ export default function InviteTableView() { columnAccessor: 'createdAt', direction: 'desc', }); - const [sorted, setSorted] = useState(data ?? []); - useEffect(() => { - if (data) { - const sorted = data.sort((a, b) => { - const cl = sortStatus.columnAccessor as keyof Invite; + const sorted = useMemo(() => { + if (!data) return []; - return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1; - }); + const { columnAccessor, direction } = sortStatus; + const key = columnAccessor as keyof Invite; - setSorted(sorted); - } - }, [sortStatus]); + return [...data].sort((a, b) => { + const av = a[key]!; + const bv = b[key]!; - useEffect(() => { - if (data) { - setSorted(data); - } - }, [data]); + if (av === bv) return 0; + return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1; + }); + }, [data, sortStatus]); return ( <> diff --git a/src/components/pages/metrics/index.tsx b/src/components/pages/metrics/index.tsx index fa9d2bff..bcb65b47 100755 --- a/src/components/pages/metrics/index.tsx +++ b/src/components/pages/metrics/index.tsx @@ -1,12 +1,12 @@ import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core'; import { DatePicker } from '@mantine/dates'; import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react'; -import { lazy, useEffect, useState } from 'react'; +import dayjs from 'dayjs'; +import { lazy, useState } from 'react'; import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph'; -import { useApiStats } from './useStats'; import { StatsCardsSkeleton } from './parts/StatsCards'; import { StatsTablesSkeleton } from './parts/StatsTables'; -import dayjs from 'dayjs'; +import { useApiStats } from './useStats'; const StorageGraph = lazy(() => import('./parts/StorageGraph')); const ViewsGraph = lazy(() => import('./parts/ViewsGraph')); @@ -35,9 +35,10 @@ export default function DashboardMetrics() { setDateRange(value); }; - useEffect(() => { - if (allTime) setDateRange([null, null]); - }, [allTime]); + const showAllTime = () => { + setAllTime(true); + setDateRange([null, null]); + }; return ( <> @@ -118,7 +119,7 @@ export default function DashboardMetrics() { size='compact-sm' variant='outline' leftSection={} - onClick={() => setAllTime(true)} + onClick={() => showAllTime()} disabled={allTime} > Show All Time diff --git a/src/components/pages/settings/parts/SettingsFileView.tsx b/src/components/pages/settings/parts/SettingsFileView.tsx index 93b07cc5..c803c653 100755 --- a/src/components/pages/settings/parts/SettingsFileView.tsx +++ b/src/components/pages/settings/parts/SettingsFileView.tsx @@ -27,6 +27,7 @@ import { IconDeviceFloppy, IconFileX, } from '@tabler/icons-react'; +import { useEffect } from 'react'; import { mutate } from 'swr'; import { useShallow } from 'zustand/shallow'; @@ -94,6 +95,24 @@ export default function SettingsFileView() { }); }; + useEffect(() => { + if (user) { + form.setValues({ + enabled: user.view.enabled || false, + content: user.view.content || '', + embed: user.view.embed || false, + embedTitle: user.view.embedTitle || '', + embedDescription: user.view.embedDescription || '', + embedSiteName: user.view.embedSiteName || '', + embedColor: user.view.embedColor || '', + align: user.view.align || 'left', + showMimetype: user.view.showMimetype || false, + showTags: user.view.showTags || false, + showFolder: user.view.showFolder || false, + }); + } + }, [user]); + return ( Viewing Files diff --git a/src/components/pages/settings/parts/SettingsMfa/PasskeyButton.tsx b/src/components/pages/settings/parts/SettingsMfa/PasskeyButton.tsx index 6f479c30..11695fb2 100755 --- a/src/components/pages/settings/parts/SettingsMfa/PasskeyButton.tsx +++ b/src/components/pages/settings/parts/SettingsMfa/PasskeyButton.tsx @@ -22,7 +22,6 @@ export default function PasskeyButton() { const [passkeyLoading, setPasskeyLoading] = useState(false); const [namerShown, setNamerShown] = useState(false); const [savedKey, setSavedKey] = useState(null); - const [options, setOptions] = useState(null); const [name, setName] = useState(''); const handleRegisterPasskey = async () => { @@ -36,7 +35,6 @@ export default function PasskeyButton() { const res = await startRegistration({ optionsJSON: data! }); setNamerShown(true); setSavedKey(res); - setOptions(data); } catch (e: any) { setPasskeyError(e.message ?? 'An error occurred while creating a passkey'); setPasskeyLoading(false); diff --git a/src/components/pages/urls/EditUrlModal.tsx b/src/components/pages/urls/EditUrlModal.tsx index e76d76eb..5b1571d0 100644 --- a/src/components/pages/urls/EditUrlModal.tsx +++ b/src/components/pages/urls/EditUrlModal.tsx @@ -53,15 +53,21 @@ export default function EditUrlModal({ const handleSave = async () => { const data: { - maxViews?: number; + maxViews?: number | null; password?: string; vanity?: string; destination?: string; enabled?: boolean; } = {}; - if (maxViews !== null) data['maxViews'] = maxViews; - if (password !== null) data['password'] = password?.trim(); + console.log(password); + + if (maxViews === null) data['maxViews'] = null; + else data['maxViews'] = maxViews; + + // dont include password if empty or null + if (password !== null && password.trim() !== '') data['password'] = password?.trim(); + if (vanity !== null && vanity !== url.vanity) data['vanity'] = vanity?.trim(); if (destination !== null && destination !== url.destination) data['destination'] = destination?.trim(); if (enabled !== url.enabled) data['enabled'] = enabled; diff --git a/src/components/pages/urls/views/UrlTableView.tsx b/src/components/pages/urls/views/UrlTableView.tsx index ccecea04..f749fc88 100755 --- a/src/components/pages/urls/views/UrlTableView.tsx +++ b/src/components/pages/urls/views/UrlTableView.tsx @@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response'; import { Url } from '@/lib/db/models/url'; import { ActionIcon, Anchor, Box, Checkbox, Group, TextInput, Tooltip } from '@mantine/core'; import { DataTable, DataTableSortStatus } from 'mantine-datatable'; -import { useEffect, useReducer, useState } from 'react'; +import { useEffect, useMemo, useReducer, useState } from 'react'; import useSWR from 'swr'; import { copyUrl, deleteUrl } from '../actions'; import { IconCopy, IconPencil, IconTrashFilled } from '@tabler/icons-react'; @@ -112,27 +112,23 @@ export default function UrlTableView() { columnAccessor: 'createdAt', direction: 'desc', }); - const [sorted, setSorted] = useState(data ?? []); const [selectedUrl, setSelectedUrl] = useState(null); - useEffect(() => { - if (data) { - const sorted = data.sort((a, b) => { - const cl = sortStatus.columnAccessor as keyof Url; + const sorted = useMemo(() => { + if (!data) return []; - return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1; - }); + const { columnAccessor, direction } = sortStatus; + const key = columnAccessor as keyof Url; - setSorted(sorted); - } - }, [sortStatus]); + return [...data].sort((a, b) => { + const av = a[key]!; + const bv = b[key]!; - useEffect(() => { - if (data) { - setSorted(data); - } - }, [data]); + if (av === bv) return 0; + return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1; + }); + }, [data, sortStatus]); useEffect(() => { for (const field of ['code', 'vanity', 'destination'] as const) { diff --git a/src/components/pages/users/views/UserTableView.tsx b/src/components/pages/users/views/UserTableView.tsx index 810fc1b3..c0880142 100755 --- a/src/components/pages/users/views/UserTableView.tsx +++ b/src/components/pages/users/views/UserTableView.tsx @@ -1,46 +1,43 @@ +import RelativeDate from '@/components/RelativeDate'; import { Response } from '@/lib/api/response'; import { User } from '@/lib/db/models/user'; +import { canInteract, roleName } from '@/lib/role'; +import { useUserStore } from '@/lib/store/user'; import { ActionIcon, Avatar, Box, Group, Tooltip } from '@mantine/core'; import { IconEdit, IconFiles, IconTrashFilled } from '@tabler/icons-react'; import { DataTable, DataTableSortStatus } from 'mantine-datatable'; -import { useEffect, useState } from 'react'; -import useSWR from 'swr'; -import EditUserModal from '../EditUserModal'; -import RelativeDate from '@/components/RelativeDate'; -import { canInteract, roleName } from '@/lib/role'; -import { useUserStore } from '@/lib/store/user'; -import { deleteUser } from '../actions'; +import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import useSWR from 'swr'; +import { deleteUser } from '../actions'; +import EditUserModal from '../EditUserModal'; export default function UserTableView() { const currentUser = useUserStore((state) => state.user); const { data, isLoading } = useSWR>('/api/users?noincl=true'); + const [selectedUser, setSelectedUser] = useState(null); + const [sortStatus, setSortStatus] = useState({ columnAccessor: 'createdAt', direction: 'desc', }); - const [sorted, setSorted] = useState(data ?? []); - const [selectedUser, setSelectedUser] = useState(null); - useEffect(() => { - if (data) { - const sorted = data.sort((a, b) => { - const cl = sortStatus.columnAccessor as keyof User; + const sorted = useMemo(() => { + if (!data) return []; - return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1; - }); + const { columnAccessor, direction } = sortStatus; + const key = columnAccessor as keyof User; - setSorted(sorted); - } - }, [sortStatus]); + return [...data].sort((a, b) => { + const av = a[key]!; + const bv = b[key]!; - useEffect(() => { - if (data) { - setSorted(data); - } - }, [data]); + if (av === bv) return 0; + return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1; + }); + }, [data, sortStatus]); return ( <> diff --git a/src/lib/store/fileTableSettings.ts b/src/lib/store/fileTableSettings.ts index 2021810c..d48136f5 100755 --- a/src/lib/store/fileTableSettings.ts +++ b/src/lib/store/fileTableSettings.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -const FIELDS = ['name', 'originalName', 'tags', 'type', 'size', 'createdAt', 'favorite', 'views'] as const; +type Field = 'name' | 'originalName' | 'tags' | 'type' | 'size' | 'createdAt' | 'favorite' | 'views'; export const defaultFields: FieldSettings[] = [ { field: 'name', visible: true }, @@ -15,7 +15,7 @@ export const defaultFields: FieldSettings[] = [ ]; export type FieldSettings = { - field: (typeof FIELDS)[number]; + field: Field; visible: boolean; }; diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 00000000..944a383f --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,19 @@ +import z from 'zod'; +import { sanitizeFilename } from './fs'; + +export function zValidatePath(val: string | undefined, ctx: z.RefinementCtx) { + if (!val) return; + + const sanitized = sanitizeFilename(val); + if (!sanitized) { + ctx.addIssue({ + code: 'custom', + message: 'Invalid path', + input: val, + }); + + return undefined; + } + + return sanitized; +} diff --git a/src/server/index.ts b/src/server/index.ts index 22706ff2..70b2f2b9 100755 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,6 +20,13 @@ import { fastifyRateLimit } from '@fastify/rate-limit'; import { fastifySensible } from '@fastify/sensible'; import { fastifyStatic } from '@fastify/static'; import fastify from 'fastify'; +import { + hasZodFastifySchemaValidationErrors, + jsonSchemaTransform, + serializerCompiler, + validatorCompiler, + ZodTypeProvider, +} from 'fastify-type-provider-zod'; import { appendFile, mkdir, readFile, writeFile } from 'fs/promises'; import ms, { StringValue } from 'ms'; import { version } from '../../package.json'; @@ -29,6 +36,7 @@ import vitePlugin from './plugins/vite'; import loadRoutes from './routes'; import { filesRoute } from './routes/files.dy'; import { urlsRoute } from './routes/urls.dy'; +import fastifySwagger from '@fastify/swagger'; const MODE = process.env.NODE_ENV || 'production'; const logger = log('server'); @@ -81,7 +89,7 @@ async function main() { } : null, trustProxy: config.core.trustProxy, - }); + }).withTypeProvider(); if (process.env.DEBUG_EVENT_EMITTER) { server.addHook('onSend', async (req, res) => { @@ -97,6 +105,21 @@ async function main() { }); } + server.setValidatorCompiler(validatorCompiler); + server.setSerializerCompiler(serializerCompiler); + + await server.register(fastifySwagger, { + openapi: { + info: { + title: 'Zipline', + description: 'Zipline API', + version: version, + }, + servers: [], + }, + transform: jsonSchemaTransform, + }); + await server.register(fastifyCookie, { secret: config.core.secret, hook: 'onRequest', @@ -223,15 +246,23 @@ async function main() { } }); - server.setErrorHandler((error: { statusCode: number; message: string }, _, res) => { + server.setErrorHandler((error: any, _, res) => { + if (hasZodFastifySchemaValidationErrors(error)) { + return res.status(400).send({ + error: error.message ?? 'Response Validation Error', + statusCode: 400, + issues: error.validation, + }); + } + if (error.statusCode) { res.status(error.statusCode); res.send({ error: error.message, statusCode: error.statusCode }); } else { - if (process.env.DEBUG === 'zipline') console.error(error); + console.error(error); res.status(500); - res.send({ error: 'Internal Server Error', statusCode: 500, message: error.message }); + res.send({ error: 'Internal Server Error', statusCode: 500 }); } }); diff --git a/src/server/routes/api/auth/invites/[id].ts b/src/server/routes/api/auth/invites/[id].ts index 9039101c..a4babe62 100755 --- a/src/server/routes/api/auth/invites/[id].ts +++ b/src/server/routes/api/auth/invites/[id].ts @@ -1,25 +1,30 @@ -import { Prisma } from '@/prisma/client'; import { prisma } from '@/lib/db'; import { Invite, inviteInviterSelect } from '@/lib/db/models/invite'; import { log } from '@/lib/logger'; +import { Prisma } from '@/prisma/client'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiAuthInvitesIdResponse = Invite; - -type Params = { - id: string; -}; - const logger = log('api').c('auth').c('invites').c('[id]'); +const paramsSchema = z.object({ + id: z.string(), +}); + export const PATH = '/api/auth/invites/:id'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Params: Params }>( +export default typedPlugin( + async (server) => { + server.get( PATH, - { preHandler: [userMiddleware, administratorMiddleware] }, + { + schema: { + params: paramsSchema, + }, + preHandler: [userMiddleware, administratorMiddleware], + }, async (req, res) => { const { id } = req.params; @@ -37,9 +42,14 @@ export default fastifyPlugin( }, ); - server.delete<{ Params: Params }>( + server.delete( PATH, - { preHandler: [userMiddleware, administratorMiddleware] }, + { + schema: { + params: paramsSchema, + }, + preHandler: [userMiddleware, administratorMiddleware], + }, async (req, res) => { const { id } = req.params; @@ -69,8 +79,6 @@ export default fastifyPlugin( } }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/invites/index.ts b/src/server/routes/api/auth/invites/index.ts index f31e9f17..a4b55c47 100755 --- a/src/server/routes/api/auth/invites/index.ts +++ b/src/server/routes/api/auth/invites/index.ts @@ -1,33 +1,37 @@ import { config } from '@/lib/config'; -import { randomCharacters } from '@/lib/random'; import { prisma } from '@/lib/db'; import { Invite, inviteInviterSelect } from '@/lib/db/models/invite'; import { log } from '@/lib/logger'; +import { randomCharacters } from '@/lib/random'; +import { secondlyRatelimit } from '@/lib/ratelimits'; import { parseExpiry } from '@/lib/uploader/parseHeaders'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; -import { secondlyRatelimit } from '@/lib/ratelimits'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiAuthInvitesResponse = Invite | Invite[]; -type Body = { - expiresAt: string; - maxUses?: number; -}; - const logger = log('api').c('auth').c('invites'); export const PATH = '/api/auth/invites'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Body: Body }>( +export default typedPlugin( + async (server) => { + server.post( PATH, - { preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1) }, + { + schema: { + body: z.object({ + expiresAt: z.string().or(z.literal('never')), + maxUses: z.number().min(1).optional(), + }), + }, + preHandler: [userMiddleware, administratorMiddleware], + ...secondlyRatelimit(1), + }, async (req, res) => { const { expiresAt, maxUses } = req.body; - if (!expiresAt) return res.badRequest('expiresAt is required'); let expires = null; if (expiresAt !== 'never') expires = parseExpiry(expiresAt); @@ -63,8 +67,6 @@ export default fastifyPlugin( return res.send(invites); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/invites/web.ts b/src/server/routes/api/auth/invites/web.ts index 1d322929..66014b08 100644 --- a/src/server/routes/api/auth/invites/web.ts +++ b/src/server/routes/api/auth/invites/web.ts @@ -2,7 +2,8 @@ import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; import { Invite } from '@/lib/db/models/invite'; import { secondlyRatelimit } from '@/lib/ratelimits'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiAuthInvitesWebResponse = Invite & { inviter: { @@ -10,48 +11,46 @@ export type ApiAuthInvitesWebResponse = Invite & { }; }; -type Query = { - code: string; -}; - export const PATH = '/api/auth/invites/web'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Querystring: Query }>(PATH, secondlyRatelimit(10), async (req, res) => { - const { code } = req.query; +export default typedPlugin( + async (server) => { + server.get( + PATH, + { schema: { querystring: z.object({ code: z.string().optional() }) }, ...secondlyRatelimit(10) }, + async (req, res) => { + const { code } = req.query; - if (!code) return res.send({ invite: null }); - if (!config.invites.enabled) return res.notFound(); + if (!code) return res.send({ invite: null }); + if (!config.invites.enabled) return res.notFound(); - const invite = await prisma.invite.findFirst({ - where: { - OR: [{ id: code }, { code }], - }, - select: { - code: true, - maxUses: true, - uses: true, - expiresAt: true, - inviter: { - select: { username: true }, + const invite = await prisma.invite.findFirst({ + where: { + OR: [{ id: code }, { code }], }, - }, - }); + select: { + code: true, + maxUses: true, + uses: true, + expiresAt: true, + inviter: { + select: { username: true }, + }, + }, + }); - if ( - !invite || - (invite.expiresAt && new Date(invite.expiresAt) < new Date()) || - (invite.maxUses && invite.uses >= invite.maxUses) - ) { - return res.notFound(); - } + if ( + !invite || + (invite.expiresAt && new Date(invite.expiresAt) < new Date()) || + (invite.maxUses && invite.uses >= invite.maxUses) + ) { + return res.notFound(); + } - delete (invite as any).expiresAt; + delete (invite as any).expiresAt; - return res.send({ invite }); - }); - - done(); + return res.send({ invite }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/login.ts b/src/server/routes/api/auth/login.ts index 70352f91..97bd7a6f 100755 --- a/src/server/routes/api/auth/login.ts +++ b/src/server/routes/api/auth/login.ts @@ -5,93 +5,96 @@ import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { verifyTotpCode } from '@/lib/totp'; import { getSession, saveSession } from '@/server/session'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiLoginResponse = { user?: User; totp?: true; }; -type Body = { - username: string; - password: string; - code?: string; -}; - const logger = log('api').c('auth').c('login'); export const PATH = '/api/auth/login'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Body: Body }>(PATH, secondlyRatelimit(2), async (req, res) => { - const session = await getSession(req, res); - - session.id = null; - session.sessionId = null; - - const { username, password, code } = req.body; - - if (!username) return res.badRequest('Username is required'); - if (!password) return res.badRequest('Password is required'); - - const user = await prisma.user.findUnique({ - where: { - username, +export default typedPlugin( + async (server) => { + server.post( + PATH, + { + schema: { + body: z.object({ + username: z.string().min(1), + password: z.string().min(1), + code: z.string().min(1).optional(), + }), }, - select: { - ...userSelect, - password: true, - token: true, - }, - }); - if (!user) return res.badRequest('Invalid username or password'); - if (!user.password) return res.badRequest('Invalid username or password'); + ...secondlyRatelimit(2), + }, + async (req, res) => { + const session = await getSession(req, res); - const valid = await verifyPassword(password, user.password); - if (!valid) { - logger.warn('invalid login attempt', { - username, - ip: req.ip ?? 'unknown', - ua: req.headers['user-agent'], + session.id = null; + session.sessionId = null; + + const { username, password, code } = req.body; + + const user = await prisma.user.findUnique({ + where: { + username, + }, + select: { + ...userSelect, + password: true, + token: true, + }, }); + if (!user) return res.badRequest('Invalid username or password'); + if (!user.password) return res.badRequest('Invalid username or password'); - return res.badRequest('Invalid username or password'); - } - - if (user.totpSecret && code) { - const valid = verifyTotpCode(code, user.totpSecret); + const valid = await verifyPassword(password, user.password); if (!valid) { - logger.warn('invalid totp code', { + logger.warn('invalid login attempt', { username, ip: req.ip ?? 'unknown', ua: req.headers['user-agent'], }); - return res.badRequest('Invalid code'); + return res.badRequest('Invalid username or password'); } - } - if (user.totpSecret && !code) - return res.send({ - totp: true, + if (user.totpSecret && code) { + const valid = verifyTotpCode(code, user.totpSecret); + if (!valid) { + logger.warn('invalid totp code', { + username, + ip: req.ip ?? 'unknown', + ua: req.headers['user-agent'], + }); + + return res.badRequest('Invalid code'); + } + } + + if (user.totpSecret && !code) + return res.send({ + totp: true, + }); + + await saveSession(session, user, false); + + delete (user as any).password; + + logger.info('user logged in successfully', { + username, + ip: req.ip ?? 'unknown', + ua: req.headers['user-agent'], }); - await saveSession(session, user, false); - - delete (user as any).password; - - logger.info('user logged in successfully', { - username, - ip: req.ip ?? 'unknown', - ua: req.headers['user-agent'], - }); - - return res.send({ - user, - }); - }); - - done(); + return res.send({ + user, + }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/logout.ts b/src/server/routes/api/auth/logout.ts index 7d385e07..97dbd4f5 100755 --- a/src/server/routes/api/auth/logout.ts +++ b/src/server/routes/api/auth/logout.ts @@ -2,7 +2,7 @@ import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; import { getSession } from '@/server/session'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiLogoutResponse = { loggedOut?: boolean; @@ -11,8 +11,8 @@ export type ApiLogoutResponse = { const logger = log('api').c('auth').c('logout'); export const PATH = '/api/auth/logout'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const current = await getSession(req, res); @@ -37,8 +37,6 @@ export default fastifyPlugin( return res.send({ loggedOut: true }); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/oauth/discord.ts b/src/server/routes/api/auth/oauth/discord.ts index 86fdcfe1..ef4328e6 100644 --- a/src/server/routes/api/auth/oauth/discord.ts +++ b/src/server/routes/api/auth/oauth/discord.ts @@ -5,7 +5,7 @@ import Logger from '@/lib/logger'; import enabled from '@/lib/oauth/enabled'; import { discordAuth } from '@/lib/oauth/providerUtil'; import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise { if (!config.features.oauthRegistration) @@ -103,13 +103,11 @@ async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger): } export const PATH = '/api/auth/oauth/discord'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, async (req, res) => { return req.oauthHandle(res, 'DISCORD', discordOauth); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/oauth/github.ts b/src/server/routes/api/auth/oauth/github.ts index e40394db..0a8a04e4 100644 --- a/src/server/routes/api/auth/oauth/github.ts +++ b/src/server/routes/api/auth/oauth/github.ts @@ -5,7 +5,7 @@ import Logger from '@/lib/logger'; import enabled from '@/lib/oauth/enabled'; import { githubAuth } from '@/lib/oauth/providerUtil'; import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise { if (!config.features.oauthRegistration) @@ -88,13 +88,11 @@ async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise } export const PATH = '/api/auth/oauth/github'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, async (req, res) => { return req.oauthHandle(res, 'GITHUB', githubOauth); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/oauth/google.ts b/src/server/routes/api/auth/oauth/google.ts index c487ce89..a44aae40 100644 --- a/src/server/routes/api/auth/oauth/google.ts +++ b/src/server/routes/api/auth/oauth/google.ts @@ -5,7 +5,7 @@ import Logger from '@/lib/logger'; import enabled from '@/lib/oauth/enabled'; import { googleAuth } from '@/lib/oauth/providerUtil'; import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise { if (!config.features.oauthRegistration) @@ -86,13 +86,11 @@ async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): P } export const PATH = '/api/auth/oauth/google'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, async (req, res) => { return req.oauthHandle(res, 'GOOGLE', googleOauth); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/oauth/index.ts b/src/server/routes/api/auth/oauth/index.ts index ce7ac5f3..52d81909 100644 --- a/src/server/routes/api/auth/oauth/index.ts +++ b/src/server/routes/api/auth/oauth/index.ts @@ -1,63 +1,61 @@ +import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; -import fastifyPlugin from 'fastify-plugin'; import { OAuthProvider, OAuthProviderType } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; -import { prisma } from '@/lib/db'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiAuthOauthResponse = OAuthProvider[]; -type Body = { - provider?: OAuthProviderType; -}; - const logger = log('api').c('auth').c('oauth'); export const PATH = '/api/auth/oauth'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { return res.send(req.user.oauthProviders); }); - server.delete<{ Body: Body }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const { password } = (await prisma.user.findFirst({ - where: { - id: req.user.id, - }, - select: { - password: true, - }, - }))!; - - 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"); - - const { provider } = req.body; - if (!provider) return res.badRequest('Provider is required'); - - const providers = await prisma.user.update({ - where: { - id: req.user.id, - }, - data: { - oauthProviders: { - deleteMany: [{ provider }], + server.delete( + PATH, + { schema: { body: z.object({ provider: z.enum(OAuthProviderType) }) }, preHandler: [userMiddleware] }, + async (req, res) => { + const { password } = (await prisma.user.findFirst({ + where: { + id: req.user.id, }, - }, - include: { - oauthProviders: true, - }, - }); + select: { + password: true, + }, + }))!; - logger.info(`${req.user.username} unlinked an oauth provider`, { - provider, - }); + 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"); - return res.send(providers.oauthProviders); - }); + const { provider } = req.body; - done(); + const providers = await prisma.user.update({ + where: { + id: req.user.id, + }, + data: { + oauthProviders: { + deleteMany: [{ provider }], + }, + }, + include: { + oauthProviders: true, + }, + }); + + logger.info(`${req.user.username} unlinked an oauth provider`, { + provider, + }); + + return res.send(providers.oauthProviders); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/oauth/oidc.ts b/src/server/routes/api/auth/oauth/oidc.ts index 35b56111..f4130c79 100644 --- a/src/server/routes/api/auth/oauth/oidc.ts +++ b/src/server/routes/api/auth/oauth/oidc.ts @@ -5,7 +5,7 @@ import Logger from '@/lib/logger'; import enabled from '@/lib/oauth/enabled'; import { oidcAuth } from '@/lib/oauth/providerUtil'; import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise { if (!config.features.oauthRegistration) @@ -88,13 +88,11 @@ async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Pro } export const PATH = '/api/auth/oauth/oidc'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, async (req, res) => { return req.oauthHandle(res, 'OIDC', oidcOauth); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/register.ts b/src/server/routes/api/auth/register.ts index facbbd03..25618ac6 100755 --- a/src/server/routes/api/auth/register.ts +++ b/src/server/routes/api/auth/register.ts @@ -5,97 +5,101 @@ import { User, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { getSession, saveSession } from '@/server/session'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; import { ApiLoginResponse } from './login'; export type ApiAuthRegisterResponse = ApiLoginResponse; -type Body = { - username: string; - password: string; - code?: string; -}; - const logger = log('api').c('auth').c('register'); export const PATH = '/api/auth/register'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Body: Body }>(PATH, { ...secondlyRatelimit(5) }, async (req, res) => { - const session = await getSession(req, res); - - 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 (!username) return res.badRequest('Username is required'); - if (!password) return res.badRequest('Password is required'); - - const oUser = await prisma.user.findUnique({ - where: { - username, +export default typedPlugin( + async (server) => { + server.post( + PATH, + { + schema: { + body: z.object({ + username: z.string().min(1), + password: z.string().min(1), + code: z.string().min(1).optional(), + }), }, - }); - if (oUser) return res.badRequest('Username is taken'); + ...secondlyRatelimit(5), + }, + async (req, res) => { + const session = await getSession(req, res); - if (code) { - const invite = await prisma.invite.findFirst({ + 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'); + + const oUser = await prisma.user.findUnique({ where: { - OR: [{ id: code }, { code }], + username, }, }); + if (oUser) return res.badRequest('Username is taken'); - 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 (code) { + const invite = await prisma.invite.findFirst({ + where: { + OR: [{ id: code }, { code }], + }, + }); - await prisma.invite.update({ - where: { - id: invite.id, - }, + 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'); + + await prisma.invite.update({ + where: { + id: invite.id, + }, + data: { + uses: invite.uses + 1, + }, + }); + + logger.info('invite used', { + user: username, + invite: invite.id, + }); + } + + const user = await prisma.user.create({ data: { - uses: invite.uses + 1, + username, + password: await hashPassword(password), + role: 'USER', + token: createToken(), + }, + select: { + ...userSelect, + password: true, + token: true, }, }); - logger.info('invite used', { - user: username, - invite: invite.id, - }); - } + await saveSession(session, user); - const user = await prisma.user.create({ - data: { + delete (user as any).password; + + logger.info('user registered successfully', { username, - password: await hashPassword(password), - role: 'USER', - token: createToken(), - }, - select: { - ...userSelect, - password: true, - token: true, - }, - }); + ip: req.ip ?? 'unknown', + ua: req.headers['user-agent'], + }); - await saveSession(session, user); - - delete (user as any).password; - - logger.info('user registered successfully', { - username, - ip: req.ip ?? 'unknown', - ua: req.headers['user-agent'], - }); - - return res.send({ - user, - }); - }); - - done(); + return res.send({ + user, + }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/auth/webauthn.ts b/src/server/routes/api/auth/webauthn.ts index e8fb6e84..4373cf2b 100755 --- a/src/server/routes/api/auth/webauthn.ts +++ b/src/server/routes/api/auth/webauthn.ts @@ -6,6 +6,7 @@ import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { TimedCache } from '@/lib/timedCache'; import { getSession, saveSession } from '@/server/session'; +import typedPlugin from '@/server/typedPlugin'; import { JsonObject } from '@prisma/client/runtime/client'; import { AuthenticationResponseJSON } from '@simplewebauthn/browser'; import { @@ -13,7 +14,7 @@ import { PublicKeyCredentialRequestOptionsJSON, verifyAuthenticationResponse, } from '@simplewebauthn/server'; -import fastifyPlugin from 'fastify-plugin'; +import z from 'zod'; import { PasskeyReg, passkeysEnabledHandler } from '../user/mfa/passkey'; export type ApiAuthWebauthnResponse = { @@ -25,17 +26,13 @@ export type ApiAuthWebauthnOptionsResponse = { options: PublicKeyCredentialRequestOptionsJSON; }; -type Body = { - response: AuthenticationResponseJSON; -}; - const logger = log('api').c('auth').c('webauthn'); const OPTIONS_CACHE = new TimedCache(2 * 60_000); export const PATH = '/api/auth/webauthn'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get( PATH + '/options', { preHandler: [passkeysEnabledHandler], ...secondlyRatelimit(20) }, @@ -70,9 +67,17 @@ export default fastifyPlugin( }, ); - server.post<{ Body: Body }>( + server.post( PATH, - { preHandler: [passkeysEnabledHandler], ...secondlyRatelimit(10) }, + { + schema: { + body: z.object({ + response: z.custom(), + }), + }, + preHandler: [passkeysEnabledHandler], + ...secondlyRatelimit(10), + }, async (req, res) => { const session = await getSession(req, res); @@ -181,8 +186,6 @@ export default fastifyPlugin( }); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/healthcheck.ts b/src/server/routes/api/healthcheck.ts index 83fc9a78..dd02a458 100755 --- a/src/server/routes/api/healthcheck.ts +++ b/src/server/routes/api/healthcheck.ts @@ -1,7 +1,7 @@ import { config } from '@/lib/config'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiHealthcheckResponse = { pass: boolean; @@ -10,9 +10,9 @@ export type ApiHealthcheckResponse = { const logger = log('api').c('healthcheck'); export const PATH = '/api/healthcheck'; -export default fastifyPlugin( - (server, _, done) => { - server.get(PATH, async (req, res) => { +export default typedPlugin( + async (server) => { + server.get(PATH, async (_, res) => { if (!config.features.healthcheck) return res.notFound(); try { @@ -23,8 +23,6 @@ export default fastifyPlugin( return res.internalServerError('there was an error during a healthcheck'); } }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/clear_temp.ts b/src/server/routes/api/server/clear_temp.ts index 79b789f7..fa7a4557 100755 --- a/src/server/routes/api/server/clear_temp.ts +++ b/src/server/routes/api/server/clear_temp.ts @@ -3,7 +3,7 @@ import { secondlyRatelimit } from '@/lib/ratelimits'; import { clearTemp } from '@/lib/server-util/clearTemp'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiServerClearTempResponse = { status?: string; @@ -12,8 +12,8 @@ export type ApiServerClearTempResponse = { const logger = log('api').c('server').c('clear_temp'); export const PATH = '/api/server/clear_temp'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.delete( PATH, { @@ -31,8 +31,6 @@ export default fastifyPlugin( return res.send({ status }); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/clear_zeros.ts b/src/server/routes/api/server/clear_zeros.ts index e54c0c56..f0e694f3 100755 --- a/src/server/routes/api/server/clear_zeros.ts +++ b/src/server/routes/api/server/clear_zeros.ts @@ -3,7 +3,7 @@ import { secondlyRatelimit } from '@/lib/ratelimits'; import { clearZeros, clearZerosFiles } from '@/lib/server-util/clearZeros'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiServerClearZerosResponse = { status?: string; @@ -13,8 +13,8 @@ export type ApiServerClearZerosResponse = { const logger = log('api').c('server').c('clear_zeros'); export const PATH = '/api/server/clear_zeros'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get( PATH, { @@ -46,8 +46,6 @@ export default fastifyPlugin( return res.send({ status }); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/export.ts b/src/server/routes/api/server/export.ts index e35f9861..69d20385 100644 --- a/src/server/routes/api/server/export.ts +++ b/src/server/routes/api/server/export.ts @@ -2,10 +2,11 @@ import { Export4 } from '@/lib/import/version4/validateExport'; import { log } from '@/lib/logger'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; import { prisma } from '@/lib/db'; +import typedPlugin from '@/server/typedPlugin'; import { cpus, hostname, platform, release } from 'os'; +import z from 'zod'; import { version } from '../../../../../package.json'; async function getCounts() { @@ -30,19 +31,20 @@ async function getCounts() { export type ApiServerExport = Export4; -type Query = { - nometrics?: string; - counts?: string; -}; - const logger = log('api').c('server').c('export'); export const PATH = '/api/server/export'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Querystring: Query }>( +export default typedPlugin( + async (server) => { + server.get( PATH, { + schema: { + querystring: z.object({ + nometrics: z.string().optional(), + counts: z.string().optional(), + }), + }, preHandler: [userMiddleware, administratorMiddleware], }, async (req, res) => { @@ -278,8 +280,6 @@ export default fastifyPlugin( .send(export4); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/folder.ts b/src/server/routes/api/server/folder.ts index ef3450f4..f8277a19 100644 --- a/src/server/routes/api/server/folder.ts +++ b/src/server/routes/api/server/folder.ts @@ -1,51 +1,55 @@ import { prisma } from '@/lib/db'; import { fileSelect } from '@/lib/db/models/file'; import { cleanFolder, Folder } from '@/lib/db/models/folder'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiServerFolderResponse = Partial; -type Params = { - id: string; -}; - -type Query = { - uploads?: boolean; -}; - export const PATH = '/api/server/folder/:id'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Params: Params; Querystring: Query }>(PATH, async (req, res) => { - const { id } = req.params; - const { uploads } = req.query; - - const folder = await prisma.folder.findUnique({ - where: { - id: id, +export default typedPlugin( + async (server) => { + server.get( + PATH, + { + schema: { + params: z.object({ + id: z.string(), + }), + querystring: z.object({ + uploads: z.string().optional(), + }), }, - include: { - files: { - select: { - ...fileSelect, - password: true, - tags: false, - }, - orderBy: { - createdAt: 'desc', + }, + async (req, res) => { + const { id } = req.params; + const { uploads } = req.query; + + const folder = await prisma.folder.findUnique({ + where: { + id: id, + }, + include: { + files: { + select: { + ...fileSelect, + password: true, + tags: false, + }, + orderBy: { + createdAt: 'desc', + }, }, }, - }, - }); + }); - if (!folder) return res.notFound(); + if (!folder) return res.notFound(); - if ((uploads && !folder.allowUploads) || (!uploads && !folder.public)) return res.notFound(); + if ((uploads && !folder.allowUploads) || (!uploads && !folder.public)) return res.notFound(); - return res.send(cleanFolder(folder, true)); - }); - - done(); + return res.send(cleanFolder(folder, true)); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/import/v3.ts b/src/server/routes/api/server/import/v3.ts index 3522aee1..f6dcfdfc 100644 --- a/src/server/routes/api/server/import/v3.ts +++ b/src/server/routes/api/server/import/v3.ts @@ -5,7 +5,8 @@ import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiServerImportV3 = { users: Record; @@ -14,23 +15,22 @@ export type ApiServerImportV3 = { urls: Record; settings: string[]; }; - -type Body = { - export3: Export3; - - importFromUser?: string; -}; - const parseDate = (date: string) => (isNaN(Date.parse(date)) ? new Date() : new Date(date)); const logger = log('api').c('server').c('import').c('v3'); export const PATH = '/api/server/import/v3'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Body: Body }>( +export default typedPlugin( + async (server) => { + server.post( PATH, { + schema: { + body: z.object({ + export3: z.custom(), + importFromUser: z.string().optional(), + }), + }, preHandler: [userMiddleware, administratorMiddleware], // 24gb, just in case bodyLimit: 24 * 1024 * 1024 * 1024, @@ -303,8 +303,6 @@ export default fastifyPlugin( }); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/import/v4.ts b/src/server/routes/api/server/import/v4.ts index 3ba6a3cf..e6c5537a 100644 --- a/src/server/routes/api/server/import/v4.ts +++ b/src/server/routes/api/server/import/v4.ts @@ -5,7 +5,8 @@ import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiServerImportV4 = { imported: { @@ -22,23 +23,23 @@ export type ApiServerImportV4 = { }; }; -type Body = { - export4: Export4; - - config: { - settings: boolean; - mergeCurrentUser: string | null; - }; -}; - const logger = log('api').c('server').c('import').c('v4'); export const PATH = '/api/server/import/v4'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Body: Body }>( +export default typedPlugin( + async (server) => { + server.post( PATH, { + schema: { + body: z.object({ + export4: z.custom(), + config: z.object({ + settings: z.boolean().optional().default(false), + mergeCurrentUser: z.string().nullable().optional().default(null), + }), + }), + }, preHandler: [userMiddleware, administratorMiddleware], // 24gb, just in case bodyLimit: 24 * 1024 * 1024 * 1024, @@ -523,8 +524,6 @@ export default fastifyPlugin( return res.send(response); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/public.ts b/src/server/routes/api/server/public.ts index a8326702..eda280c8 100644 --- a/src/server/routes/api/server/public.ts +++ b/src/server/routes/api/server/public.ts @@ -3,7 +3,7 @@ import { Config } from '@/lib/config/validate'; import { getZipline } from '@/lib/db/models/zipline'; import enabled from '@/lib/oauth/enabled'; import { isTruthy } from '@/lib/primitive'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiServerPublicResponse = { oauth: { @@ -44,8 +44,8 @@ export type ApiServerPublicResponse = { }; export const PATH = '/api/server/public'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get<{ Body: Body }>(PATH, async (_, res) => { const zipline = await getZipline(); @@ -92,8 +92,6 @@ export default fastifyPlugin( return res.send(response); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/requery_size.ts b/src/server/routes/api/server/requery_size.ts index 1e668f74..365efe4d 100755 --- a/src/server/routes/api/server/requery_size.ts +++ b/src/server/routes/api/server/requery_size.ts @@ -3,46 +3,47 @@ import { secondlyRatelimit } from '@/lib/ratelimits'; import { requerySize } from '@/lib/server-util/requerySize'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiServerRequerySizeResponse = { status?: string; }; -type Body = { - forceDelete?: boolean; - forceUpdate?: boolean; -}; - const logger = log('api').c('server').c('requery_size'); export const PATH = '/api/server/requery_size'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Body: Body }>( +export default typedPlugin( + async (server) => { + server.post( PATH, { + schema: { + body: z.object({ + forceDelete: z.boolean().default(false).optional(), + forceUpdate: z.boolean().default(false).optional(), + }), + }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), }, async (req, res) => { + const { forceDelete, forceUpdate } = req.body; const status = await requerySize({ - forceDelete: req.body.forceDelete || false, - forceUpdate: req.body.forceUpdate || false, + forceDelete, + forceUpdate, }); logger.info('requerying size', { status, requester: req.user.username, - forceDelete: req.body.forceDelete || false, - forceUpdate: req.body.forceUpdate || false, + forceDelete, + forceUpdate, }); return res.send({ status }); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/settings/index.ts b/src/server/routes/api/server/settings/index.ts index 623fdcda..e6225e41 100644 --- a/src/server/routes/api/server/settings/index.ts +++ b/src/server/routes/api/server/settings/index.ts @@ -9,7 +9,7 @@ import { secondlyRatelimit } from '@/lib/ratelimits'; import { readThemes } from '@/lib/theme/file'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; import { statSync } from 'fs'; import ms, { StringValue } from 'ms'; import { cpus } from 'os'; @@ -23,8 +23,6 @@ export type ApiServerSettingsWebResponse = { config: ReturnType; codeMap: { ext: string; mime: string; name: string }[]; }; -type Body = Partial; - export const reservedRoutes = [ '/dashboard', '/auth', @@ -79,9 +77,9 @@ const discordEmbed = z const logger = log('api').c('server').c('settings'); export const PATH = '/api/server/settings'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Body: Body }>( +export default typedPlugin( + async (server) => { + server.get( PATH, { preHandler: [userMiddleware, administratorMiddleware], @@ -102,9 +100,12 @@ export default fastifyPlugin( }, ); - server.patch<{ Body: Body }>( + server.patch( PATH, { + schema: { + body: z.custom>(), + }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), }, @@ -453,8 +454,6 @@ export default fastifyPlugin( return res.send({ settings: newSettings, tampered: global.__tamperedConfig__ || [] }); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/settings/web.ts b/src/server/routes/api/server/settings/web.ts index 44ad706a..95fef0e4 100644 --- a/src/server/routes/api/server/settings/web.ts +++ b/src/server/routes/api/server/settings/web.ts @@ -2,7 +2,7 @@ import { config } from '@/lib/config'; import { safeConfig } from '@/lib/config/safe'; import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; import { readFile } from 'fs/promises'; import { join } from 'path'; @@ -17,8 +17,8 @@ const codeJsonPath = join(process.cwd(), 'code.json'); let codeMap: ApiServerSettingsWebResponse['codeMap'] = []; export const PATH = '/api/server/settings/web'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (_, res) => { const webConfig = safeConfig(config); @@ -37,8 +37,6 @@ export default fastifyPlugin( codeMap: codeMap, } satisfies ApiServerSettingsWebResponse); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/themes.ts b/src/server/routes/api/server/themes.ts index 219dea1e..76c47cb7 100644 --- a/src/server/routes/api/server/themes.ts +++ b/src/server/routes/api/server/themes.ts @@ -2,7 +2,7 @@ import { config } from '@/lib/config'; import { Config } from '@/lib/config/validate'; import { ZiplineTheme } from '@/lib/theme'; import { readThemes } from '@/lib/theme/file'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiServerThemesResponse = { themes: ZiplineTheme[]; @@ -10,15 +10,13 @@ export type ApiServerThemesResponse = { }; export const PATH = '/api/server/themes'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, async (req, res) => { const themes = await readThemes(); return res.send({ themes, defaultTheme: config.website.theme }); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/server/thumbnails.ts b/src/server/routes/api/server/thumbnails.ts index d57d12fd..8c334f58 100644 --- a/src/server/routes/api/server/thumbnails.ts +++ b/src/server/routes/api/server/thumbnails.ts @@ -2,24 +2,26 @@ import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiServerThumbnailsResponse = { status: string; }; -type Body = { - rerun: boolean; -}; - const logger = log('api').c('server').c('thumbnails'); export const PATH = '/api/server/thumbnails'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Body: Body }>( +export default typedPlugin( + async (server) => { + server.post( PATH, { + schema: { + body: z.object({ + rerun: z.boolean().optional(), + }), + }, preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1), }, @@ -43,8 +45,6 @@ export default fastifyPlugin( }); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/setup.ts b/src/server/routes/api/setup.ts index a152e3fa..12731144 100755 --- a/src/server/routes/api/setup.ts +++ b/src/server/routes/api/setup.ts @@ -4,23 +4,19 @@ import { User, userSelect } from '@/lib/db/models/user'; import { getZipline } from '@/lib/db/models/zipline'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiSetupResponse = { firstSetup?: boolean; user?: User; }; -type Body = { - username: string; - password: string; -}; - const logger = log('api').c('setup'); export const PATH = '/api/setup'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, async (_, res) => { const { firstSetup } = await getZipline(); if (!firstSetup) return res.forbidden(); @@ -28,45 +24,53 @@ export default fastifyPlugin( return res.send({ firstSetup }); }); - server.post<{ Body: Body }>(PATH, secondlyRatelimit(5), async (req, res) => { - const { firstSetup, id } = await getZipline(); - - if (!firstSetup) return res.forbidden(); - - logger.info('first setup running'); - - const { username, password } = req.body; - if (!username) return res.badRequest('Username is required'); - if (!password) return res.badRequest('Password is required'); - - const user = await prisma.user.create({ - data: { - username, - password: await hashPassword(password), - role: 'SUPERADMIN', - token: createToken(), + server.post( + PATH, + { + schema: { + body: z.object({ + username: z.string().min(1), + password: z.string().min(1), + }), }, - select: userSelect, - }); + ...secondlyRatelimit(5), + }, + async (req, res) => { + const { firstSetup, id } = await getZipline(); - logger.info('first setup complete'); + if (!firstSetup) return res.forbidden(); - await prisma.zipline.update({ - where: { - id, - }, - data: { - firstSetup: false, - }, - }); + logger.info('first setup running'); - return res.send({ - firstSetup, - user, - }); - }); + const { username, password } = req.body; - done(); + const user = await prisma.user.create({ + data: { + username, + password: await hashPassword(password), + role: 'SUPERADMIN', + token: createToken(), + }, + select: userSelect, + }); + + logger.info('first setup complete'); + + await prisma.zipline.update({ + where: { + id, + }, + data: { + firstSetup: false, + }, + }); + + return res.send({ + firstSetup, + user, + }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index d472e8b5..c0327e36 100755 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -3,64 +3,82 @@ import { prisma } from '@/lib/db'; import { Metric } from '@/lib/db/models/metric'; import { isAdministrator } from '@/lib/role'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiStatsResponse = Metric[]; -type Query = { - from?: string; - to?: string; - all?: string; -}; - export const PATH = '/api/stats'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - if (!config.features.metrics) return res.forbidden('metrics are disabled'); - - if (config.features.metrics.adminOnly && !isAdministrator(req.user.role)) - return res.forbidden('admin only'); - - const { from, to, all } = req.query; - - const fromDate = from ? new Date(from) : new Date(Date.now() - 86400000 * 7); // defaults to a week ago - const toDate = to ? new Date(to) : new Date(); - - if (!all) { - if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) return res.badRequest('invalid date(s)'); - - 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'); - } - - const stats = await prisma.metric.findMany({ - where: { - ...(!all && { - createdAt: { - gte: fromDate, - lte: toDate, - }, +export default typedPlugin( + async (server) => { + server.get( + PATH, + { + schema: { + querystring: z.object({ + from: z + .string() + .optional() + .refine((val) => { + if (!val) return true; + const date = new Date(val); + return !isNaN(date.getTime()); + }, 'Invalid date'), + to: z + .string() + .optional() + .refine((val) => { + if (!val) return true; + const date = new Date(val); + return !isNaN(date.getTime()); + }, 'Invalid date'), + all: z.enum(['true', 'false']).default('false'), }), }, - orderBy: { - createdAt: 'desc', - }, - }); + preHandler: [userMiddleware], + }, + async (req, res) => { + if (!config.features.metrics) return res.forbidden('metrics are disabled'); - if (!config.features.metrics.showUserSpecific) { - for (let i = 0; i !== stats.length; ++i) { - const stat = stats[i].data; + if (config.features.metrics.adminOnly && !isAdministrator(req.user.role)) + return res.forbidden('admin only'); - stat.filesUsers = []; - stat.urlsUsers = []; + const { from, to, all } = req.query; + + const fromDate = from ? new Date(from) : new Date(Date.now() - 86400000 * 7); // defaults to a week ago + 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'); } - } - return res.send(stats); - }); + const stats = await prisma.metric.findMany({ + where: { + ...(all === 'false' && { + createdAt: { + gte: fromDate, + lte: toDate, + }, + }), + }, + orderBy: { + createdAt: 'desc', + }, + }); - done(); + if (!config.features.metrics.showUserSpecific) { + for (let i = 0; i !== stats.length; ++i) { + const stat = stats[i].data; + + stat.filesUsers = []; + stat.urlsUsers = []; + } + } + + return res.send(stats); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/upload/index.ts b/src/server/routes/api/upload/index.ts index ce07f689..f10f54f4 100755 --- a/src/server/routes/api/upload/index.ts +++ b/src/server/routes/api/upload/index.ts @@ -1,4 +1,3 @@ -import { Prisma } from '@/prisma/client'; import { bytes } from '@/lib/bytes'; import { compressFile, CompressResult } from '@/lib/compress'; import { config } from '@/lib/config'; @@ -6,17 +5,18 @@ import { hashPassword } from '@/lib/crypto'; import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; import { fileSelect } from '@/lib/db/models/file'; +import { sanitizeFilename } from '@/lib/fs'; import { removeGps } from '@/lib/gps'; import { log } from '@/lib/logger'; import { guess } from '@/lib/mimes'; import { formatFileName } from '@/lib/uploader/formatFileName'; -import { UploadHeaders, parseHeaders } from '@/lib/uploader/parseHeaders'; +import { parseHeaders, UploadHeaders } from '@/lib/uploader/parseHeaders'; import { onUpload } from '@/lib/webhooks'; +import { Prisma } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; import { stat } from 'fs/promises'; import { extname } from 'path'; -import { sanitizeFilename } from '@/lib/fs'; const commonDoubleExts = [ '.tar.gz', @@ -57,8 +57,8 @@ export type ApiUploadResponse = { const logger = log('api').c('upload'); export const PATH = '/api/upload'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { const rateLimit = server.rateLimit ? server.rateLimit() : (_req: any, _res: any, next: () => any) => next(); @@ -264,8 +264,6 @@ export default fastifyPlugin( return res.send(response); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/upload/partial.ts b/src/server/routes/api/upload/partial.ts index 6aeba5f0..739fdaaa 100644 --- a/src/server/routes/api/upload/partial.ts +++ b/src/server/routes/api/upload/partial.ts @@ -2,6 +2,7 @@ import { bytes } from '@/lib/bytes'; import { config } from '@/lib/config'; import { hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; +import { sanitizeFilename } from '@/lib/fs'; import { log } from '@/lib/logger'; import { guess } from '@/lib/mimes'; import { randomCharacters } from '@/lib/random'; @@ -9,12 +10,11 @@ import { formatFileName } from '@/lib/uploader/formatFileName'; import { UploadHeaders, UploadOptions, parseHeaders } from '@/lib/uploader/parseHeaders'; import { Prisma } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; import { readdir, rename, rm } from 'fs/promises'; import { join } from 'path'; import { Worker } from 'worker_threads'; import { ApiUploadResponse, getExtension } from '.'; -import { sanitizeFilename } from '@/lib/fs'; const logger = log('api').c('upload').c('partial'); @@ -26,8 +26,8 @@ export type ApiUploadPartialResponse = ApiUploadResponse & { }; export const PATH = '/api/upload/partial'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { const rateLimit = server.rateLimit ? server.rateLimit() : (_req: any, _res: any, next: () => any) => next(); @@ -280,8 +280,6 @@ export default fastifyPlugin( return res.send(response); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/avatar.ts b/src/server/routes/api/user/avatar.ts index 81b0cefe..ce09bec0 100755 --- a/src/server/routes/api/user/avatar.ts +++ b/src/server/routes/api/user/avatar.ts @@ -1,7 +1,7 @@ import { prisma } from '@/lib/db'; import { User } from '@/lib/db/models/user'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiUserTokenResponse = { user?: User; @@ -9,8 +9,8 @@ export type ApiUserTokenResponse = { }; export const PATH = '/api/user/avatar'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const u = await prisma.user.findFirstOrThrow({ where: { @@ -25,8 +25,6 @@ export default fastifyPlugin( return res.send(u.avatar); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/export.ts b/src/server/routes/api/user/export.ts index 1f6f143c..3ff67dee 100644 --- a/src/server/routes/api/user/export.ts +++ b/src/server/routes/api/user/export.ts @@ -1,77 +1,94 @@ +import { bytes } from '@/lib/bytes'; import { config } from '@/lib/config'; import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; +import { Export } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; import archiver from 'archiver'; import { createWriteStream } from 'fs'; import { rm, stat } from 'fs/promises'; import { join } from 'path'; -import { Export } from '@/prisma/client'; -import { bytes } from '@/lib/bytes'; +import z from 'zod'; export type ApiUserExportResponse = { running?: boolean; deleted?: boolean; } & Export[]; -type Query = { - id?: string; -}; - export const PATH = '/api/user/export'; +const querySchema = z.object({ + id: z.string().optional(), +}); + const logger = log('api').c('user').c('export'); -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const exports = await prisma.export.findMany({ - where: { userId: req.user.id }, - }); - - if (req.query.id) { - const file = exports.find((x) => x.id === req.query.id); - if (!file) return res.notFound(); - - if (!file.completed) return res.badRequest('Export is not completed'); - - return res.sendFile(file.path); - } - - return res.send(exports); - }); - - server.delete<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - if (!req.query.id) return res.badRequest('No id provided'); - - const exportDb = await prisma.export.findFirst({ - where: { - userId: req.user.id, - id: req.query.id, +export default typedPlugin( + async (server) => { + server.get( + PATH, + { + schema: { + querystring: querySchema, }, - }); - if (!exportDb) return res.notFound(); + preHandler: [userMiddleware], + }, + async (req, res) => { + const exports = await prisma.export.findMany({ + where: { userId: req.user.id }, + }); - const path = join(config.core.tempDirectory, exportDb.path); + if (req.query.id) { + const file = exports.find((x) => x.id === req.query.id); + if (!file) return res.notFound(); - try { - await rm(path); - } catch (e) { - logger.warn( - `failed to delete export file, it might already be deleted. ${exportDb.id}: ${exportDb.path}`, - { e }, - ); - } + if (!file.completed) return res.badRequest('Export is not completed'); - await prisma.export.delete({ where: { id: req.query.id } }); + return res.sendFile(file.path); + } - logger.info(`deleted export ${exportDb.id}: ${exportDb.path}`); + return res.send(exports); + }, + ); - return res.send({ deleted: true }); - }); + server.delete( + PATH, + { + schema: { querystring: querySchema }, + preHandler: [userMiddleware], + }, + async (req, res) => { + if (!req.query.id) return res.badRequest('No id provided'); + + const exportDb = await prisma.export.findFirst({ + where: { + userId: req.user.id, + id: req.query.id, + }, + }); + if (!exportDb) return res.notFound(); + + const path = join(config.core.tempDirectory, exportDb.path); + + try { + await rm(path); + } catch (e) { + logger.warn( + `failed to delete export file, it might already be deleted. ${exportDb.id}: ${exportDb.path}`, + { e }, + ); + } + + await prisma.export.delete({ where: { id: req.query.id } }); + + logger.info(`deleted export ${exportDb.id}: ${exportDb.path}`); + + return res.send({ deleted: true }); + }, + ); server.post(PATH, { preHandler: [userMiddleware], ...secondlyRatelimit(5) }, async (req, res) => { const files = await prisma.file.findMany({ @@ -137,8 +154,6 @@ export default fastifyPlugin( return res.send({ running: true }); }); - - done(); }, { 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 62e6871d..861846a0 100755 --- a/src/server/routes/api/user/files/[id]/index.ts +++ b/src/server/routes/api/user/files/[id]/index.ts @@ -1,37 +1,28 @@ -import { Prisma } from '@/prisma/client'; 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 { log } from '@/lib/logger'; -import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; import { canInteract } from '@/lib/role'; -import { sanitizeFilename } from '@/lib/fs'; +import { zValidatePath } from '@/lib/validation'; +import { Prisma } from '@/prisma/client'; +import { userMiddleware } from '@/server/middleware/user'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserFilesIdResponse = File; -type Body = { - favorite?: boolean; - maxViews?: number; - password?: string | null; - originalName?: string; - type?: string; - tags?: string[]; - name?: string; -}; - -type Params = { - id: string; -}; - const logger = log('api').c('user').c('files').c('[id]'); +const paramsSchema = z.object({ + id: z.string(), +}); + export const PATH = '/api/user/files/:id'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { +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 }], @@ -46,133 +37,146 @@ export default fastifyPlugin( return res.send(file); }); - server.patch<{ - Body: Body; - Params: Params; - }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const file = await prisma.file.findFirst({ - where: { - OR: [{ id: req.params.id }, { name: req.params.id }], + server.patch( + PATH, + { + schema: { + params: paramsSchema, + body: z.object({ + favorite: z.boolean().optional(), + maxViews: z.number().min(0).optional(), + password: z.string().optional().nullable(), + 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), + }), }, - 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(); - - const data: Prisma.FileUpdateInput = {}; - - if (req.body.favorite !== undefined) data.favorite = req.body.favorite; - if (req.body.originalName !== undefined) data.originalName = req.body.originalName; - if (req.body.type !== undefined) data.type = req.body.type; - - if (req.body.maxViews !== undefined) { - if (req.body.maxViews < 0) return res.badRequest('maxViews must be >= 0'); - - data.maxViews = req.body.maxViews; - } - - if (req.body.password !== undefined) { - if (req.body.password === null || req.body.password === '') { - data.password = null; - } else if (typeof req.body.password === 'string') { - data.password = await hashPassword(req.body.password); - } else { - return res.badRequest('password must be a string'); - } - } - - if (req.body.tags !== undefined) { - const tags = await prisma.tag.findMany({ + preHandler: [userMiddleware], + }, + async (req, res) => { + const file = await prisma.file.findFirst({ where: { - userId: req.user.id !== file.User?.id ? file.User?.id : req.user.id, - id: { - in: req.body.tags, + 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(); + + const data: Prisma.FileUpdateInput = {}; + + if (req.body.favorite !== undefined) data.favorite = req.body.favorite; + if (req.body.originalName !== undefined) data.originalName = req.body.originalName; + if (req.body.type !== undefined) data.type = req.body.type; + + if (req.body.maxViews !== undefined) { + data.maxViews = req.body.maxViews; + } + + if (req.body.password !== undefined) { + if (req.body.password === null || req.body.password === '') { + data.password = null; + } else { + data.password = await hashPassword(req.body.password); + } + } + + if (req.body.tags !== undefined) { + const tags = await prisma.tag.findMany({ + where: { + userId: req.user.id !== file.User?.id ? file.User?.id : req.user.id, + id: { + in: req.body.tags, + }, }, - }, - }); + }); - if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere'); + if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere'); - data.tags = { - set: req.body.tags.map((tag) => ({ id: tag })), - }; - } - - if (req.body.name !== undefined && req.body.name !== file.name) { - const sanitized = sanitizeFilename(req.body.name); - if (!sanitized) return res.badRequest('Invalid file name'); - - const name = req.body.name.trim(); - const existingFile = await prisma.file.findFirst({ - where: { - name, - }, - }); - - if (existingFile && existingFile.id !== file.id) - return res.badRequest('File with this name already exists'); - - data.name = name; - - try { - 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'); + data.tags = { + set: req.body.tags.map((tag) => ({ id: tag })), + }; } - } - const newFile = await prisma.file.update({ - where: { - id: req.params.id, - }, - data, - select: fileSelect, - }); + if (req.body.name !== undefined && req.body.name !== file.name) { + const name = req.body.name!; + const existingFile = await prisma.file.findFirst({ + where: { + name, + }, + }); - logger.info(`${req.user.username} updated file ${newFile.name}`, { - updated: Object.keys(req.body), - id: newFile.id, - owner: file.User?.id, - }); + if (existingFile && existingFile.id !== file.id) + return res.badRequest('File with this name already exists'); - return res.send(newFile); - }); + data.name = name; - server.delete<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const file = await prisma.file.findFirst({ - where: { - OR: [{ id: req.params.id }, { name: req.params.id }], - }, - include: { - User: true, - }, - }); - if (!file) return res.notFound(); + try { + 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'); + } + } - if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER')) - return res.notFound(); + const newFile = await prisma.file.update({ + where: { + id: req.params.id, + }, + data, + select: fileSelect, + }); - const deletedFile = await prisma.file.delete({ - where: { - id: file.id, - }, - select: fileSelect, - }); + logger.info(`${req.user.username} updated file ${newFile.name}`, { + updated: Object.keys(req.body), + id: newFile.id, + owner: file.User?.id, + }); - await datasource.delete(deletedFile.name); + return res.send(newFile); + }, + ); - logger.info(`${req.user.username} deleted file ${deletedFile.name}`, { - size: bytes(deletedFile.size), - owner: file.User?.id, - }); + server.delete( + 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 }], + }, + include: { + User: true, + }, + }); + if (!file) return res.notFound(); - return res.send(deletedFile); - }); + if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER')) + return res.notFound(); - done(); + const deletedFile = await prisma.file.delete({ + where: { + id: file.id, + }, + select: fileSelect, + }); + + await datasource.delete(deletedFile.name); + + logger.info(`${req.user.username} deleted file ${deletedFile.name}`, { + size: bytes(deletedFile.size), + owner: file.User?.id, + }); + + return res.send(deletedFile); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/files/[id]/password.ts b/src/server/routes/api/user/files/[id]/password.ts index 32b75fa4..ecbd4ea7 100755 --- a/src/server/routes/api/user/files/[id]/password.ts +++ b/src/server/routes/api/user/files/[id]/password.ts @@ -2,63 +2,68 @@ import { verifyPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserFilesIdPasswordResponse = { success: boolean; }; -type Body = { - password: string; -}; - -type Params = { - id: string; -}; - const logger = log('api').c('user').c('files').c('[id]').c('password'); export const PATH = '/api/user/files/:id/password'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Body: Body; Params: Params }>(PATH, { ...secondlyRatelimit(2) }, async (req, res) => { - const file = await prisma.file.findFirst({ - where: { - OR: [{ id: req.params.id }, { name: req.params.id }], +export default typedPlugin( + async (server) => { + server.post( + PATH, + { + schema: { + body: z.object({ + password: z.string().trim().min(1), + }), + params: z.object({ + id: z.string(), + }), }, - select: { - name: true, - password: true, - id: true, - }, - }); - if (!file) return res.notFound(); - if (!file.password) return res.notFound(); + ...secondlyRatelimit(2), + }, + async (req, res) => { + const file = await prisma.file.findFirst({ + where: { + OR: [{ id: req.params.id }, { name: req.params.id }], + }, + select: { + name: true, + password: true, + id: true, + }, + }); + if (!file) return res.notFound(); + if (!file.password) return res.notFound(); - const verified = await verifyPassword(req.body.password, file.password); - if (!verified) { - logger.warn('invalid password for file', { - file: file.name, - ip: req.ip ?? 'unknown', - ua: req.headers['user-agent'], + const verified = await verifyPassword(req.body.password, file.password); + if (!verified) { + logger.warn('invalid password for file', { + file: file.name, + ip: req.ip ?? 'unknown', + ua: req.headers['user-agent'], + }); + + return res.forbidden('Incorrect password'); + } + logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] }); + + res.cookie('file_pw_' + file.id, req.body.password, { + sameSite: 'lax', + maxAge: 60, + httpOnly: false, + secure: false, + path: '/', }); - return res.forbidden('Incorrect password'); - } - logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] }); - - res.cookie('file_pw_' + file.id, req.body.password, { - sameSite: 'lax', - maxAge: 60, - httpOnly: false, - secure: false, - path: '/', - }); - - return res.send({ success: true }); - }); - - done(); + return res.send({ success: true }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/files/[id]/raw.ts b/src/server/routes/api/user/files/[id]/raw.ts index 638f1d69..a7448d19 100644 --- a/src/server/routes/api/user/files/[id]/raw.ts +++ b/src/server/routes/api/user/files/[id]/raw.ts @@ -7,123 +7,147 @@ import { sanitizeFilename } from '@/lib/fs'; import { log } from '@/lib/logger'; import { canInteract } from '@/lib/role'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; - -type Params = { - id: string; -}; - -type Querystring = { - pw?: string; - download?: string; -}; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; const logger = log('routes').c('raw'); export const PATH = '/api/user/files/:id/raw'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ - Querystring: Querystring; - Params: Params; - }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const { pw, download } = req.query; +export default typedPlugin( + async (server) => { + server.get( + PATH, + { + schema: { + params: z.object({ + id: z.string(), + }), + querystring: z.object({ + pw: z.string().optional(), + download: z.string().optional(), + }), + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { pw, download } = req.query; - const id = sanitizeFilename(req.params.id); - if (!id) return res.callNotFound(); + const id = sanitizeFilename(req.params.id); + if (!id) return res.callNotFound(); - if (id.startsWith('.thumbnail')) { - const thumbnail = await prisma.thumbnail.findFirst({ - where: { - path: id, - }, - include: { - file: { - include: { - User: true, + if (id.startsWith('.thumbnail')) { + const thumbnail = await prisma.thumbnail.findFirst({ + where: { + path: id, + }, + include: { + file: { + include: { + User: true, + }, }, }, + }); + + if (!thumbnail) return res.callNotFound(); + if (thumbnail.file && thumbnail.file.userId !== req.user.id) { + if (!canInteract(req.user.role, thumbnail.file.User?.role)) return res.callNotFound(); + } + } + + const file = await prisma.file.findFirst({ + where: { + id, + }, + include: { + User: true, }, }); - if (!thumbnail) return res.callNotFound(); - if (thumbnail.file && thumbnail.file.userId !== req.user.id) { - if (!canInteract(req.user.role, thumbnail.file.User?.role)) return res.callNotFound(); - } - } - - const file = await prisma.file.findFirst({ - where: { - id, - }, - include: { - User: true, - }, - }); - - if (file && file.userId !== req.user.id) { - if (!canInteract(req.user.role, file.User?.role)) return res.callNotFound(); - } - - if (file?.deletesAt && file.deletesAt <= new Date()) { - try { - await datasource.delete(file.name); - await prisma.file.delete({ - where: { - id: file.id, - }, - }); - } catch (e) { - logger - .error('failed to delete file on expiration', { - id: file.id, - }) - .error(e as Error); + if (file && file.userId !== req.user.id) { + if (!canInteract(req.user.role, file.User?.role)) return res.callNotFound(); } - return res.callNotFound(); - } + if (file?.deletesAt && file.deletesAt <= new Date()) { + try { + await datasource.delete(file.name); + await prisma.file.delete({ + where: { + id: file.id, + }, + }); + } catch (e) { + logger + .error('failed to delete file on expiration', { + id: file.id, + }) + .error(e as Error); + } - if (file?.maxViews && file.views >= file.maxViews) { - if (!config.features.deleteOnMaxViews) return res.callNotFound(); - - try { - await datasource.delete(file.name); - await prisma.file.delete({ - where: { - id: file.id, - }, - }); - } catch (e) { - logger - .error('failed to delete file on max views', { - id: file.id, - }) - .error(e as Error); + return res.callNotFound(); } - return res.callNotFound(); - } + if (file?.maxViews && file.views >= file.maxViews) { + if (!config.features.deleteOnMaxViews) return res.callNotFound(); - if (file?.password) { - if (!pw) return res.forbidden('Password protected.'); - const verified = await verifyPassword(pw, file.password!); + try { + await datasource.delete(file.name); + await prisma.file.delete({ + where: { + id: file.id, + }, + }); + } catch (e) { + logger + .error('failed to delete file on max views', { + id: file.id, + }) + .error(e as Error); + } - if (!verified) return res.forbidden('Incorrect password.'); - } + return res.callNotFound(); + } - const size = file?.size || (await datasource.size(file?.name ?? id)); + if (file?.password) { + if (!pw) return res.forbidden('Password protected.'); + const verified = await verifyPassword(pw, file.password!); - if (req.headers.range) { - const [start, end] = parseRange(req.headers.range, size); - if (start >= size || end >= size) { - const buf = await datasource.get(file?.name ?? id); + if (!verified) return res.forbidden('Incorrect password.'); + } + + const size = file?.size || (await datasource.size(file?.name ?? id)); + + if (req.headers.range) { + 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(); + + return res + .type(file?.type || 'application/octet-stream') + .headers({ + 'Content-Length': size, + ...(file?.originalName + ? { + 'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`, + } + : download && { + 'Content-Disposition': 'attachment;', + }), + }) + .status(416) + .send(buf); + } + + const buf = await datasource.range(file?.name ?? id, start || 0, end); if (!buf) return res.callNotFound(); return res .type(file?.type || 'application/octet-stream') .headers({ - 'Content-Length': size, + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, ...(file?.originalName ? { 'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`, @@ -132,19 +156,18 @@ export default fastifyPlugin( 'Content-Disposition': 'attachment;', }), }) - .status(416) + .status(206) .send(buf); } - const buf = await datasource.range(file?.name ?? id, start || 0, end); + const buf = await datasource.get(file?.name ?? id); if (!buf) return res.callNotFound(); return res .type(file?.type || 'application/octet-stream') .headers({ - 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Content-Length': size, 'Accept-Ranges': 'bytes', - 'Content-Length': end - start + 1, ...(file?.originalName ? { 'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`, @@ -153,31 +176,10 @@ export default fastifyPlugin( 'Content-Disposition': 'attachment;', }), }) - .status(206) + .status(200) .send(buf); - } - - const buf = await datasource.get(file?.name ?? id); - if (!buf) return res.callNotFound(); - - return res - .type(file?.type || 'application/octet-stream') - .headers({ - 'Content-Length': size, - 'Accept-Ranges': 'bytes', - ...(file?.originalName - ? { - 'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`, - } - : download && { - 'Content-Disposition': 'attachment;', - }), - }) - .status(200) - .send(buf); - }); - - done(); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/files/incomplete.ts b/src/server/routes/api/user/files/incomplete.ts index cc27c332..32cbde04 100755 --- a/src/server/routes/api/user/files/incomplete.ts +++ b/src/server/routes/api/user/files/incomplete.ts @@ -3,19 +3,16 @@ import { IncompleteFile } from '@/lib/db/models/incompleteFile'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserFilesIncompleteResponse = IncompleteFile[] | { count: number }; -type Body = { - id: string[]; -}; - const logger = log('api').c('user').c('files').c('incomplete'); export const PATH = '/api/user/files/incomplete'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const incompleteFiles = await prisma.incompleteFile.findMany({ where: { @@ -26,9 +23,17 @@ export default fastifyPlugin( return res.send(incompleteFiles); }); - server.delete<{ Body: Body }>( + server.delete( PATH, - { preHandler: [userMiddleware], ...secondlyRatelimit(1) }, + { + schema: { + body: z.object({ + id: z.array(z.string()), + }), + }, + preHandler: [userMiddleware], + ...secondlyRatelimit(1), + }, async (req, res) => { if (!req.body.id) return res.badRequest('no id array provided'); @@ -57,8 +62,6 @@ export default fastifyPlugin( return res.send(incompleteFiles); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/files/index.ts b/src/server/routes/api/user/files/index.ts index ada6dc35..25b499d6 100755 --- a/src/server/routes/api/user/files/index.ts +++ b/src/server/routes/api/user/files/index.ts @@ -2,9 +2,8 @@ import { prisma } from '@/lib/db'; import { File, cleanFiles, fileSelect } from '@/lib/db/models/file'; import { canInteract } from '@/lib/role'; import { userMiddleware } from '@/server/middleware/user'; -import { Prisma } from '@/prisma/client'; -import fastifyPlugin from 'fastify-plugin'; -import { z } from 'zod'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type FileSearchField = 'name' | 'originalName' | 'type' | 'tags' | 'id'; @@ -18,241 +17,222 @@ export type ApiUserFilesResponse = { pages?: number; }; -type Query = { - page?: string; - perpage?: string; - filter?: 'dashboard' | 'none' | 'all'; - favorite?: 'true' | 'false'; - sortBy: keyof Prisma.FileOrderByWithAggregationInput; - order: 'asc' | 'desc'; - searchField?: FileSearchField; - searchQuery?: string; - id?: string; -}; - -const validateSearchField = z.enum(['name', 'originalName', 'type', 'tags', 'id']).default('name'); - -const validateSortBy = z - .enum([ - 'id', - 'createdAt', - 'updatedAt', - 'deletesAt', - 'name', - 'originalName', - 'size', - 'type', - 'views', - 'favorite', - ]) - .default('createdAt'); - -const validateOrder = z.enum(['asc', 'desc']).default('desc'); - export const PATH = '/api/user/files'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const user = await prisma.user.findUnique({ - where: { - id: req.query.id ?? req.user.id, +export default typedPlugin( + async (server) => { + server.get( + PATH, + { + schema: { + querystring: z.object({ + page: z.coerce.number().optional(), + perpage: z.coerce.number().default(15), + filter: z.enum(['dashboard', 'none', 'all']).optional().default('none'), + favorite: z.enum(['true', 'false']).optional(), + sortBy: z + .enum([ + 'id', + 'createdAt', + 'updatedAt', + 'deletesAt', + 'name', + 'originalName', + 'size', + 'type', + 'views', + 'favorite', + ]) + .optional() + .default('createdAt'), + order: z.enum(['asc', 'desc']).optional().default('desc'), + searchField: z.enum(['name', 'originalName', 'type', 'tags', 'id']).optional().default('name'), + searchQuery: z.string().optional(), + id: z.string().optional(), + }), }, - }); - - if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role)) - return res.forbidden("You can't view this user's files."); - - if (!user) return res.notFound('User not found'); - - const perpage = Number(req.query.perpage || '15'); - if (isNaN(Number(perpage))) return res.badRequest('Perpage must be a number'); - - const searchQuery = req.query.searchQuery - ? (decodeURIComponent(req.query.searchQuery.trim()) ?? null) - : null; - - const { page, filter, favorite } = req.query; - if (!page && !searchQuery) return res.badRequest('Page is required'); - if (isNaN(Number(page)) && !searchQuery) return res.badRequest('Page must be a number'); - - const sortBy = validateSortBy.safeParse(req.query.sortBy || 'createdAt'); - if (!sortBy.success) return res.badRequest('Invalid sortBy value'); - - const order = validateOrder.safeParse(req.query.order || 'desc'); - if (!order.success) return res.badRequest('Invalid order value'); - - const searchField = validateSearchField.safeParse(req.query.searchField || 'name'); - if (!searchField.success) return res.badRequest('Invalid searchField value'); - - const incompleteFiles = await prisma.incompleteFile.findMany({ - where: { - userId: user.id, - status: { - not: 'COMPLETE', + preHandler: [userMiddleware], + }, + async (req, res) => { + const user = await prisma.user.findUnique({ + where: { + id: req.query.id ?? req.user.id, }, - }, - }); + }); - if (searchQuery) { - let tagFiles: string[] = []; + if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role)) return res.notFound(); - if (searchField.data === 'tags') { - const parsedTags = searchQuery - .split(',') - .map((tag) => tag.trim()) - .filter((tag) => tag); + if (!user) return res.notFound(); - const foundTags = await prisma.tag.findMany({ - where: { - userId: user.id, - id: { - in: searchQuery - .split(',') - .map((tag) => tag.trim()) - .filter((tag) => tag), - }, - }, - include: { - files: { - select: { - id: true, - }, - }, - }, - }); + const { perpage, searchQuery, searchField, page, filter, favorite, sortBy, order } = req.query; - if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere'); - - tagFiles = foundTags - .map((tag) => tag.files.map((file) => file.id)) - .reduce((a, b) => a.filter((c) => b.includes(c))); - } - - const similarityResult = await prisma.file.findMany({ + const incompleteFiles = await prisma.incompleteFile.findMany({ where: { userId: user.id, - ...(filter === 'dashboard' && { - OR: [ - { - type: { startsWith: 'image/' }, - }, - { - type: { startsWith: 'video/' }, - }, - { - type: { startsWith: 'audio/' }, - }, - { - type: { startsWith: 'text/' }, - }, - ], - }), - ...(favorite === 'true' && - filter !== 'all' && { - favorite: true, - }), - ...(searchField.data === 'tags' - ? { - id: { - in: tagFiles, - notIn: incompleteFiles.map((file) => file.metadata.file.id), - }, - } - : searchField.data === 'id' - ? { - id: { - contains: searchQuery, - notIn: incompleteFiles.map((file) => file.metadata.file.id), - mode: 'insensitive', - }, - } - : { - [searchField.data]: { - contains: searchQuery, - mode: 'insensitive', - }, - id: { - notIn: incompleteFiles.map((file) => file.metadata.file.id), - }, - }), + status: { + not: 'COMPLETE', + }, }, - select: fileSelect, - orderBy: { - [sortBy.data]: order.data, - }, - skip: (Number(page) - 1) * perpage, - take: perpage, }); - return res.send({ - page: cleanFiles(similarityResult), - search: { - field: searchField.data, - query: - searchField.data === 'tags' - ? searchQuery + if (searchQuery) { + let tagFiles: string[] = []; + + if (searchField === 'tags') { + const parsedTags = searchQuery + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag); + + const foundTags = await prisma.tag.findMany({ + where: { + userId: user.id, + id: { + in: searchQuery .split(',') .map((tag) => tag.trim()) - .filter((tag) => tag) - : searchQuery, - }, - }); - } + .filter((tag) => tag), + }, + }, + include: { + files: { + select: { + id: true, + }, + }, + }, + }); - const where = { - userId: user.id, - ...(filter === 'dashboard' && { - OR: [ - { - type: { startsWith: 'image/' }, + if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere'); + + tagFiles = foundTags + .map((tag) => tag.files.map((file) => file.id)) + .reduce((a, b) => a.filter((c) => b.includes(c))); + } + + const similarityResult = await prisma.file.findMany({ + where: { + userId: user.id, + ...(filter === 'dashboard' && { + OR: [ + { + type: { startsWith: 'image/' }, + }, + { + type: { startsWith: 'video/' }, + }, + { + type: { startsWith: 'audio/' }, + }, + { + type: { startsWith: 'text/' }, + }, + ], + }), + ...(favorite === 'true' && + filter !== 'all' && { + favorite: true, + }), + ...(searchField === 'tags' + ? { + id: { + in: tagFiles, + notIn: incompleteFiles.map((file) => file.metadata.file.id), + }, + } + : searchField === 'id' + ? { + id: { + contains: searchQuery, + notIn: incompleteFiles.map((file) => file.metadata.file.id), + mode: 'insensitive', + }, + } + : { + [searchField]: { + contains: searchQuery, + mode: 'insensitive', + }, + id: { + notIn: incompleteFiles.map((file) => file.metadata.file.id), + }, + }), }, - { - type: { startsWith: 'video/' }, + select: fileSelect, + orderBy: { + [sortBy]: order, }, - { - type: { startsWith: 'audio/' }, + skip: (Number(page) - 1) * perpage, + take: perpage, + }); + + return res.send({ + page: cleanFiles(similarityResult), + search: { + field: searchField, + query: + searchField === 'tags' + ? searchQuery + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag) + : searchQuery, }, - { - type: { startsWith: 'text/' }, - }, - ], - }), - ...(favorite === 'true' && - filter !== 'all' && { - favorite: true, + }); + } + + const where = { + userId: user.id, + ...(filter === 'dashboard' && { + OR: [ + { + type: { startsWith: 'image/' }, + }, + { + type: { startsWith: 'video/' }, + }, + { + type: { startsWith: 'audio/' }, + }, + { + type: { startsWith: 'text/' }, + }, + ], }), - id: { - notIn: incompleteFiles.map((file) => file.metadata.file.id), - }, - }; + ...(favorite === 'true' && + filter !== 'all' && { + favorite: true, + }), + id: { + notIn: incompleteFiles.map((file) => file.metadata.file.id), + }, + }; - const count = await prisma.file.count({ - where, - }); - - const files = cleanFiles( - await prisma.file.findMany({ + const count = await prisma.file.count({ where, - select: { - ...fileSelect, - password: true, - }, - orderBy: { - [sortBy.data]: order.data, - }, - skip: (Number(page) - 1) * perpage, - take: perpage, - }), - ); + }); - return res.send({ - page: files, - total: count, - pages: Math.ceil(count / perpage), - }); - }); + const files = cleanFiles( + await prisma.file.findMany({ + where, + select: { + ...fileSelect, + password: true, + }, + orderBy: { + [sortBy]: order, + }, + skip: (Number(page) - 1) * perpage, + take: perpage, + }), + ); - done(); + return res.send({ + page: files, + total: count, + pages: Math.ceil(count / perpage), + }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/files/transaction.ts b/src/server/routes/api/user/files/transaction.ts index 4fc8c8d9..df062a8c 100755 --- a/src/server/routes/api/user/files/transaction.ts +++ b/src/server/routes/api/user/files/transaction.ts @@ -5,23 +5,14 @@ import { secondlyRatelimit } from '@/lib/ratelimits'; import { canInteract } from '@/lib/role'; import { Role } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserFilesTransactionResponse = { count: number; name?: string; }; -type Body = { - files: string[]; - - favorite?: boolean; - - folder?: string; - - delete_datasourceFiles?: boolean; -}; - const logger = log('api').c('user').c('files').c('transaction'); function checkInteraction( @@ -42,16 +33,24 @@ function checkInteraction( } export const PATH = '/api/user/files/transaction'; -export default fastifyPlugin( - (server, _, done) => { - server.patch<{ Body: Body }>( +export default typedPlugin( + async (server) => { + server.patch( PATH, - { preHandler: [userMiddleware], ...secondlyRatelimit(2) }, + { + schema: { + body: z.object({ + files: z.array(z.string()).min(1), + favorite: z.boolean().optional(), + folder: z.string().optional(), + }), + }, + preHandler: [userMiddleware], + ...secondlyRatelimit(2), + }, async (req, res) => { const { files, favorite, folder } = req.body; - if (!files || !files.length) return res.badRequest('Cannot process transaction without files'); - if (typeof favorite === 'boolean') { const toFavoriteFiles = await prisma.file.findMany({ where: { @@ -127,14 +126,21 @@ export default fastifyPlugin( }, ); - server.delete<{ Body: Body }>( + server.delete( PATH, - { preHandler: [userMiddleware], ...secondlyRatelimit(2) }, + { + schema: { + body: z.object({ + files: z.array(z.string()).min(1), + delete_datasourceFiles: z.boolean().optional(), + }), + }, + preHandler: [userMiddleware], + ...secondlyRatelimit(2), + }, async (req, res) => { const { files } = req.body; - if (!files || !files.length) return res.badRequest('Cannot process transaction without files'); - const { delete_datasourceFiles } = req.body; logger.debug('preparing transaction', { @@ -186,8 +192,6 @@ export default fastifyPlugin( return res.send(resp); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/folders/[id]/export.ts b/src/server/routes/api/user/folders/[id]/export.ts index 63d065f8..0bb1bb73 100644 --- a/src/server/routes/api/user/folders/[id]/export.ts +++ b/src/server/routes/api/user/folders/[id]/export.ts @@ -2,67 +2,67 @@ import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; +import typedPlugin from '@/server/typedPlugin'; import archiver from 'archiver'; -import fastifyPlugin from 'fastify-plugin'; +import z from 'zod'; export type ApiUserFoldersIdExportResponse = null; -type Params = { - id: string; -}; - const logger = log('api').c('user').c('folders').c('[id]').c('export'); export const PATH = '/api/user/folders/:id/export'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const { id } = req.params; +export default typedPlugin( + async (server) => { + server.get( + PATH, + { schema: { params: z.object({ id: z.string() }) }, preHandler: [userMiddleware] }, + async (req, res) => { + const { id } = req.params; - const folder = await prisma.folder.findUnique({ - where: { - id, - }, - include: { - files: true, - }, - }); - if (!folder) return res.notFound('Folder not found'); - if (req.user.id !== folder.userId) return res.forbidden('You do not own this folder'); + const folder = await prisma.folder.findUnique({ + where: { + id, + }, + include: { + files: 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.files.length) return res.badRequest("Can't export an empty folder."); + if (!folder.files.length) return res.badRequest("Can't export an empty folder."); - logger.info(`folder export requested: ${folder.name}`, { user: req.user.id, folder: folder.id }); + logger.info(`folder export requested: ${folder.name}`, { user: req.user.id, folder: folder.id }); - res.hijack(); + res.hijack(); - const zip = archiver('zip', { - zlib: { level: 9 }, - }); + const zip = archiver('zip', { + zlib: { level: 9 }, + }); - zip.pipe(res.raw); + zip.pipe(res.raw); - for (const file of folder.files) { - const stream = await datasource.get(file.name); - if (!stream) { - logger.warn('failed to get file stream for folder export', { file: file.id, folder: folder.id }); - continue; + for (const file of folder.files) { + const stream = await datasource.get(file.name); + if (!stream) { + logger.warn('failed to get file stream for folder export', { file: file.id, folder: folder.id }); + continue; + } + + zip.append(stream, { name: file.name }); } - zip.append(stream, { name: file.name }); - } + zip.on('error', (err) => { + logger.error('error during folder export zip creation', { folder: folder.id }).error(err as Error); + }); - zip.on('error', (err) => { - logger.error('error during folder export zip creation', { folder: folder.id }).error(err as Error); - }); + zip.on('finish', () => { + logger.info(`folder export completed: ${folder.name}`, { user: req.user.id, folder: folder.id }); + }); - zip.on('finish', () => { - logger.info(`folder export completed: ${folder.name}`, { user: req.user.id, folder: folder.id }); - }); - - await zip.finalize(); - }); - done(); + await zip.finalize(); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/folders/[id]/index.ts b/src/server/routes/api/user/folders/[id]/index.ts index c9ce5cc9..79a87cf1 100755 --- a/src/server/routes/api/user/folders/[id]/index.ts +++ b/src/server/routes/api/user/folders/[id]/index.ts @@ -5,23 +5,12 @@ import { User } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { canInteract } from '@/lib/role'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import z from 'zod'; export type ApiUserFoldersIdResponse = Folder; -type Params = { - id: string; -}; - -type Body = { - id?: string; - isPublic?: boolean; - name?: string; - allowUploads?: boolean; - - delete?: 'file' | 'folder'; -}; - // TODO: need to refactor interaction checks to use this function in the future function checkInteraction(current?: Partial | null, owner?: Partial | null) { if (!current || !owner) return false; @@ -34,23 +23,98 @@ function checkInteraction(current?: Partial | null, owner?: Partial const logger = log('api').c('user').c('folders').c('[id]'); -export const PATH = '/api/user/folders/:id'; -export default fastifyPlugin( - (server, _, done) => { - server.route<{ - Body: Body; - Params: Params; - }>({ - url: PATH, - method: ['GET', 'PUT', 'PATCH', 'DELETE'], - preHandler: [userMiddleware], - handler: async (req, res) => { - const { id } = req.params; +const paramsSchema = z.object({ + id: z.string(), +}); - const folder = await prisma.folder.findUnique({ +const folderExistsAndEditable = async (req: FastifyRequest, res: FastifyReply) => { + const { id } = req.params as z.infer; + + const folder = await prisma.folder.findUnique({ + where: { + id, + }, + include: { + User: true, + }, + }); + if (!folder) return res.notFound('Folder not found'); + if (!checkInteraction(req.user, folder.User)) return res.notFound('Folder not found'); +}; + +export const PATH = '/api/user/folders/:id'; +export default typedPlugin( + async (server) => { + server.get(PATH, { schema: { params: paramsSchema }, preHandler: [userMiddleware] }, async (req, res) => { + const { id } = req.params; + + const folder = await prisma.folder.findUnique({ + where: { + id, + }, + include: { + files: { + select: { + ...fileSelect, + password: true, + }, + }, + User: true, + }, + }); + if (!folder) return res.notFound('Folder not found'); + if (!checkInteraction(req.user, folder.User)) return res.notFound('Folder not found'); + + return res.send(cleanFolder(folder)); + }); + + server.put( + PATH, + { + schema: { + body: z.object({ + id: z.string(), + }), + params: paramsSchema, + }, + preHandler: [userMiddleware, folderExistsAndEditable], + }, + async (req, res) => { + const { id: folderId } = req.params; + const { id } = req.body; + + 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'); + + const fileInFolder = await prisma.file.findFirst({ + where: { + id, + Folder: { + id: folderId, + }, + }, + }); + if (fileInFolder) return res.badRequest('File already in folder'); + + const nFolder = await prisma.folder.update({ + where: { + id: folderId, + }, + data: { + files: { + connect: { + id, + }, + }, + }, include: { files: { select: { @@ -61,10 +125,101 @@ export default fastifyPlugin( User: true, }, }); - if (!folder) return res.notFound('Folder not found'); - if (!checkInteraction(req.user, folder.User)) return res.notFound('Folder not found'); - if (req.method === 'PUT') { + logger.info('file added to folder', { + folder: folderId, + file: id, + }); + + return res.send(cleanFolder(nFolder)); + }, + ); + + server.patch( + PATH, + { + schema: { + body: z.object({ + isPublic: z.boolean().optional(), + name: z.string().min(1).optional(), + allowUploads: z.boolean().optional(), + }), + params: paramsSchema, + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { id: folderId } = req.params; + const { isPublic, name, allowUploads } = req.body; + + const nFolder = await prisma.folder.update({ + where: { + id: folderId, + }, + data: { + ...(isPublic !== undefined && { public: isPublic }), + ...(name && { name }), + ...(allowUploads !== undefined && { allowUploads }), + }, + include: { + files: { + select: { + ...fileSelect, + password: true, + }, + }, + }, + }); + + logger.info('folder updated', { + folder: nFolder.id, + isPublic, + name, + allowUploads, + }); + + return res.send(cleanFolder(nFolder)); + }, + ); + + server.delete( + PATH, + { + schema: { + body: z.object({ + delete: z.enum(['file', 'folder']), + id: z.string().min(1).optional(), + }), + params: paramsSchema, + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { id: folderId } = req.params; + const { delete: del } = req.body; + + if (del === 'folder') { + const nFolder = await prisma.folder.delete({ + where: { + id: folderId, + }, + include: { + files: { + select: { + ...fileSelect, + password: true, + }, + }, + User: true, + }, + }); + + logger.info('folder deleted', { + folder: nFolder.id, + }); + + return res.send(cleanFolder(nFolder)); + } else if (del === 'file') { const { id } = req.body; if (!id) return res.badRequest('File id is required'); @@ -83,19 +238,19 @@ export default fastifyPlugin( where: { id, Folder: { - id: folder.id, + id: folderId, }, }, }); - if (fileInFolder) return res.badRequest('File already in folder'); + if (!fileInFolder) return res.badRequest('File not in folder'); const nFolder = await prisma.folder.update({ where: { - id: folder.id, + id: folderId, }, data: { files: { - connect: { + disconnect: { id, }, }, @@ -107,132 +262,18 @@ export default fastifyPlugin( password: true, }, }, - User: true, }, }); - logger.info('file added to folder', { - folder: folder.id, + logger.info('file removed from folder', { + folder: nFolder.id, file: id, }); return res.send(cleanFolder(nFolder)); - } else if (req.method === 'PATCH') { - const { isPublic, name, allowUploads } = req.body; - - const nFolder = await prisma.folder.update({ - where: { - id: folder.id, - }, - data: { - ...(isPublic !== undefined && { public: isPublic }), - ...(name && { name }), - ...(allowUploads !== undefined && { allowUploads }), - }, - include: { - files: { - select: { - ...fileSelect, - password: true, - }, - }, - }, - }); - - logger.info('folder updated', { - folder: folder.id, - isPublic, - name, - allowUploads, - }); - - return res.send(cleanFolder(nFolder)); - } else if (req.method === 'DELETE') { - const { delete: del } = req.body; - - if (del === 'folder') { - const nFolder = await prisma.folder.delete({ - where: { - id: folder.id, - }, - include: { - files: { - select: { - ...fileSelect, - password: true, - }, - }, - User: true, - }, - }); - - logger.info('folder deleted', { - folder: folder.id, - }); - - return res.send(cleanFolder(nFolder)); - } else if (del === 'file') { - const { id } = req.body; - if (!id) return res.badRequest('File id is required'); - - 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'); - - const fileInFolder = await prisma.file.findFirst({ - where: { - id, - Folder: { - id: folder.id, - }, - }, - }); - if (!fileInFolder) return res.badRequest('File not in folder'); - - const nFolder = await prisma.folder.update({ - where: { - id: folder.id, - }, - data: { - files: { - disconnect: { - id, - }, - }, - }, - include: { - files: { - select: { - ...fileSelect, - password: true, - }, - }, - }, - }); - - logger.info('file removed from folder', { - folder: folder.id, - file: id, - }); - - return res.send(cleanFolder(nFolder)); - } - - return res.badRequest('Invalid delete type'); } - - return res.send(cleanFolder(folder)); }, - }); - - done(); + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/folders/index.ts b/src/server/routes/api/user/folders/index.ts index f245c045..7afab36c 100755 --- a/src/server/routes/api/user/folders/index.ts +++ b/src/server/routes/api/user/folders/index.ts @@ -5,75 +5,85 @@ import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { canInteract } from '@/lib/role'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserFoldersResponse = Folder | Folder[]; -type Body = { - files?: string[]; - - name?: string; - isPublic?: boolean; -}; - -type Query = { - noincl?: boolean; - user?: string; -}; - const logger = log('api').c('user').c('folders'); export const PATH = '/api/user/folders'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const { noincl, user } = req.query; +export default typedPlugin( + async (server) => { + server.get( + PATH, + { + schema: { + querystring: z.object({ + noincl: z.coerce.boolean().optional(), + user: z.string().optional(), + }), + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { noincl, user } = req.query; - if (user) { - const user = await prisma.user.findUnique({ - where: { - id: req.user.id, - }, - }); + if (user) { + const user = await prisma.user.findUnique({ + where: { + id: req.user.id, + }, + }); - if (!user) return res.notFound(); - if (req.user.id !== user.id) { - if (!canInteract(req.user.role, user.role)) return res.notFound(); + if (!user) return res.notFound(); + if (req.user.id !== user.id) { + if (!canInteract(req.user.role, user.role)) return res.notFound(); + } } - } - const folders = await prisma.folder.findMany({ - where: { - userId: user || req.user.id, - }, - orderBy: { - createdAt: 'desc', - }, - ...(!noincl && { - include: { - files: { - select: { - ...fileSelect, - password: true, - }, - orderBy: { - createdAt: 'desc', + const folders = await prisma.folder.findMany({ + where: { + userId: user || req.user.id, + }, + orderBy: { + createdAt: 'desc', + }, + ...(!noincl && { + include: { + files: { + select: { + ...fileSelect, + password: true, + }, + orderBy: { + createdAt: 'desc', + }, }, }, - }, - }), - }); + }), + }); - return res.send(cleanFolders(folders)); - }); + return res.send(cleanFolders(folders)); + }, + ); - server.post<{ Body: Body }>( + server.post( PATH, - { preHandler: [userMiddleware], ...secondlyRatelimit(2) }, + { + schema: { + body: z.object({ + name: z.string().trim().min(1), + isPublic: z.boolean().optional(), + files: z.array(z.string()).optional(), + }), + }, + preHandler: [userMiddleware], + ...secondlyRatelimit(2), + }, async (req, res) => { const { name, isPublic } = req.body; let files = req.body.files; - if (!name) return res.badRequest('Name is required'); if (files) { const filesAdd = await prisma.file.findMany({ @@ -122,8 +132,6 @@ export default fastifyPlugin( return res.send(cleanFolder(folder)); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/index.ts b/src/server/routes/api/user/index.ts index b41bee85..177c9de9 100755 --- a/src/server/routes/api/user/index.ts +++ b/src/server/routes/api/user/index.ts @@ -5,43 +5,48 @@ import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { userMiddleware } from '@/server/middleware/user'; import { getSession, saveSession } from '@/server/session'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserResponse = { user?: User; }; -type Body = { - username?: string; - password?: string; - avatar?: string; - view?: { - content?: string; - embed?: boolean; - embedTitle?: string; - embedDescription?: string; - embedColor?: string; - embedSiteName?: string; - enabled?: boolean; - align?: 'left' | 'center' | 'right'; - showMimetype?: boolean; - showTags?: boolean; - showFolder?: boolean; - }; -}; - const logger = log('api').c('user'); export const PATH = '/api/user'; -export default fastifyPlugin( - (server, _, done) => { +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.patch<{ Body: Body }>( + server.patch( PATH, { + schema: { + body: z.object({ + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), + avatar: z.string().nullable().optional(), + view: z + .object({ + content: z.string().optional().nullable(), + embed: z.boolean().optional(), + embedTitle: z.string().optional().nullable(), + embedDescription: z.string().optional().nullable(), + embedColor: z.string().optional().nullable(), + embedSiteName: z.string().optional().nullable(), + enabled: z.boolean().optional(), + align: z.enum(['left', 'center', 'right']).optional(), + showMimetype: z.boolean().optional(), + showTags: z.boolean().optional(), + showFolder: z.boolean().optional(), + }) + .partial() + .optional(), + }), + }, preHandler: [userMiddleware], ...secondlyRatelimit(1), }, @@ -112,8 +117,6 @@ export default fastifyPlugin( return res.send({ user, token: req.cookies.zipline_token }); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/mfa/passkey.ts b/src/server/routes/api/user/mfa/passkey.ts index 7248899f..ccc5d824 100755 --- a/src/server/routes/api/user/mfa/passkey.ts +++ b/src/server/routes/api/user/mfa/passkey.ts @@ -7,6 +7,7 @@ import { secondlyRatelimit } from '@/lib/ratelimits'; import { TimedCache } from '@/lib/timedCache'; import { Prisma } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; +import typedPlugin from '@/server/typedPlugin'; import { AuthenticatorTransportFuture, generateRegistrationOptions, @@ -16,26 +17,17 @@ import { verifyRegistrationResponse, } from '@simplewebauthn/server'; import { FastifyReply, FastifyRequest } from 'fastify'; -import fastifyPlugin from 'fastify-plugin'; +import z from 'zod'; export type ApiUserMfaPasskeyResponse = User | User['passkeys']; -type Body = { - response: RegistrationResponseJSON; - name?: string; - - id?: string; -}; - 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 = (_: FastifyRequest, res: FastifyReply, done: () => void) => { +export const passkeysEnabledHandler = async (_: FastifyRequest, res: FastifyReply) => { if (!passkeysEnabled()) return res.notFound(); - - done(); }; export type PasskeyReg = { @@ -53,8 +45,8 @@ export type PasskeyReg = { const OPTIONS_CACHE = new TimedCache(3 * 60_000); // 3 min ttl export const PATH = '/api/user/mfa/passkey'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware, passkeysEnabledHandler] }, async (req, res) => { const passkeys = await prisma.userPasskey.findMany({ where: { @@ -111,18 +103,20 @@ export default fastifyPlugin( }, ); - server.post<{ Body: Body }>( + server.post( PATH, { + schema: { + body: z.object({ + response: z.custom(), + name: z.string().trim().min(1), + }), + }, preHandler: [userMiddleware, passkeysEnabledHandler], ...secondlyRatelimit(1), }, async (req, res) => { const { response, name } = req.body; - if (!response) return res.badRequest('Missing webauthn response'); - - const normalizedName = (name ?? '').trim(); - if (normalizedName.length === 0) return res.badRequest('Passkey name cannot be empty'); const optionsCached = OPTIONS_CACHE.get(req.user.id); if (!optionsCached) return res.badRequest('passkey registration timed out, try again later'); @@ -150,7 +144,7 @@ export default fastifyPlugin( data: { passkeys: { create: { - name: normalizedName, + name, reg: { webauthn: { webAuthnUserID: optionsCached.user.id, @@ -177,12 +171,18 @@ export default fastifyPlugin( }, ); - server.delete<{ Body: Body }>( + server.delete( PATH, - { preHandler: [userMiddleware, passkeysEnabledHandler] }, + { + schema: { + body: z.object({ + id: z.string(), + }), + }, + preHandler: [userMiddleware, passkeysEnabledHandler], + }, async (req, res) => { const { id } = req.body; - if (!id) return res.badRequest('Missing id'); const user = await prisma.user.update({ where: { id: req.user.id }, @@ -201,8 +201,6 @@ export default fastifyPlugin( return res.send(user); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/mfa/totp.ts b/src/server/routes/api/user/mfa/totp.ts index cc671f99..c771506b 100755 --- a/src/server/routes/api/user/mfa/totp.ts +++ b/src/server/routes/api/user/mfa/totp.ts @@ -4,28 +4,23 @@ import { User, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { generateKey, totpQrcode, verifyTotpCode } from '@/lib/totp'; import { userMiddleware } from '@/server/middleware/user'; +import typedPlugin from '@/server/typedPlugin'; import { FastifyReply, FastifyRequest } from 'fastify'; -import fastifyPlugin from 'fastify-plugin'; +import z from 'zod'; export type ApiUserMfaTotpResponse = User | { secret: string } | { secret: string; qrcode: string }; -type Body = { - code?: string; - secret?: string; -}; - 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'); - } + if (!config.mfa.totp.enabled) return res.badRequest('TOTP is disabled'); + next(); }; export const PATH = '/api/user/mfa/totp'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware, totpEnabledMiddleware] }, async (req, res) => { if (!req.user.totpSecret) { const secret = generateKey(); @@ -50,15 +45,19 @@ export default fastifyPlugin( }); }); - server.post<{ Body: Body }>( + server.post( PATH, - { preHandler: [userMiddleware, totpEnabledMiddleware] }, + { + schema: { + body: z.object({ + code: z.string().min(6).max(6), + secret: z.string(), + }), + }, + preHandler: [userMiddleware, totpEnabledMiddleware], + }, async (req, res) => { const { code, secret } = req.body; - if (!code) return res.badRequest('Missing code'); - if (code.length !== 6) return res.badRequest('Invalid code'); - - if (!secret) return res.badRequest('Missing secret'); const valid = verifyTotpCode(code, secret); if (!valid) return res.badRequest('Invalid code'); @@ -77,15 +76,20 @@ export default fastifyPlugin( }, ); - server.delete<{ Body: Body }>( + server.delete( PATH, - { preHandler: [userMiddleware, totpEnabledMiddleware] }, + { + schema: { + body: z.object({ + code: z.string().min(6).max(6), + }), + }, + preHandler: [userMiddleware, totpEnabledMiddleware], + }, async (req, res) => { if (!req.user.totpSecret) return res.badRequest("You don't have TOTP enabled"); const { code } = req.body; - if (!code) return res.badRequest('Missing code'); - if (code.length !== 6) return res.badRequest('Invalid code'); const valid = verifyTotpCode(code, req.user.totpSecret); if (!valid) return res.badRequest('Invalid code'); @@ -103,8 +107,6 @@ export default fastifyPlugin( return res.send(user); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/recent.ts b/src/server/routes/api/user/recent.ts index 53c5450a..b149c07a 100755 --- a/src/server/routes/api/user/recent.ts +++ b/src/server/routes/api/user/recent.ts @@ -1,41 +1,46 @@ import { prisma } from '@/lib/db'; import { File, cleanFiles, fileSelect } from '@/lib/db/models/file'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserRecentResponse = File[]; -type Query = { - take?: string; -}; - export const PATH = '/api/user/recent'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const { take: rawTake } = req.query; - const take = rawTake ? parseInt(rawTake, 10) : undefined; +export default typedPlugin( + async (server) => { + server.get( + PATH, + { + schema: { + querystring: z.object({ + take: z.coerce.number().min(1).max(100).default(3), + }), + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { take } = req.query; - const files = cleanFiles( - await prisma.file.findMany({ - where: { - userId: req.user.id, - }, - select: { - ...fileSelect, - password: true, - }, - orderBy: { - createdAt: 'desc', - }, - take: take ?? 3, - }), - ); + const files = cleanFiles( + await prisma.file.findMany({ + where: { + userId: req.user.id, + }, + select: { + ...fileSelect, + password: true, + }, + orderBy: { + createdAt: 'desc', + }, + take, + }), + ); - return res.send(files); - }); - - done(); + return res.send(files); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/sessions.ts b/src/server/routes/api/user/sessions.ts index bad80f28..b55f572f 100644 --- a/src/server/routes/api/user/sessions.ts +++ b/src/server/routes/api/user/sessions.ts @@ -2,23 +2,18 @@ import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; import { getSession } from '@/server/session'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserSessionsResponse = { current: string; other: string[]; }; - -type Body = { - sessionId?: string; - all?: boolean; -}; - const logger = log('api').c('user').c('sessions'); export const PATH = '/api/user/sessions'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const currentSession = await getSession(req, res); @@ -28,62 +23,71 @@ export default fastifyPlugin( }); }); - server.delete<{ Body: Body }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const currentSession = await getSession(req, res); + server.delete( + PATH, + { + schema: { + body: z.object({ + sessionId: z.string(), + all: z.boolean().optional(), + }), + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const currentSession = await getSession(req, res); + + if (req.body.all) { + await prisma.user.update({ + where: { + id: req.user.id, + }, + data: { + sessions: { + set: [currentSession.sessionId!], + }, + }, + }); + + logger.info('user logged out all logged in sessions', { + user: req.user.username, + }); + + return res.send({ + current: currentSession.sessionId, + other: [], + }); + } + + if (req.body.sessionId === currentSession.sessionId) + return res.badRequest('Cannot delete current session'); + if (!req.user.sessions.includes(req.body.sessionId)) + return res.badRequest('Session not found in logged in sessions'); + + const sessionsWithout = req.user.sessions.filter((session) => session !== req.body.sessionId); - if (req.body.all) { await prisma.user.update({ where: { id: req.user.id, }, data: { sessions: { - set: [currentSession.sessionId!], + set: sessionsWithout, }, }, }); - logger.info('user logged out all logged in sessions', { + logger.info('user logged out of session', { user: req.user.username, + session: req.body.sessionId, }); return res.send({ current: currentSession.sessionId, - other: [], + other: sessionsWithout, }); - } - - if (!req.body.sessionId) return res.badRequest('No session provided'); - if (req.body.sessionId === currentSession.sessionId) - return res.badRequest('Cannot delete current session'); - if (!req.user.sessions.includes(req.body.sessionId)) - return res.badRequest('Session not found in logged in sessions'); - - const sessionsWithout = req.user.sessions.filter((session) => session !== req.body.sessionId); - - await prisma.user.update({ - where: { - id: req.user.id, - }, - data: { - sessions: { - set: sessionsWithout, - }, - }, - }); - - logger.info('user logged out of session', { - user: req.user.username, - session: req.body.sessionId, - }); - - return res.send({ - current: currentSession.sessionId, - other: sessionsWithout, - }); - }); - - done(); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/stats.ts b/src/server/routes/api/user/stats.ts index a38cb555..5b06b20f 100755 --- a/src/server/routes/api/user/stats.ts +++ b/src/server/routes/api/user/stats.ts @@ -1,6 +1,6 @@ import { prisma } from '@/lib/db'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiUserStatsResponse = { filesUploaded: number; @@ -16,8 +16,8 @@ export type ApiUserStatsResponse = { }; export const PATH = '/api/user/stats'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const aggFile = await prisma.file.aggregate({ where: { @@ -90,8 +90,6 @@ export default fastifyPlugin( sortTypeCount, }); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/tags/[id].ts b/src/server/routes/api/user/tags/[id].ts index 96c86693..9b1906ff 100755 --- a/src/server/routes/api/user/tags/[id].ts +++ b/src/server/routes/api/user/tags/[id].ts @@ -2,98 +2,119 @@ import { prisma } from '@/lib/db'; import { Tag, tagSelect } from '@/lib/db/models/tag'; import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserTagsIdResponse = Tag; -type Body = { - name?: string; - color?: string; -}; - -type Params = { - id: string; -}; - const logger = log('api').c('user').c('tags').c('[id]'); +const paramsSchema = z.object({ + id: z.string(), +}); + export const PATH = '/api/user/tags/:id'; -export default fastifyPlugin( - (server, _, done) => { - server.route<{ - Params: Params; - Body: Body; - }>({ - url: PATH, - method: ['GET', 'DELETE', 'PATCH'], - preHandler: [userMiddleware], - handler: async (req, res) => { +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, + }, + select: tagSelect, + }); + if (!tag) return res.notFound(); + + return res.send(tag); + }); + + server.delete( + PATH, + { + schema: { params: paramsSchema }, + preHandler: [userMiddleware], + }, + async (req, res) => { const { id } = req.params; - const tag = await prisma.tag.findFirst({ + const tag = await prisma.tag.deleteMany({ where: { userId: req.user.id, id, }, + }); + + if (tag.count === 0) return res.notFound(); + + logger.info('tag deleted', { + id, + user: req.user.username, + }); + + return res.send({ success: true }); + }, + ); + + server.patch( + PATH, + { + schema: { + params: paramsSchema, + body: z.object({ + name: z.string().min(1).optional(), + color: z + .string() + .regex(/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/) + .optional(), + }), + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { id } = req.params; + const { name, color } = req.body; + + const existingTag = await prisma.tag.findFirst({ + where: { + userId: req.user.id, + id, + }, + }); + if (!existingTag) return res.notFound(); + + if (name) { + const existing = await prisma.tag.findFirst({ + where: { + name, + }, + }); + + if (existing) return res.badRequest('tag name already exists'); + } + + const tag = await prisma.tag.update({ + where: { + id: existingTag.id, + }, + data: { + ...(name && { name }), + ...(color && { color }), + }, select: tagSelect, }); - if (!tag) return res.notFound(); - if (req.method === 'DELETE') { - const tag = await prisma.tag.delete({ - where: { - id, - }, - select: tagSelect, - }); - - logger.info('tag deleted', { - id: tag.id, - name: tag.name, - user: req.user.username, - }); - - return res.send(tag); - } - - if (req.method === 'PATCH') { - const { name, color } = req.body; - - if (name) { - const existing = await prisma.tag.findFirst({ - where: { - name, - }, - }); - - if (existing) return res.badRequest('tag name already exists'); - } - - const tag = await prisma.tag.update({ - where: { - id, - }, - data: { - ...(name && { name }), - ...(color && { color }), - }, - select: tagSelect, - }); - - logger.info('tag updated', { - id: tag.id, - name: tag.name, - user: req.user.username, - }); - - return res.send(tag); - } + logger.info('tag updated', { + id: tag.id, + name: tag.name, + user: req.user.username, + }); return res.send(tag); }, - }); - - done(); + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/tags/index.ts b/src/server/routes/api/user/tags/index.ts index fe48830c..ab45bda2 100755 --- a/src/server/routes/api/user/tags/index.ts +++ b/src/server/routes/api/user/tags/index.ts @@ -3,20 +3,16 @@ import { Tag, tagSelect } from '@/lib/db/models/tag'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserTagsResponse = Tag | Tag[]; -type Body = { - name: string; - color: string; -}; - const logger = log('api').c('user').c('tags'); export const PATH = '/api/user/tags'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const tags = await prisma.tag.findMany({ where: { @@ -28,15 +24,21 @@ export default fastifyPlugin( return res.send(tags); }); - server.post<{ Body: Body }>( + server.post( PATH, - { preHandler: [userMiddleware], ...secondlyRatelimit(1) }, + { + schema: { + body: z.object({ + name: z.string().min(1), + color: z.string().regex(/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/), + }), + }, + preHandler: [userMiddleware], + ...secondlyRatelimit(1), + }, async (req, res) => { const { name, color } = req.body; - if (!name) return res.badRequest('Name is required'); - if (!color) return res.badRequest('Color is required'); - const existingTag = await prisma.tag.findFirst({ where: { name, @@ -64,8 +66,6 @@ export default fastifyPlugin( return res.send(tag); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/token.ts b/src/server/routes/api/user/token.ts index d9be94ff..d6b90627 100755 --- a/src/server/routes/api/user/token.ts +++ b/src/server/routes/api/user/token.ts @@ -5,7 +5,7 @@ import { User, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiUserTokenResponse = { user?: User; @@ -15,8 +15,8 @@ export type ApiUserTokenResponse = { const logger = log('api').c('user').c('token'); export const PATH = '/api/user/token'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const user = await prisma.user.findUnique({ where: { @@ -59,8 +59,6 @@ export default fastifyPlugin( token: encryptToken(user.token, config.core.secret), }); }); - - done(); }, { 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 a4f4cfa8..0edd46c6 100755 --- a/src/server/routes/api/user/urls/[id]/index.ts +++ b/src/server/routes/api/user/urls/[id]/index.ts @@ -3,51 +3,70 @@ import { prisma } from '@/lib/db'; import { Url } from '@/lib/db/models/url'; import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUserUrlsIdResponse = Url; -type Params = { - id: string; -}; - const logger = log('api').c('user').c('urls').c('[id]'); +const paramsSchema = z.object({ + id: z.string(), +}); + export const PATH = '/api/user/urls/:id'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const { id } = req.params; - - const url = await prisma.url.findFirst({ - where: { - id: id, - }, - omit: { - password: true, - }, - }); - - if (!url) return res.notFound(); - if (url.userId !== req.user.id) return res.forbidden("You don't own this URL"); - - return res.send(url); - }); - - server.patch<{ Body: Partial; Params: Params }>( +export default typedPlugin( + async (server) => { + server.get( PATH, - { preHandler: [userMiddleware] }, + { + schema: { params: paramsSchema }, + preHandler: [userMiddleware], + }, async (req, res) => { const { id } = req.params; const url = await prisma.url.findFirst({ where: { id: id, + userId: req.user.id, + }, + omit: { + password: true, + }, + }); + if (!url) return res.notFound(); + + return res.send(url); + }, + ); + + server.patch( + PATH, + { + schema: { + params: paramsSchema, + body: z.object({ + password: z.string().optional().nullable(), + vanity: z.string().min(1).optional(), + maxViews: z.number().min(0).optional().nullable(), + destination: z.httpUrl().optional(), + enabled: z.boolean().optional(), + }), + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { id } = req.params; + + const url = await prisma.url.findFirst({ + where: { + id: id, + userId: req.user.id, }, }); if (!url) return res.notFound(); - if (url.userId !== req.user.id) return res.forbidden(); let password: string | null | undefined = undefined; if (req.body.password !== undefined) { @@ -70,9 +89,6 @@ export default fastifyPlugin( if (existingUrl) return res.badRequest('vanity already exists'); } - if (req.body.maxViews !== undefined && req.body.maxViews! < 0) - return res.badRequest('maxViews must be >= 0'); - const updatedUrl = await prisma.url.update({ where: { id: id, @@ -97,35 +113,40 @@ export default fastifyPlugin( }, ); - server.delete<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const { id } = req.params; + server.delete( + PATH, + { + schema: { params: paramsSchema }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { id } = req.params; - const url = await prisma.url.findFirst({ - where: { - id: id, - userId: req.user.id, - }, - }); + const url = await prisma.url.findFirst({ + where: { + id: id, + userId: req.user.id, + }, + }); - if (!url) return res.notFound(); + if (!url) return res.notFound(); - const deletedUrl = await prisma.url.delete({ - where: { - id: id, - }, - omit: { - password: true, - }, - }); + const deletedUrl = await prisma.url.delete({ + where: { + id: id, + }, + omit: { + password: true, + }, + }); - logger.info(`${req.user.username} deleted URL ${deletedUrl.id}`, { - dest: deletedUrl.destination, - }); + logger.info(`${req.user.username} deleted URL ${deletedUrl.id}`, { + dest: deletedUrl.destination, + }); - return res.send(deletedUrl); - }); - - done(); + return res.send(deletedUrl); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/urls/[id]/password.ts b/src/server/routes/api/user/urls/[id]/password.ts index 510223dd..ac5efea7 100755 --- a/src/server/routes/api/user/urls/[id]/password.ts +++ b/src/server/routes/api/user/urls/[id]/password.ts @@ -2,62 +2,70 @@ import { verifyPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; + export type ApiUserUrlsIdPasswordResponse = { success: boolean; }; -type Body = { - password: string; -}; - -type Params = { - id: string; -}; - const logger = log('api').c('user').c('urls').c('[id]').c('password'); export const PATH = '/api/user/urls/:id/password'; -export default fastifyPlugin( - (server, _, done) => { - server.post<{ Params: Params; Body: Body }>(PATH, { ...secondlyRatelimit(2) }, async (req, res) => { - const url = await prisma.url.findFirst({ - where: { - OR: [{ id: req.params.id }, { code: req.params.id }, { vanity: req.params.id }], +export default typedPlugin( + async (server) => { + server.post( + PATH, + { + schema: { + params: z.object({ + id: z.string(), + }), + body: z.object({ + password: z.string().min(1), + }), }, - select: { - password: true, - id: true, - }, - }); - if (!url) return res.notFound(); - if (!url.password) return res.notFound(); + ...secondlyRatelimit(2), + }, + async (req, res) => { + const url = await prisma.url.findFirst({ + where: { + OR: [{ id: req.params.id }, { code: req.params.id }, { vanity: req.params.id }], + }, + select: { + password: true, + id: true, + }, + }); + if (!url) return res.notFound(); + if (!url.password) return res.notFound(); - const verified = await verifyPassword(req.body.password, url.password); - if (!verified) { - logger.warn('invalid password for URL', { - url: url.id, - ip: req.ip ?? 'unknown', + const verified = await verifyPassword(req.body.password, url.password); + if (!verified) { + logger.warn('invalid password for URL', { + url: url.id, + ip: req.ip ?? 'unknown', + ua: req.headers['user-agent'], + }); + + return res.notFound(); + } + + logger.info(`url ${url.id} was accessed with the correct password`, { ua: req.headers['user-agent'], }); - return res.forbidden('Incorrect password'); - } + res.cookie('url_pw_' + url.id, req.body.password, { + sameSite: 'lax', + maxAge: 60, + httpOnly: false, + secure: false, + path: '/', + }); - logger.info(`url ${url.id} was accessed with the correct password`, { ua: req.headers['user-agent'] }); - - res.cookie('url_pw_' + url.id, req.body.password, { - sameSite: 'lax', - maxAge: 60, - httpOnly: false, - secure: false, - path: '/', - }); - - return res.send({ success: true }); - }); - - done(); + return res.send({ success: true }); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/user/urls/index.ts b/src/server/routes/api/user/urls/index.ts index 876c8fe5..1c8d0bca 100755 --- a/src/server/routes/api/user/urls/index.ts +++ b/src/server/routes/api/user/urls/index.ts @@ -1,13 +1,13 @@ import { config } from '@/lib/config'; import { hashPassword } from '@/lib/crypto'; -import { randomCharacters } from '@/lib/random'; import { prisma } from '@/lib/db'; import { cleanUrlPasswords, Url } from '@/lib/db/models/url'; import { log } from '@/lib/logger'; -import { z } from 'zod'; +import { randomCharacters } from '@/lib/random'; import { onShorten } from '@/lib/webhooks'; -import fastifyPlugin from 'fastify-plugin'; import { userMiddleware } from '@/server/middleware/user'; +import typedPlugin from '@/server/typedPlugin'; +import { z } from 'zod'; export type ApiUserUrlsResponse = | Url[] @@ -15,42 +15,36 @@ export type ApiUserUrlsResponse = url: string; } & Omit); -type Body = { - vanity?: string; - destination: string; - enabled?: boolean; -}; - -type Headers = { - 'x-zipline-max-views': string; - 'x-zipline-no-json': string; - 'x-zipline-domain': string; - 'x-zipline-password': string; -}; - -type Query = { - searchField?: 'destination' | 'vanity' | 'code'; - searchQuery?: string; -}; - export const PATH = '/api/user/urls'; - -const validateSearchField = z.enum(['destination', 'vanity', 'code']).default('destination'); - const logger = log('api').c('user').c('urls'); -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { const rateLimit = server.rateLimit ? server.rateLimit() : (_req: any, _res: any, next: () => any) => next(); - server.post<{ Body: Body; Headers: Headers }>( + server.post( PATH, - { preHandler: [userMiddleware, rateLimit] }, + { + schema: { + body: z.object({ + vanity: z.string().min(1).max(100).optional(), + destination: z.string().min(1), + enabled: z.boolean().optional(), + }), + headers: z.object({ + 'x-zipline-max-views': z.coerce.number().min(1).optional(), + 'x-zipline-no-json': z.coerce.boolean().optional(), + 'x-zipline-domain': z.string().optional(), + 'x-zipline-password': z.string().optional(), + }), + }, + preHandler: [userMiddleware, rateLimit], + }, async (req, res) => { const { vanity, destination, enabled } = req.body; - const noJson = !!req.headers['x-zipline-no-json']; + const noJson = req.headers['x-zipline-no-json']; const countUrls = await prisma.url.count({ where: { @@ -62,8 +56,6 @@ export default fastifyPlugin( `Shortening this URL would exceed your quota of ${req.user.quota.maxUrls} URLs.`, ); - let maxViews: number | undefined; - let returnDomain; const headerDomain = req.headers['x-zipline-domain']; if (headerDomain) { @@ -71,12 +63,7 @@ export default fastifyPlugin( returnDomain = domainArray[Math.floor(Math.random() * domainArray.length)].trim(); } - const maxViewsHeader = req.headers['x-zipline-max-views']; - if (maxViewsHeader) { - maxViews = Number(maxViewsHeader); - if (isNaN(maxViews)) return res.badRequest('Max views must be a number'); - if (maxViews < 0) return res.badRequest('Max views must be greater than 0'); - } + const maxViews = req.headers['x-zipline-max-views']; const password = req.headers['x-zipline-password'] ? await hashPassword(req.headers['x-zipline-password']) @@ -145,40 +132,46 @@ export default fastifyPlugin( }, ); - server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { - const searchQuery = req.query.searchQuery - ? (decodeURIComponent(req.query.searchQuery.trim()) ?? null) - : null; - const searchField = validateSearchField.safeParse(req.query.searchField || 'destination'); - if (!searchField.success) return res.badRequest('Invalid searchField value'); + server.get( + PATH, + { + schema: { + querystring: z.object({ + searchField: z.enum(['destination', 'vanity', 'code']).default('destination'), + searchQuery: z.string().min(1).optional(), + }), + }, + preHandler: [userMiddleware], + }, + async (req, res) => { + const { searchField, searchQuery } = req.query; - if (searchQuery) { - const similarityResult = await prisma.url.findMany({ - where: { - [searchField.data]: { - mode: 'insensitive', - contains: searchQuery, + if (searchQuery) { + const similarityResult = await prisma.url.findMany({ + where: { + [searchField]: { + mode: 'insensitive', + contains: searchQuery, + }, + userId: req.user.id, }, + omit: { + password: true, + }, + }); + + return res.send(similarityResult); + } + + const urls = await prisma.url.findMany({ + where: { userId: req.user.id, }, - omit: { - password: true, - }, }); - return res.send(similarityResult); - } - - const urls = await prisma.url.findMany({ - where: { - userId: req.user.id, - }, - }); - - return res.send(cleanUrlPasswords(urls)); - }); - - done(); + return res.send(cleanUrlPasswords(urls)); + }, + ); }, { name: PATH }, ); diff --git a/src/server/routes/api/users/[id]/index.ts b/src/server/routes/api/users/[id]/index.ts index b7b2d77a..c39f9a77 100755 --- a/src/server/routes/api/users/[id]/index.ts +++ b/src/server/routes/api/users/[id]/index.ts @@ -5,43 +5,29 @@ import { prisma } from '@/lib/db'; import { User, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { canInteract } from '@/lib/role'; +import { Role, UserFilesQuota } from '@/prisma/client'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import { UserFilesQuota } from '@/prisma/client'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; import { z } from 'zod'; export type ApiUsersIdResponse = User; -type Body = { - username?: string; - password?: string; - avatar?: string; - role?: 'USER' | 'ADMIN' | 'SUPERADMIN'; - quota?: { - filesType?: UserFilesQuota & 'NONE'; - maxFiles?: number; - maxBytes?: string; - - maxUrls?: number; - }; - - delete?: boolean; -}; - -type Params = { - id: string; -}; - const logger = log('api').c('users').c('[id]'); -const zNumber = z.number(); + +const paramsSchema = z.object({ + id: z.string(), +}); export const PATH = '/api/users/:id'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Params: Params }>( +export default typedPlugin( + async (server) => { + server.get( PATH, - { preHandler: [userMiddleware, administratorMiddleware] }, + { + schema: { params: paramsSchema }, + preHandler: [userMiddleware, administratorMiddleware], + }, async (req, res) => { const user = await prisma.user.findUnique({ where: { @@ -56,9 +42,28 @@ export default fastifyPlugin( }, ); - server.patch<{ Params: Params; Body: Body }>( + server.patch( PATH, - { preHandler: [userMiddleware, administratorMiddleware] }, + { + schema: { + params: paramsSchema, + body: z.object({ + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), + avatar: z.url().optional(), + role: z.enum(Role).optional(), + quota: z + .object({ + filesType: z.enum(UserFilesQuota).and(z.literal('NONE')).optional(), + maxFiles: z.number().min(1).optional(), + maxBytes: z.string().min(1).optional(), + maxUrls: z.number().min(1).optional(), + }) + .optional(), + }), + }, + preHandler: [userMiddleware, administratorMiddleware], + }, async (req, res) => { const user = await prisma.user.findUnique({ where: { @@ -66,14 +71,9 @@ export default fastifyPlugin( }, select: userSelect, }); - if (!user) return res.notFound('User not found'); const { username, password, avatar, role, quota } = req.body; - - if (role && !z.enum(['USER', 'ADMIN']).safeParse(role).success) - return res.badRequest('Invalid role (USER, ADMIN)'); - if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot assign this role'); let finalQuota: @@ -85,14 +85,6 @@ export default fastifyPlugin( } | undefined = undefined; if (quota) { - if (quota.filesType && !z.enum(['BY_BYTES', 'BY_FILES', 'NONE']).safeParse(quota.filesType).success) - return res.badRequest('Invalid filesType (BY_BYTES, BY_FILES, NONE)'); - - if (quota.maxFiles && !zNumber.safeParse(quota.maxFiles).success) - return res.badRequest('Invalid maxFiles'); - if (quota.maxUrls && !zNumber.safeParse(quota.maxUrls).success) - return res.badRequest('Invalid maxUrls'); - if (quota.filesType === 'BY_BYTES' && quota.maxBytes === undefined) return res.badRequest('maxBytes is required'); if (quota.filesType === 'BY_FILES' && quota.maxFiles === undefined) @@ -160,9 +152,17 @@ export default fastifyPlugin( }, ); - server.delete<{ Params: Params; Body: Body }>( + server.delete( PATH, - { preHandler: [userMiddleware, administratorMiddleware] }, + { + schema: { + params: paramsSchema, + body: z.object({ + delete: z.boolean().optional(), + }), + }, + preHandler: [userMiddleware, administratorMiddleware], + }, async (req, res) => { const user = await prisma.user.findUnique({ where: { @@ -237,8 +237,6 @@ export default fastifyPlugin( return res.send(deletedUser); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/users/[id]/tags.ts b/src/server/routes/api/users/[id]/tags.ts index a233739d..b918f5c0 100644 --- a/src/server/routes/api/users/[id]/tags.ts +++ b/src/server/routes/api/users/[id]/tags.ts @@ -3,22 +3,26 @@ import { Tag, tagSelect } from '@/lib/db/models/tag'; import { canInteract } from '@/lib/role'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; +import z from 'zod'; export type ApiUsersIdTagsResponse = Tag[]; -type Params = { - id: string; -}; - // const logger = log('api').c('user').c('id').c('tags'); export const PATH = '/api/users/:id/tags'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Params: Params }>( +export default typedPlugin( + async (server) => { + server.get( PATH, - { preHandler: [userMiddleware, administratorMiddleware] }, + { + schema: { + params: z.object({ + id: z.string(), + }), + }, + preHandler: [userMiddleware, administratorMiddleware], + }, async (req, res) => { const { id } = req.params; @@ -41,8 +45,6 @@ export default fastifyPlugin( return res.send(tags); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/users/index.ts b/src/server/routes/api/users/index.ts index a76cd841..f5f26d66 100755 --- a/src/server/routes/api/users/index.ts +++ b/src/server/routes/api/users/index.ts @@ -5,34 +5,32 @@ import { User, userSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; import { canInteract } from '@/lib/role'; +import { Role } from '@/prisma/client'; import { administratorMiddleware } from '@/server/middleware/administrator'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; import { readFile } from 'fs/promises'; import { z } from 'zod'; -import { Role } from '@/prisma/client'; export type ApiUsersResponse = User[] | User; -type Query = { - noincl?: 'true' | 'false'; -}; - -type Body = { - username?: string; - password?: string; - avatar?: string; - role?: Role; -}; - const logger = log('api').c('users'); +const querySchema = z.object({ + noincl: z.coerce.boolean().default(false), +}); + export const PATH = '/api/users'; -export default fastifyPlugin( - (server, _, done) => { - server.get<{ Querystring: Query }>( +export default typedPlugin( + async (server) => { + server.get( PATH, - { preHandler: [userMiddleware, administratorMiddleware] }, + { + schema: { + querystring: querySchema, + }, + preHandler: [userMiddleware, administratorMiddleware], + }, async (req, res) => { const users = await prisma.user.findMany({ select: { @@ -40,7 +38,7 @@ export default fastifyPlugin( avatar: true, }, where: { - ...(req.query.noincl === 'true' && { id: { not: req.user.id } }), + ...(req.query.noincl && { id: { not: req.user.id } }), }, }); @@ -48,15 +46,24 @@ export default fastifyPlugin( }, ); - server.post<{ Querystring: Query; Body: Body }>( + server.post( PATH, - { preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1) }, + { + schema: { + querystring: querySchema, + body: z.object({ + username: z.string().min(1), + password: z.string().min(1), + avatar: z.string().optional(), + role: z.enum(Role).default('USER').optional(), + }), + }, + preHandler: [userMiddleware, administratorMiddleware], + ...secondlyRatelimit(1), + }, async (req, res) => { const { username, password, avatar, role } = req.body; - if (!username) return res.badRequest('Username is required'); - if (!password) return res.badRequest('Password is required'); - let avatar64 = null; try { @@ -69,16 +76,13 @@ export default fastifyPlugin( logger.debug('failed to read default avatar', { path: config.website.defaultAvatar }); } - if (role && !z.enum(['USER', 'ADMIN']).safeParse(role).success) - return res.badRequest('Invalid role (USER, ADMIN)'); - if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot create this role'); const user = await prisma.user.create({ data: { username, password: await hashPassword(password), - role: role ?? 'USER', + role: role, avatar: avatar64 ?? null, token: createToken(), }, @@ -97,8 +101,6 @@ export default fastifyPlugin( return res.send(user); }, ); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/api/version.ts b/src/server/routes/api/version.ts index 6602ce7d..41cbbe62 100755 --- a/src/server/routes/api/version.ts +++ b/src/server/routes/api/version.ts @@ -2,7 +2,7 @@ import { config } from '@/lib/config'; import { log } from '@/lib/logger'; import { getVersion } from '@/lib/version'; import { userMiddleware } from '@/server/middleware/user'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '@/server/typedPlugin'; export type ApiVersionResponse = { details: ReturnType; @@ -36,8 +36,8 @@ let cachedData: VersionAPI | null = null; let cachedAt = 0; export const PATH = '/api/version'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, { preHandler: [userMiddleware] }, async (_, res) => { if (!config.features.versionChecking) return res.notFound(); @@ -74,8 +74,6 @@ export default fastifyPlugin( return res.internalServerError('failed to fetch version details: ' + (e as Error).message); } }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/favicon.ts b/src/server/routes/favicon.ts index 00cd6432..8109c5ae 100644 --- a/src/server/routes/favicon.ts +++ b/src/server/routes/favicon.ts @@ -1,12 +1,12 @@ import { config } from '@/lib/config'; -import fastifyPlugin from 'fastify-plugin'; import { join } from 'path'; +import typedPlugin from '../typedPlugin'; export const FAVICON_SIZES = [16, 32, 64, 128, 512]; export const PATH = '/favicon.ico'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, (_, res) => { return res.sendFile('favicon.ico', join(process.cwd(), 'public')); }); @@ -20,8 +20,6 @@ export default fastifyPlugin( return res.sendFile(`favicon-${str}.png`, join(process.cwd(), 'public')); }); } - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/invites.ts b/src/server/routes/invites.ts index e5e64d78..52c43924 100644 --- a/src/server/routes/invites.ts +++ b/src/server/routes/invites.ts @@ -1,16 +1,14 @@ -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '../typedPlugin'; export const PATH = '/invite/:id'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get<{ Params: { id: string } }>(PATH, async (req, res) => { const { id } = req.params; if (!id) return res.callNotFound(); return res.redirect(`/auth/register?code=${encodeURIComponent(id)}`); }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/manifest.json.ts b/src/server/routes/manifest.json.ts index b7335652..03e8ee3b 100644 --- a/src/server/routes/manifest.json.ts +++ b/src/server/routes/manifest.json.ts @@ -1,5 +1,5 @@ import { config } from '@/lib/config'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '../typedPlugin'; import { FAVICON_SIZES } from './favicon'; function generateIcons(sizes: number[]) { @@ -11,8 +11,8 @@ function generateIcons(sizes: number[]) { } export const PATH = '/manifest.json'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, async (_, res) => { if (!config.pwa.enabled) return res.callNotFound(); @@ -28,8 +28,6 @@ export default fastifyPlugin( icons: generateIcons(FAVICON_SIZES), }; }); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/raw/[id].ts b/src/server/routes/raw/[id].ts index 08b1fb8c..4f131477 100644 --- a/src/server/routes/raw/[id].ts +++ b/src/server/routes/raw/[id].ts @@ -5,8 +5,8 @@ import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { guess } from '@/lib/mimes'; +import typedPlugin from '@/server/typedPlugin'; import { FastifyReply, FastifyRequest } from 'fastify'; -import fastifyPlugin from 'fastify-plugin'; const viewsCache = new Map(); const VIEW_WINDOW = 5 * 1000; @@ -188,11 +188,9 @@ export const rawFileHandler = async ( }; export const PATH = '/raw/:id'; -export default fastifyPlugin( - (server, _, done) => { +export default typedPlugin( + async (server) => { server.get(PATH, rawFileHandler); - - done(); }, { name: PATH }, ); diff --git a/src/server/routes/robots.txt.ts b/src/server/routes/robots.txt.ts index f74e0bd1..4b5de6fb 100644 --- a/src/server/routes/robots.txt.ts +++ b/src/server/routes/robots.txt.ts @@ -1,16 +1,14 @@ import { config } from '@/lib/config'; -import fastifyPlugin from 'fastify-plugin'; +import typedPlugin from '../typedPlugin'; export const PATH = '/robots.txt'; -export default fastifyPlugin( - (server, _, done) => { - server.get(PATH, async (req, res) => { +export default typedPlugin( + async (server) => { + server.get(PATH, async (_, res) => { if (!config.features.robotsTxt) return res.callNotFound(); return 'User-Agent: *\nDisallow: /'; }); - - done(); }, { name: PATH }, ); diff --git a/src/server/typedPlugin.ts b/src/server/typedPlugin.ts new file mode 100644 index 00000000..5937a809 --- /dev/null +++ b/src/server/typedPlugin.ts @@ -0,0 +1,9 @@ +import fastifyPlugin from 'fastify-plugin'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; + +export default function typedPlugin( + plugin: FastifyPluginAsyncZod, + opts?: Parameters[1], +) { + return fastifyPlugin(plugin as any, opts); +}