Compare commits

...

3 Commits

Author SHA1 Message Date
diced 57ab8deacb feat: migrate to ESM (WIP) 2026-07-06 18:51:18 -07:00
diced 95ffa476f1 chore: finally update to prisma@7.8.0 2026-07-06 18:26:55 -07:00
diced f313ae1881 fix: various logic errors 2026-07-06 18:04:29 -07:00
37 changed files with 2618 additions and 5114 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
COPY src ./src
COPY .gitignore ./.gitignore
COPY postcss.config.cjs ./postcss.config.cjs
COPY prettier.config.cjs ./prettier.config.cjs
COPY postcss.config.mjs ./postcss.config.mjs
COPY prettier.config.mjs ./prettier.config.mjs
COPY eslint.config.mjs ./eslint.config.mjs
COPY vite.config.ts ./vite.config.ts
COPY tsup.config.ts ./tsup.config.ts
+1 -1
View File
@@ -2,4 +2,4 @@
set -e
cd ${ZIPLINE_ROOT:-/zipline}
exec node --enable-source-maps build/server
exec node --enable-source-maps build/server/index.js
+1 -1
View File
@@ -2,5 +2,5 @@
set -e
cd ${ZIPLINE_ROOT:-/zipline}
exec node --enable-source-maps build/ctl "$@"
exec node --enable-source-maps build/ctl/index.js "$@"
+13 -12
View File
@@ -3,14 +3,15 @@
"private": true,
"license": "MIT",
"version": "4.6.4",
"type": "module",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require ./src/dotenv.js ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require ./src/dotenv.js --enable-source-maps ./build/ctl",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --import ./src/dotenv.js --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --import ./src/dotenv.js --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --import ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --import ./src/dotenv.js ./build/server/index.js",
"start:inspector": "cross-env NODE_ENV=production node --import ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./build/server/index.js",
"ctl": "NODE_ENV=production node --import ./src/dotenv.js --enable-source-maps ./build/ctl/index.js",
"validate": "tsx scripts/validate.ts",
"openapi": "tsx scripts/openapi.ts",
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
@@ -42,11 +43,11 @@
"@mantine/hooks": "^9.4.1",
"@mantine/modals": "^9.4.1",
"@mantine/notifications": "^9.4.1",
"@prisma/adapter-pg": "6.13.0",
"@prisma/client": "6.13.0",
"@prisma/engines": "6.13.0",
"@prisma/internals": "6.13.0",
"@prisma/migrate": "6.13.0",
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"@prisma/engines": "7.8.0",
"@prisma/internals": "7.8.0",
"@prisma/migrate": "7.8.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@smithy/node-http-handler": "^4.9.3",
@@ -74,7 +75,7 @@
"ms": "^2.1.3",
"nuqs": "^2.9.0",
"otplib": "^13.4.1",
"prisma": "6.13.0",
"prisma": "7.8.0",
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+2371 -5004
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
+1 -1
View File
@@ -1,5 +1,5 @@
/** @type {import('prettier').Config} */
module.exports = {
export default {
singleQuote: true,
jsxSingleQuote: true,
printWidth: 110,
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'prisma/config';
try {
process.loadEnvFile('.env');
} catch {}
export default defineConfig({
schema: './prisma/schema.prisma',
migrations: {
path: './prisma/migrations',
},
datasource: {
url: process.env.DATABASE_URL!,
},
});
+3 -5
View File
@@ -1,13 +1,11 @@
generator client {
provider = "prisma-client"
output = "../src/prisma"
moduleFormat = "cjs"
previewFeatures = ["queryCompiler", "driverAdapters"]
provider = "prisma-client"
output = "../src/prisma"
moduleFormat = "esm"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Zipline {
+2 -1
View File
@@ -1,10 +1,11 @@
import { readFile, writeFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
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 GEN_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'openapi.json');
const ALL_ERRORS = Object.keys(API_ERRORS)
.map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON())
@@ -57,9 +57,12 @@ function Form({ user, setUser, token }: { user: User; setUser: (u: User) => void
initialValues: {
username: user.username,
password: '',
currentPassword: '',
},
validate: {
username: (value) => (value.length < 1 ? 'Username is required' : null),
currentPassword: (value, values) =>
values.password && !value ? 'Enter your current password to change it' : null,
},
});
@@ -67,16 +70,22 @@ function Form({ user, setUser, token }: { user: User; setUser: (u: User) => void
const send: {
username?: string;
password?: string;
currentPassword?: string;
} = {};
if (values.username !== user.username) send['username'] = values.username.trim();
if (values.password) send['password'] = values.password.trim();
if (values.password) {
send['password'] = values.password.trim();
send['currentPassword'] = values.currentPassword;
}
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', send);
if (!data && error) {
if (ApiError.check(error, 1039)) {
form.setFieldError('username', error.error);
} else if (ApiError.check(error, 1066) || ApiError.check(error, 1067)) {
form.setFieldError('currentPassword', error.error);
} else {
notifications.show({
title: 'Error while updating user',
@@ -91,6 +100,9 @@ function Form({ user, setUser, token }: { user: User; setUser: (u: User) => void
if (!data?.user) return;
form.setFieldValue('password', '');
form.setFieldValue('currentPassword', '');
mutate('/api/user');
mutate('/api/user/token');
setUser(data.user);
@@ -142,6 +154,15 @@ function Form({ user, setUser, token }: { user: User; setUser: (u: User) => void
{...form.getInputProps('password')}
leftSection={<IconAsteriskSimple size='1rem' />}
/>
{form.values.password && (
<PasswordInput
label='Current password'
description='Required to change your password.'
autoComplete='current-password'
{...form.getInputProps('currentPassword')}
leftSection={<IconAsteriskSimple size='1rem' />}
/>
)}
<Button type='submit' mt='md' leftSection={<IconDeviceFloppy size='1rem' />}>
Save
+1 -2
View File
@@ -4,6 +4,7 @@ import { DATABASE_TO_PROP } from '@/lib/config/read/db';
import { ENVS } from '@/lib/config/read/env';
import { getProperty } from '@/lib/config/read/transform';
import { validateConfigObject } from '@/lib/config/validate';
import { prisma } from '@/lib/db';
import { randomCharacters } from '@/lib/random';
function convertValueToEnv(
@@ -57,8 +58,6 @@ export async function exportConfig({ yml, showDefaults }: { yml?: boolean; showD
await reloadSettings();
const { prisma } = await import('@/lib/db/index.js');
const ziplineTable = await prisma.zipline.findFirst({
omit: {
id: true,
+2 -2
View File
@@ -1,5 +1,7 @@
import { bytes } from '@/lib/bytes';
import { config, reloadSettings } from '@/lib/config';
import { getDatasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { guess } from '@/lib/mimes';
import { statSync } from 'fs';
import { mkdir, readdir } from 'fs/promises';
@@ -14,7 +16,6 @@ export async function importDir(
await reloadSettings();
const { prisma } = await import('@/lib/db/index.js');
let userId: string;
if (id) {
@@ -85,7 +86,6 @@ export async function importDir(
if (config.datasource.type === 'local')
await mkdir(config.datasource.local!.directory, { recursive: true });
const { getDatasource } = await import('@/lib/datasource/index.js');
const datasource = getDatasource(config);
if (!datasource) return console.error('No datasource configured');
+1 -1
View File
@@ -1,4 +1,5 @@
import { userSelect } from '@/lib/db/models/user';
import { prisma } from '@/lib/db';
export async function listUsers({ extra, format, id }: { extra?: string[]; format?: boolean; id?: string }) {
if (extra?.includes('list')) {
@@ -20,7 +21,6 @@ export async function listUsers({ extra, format, id }: { extra?: string[]; forma
}
}
const { prisma } = await import('@/lib/db/index.js');
const users = await prisma.user.findMany({
where: id ? { id } : undefined,
select,
+1 -3
View File
@@ -1,10 +1,8 @@
import { reloadSettings } from '@/lib/config';
import { config, reloadSettings } from '@/lib/config';
export async function readConfig({ format }: { format: boolean }) {
await reloadSettings();
const { config } = await import('@/lib/config/index.js');
console.log(JSON.stringify(config, null, format ? 2 : 0));
process.exit(0);
}
+1 -1
View File
@@ -1,10 +1,10 @@
import { hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
const SUPPORTED_FIELDS = ['username', 'password', 'role', 'avatar', 'token', 'totpSecret'];
export async function setUser(property: string, value: string, { id }: { id: string }) {
if (!SUPPORTED_FIELDS.includes(property)) return console.error('Unsupported field:', property);
const { prisma } = await import('@/lib/db/index.js');
const user = await prisma.user.findFirst({
where: { id },
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { Command } from 'commander';
import { version } from '../../package.json';
import packageJson from '../../package.json' with { type: 'json' };
import { listUsers } from './commands/list-users';
import { readConfig } from './commands/read-config';
import { setUser } from './commands/set-user';
@@ -8,7 +8,7 @@ import { exportConfig } from './commands/export-config';
const cli = new Command();
cli.name('ziplinectl').version(version).description('controll utility for zipline');
cli.name('ziplinectl').version(packageJson.version).description('control utility for zipline');
cli
.command('read-config')
+2
View File
@@ -64,6 +64,8 @@ export const API_ERRORS = {
1063: 'Already linked to this OAuth provider',
1064: 'Invalid OAuth state parameter',
1065: 'Invalid MIME type',
1066: 'Current password is incorrect',
1067: 'Current password is required to set a new password',
// 2xxx, session errors
2000: 'Invalid login session',
+1 -1
View File
@@ -1,5 +1,5 @@
import enabled from '../oauth/enabled';
import { version } from '../../../package.json';
import { version } from '../version';
import { Config } from './validate';
export type SafeConfig = Omit<
+37 -10
View File
@@ -1,19 +1,42 @@
import { Migrate } from '@prisma/migrate';
import migratePkg from '@prisma/migrate';
import { defineConfig } from 'prisma/config';
import prismaInternals from '@prisma/internals';
import { log } from '@/lib/logger';
import { loadSchemaContext } from '@prisma/internals';
// @ts-ignore
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
import ensureDatabaseExistsPkg from '@prisma/migrate/dist/utils/ensureDatabaseExists.js';
const { Migrate } = migratePkg;
const { ensureDatabaseExists } = ensureDatabaseExistsPkg;
const {
createSchemaPathInput,
getSchemaDatasourceProvider,
inferDirectoryConfig,
loadSchemaContext,
validatePrismaConfigWithDatasource,
} = prismaInternals;
export async function runMigrations() {
const baseDir = process.cwd();
const config = defineConfig({
schema: './prisma/schema.prisma',
migrations: { path: './prisma/migrations' },
datasource: { url: process.env.DATABASE_URL! },
});
const schemaContext = await loadSchemaContext({
schemaPathFromArg: './prisma/schema.prisma',
schemaPath: createSchemaPathInput({ schemaPathFromConfig: config.schema, baseDir }),
printLoadMessage: false,
});
const { migrationsDirPath } = inferDirectoryConfig(schemaContext, config);
const validatedConfig = validatePrismaConfigWithDatasource({ config, cmd: 'migrate deploy' });
const migrate = await Migrate.setup({
schemaEngineConfig: config,
baseDir,
migrationsDirPath,
schemaContext,
migrationsDirPath: './prisma/migrations',
});
const logger = log('migrations');
@@ -22,7 +45,11 @@ export async function runMigrations() {
try {
logger.debug('ensuring database exists...');
const dbCreated = await ensureDatabaseExists(schemaContext.primaryDatasource);
const dbCreated = await ensureDatabaseExists(
baseDir,
getSchemaDatasourceProvider(schemaContext),
validatedConfig,
);
if (dbCreated) {
logger.info('database created');
}
@@ -30,7 +57,7 @@ export async function runMigrations() {
logger.error('failed to create database' + e);
logger.error('try creating the database manually and running the server again');
migrate.stop();
await migrate.stop();
process.exit(1);
}
@@ -42,10 +69,10 @@ export async function runMigrations() {
} catch (e) {
logger.error('failed to apply migrations' + e);
migrate.stop();
await migrate.stop();
process.exit(1);
} finally {
migrate.stop();
await migrate.stop();
}
if (migrationIds?.length === 0) {
+7
View File
@@ -0,0 +1,7 @@
declare module '@prisma/migrate' {
export * from '@prisma/migrate/dist/src/index';
}
declare module '@prisma/migrate/dist/utils/ensureDatabaseExists.js' {
export * from '@prisma/migrate/dist/src/utils/ensureDatabaseExists';
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Worker } from 'worker_threads';
import Logger, { log } from '../logger';
import { config } from '../config';
import { MAX_SAFE_TIMEOUT_MS } from '../config/validate';
import { createWorker } from '../worker';
export interface Task {
id: string;
@@ -112,7 +113,7 @@ export class Tasks {
task.data.config = config;
}
const worker = new Worker(task.path, {
const worker = createWorker(task.path, {
workerData: task.data,
});
+12 -12
View File
@@ -3,21 +3,21 @@ import { basename, join } from 'path';
import { ZiplineTheme } from '.';
import { exists } from '../fs';
import dark_gray from './builtins/dark_gray.theme.json';
import light_gray from './builtins/light_gray.theme.json';
import black_dark from './builtins/black_dark.theme.json';
import dark_gray from './builtins/dark_gray.theme.json' with { type: 'json' };
import light_gray from './builtins/light_gray.theme.json' with { type: 'json' };
import black_dark from './builtins/black_dark.theme.json' with { type: 'json' };
import light_blue from './builtins/light_blue.theme.json';
import dark_blue from './builtins/dark_blue.theme.json';
import light_blue from './builtins/light_blue.theme.json' with { type: 'json' };
import dark_blue from './builtins/dark_blue.theme.json' with { type: 'json' };
import cat_frappe from './builtins/catppuccin_frappe.theme.json';
import cat_latte from './builtins/catppuccin_latte.theme.json';
import cat_macchiato from './builtins/catppuccin_macchiato.theme.json';
import cat_mocha from './builtins/catppuccin_mocha.theme.json';
import cat_frappe from './builtins/catppuccin_frappe.theme.json' with { type: 'json' };
import cat_latte from './builtins/catppuccin_latte.theme.json' with { type: 'json' };
import cat_macchiato from './builtins/catppuccin_macchiato.theme.json' with { type: 'json' };
import cat_mocha from './builtins/catppuccin_mocha.theme.json' with { type: 'json' };
import midnight_orange from './builtins/midnight_orange.theme.json';
import midnight_blue from './builtins/midnight_blue.theme.json';
import midnight_purple from './builtins/midnight_purple.theme.json';
import midnight_orange from './builtins/midnight_orange.theme.json' with { type: 'json' };
import midnight_blue from './builtins/midnight_blue.theme.json' with { type: 'json' };
import midnight_purple from './builtins/midnight_purple.theme.json' with { type: 'json' };
import { log } from '../logger';
+3 -1
View File
@@ -1,9 +1,11 @@
import { version } from '../../package.json';
import packageJson from '../../package.json' with { type: 'json' };
import { execSync } from 'child_process';
import { log } from './logger';
const logger = log('version');
export const version = packageJson.version;
export function gitSha() {
const envValue = process.env.ZIPLINE_GIT_SHA;
if (envValue && envValue !== 'unknown') return envValue;
+12
View File
@@ -0,0 +1,12 @@
import { Worker, type WorkerOptions } from 'worker_threads';
export function workerUrl(pathFromRoot: string) {
return new URL(pathFromRoot, new URL('../../build/', import.meta.url));
}
export function createWorker(pathFromRoot: string, options: WorkerOptions) {
return new Worker(workerUrl(pathFromRoot), {
...options,
type: 'module',
} as WorkerOptions);
}
+1 -1
View File
@@ -4,10 +4,10 @@ import { getDatasource } from '@/lib/datasource';
import { runMigrations } from '@/lib/db/migration';
import { log } from '@/lib/logger';
import type { Tasks } from '@/lib/tasks';
import { version } from '@/lib/version';
import fastify from 'fastify';
import { ZodTypeProvider } from 'fastify-type-provider-zod';
import { mkdir } from 'fs/promises';
import { version } from '../../package.json';
import { registerHandlers } from './startup/handlers';
import { listenServer } from './startup/listen';
import { startMemoryLog } from './startup/memory';
-2
View File
@@ -69,8 +69,6 @@ async function vitePlugin(fastify: FastifyInstance) {
template = await readFile(resolve(`./src/client/ssr-${type}/`, 'index.html'), 'utf-8');
template = await fastify.vite.transformIndexHtml(url, template);
// expose __dirname since dev modules are loaded in esm
global.__dirname = __dirname;
render = (await fastify.vite.ssrLoadModule(`/ssr-${type}/server.tsx`)).render;
} else {
template = await readFile(resolve('./build', `client/ssr-${type}/index.html`), 'utf-8');
+1 -1
View File
@@ -9,7 +9,7 @@ import { zQsBoolean } from '@/lib/validation';
import typedPlugin from '@/server/typedPlugin';
import { cpus, hostname, platform, release } from 'os';
import z from 'zod';
import { version } from '../../../../../package.json';
import { version } from '@/lib/version';
const exportCountsSchema = z.object({
users: z.number(),
+10 -1
View File
@@ -7,6 +7,7 @@ import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { userSelect } from '@/lib/db/models/user';
import { sanitizeFilename } from '@/lib/fs';
import { removeGps } from '@/lib/gps';
import { log } from '@/lib/logger';
@@ -117,7 +118,15 @@ export default typedPlugin(
if (!files.length) throw new ApiError(1062);
const totalFileSize = files.reduce((acc, x) => acc + x.file.bytesRead, 0);
const quotaCheck = await checkQuota(req.user, totalFileSize, files.length);
// use quota of user if anonymous
const quotaUser = req.user
? req.user
: folder?.userId
? await prisma.user.findUnique({ where: { id: folder.userId }, select: userSelect })
: null;
const quotaCheck = await checkQuota(quotaUser, totalFileSize, files.length);
if (quotaCheck !== true)
throw new ApiError(5002, typeof quotaCheck === 'string' ? quotaCheck : undefined);
+11 -3
View File
@@ -4,6 +4,7 @@ import { bytes } from '@/lib/bytes';
import { config } from '@/lib/config';
import { hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { userSelect } from '@/lib/db/models/user';
import { sanitizeFilename } from '@/lib/fs';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
@@ -15,7 +16,7 @@ import typedPlugin from '@/server/typedPlugin';
import { z } from 'zod';
import { readdir, rename, rm } from 'fs/promises';
import { join } from 'path';
import { Worker } from 'worker_threads';
import { createWorker } from '@/lib/worker';
import { ApiUploadResponse } from '.';
const logger = log('api').c('upload').c('partial');
@@ -139,8 +140,15 @@ export default typedPlugin(
const cache = partialsCache.get(options.partial.identifier);
if (!cache) throw new ApiError(1003);
// use quota of user if anonymous
const quotaUser = req.user
? req.user
: folder?.userId
? await prisma.user.findUnique({ where: { id: folder.userId }, select: userSelect })
: null;
// 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(quotaUser, cache.length + fileSize, 1);
if (quotaCheck !== true) {
await deletePartial(options.partial.identifier);
throw new ApiError(5002, typeof quotaCheck === 'string' ? quotaCheck : undefined);
@@ -226,7 +234,7 @@ export default typedPlugin(
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`
}/${fileUpload.name}`;
const worker = new Worker('./build/offload/partial.js', {
const worker = createWorker('offload/partial.js', {
workerData: {
user: {
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
@@ -147,7 +147,7 @@ export default typedPlugin(
const newFile = await prisma.file.update({
where: {
id: req.params.id,
id: file.id,
},
data,
select: fileSelect,
+37 -31
View File
@@ -7,6 +7,7 @@ import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { sanitizeFilename } from '@/lib/fs';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
import { canInteract } from '@/lib/role';
import { zQsBoolean } from '@/lib/validation';
import { userMiddleware } from '@/server/middleware/user';
@@ -61,22 +62,37 @@ export default typedPlugin(
if (thumbnail.file && thumbnail.file.userId !== req.user.id) {
if (!canInteract(req.user.role, thumbnail.file.User?.role)) throw new ApiError(9002);
}
const size = await datasource.size(thumbnail.path);
if (!size) throw new ApiError(9002);
const buf = await datasource.get(thumbnail.path);
if (!buf) throw new ApiError(9002);
return res
.type(await guess(thumbnail.path.replace('.thumbnail-', '').split('.').pop() || 'jpg'))
.headers({
'Content-Length': size,
})
.status(200)
.send(buf);
}
const file = await prisma.file.findFirst({
where: {
id,
OR: [{ id }, { name: id }],
},
include: {
User: true,
},
});
if (!file) throw new ApiError(9002);
if (file && file.userId !== req.user.id) {
if (file.userId !== req.user.id) {
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()) {
try {
await datasource.delete(file.name);
await prisma.file.delete({
@@ -95,7 +111,7 @@ export default typedPlugin(
throw new ApiError(9002);
}
if (file?.maxViews && file.views >= file.maxViews) {
if (file.maxViews && file.views >= file.maxViews) {
if (!config.features.deleteOnMaxViews) throw new ApiError(9002);
try {
@@ -116,38 +132,40 @@ export default typedPlugin(
throw new ApiError(9002);
}
if (file?.password) {
if (file.password) {
const valid = verifyAccessToken(token, 'file', file.id);
if (!valid) throw new ApiError(3018);
}
const size = file?.size || (await datasource.size(file?.name ?? id));
const fileType = file?.type || 'application/octet-stream';
const size = file.size || (await datasource.size(file.name));
const fileType = file.type || 'application/octet-stream';
const contentType = fileType.startsWith('text/') ? `${fileType}; charset=utf-8` : fileType;
const commonHeaders = {
'Content-Disposition': file.originalName
? `${download ? 'attachment; ' : ''}filename*=utf-8''${encodeURIComponent(file.originalName)}`
: download
? 'attachment;'
: undefined,
};
if (req.headers.range) {
const [start, end] = parseRange(req.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(file?.name ?? id);
const buf = await datasource.get(file.name);
if (!buf) throw new ApiError(9002);
return res
.type(contentType)
.headers({
'Content-Length': size,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename*=utf-8''${encodeURIComponent(file.originalName)}`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
...commonHeaders,
})
.status(416)
.send(buf);
}
const buf = await datasource.range(file?.name ?? id, start || 0, end);
const buf = await datasource.range(file.name, start || 0, end);
if (!buf) throw new ApiError(9002);
return res
@@ -156,19 +174,13 @@ export default typedPlugin(
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename*=utf-8''${encodeURIComponent(file.originalName)}`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
...commonHeaders,
})
.status(206)
.send(buf);
}
const buf = await datasource.get(file?.name ?? id);
const buf = await datasource.get(file.name);
if (!buf) throw new ApiError(9002);
return res
@@ -176,13 +188,7 @@ export default typedPlugin(
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename*=utf-8''${encodeURIComponent(file.originalName)}`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
...commonHeaders,
})
.status(200)
.send(buf);
+30 -3
View File
@@ -1,5 +1,5 @@
import { ApiError } from '@/lib/api/errors';
import { hashPassword } from '@/lib/crypto';
import { hashPassword, verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSchema, userSelect } from '@/lib/db/models/user';
import { log } from '@/lib/logger';
@@ -47,6 +47,7 @@ export default typedPlugin(
body: z.object({
username: zStringTrimmed.optional(),
password: zStringTrimmed.optional(),
currentPassword: z.string().optional(),
avatar: z.string().nullish(),
view: z
.object({
@@ -89,6 +90,26 @@ export default typedPlugin(
if (existing) throw new ApiError(1038);
}
const changingPassword = !!req.body.password;
if (changingPassword) {
if (req.user.password) {
if (!req.body.currentPassword) throw new ApiError(1067);
const valid = await verifyPassword(req.body.currentPassword, req.user.password);
if (!valid) {
logger.warn('invalid current password on password change', {
user: req.user.username,
ip: req.ip ?? 'unknown',
ua: req.headers['user-agent'],
});
throw new ApiError(1066);
}
}
}
const currentSession = await getSession(req, res);
const user = await prisma.user.update({
where: {
id: req.user.id,
@@ -96,6 +117,13 @@ export default typedPlugin(
data: {
...(req.body.username && { username: req.body.username }),
...(req.body.password && { password: await hashPassword(req.body.password) }),
...(changingPassword && {
sessions: {
deleteMany: {
NOT: { id: currentSession.sessionId ?? '' },
},
},
}),
...(req.body.avatar !== undefined && { avatar: req.body.avatar || null }),
...(req.body.view && {
view: {
@@ -144,8 +172,7 @@ export default typedPlugin(
},
});
const session = await getSession(req, res);
await saveSession(session, user, false);
await saveSession(currentSession, user, false);
delete (user as any).password;
+1 -1
View File
@@ -10,7 +10,7 @@ import { fastifyStatic } from '@fastify/static';
import fastifySwagger from '@fastify/swagger';
import type { FastifyInstance } from 'fastify';
import { jsonSchemaTransform, serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import { version } from '../../../package.json';
import { version } from '@/lib/version';
import { checkRateLimit } from '../plugins/checkRateLimit';
import oauthPlugin from '../plugins/oauth';
import vitePlugin from '../plugins/vite';
+1 -1
View File
@@ -32,7 +32,7 @@ export function startTasks(server: FastifyInstance) {
for (let i = 0; i !== config.features.thumbnails.num_threads; ++i) {
tasks.worker(
`thumbnail-${i}`,
'./build/offload/thumbnails.js',
'offload/thumbnails.js',
{
id: `thumbnail-${i}`,
enabled: config.features.thumbnails.enabled,
+4 -2
View File
@@ -7,14 +7,14 @@ export default defineConfig(async (_) => {
return [
{
platform: 'node',
format: 'cjs',
format: 'esm',
outExtension: () => ({ js: '.js' }),
clean: true,
sourcemap: true,
entry: await glob('./src/**/*.ts', {
ignore: ['./src/components/**/*.ts', './src/client/**/*.(ts|tsx|html)'],
}),
shims: true,
shims: false,
esbuildPlugins: [],
outDir: 'build',
bundle: false,
@@ -23,6 +23,8 @@ export default defineConfig(async (_) => {
await replaceTscAliasPaths({
configFile: 'tsconfig.json',
outDir: 'build',
resolveFullPaths: true,
resolveFullExtension: '.js',
});
console.log('[built-ins] copying builtins...');
+5 -2
View File
@@ -1,7 +1,10 @@
import react from '@vitejs/plugin-react';
import path from 'path';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig(({ mode }) => {
const isSSR = mode.startsWith('ssr');
@@ -18,7 +21,7 @@ export default defineConfig(({ mode }) => {
'ssr-view-url': path.resolve(__dirname, 'src/client/ssr-view-url/index.html'),
},
output: {
format: isSSR ? 'cjs' : 'esm',
format: 'esm',
entryFileNames: isSSR ? `${mode}.js` : 'assets/[name]-[hash].js',
},
},