mirror of
https://github.com/diced/zipline.git
synced 2026-01-25 18:54:50 -08:00
feat: input validation schemas (very wip)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
79
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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!} />
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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
19
src/lib/validation.ts
Normal 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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
9
src/server/typedPlugin.ts
Normal file
9
src/server/typedPlugin.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user