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