mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
Merge branch 'trunk' into drizzle
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
173
src/server/routes/api/user/files/[id]/raw.ts
Normal file
173
src/server/routes/api/user/files/[id]/raw.ts
Normal 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 },
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user