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