Compare commits

...

15 Commits

Author SHA1 Message Date
diced 1e26037f45 feat(v4.6.4): version 2026-07-06 12:31:58 -07:00
diced 7e61bffcd3 fix: lint errors 2026-07-06 12:31:42 -07:00
diced 157a94b099 fix: sanitize folder name when export 2026-07-06 12:25:38 -07:00
diced 6093356c34 fix: folder ownership issues 2026-07-06 12:22:44 -07:00
diced e0806588f4 fix: add oauth nonce to prevent csrf 2026-07-06 12:22:33 -07:00
diced 976f057584 fix: add content security policy 2026-07-06 12:22:20 -07:00
dicedtomato aa4897349f Merge commit from fork 2026-07-06 11:30:42 -07:00
diced b13e2cc9cb fix: add color to logger + fix worker issues 2026-07-04 13:38:06 -07:00
diced 373f54fc24 fix: ineffective dynamic import 2026-07-04 13:37:56 -07:00
diced 6a9dcf02a7 chore: update dependencies 2026-07-04 13:37:42 -07:00
diced d4151c6801 fix: remove unused deps 2026-07-04 13:13:19 -07:00
diced 1c8adf414f fix: remove fluent-ffmpeg 2026-07-03 23:30:04 -07:00
diced 388c5827e9 fix: lessen ratelimits on login (#1116) 2026-07-03 16:17:48 -07:00
dicedtomato a120c0984c Merge commit from fork 2026-07-03 14:21:32 -07:00
dicedtomato fa30c6757b Merge commit from fork 2026-07-03 14:12:18 -07:00
32 changed files with 6432 additions and 3447 deletions
+45 -53
View File
@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.6.3",
"version": "4.6.4",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
@@ -22,107 +22,99 @@
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1046.0",
"@aws-sdk/lib-storage": "3.1046.0",
"@aws-sdk/client-s3": "3.1079.0",
"@aws-sdk/lib-storage": "3.1079.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/rate-limit": "^11.1.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.1.3",
"@fastify/swagger": "^9.7.0",
"@mantine/charts": "^9.2.0",
"@mantine/code-highlight": "^9.2.0",
"@mantine/core": "^9.2.0",
"@mantine/dates": "^9.2.0",
"@mantine/dropzone": "^9.2.0",
"@mantine/form": "^9.2.0",
"@mantine/hooks": "^9.2.0",
"@mantine/modals": "^9.2.0",
"@mantine/notifications": "^9.2.0",
"@mantine/charts": "^9.4.1",
"@mantine/core": "^9.4.1",
"@mantine/dates": "^9.4.1",
"@mantine/dropzone": "^9.4.1",
"@mantine/form": "^9.4.1",
"@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",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@smithy/node-http-handler": "^4.7.2",
"@simplewebauthn/server": "^13.3.2",
"@smithy/node-http-handler": "^4.9.3",
"@tabler/icons-react": "^3.44.0",
"archiver": "7.0.1",
"argon2": "^0.44.0",
"asciinema-player": "^3.15.1",
"asciinema-player": "^3.17.0",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.3",
"cookie": "^1.1.1",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"dayjs": "^1.11.20",
"dayjs": "^1.11.21",
"detect-browser": "^5.3.0",
"devalue": "^5.8.0",
"devalue": "^5.8.1",
"fast-glob": "^3.3.3",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"fastify-type-provider-zod": "^6.1.0",
"fluent-ffmpeg": "^2.1.3",
"fastify": "^5.9.0",
"fastify-plugin": "^6.0.0",
"fastify-type-provider-zod": "^7.0.0",
"he": "^1.2.0",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^3.12.0",
"katex": "^0.16.46",
"mantine-datatable": "^9.2.0",
"isomorphic-dompurify": "^3.18.0",
"katex": "^0.17.0",
"mantine-datatable": "^9.3.1",
"marked-react": "^4.0.0",
"ms": "^2.1.3",
"multer": "2.1.1",
"nuqs": "^2.8.9",
"otplib": "^13.4.0",
"nuqs": "^2.9.0",
"otplib": "^13.4.1",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.0",
"react-virtuoso": "^4.18.7",
"sharp": "^0.34.5",
"swr": "^2.4.1",
"vite": "^8.0.12",
"zod": "^4.3.6",
"zustand": "^5.0.13"
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"react-virtuoso": "^4.18.10",
"sharp": "^0.35.3",
"swr": "^2.4.2",
"vite": "^8.1.3",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/he": "^1.2.3",
"@types/katex": "^0.16.8",
"@types/ms": "^2.1.0",
"@types/multer": "^2.1.0",
"@types/node": "^24.12.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-refresh": "^0.5.3",
"eslint-plugin-unused-imports": "^4.3.0",
"postcss": "^8.5.14",
"postcss": "^8.5.16",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.3",
"sass": "^1.98.0",
"prettier": "^3.9.4",
"sass": "^1.101.0",
"tsc-alias": "^1.8.17",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3"
"typescript-eslint": "^8.62.1"
},
"engines": {
"node": ">=22"
+5942 -3197
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,14 +1,14 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="manifest.json">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="manifest.json" />
<title>Zipline</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
<title>Zipline</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+1 -2
View File
@@ -3,8 +3,7 @@ import { DatePicker } from '@mantine/dates';
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { lazy, useState } from 'react';
import { StatsCardsSkeleton } from './parts/StatsCards';
import { StatsTablesSkeleton } from './parts/StatsTables';
import { StatsCardsSkeleton, StatsTablesSkeleton } from './parts/Skeletons';
import { useApiStats } from './useStats';
const FilesUrlsCountGraph = lazy(() => import('./parts/FilesUrlsCountGraph'));
@@ -0,0 +1,109 @@
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text } from '@mantine/core';
export function StatsCardsSkeleton() {
return (
<SimpleGrid
cols={{
base: 1,
md: 2,
lg: 3,
}}
mb='sm'
>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} height={100} animate />
))}
</SimpleGrid>
);
}
function SkeletonText() {
return (
<Table.Td>
<Skeleton animate>
<Text>...</Text>
</Skeleton>
</Table.Td>
);
}
export function StatsTablesSkeleton() {
return (
<>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Paper radius='md' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Files</Table.Th>
<Table.Th>Storage Used</Table.Th>
<Table.Th>Views</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder mah={500} radius='md'>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>URLs</Table.Th>
<Table.Th>Views</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder radius='md'>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder p='sm'>
<Skeleton height={500} />
</Paper>
</SimpleGrid>
</>
);
}
@@ -1,6 +1,6 @@
import { bytes } from '@/lib/bytes';
import { MetricsPoint } from '@/lib/metrics';
import { Group, Paper, rgba, SimpleGrid, Skeleton, Text } from '@mantine/core';
import { Group, Paper, rgba, SimpleGrid, Text } from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
@@ -69,23 +69,6 @@ function StatCard({
);
}
export function StatsCardsSkeleton() {
return (
<SimpleGrid
cols={{
base: 1,
md: 2,
lg: 3,
}}
mb='sm'
>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} height={100} animate />
))}
</SimpleGrid>
);
}
export default function StatsCards({ points }: { points: MetricsPoint[] }) {
if (!points.length) return null;
@@ -1,99 +1,8 @@
import { bytes } from '@/lib/bytes';
import { Metric } from '@/lib/db/models/metric';
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text } from '@mantine/core';
import { Paper, ScrollArea, SimpleGrid, Table } from '@mantine/core';
import TypesPieChart from './TypesPieChart';
function SkeletonText() {
return (
<Table.Td>
<Skeleton animate>
<Text>...</Text>
</Skeleton>
</Table.Td>
);
}
export function StatsTablesSkeleton() {
return (
<>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Paper radius='md' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Files</Table.Th>
<Table.Th>Storage Used</Table.Th>
<Table.Th>Views</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder mah={500} radius='md'>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>URLs</Table.Th>
<Table.Th>Views</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder radius='md'>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder p='sm'>
<Skeleton height={500} />
</Paper>
</SimpleGrid>
</>
);
}
export default function StatsTables({ latest }: { latest: Metric | null }) {
if (!latest) return null;
+6
View File
@@ -0,0 +1,6 @@
import { FastifyReply } from 'fastify';
export function setContentSecurity(res: FastifyReply) {
res.header('X-Content-Type-Options', 'nosniff');
res.header('Content-Security-Policy', 'sandbox');
}
+1 -9
View File
@@ -2,15 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Field =
| 'name'
| 'originalName'
| 'tags'
| 'type'
| 'size'
| 'createdAt'
| 'favorite'
| 'views'
| 'anonymous';
'name' | 'originalName' | 'tags' | 'type' | 'size' | 'createdAt' | 'favorite' | 'views' | 'anonymous';
const FIELDS: {
property: Field;
+26 -17
View File
@@ -14,6 +14,15 @@ declare global {
ZIPLINE_BUILD?: string;
ZIPLINE_DB_LOG?: string;
ZIPLINE_OVERRIDE_DISABLED_WORKER_LOG?: string;
ZIPLINE_OVERRIDE_LOG_DATE_FORMAT?: string;
ZIPLINE_MONITOR_MEMORY?: string;
ZIPLINE_NO_COLOR?: string;
ZIPLINE_OUTPUT_OPENAPI?: string;
ZIPLINE_GIT_SHA?: string;
FFMPEG_PATH?: string;
DEBUG?: string;
}
}
}
@@ -305,11 +314,11 @@ export const schema = z.object({
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
redirectUri: z.undefined(),
allowedIds: z.undefined().or(z.array(z.string()).default([])),
deniedIds: z.undefined().or(z.array(z.string()).default([])),
clientId: z.undefined().optional(),
clientSecret: z.undefined().optional(),
redirectUri: z.undefined().optional(),
allowedIds: z.undefined().optional().or(z.array(z.string()).default([])),
deniedIds: z.undefined().optional().or(z.array(z.string()).default([])),
}),
),
github: z
@@ -320,9 +329,9 @@ export const schema = z.object({
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
redirectUri: z.undefined(),
clientId: z.undefined().optional(),
clientSecret: z.undefined().optional(),
redirectUri: z.undefined().optional(),
}),
),
google: z
@@ -333,9 +342,9 @@ export const schema = z.object({
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
redirectUri: z.undefined(),
clientId: z.undefined().optional(),
clientSecret: z.undefined().optional(),
redirectUri: z.undefined().optional(),
}),
),
oidc: z
@@ -349,12 +358,12 @@ export const schema = z.object({
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
authorizeUrl: z.undefined(),
userinfoUrl: z.undefined(),
tokenUrl: z.undefined(),
redirectUri: z.undefined(),
clientId: z.undefined().optional(),
clientSecret: z.undefined().optional(),
authorizeUrl: z.undefined().optional(),
userinfoUrl: z.undefined().optional(),
tokenUrl: z.undefined().optional(),
redirectUri: z.undefined().optional(),
}),
),
}),
+137
View File
@@ -0,0 +1,137 @@
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { accessSync, constants, statSync } from 'fs';
import { delimiter, join } from 'path';
let cachedFfmpeg: string | null;
function isExec(path: string): boolean {
try {
if (!statSync(path).isFile()) return false;
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}
export function findFfmpeg(): string | null {
if (cachedFfmpeg !== undefined) return cachedFfmpeg;
if (process.env.FFMPEG_PATH && isExec(process.env.FFMPEG_PATH)) {
cachedFfmpeg = process.env.FFMPEG_PATH;
return cachedFfmpeg;
}
const extensions =
process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : [''];
for (const dir of (process.env.PATH || '').split(delimiter)) {
if (!dir) continue;
for (const ext of extensions) {
const candidate = join(dir, `ffmpeg${ext.toLowerCase()}`);
if (isExec(candidate)) {
cachedFfmpeg = candidate;
return cachedFfmpeg;
}
}
}
cachedFfmpeg = null;
return cachedFfmpeg;
}
export class FfmpegCommand extends EventEmitter {
private input: string;
private videoFilterList: string[] = [];
private frameCount?: number;
private outputPath?: string;
constructor(input: string) {
super();
this.input = input;
}
public on(event: 'start', listener: (command: string) => void): this;
public on(event: 'error', listener: (err: Error, stdout: string, stderr: string) => void): this;
public on(event: 'end', listener: (stdout: string, stderr: string) => void): this;
public on(event: string, listener: (...args: any[]) => void): this {
return super.on(event, listener);
}
public videoFilters(...filters: string[]): this {
this.videoFilterList.push(...filters);
return this;
}
public frames(count: number): this {
this.frameCount = count;
return this;
}
public output(path: string): this {
this.outputPath = path;
return this;
}
private buildArgs(): string[] {
const args = ['-hide_banner', '-i', this.input];
if (this.videoFilterList.length) args.push('-filter:v', this.videoFilterList.join(','));
if (this.frameCount !== undefined) args.push('-frames:v', String(this.frameCount));
args.push('-y', this.outputPath!);
return args;
}
public run(): void {
if (!this.outputPath) {
this.emit('error', new Error('no output file specified'), '', '');
return;
}
const ffmpegPath = findFfmpeg();
if (!ffmpegPath) {
this.emit(
'error',
new Error('cannot find ffmpeg executable, set FFMPEG_PATH or add ffmpeg to your PATH'),
'',
'',
);
return;
}
const args = this.buildArgs();
this.emit('start', [ffmpegPath, ...args].join(' '));
const proc = spawn(ffmpegPath, args, { windowsHide: true });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (chunk) => (stdout += chunk));
proc.stderr.on('data', (chunk) => (stderr += chunk));
proc.on('error', (err) => {
this.emit('error', err, stdout, stderr);
});
proc.on('close', (code, signal) => {
if (code === 0) {
this.emit('end', stdout, stderr);
} else if (signal) {
this.emit('error', new Error(`ffmpeg was killed with signal ${signal}`), stdout, stderr);
} else {
const lastLine = stderr.trim().split('\n').pop() ?? '';
this.emit('error', new Error(`ffmpeg exited with code ${code}: ${lastLine}`), stdout, stderr);
}
});
}
}
export default function ffmpeg(input: string): FfmpegCommand {
return new FfmpegCommand(input);
}
+40 -11
View File
@@ -1,18 +1,36 @@
import dayjs from 'dayjs';
import { green, red, yellow, gray, white, bold, blue } from 'colorette';
import { isatty } from 'tty';
import { styleText } from 'util';
import { isMainThread } from 'worker_threads';
const canStyle = !process.env.ZIPLINE_NO_COLOR && isatty(1);
const style = (format: Parameters<typeof styleText>[0], text: string) =>
canStyle ? styleText(format, text, { validateStream: false }) : text;
const colors: Record<string, (text: string) => string> = {
green: (text: string) => style('green', text),
red: (text: string) => style('red', text),
yellow: (text: string) => style('yellow', text),
gray: (text: string) => style('gray', text),
white: (text: string) => style('white', text),
bold: (text: string) => style('bold', text),
blue: (text: string) => style('blue', text),
};
export type LoggerLevel = 'info' | 'warn' | 'error' | 'debug' | 'trace';
export function log(name: string) {
return new Logger(name);
}
const SEPARATOR = '.';
export default class Logger {
public constructor(public name: string) {}
public c(name: string) {
return new Logger(`${this.name}::${name}`);
return new Logger(`${this.name}${SEPARATOR}${name}`);
}
private isZiplineDebug(): boolean {
@@ -28,25 +46,34 @@ export default class Logger {
}
private format(message: string, level: LoggerLevel) {
const timestamp = dayjs().format('YYYY-MM-DDTHH:mm:ss');
const timestamp = dayjs().format(process.env.ZIPLINE_OVERRIDE_LOG_DATE_FORMAT ?? 'YYYY-MM-DDTHH:mm:ss');
return `${gray('[')}${timestamp} ${this.formatLevel(level)} ${this.name}${gray(']')} ${message}`;
return `${colors.gray('[')}${timestamp} ${this.formatLevel(level)} ${this.formatName()}${colors.gray(']')} ${message}`;
}
private formatName() {
if (!canStyle) return this.name;
return this.name
.split(SEPARATOR)
.map((part) => part)
.join(colors.gray(SEPARATOR));
}
private formatLevel(level: LoggerLevel) {
switch (level) {
case 'info':
return green('INFO ');
return colors.green('INFO ');
case 'warn':
return yellow('WARN ');
return colors.yellow('WARN ');
case 'error':
return red('ERROR');
return colors.red('ERROR');
case 'debug':
return yellow(bold('DEBUG'));
return colors.yellow(colors.bold('DEBUG'));
case 'trace':
return gray(bold('TRACE'));
return colors.gray(colors.bold('TRACE'));
default:
return white(bold('?????'));
return colors.white(colors.bold('?????'));
}
}
@@ -54,7 +81,9 @@ export default class Logger {
return (
' ' +
Object.entries(extra)
.map(([key, value]) => `${blue(key)}${gray('=')}${JSON.stringify(value, this.replacer)}`)
.map(
([key, value]) => `${colors.blue(key)}${colors.gray('=')}${JSON.stringify(value, this.replacer)}`,
)
.join(' ')
);
}
+14
View File
@@ -1,14 +1,27 @@
import { config } from '@/lib/config';
import { decrypt, encrypt } from '@/lib/crypto';
import { randomCharacters } from '@/lib/random';
export type OAuthStateJSON = {
mode: 'default' | 'link';
nonce?: string;
};
export function encryptOAuthState(value: OAuthStateJSON): string {
return encrypt(JSON.stringify(value), config.core.secret);
}
export async function generateOAuthState(
session: { oauthState?: string; save: () => Promise<void> },
mode: OAuthStateJSON['mode'],
): Promise<string> {
const nonce = randomCharacters(32);
session.oauthState = nonce;
await session.save();
return encryptOAuthState({ mode, nonce });
}
export function decryptOAuthState(state?: string): string | null {
if (!state) return null;
@@ -33,6 +46,7 @@ export function parseOAuthState(state?: string): OAuthStateJSON | null {
return {
mode: parsed.mode,
nonce: parsed.nonce,
};
} catch {
return null;
+2 -2
View File
@@ -1,3 +1,3 @@
export const secondlyRatelimit = (seconds: number) => ({
config: { rateLimit: { max: 1, timeWindow: `${seconds} seconds`, allowList: [] } },
export const secondlyRatelimit = (seconds: number, max: number = 1) => ({
config: { rateLimit: { max, timeWindow: `${seconds} seconds`, allowList: [] } },
});
+1 -1
View File
@@ -277,7 +277,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
.split('/')
.map((x) => Number(x));
if (isNaN(start) || isNaN(end) || isNaN(total))
if (!Number.isInteger(start) || !Number.isInteger(end) || !Number.isInteger(total))
throwHeaderError('content-range', 'Invalid content-range');
response.partial = {
+6 -4
View File
@@ -11,7 +11,7 @@ import { UploadOptions } from '@/lib/uploader/parseHeaders';
import { onUpload } from '@/lib/webhooks';
import { Upload } from '@aws-sdk/lib-storage';
import { createReadStream, createWriteStream } from 'fs';
import { open, readdir, rm } from 'fs/promises';
import { open, readdir, rm, stat } from 'fs/promises';
import { join } from 'path';
import { isMainThread, workerData } from 'worker_threads';
import { dbProxy } from './proxiedDb';
@@ -141,6 +141,8 @@ async function main() {
}
}
const finalSize = await stat(finalPath).then((s) => s.size);
if (config.datasource.type === 's3') {
logger.debug('starting multipart upload process for s3');
@@ -182,10 +184,10 @@ async function main() {
},
});
await runComplete(file.id);
await runComplete(file.id, finalSize);
}
async function runComplete(id: string) {
async function runComplete(id: string, size: number) {
const userr = await dbProxy<User>('user.findUnique', {
where: {
id: user.id,
@@ -199,7 +201,7 @@ async function runComplete(id: string) {
id,
},
data: {
size: options.partial!.range[2],
size,
...(options.maxViews && { maxViews: options.maxViews }),
...(options.deletesAt && options.deletesAt !== 'never'
? { deletesAt: options.deletesAt }
+1 -1
View File
@@ -5,7 +5,7 @@ import { Datasource } from '@/lib/datasource/Datasource';
import type { File } from '@/lib/db/models/file';
import { log } from '@/lib/logger';
import { randomCharacters } from '@/lib/random';
import ffmpeg from 'fluent-ffmpeg';
import ffmpeg from '@/lib/ffmpeg';
import { createWriteStream, existsSync, readFileSync, unlinkSync } from 'fs';
import { join } from 'path';
import { isMainThread, parentPort, workerData } from 'worker_threads';
+1 -4
View File
@@ -5,7 +5,6 @@ import { User, userSelect } from '@/lib/db/models/user';
import { FastifyReply } from 'fastify';
import { FastifyRequest } from 'fastify/types/request';
import { getSession } from '../session';
import * as cookie from 'cookie';
import { ApiError } from '@/lib/api/errors';
declare module 'fastify' {
@@ -42,14 +41,12 @@ export function parseUserToken(
}
export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
const cookies = cookie.parse(req.headers.cookie ?? '');
// conditions met to allow anonymous folder uploads but later handled in the upload route
const anonFolderUpload =
req.headers['x-zipline-folder'] &&
['/api/upload', '/api/upload/partial'].includes(req.url.toLowerCase().split('?')[0]) &&
!req.headers.authorization &&
!cookies['zipline_session'];
!req.cookies['zipline_session'];
if (anonFolderUpload) return;
const authorization = req.headers.authorization;
+12
View File
@@ -73,6 +73,18 @@ async function oauthPlugin(fastify: FastifyInstance) {
const state = parseOAuthState(query.state);
if (!state) throw new ApiError(1064);
if (!state.nonce || !session.oauthState || state.nonce !== session.oauthState) {
logger.warn('oauth state nonce mismatch!!', {
provider,
ua: this.headers['user-agent'],
});
throw new ApiError(1064);
}
delete session.oauthState;
await session.save();
const user = await prisma.user.findFirst({
where: {
sessions: {
+1 -1
View File
@@ -42,7 +42,7 @@ export default typedPlugin(
}),
},
},
...secondlyRatelimit(2),
...secondlyRatelimit(10, 7),
},
async (req, res) => {
const session = await getSession(req, res);
+6 -3
View File
@@ -3,12 +3,15 @@ import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { encryptOAuthState } from '@/lib/oauth/state';
import { generateOAuthState } from '@/lib/oauth/state';
import { discordAuthorizeURL, discordUser } from '@/lib/oauth/providers';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
async function discordOauth(
{ code, host, state, session }: OAuthQuery,
logger: Logger,
): Promise<OAuthResponse> {
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { discord: discordEnabled } = enabled(config);
@@ -20,7 +23,7 @@ async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger):
discordAuthorizeURL({
clientId: config.oauth.discord.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
state: await generateOAuthState(session, state === 'link' ? 'link' : 'default'),
redirectUri: config.oauth.discord.redirectUri!,
}),
);
+6 -3
View File
@@ -4,11 +4,14 @@ import { config } from '@/lib/config';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { githubAuthorizeURL, githubUser } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { generateOAuthState } from '@/lib/oauth/state';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function githubOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
async function githubOauth(
{ code, host, state, session }: OAuthQuery,
logger: Logger,
): Promise<OAuthResponse> {
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { github: githubEnabled } = enabled(config);
@@ -19,7 +22,7 @@ async function githubOauth({ code, host, state }: OAuthQuery, logger: Logger): P
throw new RedirectError(
githubAuthorizeURL({
clientId: config.oauth.github.clientId!,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
state: await generateOAuthState(session, state === 'link' ? 'link' : 'default'),
redirectUri: config.oauth.github.redirectUri!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
}),
+6 -3
View File
@@ -3,12 +3,15 @@ import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { encryptOAuthState } from '@/lib/oauth/state';
import { generateOAuthState } from '@/lib/oauth/state';
import { googleAuthorizeURL, googleUser } from '@/lib/oauth/providers';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
async function googleOauth(
{ code, host, state, session }: OAuthQuery,
logger: Logger,
): Promise<OAuthResponse> {
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { google: googleEnabled } = enabled(config);
@@ -20,7 +23,7 @@ async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): P
googleAuthorizeURL({
clientId: config.oauth.google.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
state: await generateOAuthState(session, state === 'link' ? 'link' : 'default'),
redirectUri: config.oauth.google.redirectUri!,
}),
);
+3 -3
View File
@@ -5,7 +5,7 @@ import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { generatePKCEChallenge, generatePKCEVerifier } from '@/lib/oauth/pkce';
import { oidcAuthorizeURL, oidcUser } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { generateOAuthState } from '@/lib/oauth/state';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
@@ -21,13 +21,13 @@ async function oidcOauth({ code, host, state, session }: OAuthQuery, logger: Log
const codeChallenge = generatePKCEChallenge(pkceVerifier);
session.pkceVerifier = pkceVerifier;
await session.save();
const oauthState = await generateOAuthState(session, state === 'link' ? 'link' : 'default');
throw new RedirectError(
oidcAuthorizeURL({
clientId: config.oauth.oidc.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
state: oauthState,
redirectUri: config.oauth.oidc.redirectUri!,
authorizeUrl: config.oauth.oidc.authorizeUrl!,
codeChallenge,
+1 -1
View File
@@ -72,7 +72,7 @@ export default typedPlugin(
if (!folder.public && !folder.allowUploads) throw new ApiError(9002);
const { page, perpage, sortBy, order } = req.query;
if (!page && folder.allowUploads) {
if ((!page && folder.allowUploads) || !folder.public) {
return res.send({
folder: {
id: folder.id,
+3 -1
View File
@@ -96,7 +96,9 @@ export default typedPlugin(
},
});
if (!folder) throw new ApiError(4001);
if (!req.user && !folder.allowUploads) throw new ApiError(3002);
const ownsFolder = req.user ? folder.userId === req.user.id : false;
if (!ownsFolder && !folder.allowUploads) throw new ApiError(req.user ? 3011 : 3002);
}
let files: SavedMultipartFile[] = [];
+24 -5
View File
@@ -22,12 +22,12 @@ const logger = log('api').c('upload').c('partial');
const partialsCache = new Map<string, { length: number; options: UploadOptions; prefix: string }>();
function createPartial(length: number, options: UploadOptions) {
function createPartial(options: UploadOptions) {
const identifier = randomCharacters(8);
const prefix = `zipline_partial_${identifier}_`;
partialsCache.set(identifier, { length, options, prefix });
partialsCache.set(identifier, { length: 0, options, prefix });
return identifier;
}
@@ -86,7 +86,9 @@ export default typedPlugin(
},
});
if (!folder) throw new ApiError(4001);
if (!req.user && !folder.allowUploads) throw new ApiError(3002);
const ownsFolder = req.user ? folder.userId === req.user.id : false;
if (!ownsFolder && !folder.allowUploads) throw new ApiError(req.user ? 3011 : 3002);
}
const { files } = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
@@ -114,9 +116,21 @@ export default typedPlugin(
const file = files[0];
const fileSize = file.file.bytesRead;
const [start, end, total] = options.partial.range;
if (start < 0 || end < start || total < 0 || end > total || end - start !== fileSize) {
if (options.partial.identifier) await deletePartial(options.partial.identifier);
throw new ApiError(1002);
}
if (total > bytes(config.files.maxFileSize)) {
if (options.partial.identifier) await deletePartial(options.partial.identifier);
throw new ApiError(5001);
}
// caching for partial uploads server side checks and performance
if (options.partial.range[0] === 0) {
options.partial.identifier = createPartial(fileSize, options);
if (start === 0) {
options.partial.identifier = createPartial(options);
} else {
if (!options.partial.identifier || !partialsCache.has(options.partial.identifier))
throw new ApiError(1003);
@@ -140,6 +154,11 @@ export default typedPlugin(
cache.length += fileSize;
if (options.partial.lastchunk && cache.length !== total) {
await deletePartial(options.partial.identifier);
throw new ApiError(1002);
}
// handle partial stuff
const sanitized = sanitizeFilename(
`${cache.prefix}${options.partial.range[0]}_${options.partial.range[1]}`,
@@ -1,4 +1,5 @@
import { verifyAccessToken } from '@/lib/accessToken';
import { setContentSecurity } from '@/lib/api/contentSecurity';
import { ApiError } from '@/lib/api/errors';
import { parseRange } from '@/lib/api/range';
import { config } from '@/lib/config';
@@ -37,6 +38,8 @@ export default typedPlugin(
async (req, res) => {
const { token, download } = req.query;
setContentSecurity(res);
const id = sanitizeFilename(req.params.id);
if (!id) throw new ApiError(9002);
@@ -104,8 +104,14 @@ export default typedPlugin(
res.hijack();
const dl = `${folder.name}.zip`;
const sanitized = dl.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
res.raw.setHeader('Content-Type', 'application/zip');
res.raw.setHeader('Content-Disposition', `attachment; filename="${folder.name}.zip"`);
res.raw.setHeader(
'Content-Disposition',
`attachment; filename="${sanitized}"; filename*=UTF-8''${encodeURIComponent(dl)}`,
);
const zip = archiver('zip', {
zlib: { level: 9 },
+3
View File
@@ -8,6 +8,7 @@ import { prisma } from '@/lib/db';
import { sanitizeFilename } from '@/lib/fs';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
import { setContentSecurity } from '@/lib/api/contentSecurity';
import { TimedCache } from '@/lib/timedCache';
import typedPlugin from '@/server/typedPlugin';
import { FastifyReply, FastifyRequest } from 'fastify';
@@ -36,6 +37,8 @@ export const rawFileHandler = async (
const { id } = req.params;
const { token, download } = req.query;
setContentSecurity(res);
const idSanitized = sanitizeFilename(id);
if (!idSanitized) return res.callNotFound();
+3 -2
View File
@@ -2,7 +2,7 @@ import { detectClient, ZiplineClient } from '@/lib/api/detect';
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { randomCharacters } from '@/lib/random';
import { parse } from 'cookie';
import { fastifyCookie } from '@fastify/cookie';
import { FastifyReply, FastifyRequest } from 'fastify';
import { IncomingMessage, ServerResponse } from 'http';
import { getIronSession, type SessionOptions } from 'iron-session';
@@ -24,6 +24,7 @@ export type ZiplineSession = {
client: ZiplineClient;
pkceVerifier?: string;
oauthState?: string;
tokenAuth?: boolean;
};
@@ -50,7 +51,7 @@ export async function getSession(
const headers = (req as FastifyRequest).headers || (req as IncomingMessage).headers;
session.client = detectClient(<Record<string, string>>headers);
const cookies = parse(headers.cookie || '');
const cookies = fastifyCookie.parse(headers.cookie || '');
if (headers['authorization'] && !cookies['zipline_session']) {
const token = parseUserToken(headers['authorization'], true);
+9 -2
View File
@@ -68,12 +68,19 @@ export async function registerPlugins(server: FastifyInstance) {
max: config.ratelimit.max,
timeWindow: config.ratelimit.window ?? undefined,
keyGenerator: (req) => {
return `${req.user?.id ?? req.ip}-${req.url}-${req.method}`;
const route = req.routeOptions?.url ?? req.url.split('?')[0];
return `${req.user?.id ?? req.ip}-${route}-${req.method}`;
},
allowList: async (req, key) => {
if (config.ratelimit.adminBypass && isAdministrator(req.user?.role)) return true;
if (config.ratelimit.allowList.includes(key)) return true;
if (Object.keys(req.headers).includes('x-zipline-p-filename')) return true;
if (
req.method === 'POST' &&
req.routeOptions?.url === '/api/upload/partial' &&
Object.keys(req.headers).includes('x-zipline-p-filename')
)
return true;
return false;
},