feat: input validation schemas (very wip)

This commit is contained in:
diced
2026-01-10 23:32:59 -08:00
parent 4d978c11b1
commit 260c283872
79 changed files with 2277 additions and 2070 deletions

View File

@@ -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(

View File

@@ -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",

79
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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<string[]>(() => 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) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
useEffect(() => {
if (file) {
setValue(file.tags?.map((x) => x.id) ?? []);
} else {
setValue([]);
}
}, [file]);
return (
<>
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file!} />

View File

@@ -212,35 +212,23 @@ export default function FileTable({
| 'favorite'
>('createdAt');
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
const [selectedFile, setSelectedFile] = useState<File | null>(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<File[]>([]);
const combobox = useCombobox();
@@ -273,6 +261,11 @@ export default function FileTable({
}),
});
const [selectedFileId, setSelectedFile] = useState<string | null>(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 (
<>
<FileModal
@@ -594,7 +572,7 @@ export default function FileTable({
setSort(data.columnAccessor as any);
setOrder(data.direction);
}}
onCellClick={({ record }) => setSelectedFile(record)}
onCellClick={({ record }) => setSelectedFile(record.id)}
selectedRecords={selectedFiles}
onSelectedRecordsChange={setSelectedFiles}
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}

View File

@@ -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 (
<>

View File

@@ -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<Folder[]>(data ?? []);
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
const [editNameOpen, setEditNameOpen] = useState<Folder | null>(null);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Folder;
const sorted = useMemo<Folder[]>(() => {
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 (
<>

View File

@@ -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<Invite[]>(data ?? []);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Invite;
const sorted = useMemo<Invite[]>(() => {
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 (
<>

View File

@@ -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={<IconCalendarTime size='1rem' />}
onClick={() => setAllTime(true)}
onClick={() => showAllTime()}
disabled={allTime}
>
Show All Time

View File

@@ -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 (
<Paper withBorder p='sm'>
<Title order={2}>Viewing Files</Title>

View File

@@ -22,7 +22,6 @@ export default function PasskeyButton() {
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [namerShown, setNamerShown] = useState(false);
const [savedKey, setSavedKey] = useState<RegistrationResponseJSON | null>(null);
const [options, setOptions] = useState<PublicKeyCredentialCreationOptionsJSON | null>(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);

View File

@@ -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;

View File

@@ -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<Url[]>(data ?? []);
const [selectedUrl, setSelectedUrl] = useState<Url | null>(null);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Url;
const sorted = useMemo<Url[]>(() => {
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) {

View File

@@ -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<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<User[]>(data ?? []);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof User;
const sorted = useMemo<User[]>(() => {
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 (
<>

View File

@@ -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;
};

19
src/lib/validation.ts Normal file
View File

@@ -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;
}

View File

@@ -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<ZodTypeProvider>();
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 });
}
});

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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<OAuthResponse> {
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 },
);

View File

@@ -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<OAuthResponse> {
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 },
);

View File

@@ -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<OAuthResponse> {
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 },
);

View File

@@ -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 },
);

View File

@@ -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<OAuthResponse> {
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 },
);

View File

@@ -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>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>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 },
);

View File

@@ -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<string, PublicKeyCredentialRequestOptionsJSON>(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<AuthenticationResponseJSON>(),
}),
},
preHandler: [passkeysEnabledHandler],
...secondlyRatelimit(10),
},
async (req, res) => {
const session = await getSession(req, res);
@@ -181,8 +186,6 @@ export default fastifyPlugin(
});
},
);
done();
},
{ name: PATH },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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<Folder>;
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 },
);

View File

@@ -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<string, string>;
@@ -14,23 +15,22 @@ export type ApiServerImportV3 = {
urls: Record<string, string>;
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<Export3>(),
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 },
);

View File

@@ -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<Export4>(),
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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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<typeof safeConfig>;
codeMap: { ext: string; mime: string; name: string }[];
};
type Body = Partial<Settings>;
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<Partial<Settings>>(),
},
preHandler: [userMiddleware, administratorMiddleware],
...secondlyRatelimit(1),
},
@@ -453,8 +454,6 @@ export default fastifyPlugin(
return res.send({ settings: newSettings, tampered: global.__tamperedConfig__ || [] });
},
);
done();
},
{ name: PATH },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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<User> | null, owner?: Partial<User> | null) {
if (!current || !owner) return false;
@@ -34,23 +23,98 @@ function checkInteraction(current?: Partial<User> | null, owner?: Partial<User>
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<typeof paramsSchema>;
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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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<string, PublicKeyCredentialCreationOptionsJSON>(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<RegistrationResponseJSON>(),
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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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<Url>; 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 },
);

View File

@@ -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 },
);

View File

@@ -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<Url, 'password'>);
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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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<typeof getVersion>;
@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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<string, number>();
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 },
);

View File

@@ -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 },
);

View File

@@ -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<typeof fastifyPlugin>[1],
) {
return fastifyPlugin(plugin as any, opts);
}