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:
arsam
2026-09-18 23:12:36 +03:30
parent 8b3fceb5d8
commit 2a23294ac9
+160
View File
@@ -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 };