mirror of
https://github.com/diced/zipline.git
synced 2026-07-07 04:16:59 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e26037f45 | |||
| 7e61bffcd3 | |||
| 157a94b099 | |||
| 6093356c34 | |||
| e0806588f4 | |||
| 976f057584 | |||
| aa4897349f | |||
| b13e2cc9cb | |||
| 373f54fc24 | |||
| 6a9dcf02a7 | |||
| d4151c6801 | |||
| 1c8adf414f | |||
| 388c5827e9 | |||
| a120c0984c | |||
| fa30c6757b |
+45
-53
@@ -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"
|
||||
|
||||
Generated
+5942
-3197
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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(' ')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [] } },
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -42,7 +42,7 @@ export default typedPlugin(
|
||||
}),
|
||||
},
|
||||
},
|
||||
...secondlyRatelimit(2),
|
||||
...secondlyRatelimit(10, 7),
|
||||
},
|
||||
async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
|
||||
@@ -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!,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
}),
|
||||
|
||||
@@ -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!,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user