Merge branch 'trunk' into drizzle

This commit is contained in:
dicedtomato
2025-09-05 21:07:54 -07:00
committed by GitHub
8 changed files with 355 additions and 249 deletions

View File

@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.2.3",
"version": "4.3.0",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",

View File

@@ -137,11 +137,6 @@ export async function render(
}
}
await prisma.file.update({
where: { id: file.id },
data: { views: { increment: 1 } },
});
const data = {
file,
password: hasPassword,

View File

@@ -17,6 +17,7 @@ import Asciinema from '../render/Asciinema';
import Pdf from '../render/Pdf';
import Render from '../render/Render';
import fileIcon from './fileIcon';
import { useUserStore } from '@/lib/store/user';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
@@ -79,8 +80,12 @@ export default function DashboardFileType({
code?: boolean;
allowZoom?: boolean;
}) {
const user = useUserStore((state) => state.user);
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const fileRoute = user ? `/api/user/files/${(file as DbFile).id}/raw` : `/raw/${file.name}`;
const thumbnailRoute = user
? `/api/user/files/${(file as DbFile).thumbnail?.path}/raw`
: `/raw/${(file as DbFile).thumbnail?.path}`;
const dbFile = 'id' in file;
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
@@ -108,7 +113,7 @@ export default function DashboardFileType({
}
if (file.size > 1 * 1024 * 1024) {
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`, {
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`, {
headers: {
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
},
@@ -121,7 +126,7 @@ export default function DashboardFileType({
return;
}
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`);
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(text);
@@ -176,15 +181,12 @@ export default function DashboardFileType({
autoPlay
muted
controls
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
/>
) : (file as DbFile).thumbnail && dbFile ? (
<Box pos='relative'>
<MantineImage
src={`/raw/${(file as DbFile).thumbnail!.path}`}
alt={file.name || 'Video thumbnail'}
/>
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
<Center
pos='absolute'
@@ -210,7 +212,7 @@ export default function DashboardFileType({
return show ? (
<Center>
<MantineImage
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
@@ -222,9 +224,7 @@ export default function DashboardFileType({
{allowZoom && open && (
<FileZoomModal setOpen={setOpen}>
<MantineImage
src={
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
@@ -241,7 +241,7 @@ export default function DashboardFileType({
<MantineImage
fit='contain'
mah={400}
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
/>
);
@@ -253,7 +253,7 @@ export default function DashboardFileType({
muted
controls
style={{ width: '100%' }}
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
/>
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
@@ -286,7 +286,7 @@ export default function DashboardFileType({
case isAsciicast === true:
return show && dbFile ? (
<Asciinema src={`/raw/${file.name}${password ? `?pw=${password}` : ''}`} />
<Asciinema src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
) : (
<Placeholder
text={`Click to download asciinema cast ${file.name}`}
@@ -296,7 +296,7 @@ export default function DashboardFileType({
case file.type === 'application/pdf':
return show && dbFile ? (
<Pdf src={`/raw/${file.name}${password ? `?pw=${password}` : ''}`} />
<Pdf src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
) : (
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
);
@@ -309,7 +309,7 @@ export default function DashboardFileType({
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
onClick={() => window.open(`/raw/${file.name}${password ? `?pw=${password}` : ''}`)}
onClick={() => window.open(`${fileRoute}${password ? `?pw=${password}` : ''}`)}
text={`Click to view file ${file.name} in a new tab`}
Icon={fileIcon(file.type)}
/>

View File

@@ -177,6 +177,14 @@ async function main() {
server.get('/', (_, res) => res.redirect('/dashboard', 301));
server.setNotFoundHandler((req, res) => {
if (MODE === 'development' && server.vite)
return res.status(404).send({
message: `Route ${req.method}:${req.url} not found`,
error: 'Not Found',
statusCode: 404,
dev: true,
});
if (req.url.startsWith('/api/')) {
return res.status(404).send({
message: `Route ${req.method}:${req.url} not found`,

View File

@@ -86,6 +86,8 @@ async function vitePlugin(fastify: FastifyInstance) {
return this.redirect(redirect, status);
}
if (status && [404, 410].includes(status)) return this.callNotFound();
const finalHtml = template.replace(ZIPLINE_SSR_META, meta!).replace(ZIPLINE_SSR_INSERT, html);
return this.type('text/html').send(finalHtml);

View File

@@ -0,0 +1,173 @@
import { parseRange } from '@/lib/api/range';
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { canInteract } from '@/lib/role';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
type Params = {
id: string;
};
type Querystring = {
pw?: string;
download?: string;
};
const logger = log('routes').c('raw');
export const PATH = '/api/user/files/:id/raw';
export default fastifyPlugin(
(server, _, done) => {
server.get<{
Querystring: Querystring;
Params: Params;
}>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const { id } = req.params;
const { pw, download } = req.query;
if (id.startsWith('.thumbnail')) {
const thumbnail = await prisma.thumbnail.findFirst({
where: {
path: id,
file: {
userId: req.user.id,
},
},
});
if (!thumbnail) return res.callNotFound();
}
const file = await prisma.file.findFirst({
where: {
id,
},
include: {
User: true,
},
});
if (file && file.userId !== req.user.id) {
if (!canInteract(req.user.role, file.User?.role)) return res.callNotFound();
}
if (file?.deletesAt && file.deletesAt <= new Date()) {
try {
await datasource.delete(file.name);
await prisma.file.delete({
where: {
id: file.id,
},
});
} catch (e) {
logger
.error('failed to delete file on expiration', {
id: file.id,
})
.error(e as Error);
}
return res.callNotFound();
}
if (file?.maxViews && file.views >= file.maxViews) {
if (!config.features.deleteOnMaxViews) return res.callNotFound();
try {
await datasource.delete(file.name);
await prisma.file.delete({
where: {
id: file.id,
},
});
} catch (e) {
logger
.error('failed to delete file on max views', {
id: file.id,
})
.error(e as Error);
}
return res.callNotFound();
}
if (file?.password) {
if (!pw) return res.forbidden('Password protected.');
const verified = await verifyPassword(pw, file.password!);
if (!verified) return res.forbidden('Incorrect password.');
}
const size = file?.size || (await datasource.size(file?.name ?? id));
if (req.headers.range) {
const [start, end] = parseRange(req.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(file?.name ?? id);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(416)
.send(buf);
}
const buf = await datasource.range(file?.name ?? id, start || 0, end);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(206)
.send(buf);
}
const buf = await datasource.get(file?.name ?? id);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(200)
.send(buf);
});
done();
},
{ name: PATH },
);

View File

@@ -1,10 +1,6 @@
import { parseRange } from '@/lib/api/range';
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { FastifyReply, FastifyRequest } from 'fastify';
import { rawFileHandler } from './raw/[id]';
type Params = {
id: string;
@@ -15,14 +11,11 @@ type Query = {
download?: string;
};
const logger = log('routes').c('files');
export async function filesRoute(
req: FastifyRequest<{ Params: Params; Querystring: Query }>,
res: FastifyReply,
) {
const { id } = req.params;
const { pw, download } = req.query;
const file = await prisma.file.findFirst({
where: {
name: decodeURIComponent(id),
@@ -33,120 +26,8 @@ export async function filesRoute(
});
if (!file) return res.callNotFound();
if (file.deletesAt && file.deletesAt <= new Date()) {
try {
await datasource.delete(file.name);
await prisma.file.delete({
where: {
id: file.id,
},
});
} catch (e) {
logger
.error('failed to delete file on expiration', {
id: file.id,
})
.error(e as Error);
}
return res.callNotFound();
}
if (file.maxViews && file.views >= file.maxViews) {
if (!config.features.deleteOnMaxViews) return res.callNotFound();
try {
await datasource.delete(file.name);
await prisma.file.delete({
where: {
id: file.id,
},
});
} catch (e) {
logger
.error('failed to delete file on max views', {
id: file.id,
})
.error(e as Error);
}
return res.callNotFound();
}
if (file.User?.view.enabled) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
if (file.type.startsWith('text/')) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
const stream = await datasource.get(file.name);
if (!stream) return res.callNotFound();
if (file.password) {
if (!pw) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
const verified = await verifyPassword(pw as string, file.password!);
if (!verified) {
logger.warn('password protected file accessed with an incorrect password', { id: file.id, ip: req.ip });
return res.callNotFound();
}
}
if (!req.headers.range) {
await prisma.file.update({
where: {
id: file.id,
},
data: {
views: {
increment: 1,
},
},
});
}
const size = file?.size || (await datasource.size(file?.name ?? id));
if (req.headers.range) {
const [start, end] = parseRange(req.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(file?.name ?? id);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(416)
.send(buf);
}
const buf = await datasource.range(file?.name ?? id, start || 0, end);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(206)
.send(buf);
}
const buf = await datasource.get(file?.name ?? id);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(200)
.send(buf);
return rawFileHandler(req, res);
}

View File

@@ -4,8 +4,13 @@ import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
import { FastifyReply, FastifyRequest } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
const viewsCache = new Map<string, number>();
const VIEW_WINDOW = 5 * 1000;
type Params = {
id: string;
};
@@ -17,133 +22,175 @@ type Querystring = {
const logger = log('routes').c('raw');
export const PATH = '/raw/:id';
export default fastifyPlugin(
(server, _, done) => {
server.get<{
Querystring: Querystring;
Params: Params;
}>(PATH, async (req, res) => {
const { id } = req.params;
const { pw, download } = req.query;
export const rawFileHandler = async (
req: FastifyRequest<{
Params: Params;
Querystring: Querystring;
}>,
res: FastifyReply,
) => {
const { id } = req.params;
const { pw, download } = req.query;
const file = await prisma.file.findFirst({
if (id.startsWith('.thumbnail')) {
const thumbnail = await prisma.thumbnail.findFirst({
where: {
path: id,
},
});
if (!thumbnail) return res.callNotFound();
const size = await datasource.size(thumbnail.path);
if (!size) return res.callNotFound();
const buf = await datasource.get(thumbnail.path);
if (!buf) return res.callNotFound();
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: {
name: decodeURIComponent(id),
},
});
if (!file) return res.callNotFound();
if (file?.deletesAt && file.deletesAt <= new Date()) {
try {
await datasource.delete(file.name);
await prisma.file.delete({
where: {
name: decodeURIComponent(id),
id: file.id,
},
});
} catch (e) {
logger.error('failed to delete file on expiration', { id: file.id }).error(e as Error);
}
return res.callNotFound();
}
if (file?.deletesAt && file.deletesAt <= new Date()) {
try {
await datasource.delete(file.name);
await prisma.file.delete({
where: {
id: file.id,
},
});
} catch (e) {
logger
.error('failed to delete file on expiration', {
id: file.id,
})
.error(e as Error);
}
if (file?.password) {
if (!pw) return res.forbidden('Password protected.');
const verified = await verifyPassword(pw, file.password!);
return res.callNotFound();
if (!verified) return res.forbidden('Incorrect password.');
}
const size = file?.size || (await datasource.size(file?.name ?? id));
// view stuff
const now = Date.now();
const isView = !req.headers.range || req.headers.range.startsWith('bytes=0');
const key = `${req.ip}-${req.headers['user-agent'] ?? 'unknown'}-${file.id}`;
const last = viewsCache.get(key) || 0;
const canCountView = isView && now - last > VIEW_WINDOW;
const updatedViews = (file.views || 0) + (canCountView ? 1 : 0);
// check using future values
if (file.maxViews && updatedViews > file.maxViews) {
if (config.features.deleteOnMaxViews) {
try {
await datasource.delete(file.name);
await prisma.file.delete({
where: { id: file.id },
});
} catch (e) {
logger.error('failed to delete file on max views', { id: file.id }).error(e as Error);
}
}
return res.callNotFound();
}
if (file?.maxViews && file.views >= file.maxViews) {
if (!config.features.deleteOnMaxViews) return res.callNotFound();
const countView = async () => {
if (!file || !canCountView) return;
viewsCache.set(key, now);
try {
await datasource.delete(file.name);
await prisma.file.delete({
where: {
id: file.id,
},
});
} catch (e) {
logger
.error('failed to delete file on max views', {
id: file.id,
})
.error(e as Error);
}
return res.callNotFound();
}
if (file?.password) {
if (!pw) return res.forbidden('Password protected.');
const verified = await verifyPassword(pw, file.password!);
if (!verified) return res.forbidden('Incorrect password.');
}
const size = file?.size || (await datasource.size(file?.name ?? id));
if (req.headers.range) {
const [start, end] = parseRange(req.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(file?.name ?? id);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(416)
.send(buf);
}
const buf = await datasource.range(file?.name ?? id, start || 0, end);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(206)
.send(buf);
}
try {
await prisma.file.update({
where: { id: file.id },
data: { views: { increment: 1 } },
});
} catch (e) {
logger.error('failed to increment view counter', { id: file.id }).error(e as Error);
}
};
if (req.headers.range) {
const [start, end] = parseRange(req.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(file?.name ?? id);
if (!buf) return res.callNotFound();
await countView();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
: download && { 'Content-Disposition': 'attachment;' }),
})
.status(200)
.status(416)
.send(buf);
});
}
const buf = await datasource.range(file?.name ?? id, start || 0, end);
if (!buf) return res.callNotFound();
await countView();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && { 'Content-Disposition': 'attachment;' }),
})
.status(206)
.send(buf);
}
const buf = await datasource.get(file?.name ?? id);
if (!buf) return res.callNotFound();
await countView();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
}
: download && { 'Content-Disposition': 'attachment;' }),
})
.status(200)
.send(buf);
};
export const PATH = '/raw/:id';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, rawFileHandler);
done();
},