mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
feat: better max-views handling (#874)
This commit is contained in:
@@ -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,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)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
153
src/server/routes/api/user/files/[id]/raw.ts
Normal file
153
src/server/routes/api/user/files/[id]/raw.ts
Normal 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 },
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user