feat: better max-views handling (#874)

This commit is contained in:
diced
2025-09-04 22:53:20 -07:00
parent c15bf27b8a
commit 1924c22e1b
6 changed files with 274 additions and 94 deletions

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,7 +80,9 @@ 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 dbFile = 'id' in file;
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
@@ -108,7 +111,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 +124,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,7 +179,7 @@ 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 ? (
@@ -210,7 +213,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 +225,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 +242,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 +254,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 +287,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 +297,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 +310,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,153 @@
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 { 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;
const file = await prisma.file.findFirst({
where: {
id,
userId: req.user.id,
},
});
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

@@ -23,75 +23,116 @@ export default fastifyPlugin(
server.get<{
Querystring: Querystring;
Params: Params;
}>(PATH, async (req, res) => {
const { id } = req.params;
const { pw, download } = req.query;
}>(
PATH,
{
onResponse: async (req) => {
const { id } = req.params;
const file = await prisma.file.findFirst({
where: {
name: decodeURIComponent(id),
try {
await prisma.file.updateMany({
where: {
name: decodeURIComponent(id),
},
data: {
views: {
increment: 1,
},
},
});
} catch {}
},
});
},
async (req, res) => {
const { id } = req.params;
const { pw, download } = req.query;
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);
const file = await prisma.file.findFirst({
where: {
name: decodeURIComponent(id),
},
});
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();
}
return res.callNotFound();
}
if (file?.maxViews && file.views >= file.maxViews) {
if (!config.features.deleteOnMaxViews) 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);
}
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();
}
return res.callNotFound();
}
if (file?.password) {
if (!pw) return res.forbidden('Password protected.');
const verified = await verifyPassword(pw, file.password!);
if (file?.password) {
if (!pw) return res.forbidden('Password protected.');
const verified = await verifyPassword(pw, file.password!);
if (!verified) return res.forbidden('Incorrect password.');
}
if (!verified) return res.forbidden('Incorrect password.');
}
const size = file?.size || (await datasource.size(file?.name ?? id));
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();
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);
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-Length': size,
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
@@ -100,19 +141,18 @@ export default fastifyPlugin(
'Content-Disposition': 'attachment;',
}),
})
.status(416)
.status(206)
.send(buf);
}
const buf = await datasource.range(file?.name ?? id, start || 0, end);
const buf = await datasource.get(file?.name ?? id);
if (!buf) return res.callNotFound();
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Content-Length': size,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file?.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
@@ -121,29 +161,10 @@ export default fastifyPlugin(
'Content-Disposition': 'attachment;',
}),
})
.status(206)
.status(200)
.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();
},