fix: new view counting method

This commit is contained in:
diced
2025-09-05 00:23:14 -07:00
parent 1924c22e1b
commit ac61964c37
2 changed files with 150 additions and 267 deletions

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,12 @@ 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 fastifyPlugin from 'fastify-plugin';
const viewsCache = new Map<string, number>();
const VIEW_WINDOW = 5 * 1000;
type Params = {
id: string;
};
@@ -17,154 +21,152 @@ type Querystring = {
const logger = log('routes').c('raw');
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({
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: {
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?.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));
// 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();
}
const countView = async () => {
if (!file || !canCountView) return;
viewsCache.set(key, now);
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,
...(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();
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<{
Querystring: Querystring;
Params: Params;
}>(
PATH,
{
onResponse: async (req) => {
const { id } = req.params;
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;
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();
}
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);
},
);
server.get(PATH, rawFileHandler);
done();
},