Import source: backend/src/routes/files
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { Readable } from 'node:stream';
|
||||
import express from 'express';
|
||||
import busboy from 'busboy';
|
||||
import { fileTypeStream } from 'file-type';
|
||||
import { env } from '../config/env.js';
|
||||
import { rateLimit } from '../middleware/rateLimit.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { store } from '../services/store.js';
|
||||
import type { FileRecord } from '../types.js';
|
||||
import { randomId } from '../utils/crypto.js';
|
||||
import { fileToApi, sanitizeFilename } from '../utils/files.js';
|
||||
import * as gdrive from '../utils/gdrive.js';
|
||||
import { getSessionId } from '../middleware/session.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// File types blocked by magic bytes regardless of claimed Content-Type
|
||||
const BLOCKED_MIME = new Set([
|
||||
'application/x-msdownload', // EXE / DLL
|
||||
'application/x-dosexec', // COM
|
||||
'application/x-executable', // ELF
|
||||
'application/x-msi', // MSI installer
|
||||
'application/vnd.microsoft.portable-executable', // PE format
|
||||
]);
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GB`;
|
||||
if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(0)} MB`;
|
||||
return `${(n / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
// 20 uploads per IP per hour
|
||||
router.post('/upload', rateLimit(20, 3_600_000), (req, res, next) => {
|
||||
const ct = req.headers['content-type'] ?? '';
|
||||
if (!ct.includes('multipart/form-data')) {
|
||||
return next(new HttpError(400, 'MULTIPART_REQUIRED', 'Upload must be multipart/form-data'));
|
||||
}
|
||||
|
||||
const bb = busboy({
|
||||
headers: req.headers as Record<string, string | string[]>,
|
||||
limits: { fileSize: env.maxFileSizeBytes, files: 1 },
|
||||
});
|
||||
|
||||
let expiresIn = 2_592_000; // 30 days default
|
||||
let uploader = 'You';
|
||||
let fileHandled = false;
|
||||
|
||||
bb.on('field', (name, value) => {
|
||||
if (name === 'expiresIn') {
|
||||
const n = parseInt(value, 10);
|
||||
if (!isNaN(n)) expiresIn = Math.min(Math.max(n, 3600), 7_776_000);
|
||||
}
|
||||
if (name === 'uploader') uploader = value.trim().slice(0, 80) || 'You';
|
||||
});
|
||||
|
||||
bb.on('file', (_field, fileStream, info) => {
|
||||
if (fileHandled) { fileStream.resume(); return; }
|
||||
fileHandled = true;
|
||||
|
||||
let fileTooLarge = false;
|
||||
fileStream.on('limit', () => { fileTooLarge = true; });
|
||||
|
||||
const originalName = sanitizeFilename(info.filename || 'file');
|
||||
const linkId = randomId(5);
|
||||
const now = new Date();
|
||||
|
||||
(async () => {
|
||||
// Detect type from first ~4100 bytes; rest of stream flows through unmodified
|
||||
const ftStream = await fileTypeStream(fileStream as unknown as Readable);
|
||||
const detectedMime = ftStream.fileType?.mime ?? null;
|
||||
|
||||
if (detectedMime && BLOCKED_MIME.has(detectedMime)) {
|
||||
ftStream.destroy();
|
||||
throw new HttpError(415, 'FILE_TYPE_BLOCKED', `File type "${detectedMime}" is not allowed`);
|
||||
}
|
||||
|
||||
// Count bytes as they stream to Drive
|
||||
let sizeBytes = 0;
|
||||
const counter = new PassThrough();
|
||||
counter.on('data', (chunk: Buffer) => { sizeBytes += chunk.length; });
|
||||
ftStream.pipe(counter);
|
||||
|
||||
// Stream directly from HTTP request body → Drive API (no rclone, no disk)
|
||||
const gdriveId = await gdrive.uploadStream(counter, originalName, detectedMime ?? 'application/octet-stream');
|
||||
|
||||
if (fileTooLarge) {
|
||||
await gdrive.deleteFile(gdriveId).catch(() => {});
|
||||
throw new HttpError(413, 'FILE_TOO_LARGE', `File exceeds the ${formatBytes(env.maxFileSizeBytes)} limit`);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const file: FileRecord = {
|
||||
id,
|
||||
linkId,
|
||||
gdriveId,
|
||||
sessionId: getSessionId(req),
|
||||
originalName,
|
||||
mimeType: detectedMime ?? 'application/octet-stream',
|
||||
sizeBytes,
|
||||
status: 'uploaded',
|
||||
uploader,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(),
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
};
|
||||
|
||||
await store.update((db) => { db.files.unshift(file); });
|
||||
res.status(201).json({ data: fileToApi(file) });
|
||||
})().catch(next);
|
||||
});
|
||||
|
||||
bb.on('finish', () => {
|
||||
if (!fileHandled) next(new HttpError(400, 'FILE_REQUIRED', 'A file upload is required'));
|
||||
});
|
||||
|
||||
bb.on('error', next);
|
||||
req.pipe(bb);
|
||||
});
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const page = Math.max(Number(req.query.page ?? 1), 1);
|
||||
const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 100);
|
||||
const sid = getSessionId(req);
|
||||
const db = await store.read();
|
||||
const active = db.files.filter((f) => f.status === 'uploaded' && f.sessionId === sid);
|
||||
const start = (page - 1) * limit;
|
||||
res.json({
|
||||
data: active.slice(start, start + limit).map(fileToApi),
|
||||
meta: { page, limit, total: active.length },
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const db = await store.read();
|
||||
const file = db.files.find((f) => f.id === req.params.id && f.status === 'uploaded');
|
||||
if (!file) throw new HttpError(404, 'FILE_NOT_FOUND', 'File not found');
|
||||
res.json({ data: fileToApi(file) });
|
||||
});
|
||||
|
||||
// Admin-only delete (enforced in admin router; this route is not exposed publicly)
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const sid = getSessionId(req);
|
||||
const deleted = await store.update(async (db) => {
|
||||
const file = db.files.find(
|
||||
(f) => f.id === req.params.id && f.status === 'uploaded' && f.sessionId === sid,
|
||||
);
|
||||
if (!file) return null;
|
||||
file.status = 'deleted';
|
||||
file.deletedAt = new Date().toISOString();
|
||||
await gdrive.deleteFile(file.gdriveId).catch(() => {});
|
||||
return file;
|
||||
});
|
||||
|
||||
if (!deleted) throw new HttpError(404, 'FILE_NOT_FOUND', 'File not found or not yours');
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export { router as filesRouter };
|
||||
Reference in New Issue
Block a user