Enhance upload handling: add db-backed upload rate limiting and lease management, enforce stricter validation for deployment configurations, optimize test suite and retired functionality, and improve input handling and error recovery.
CI / check (22) (push) Failing after 4m52s
CI / check (24) (push) Successful in 9m43s

This commit is contained in:
2026-10-13 14:16:32 +02:00
parent dd23a19ed2
commit 5489e03138
10 changed files with 85 additions and 27 deletions
+18 -3
View File
@@ -22,6 +22,13 @@ const trustProxy = (value, fallback = false) => {
return Number.isInteger(parsed) ? parsed : value;
};
const validHttpUrl = (value, name, {allowEmpty = false} = {}) => {
if (allowEmpty && !value) return;
let parsed;
try { parsed = new URL(value); } catch { throw new Error(`${name} must be a valid URL.`); }
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(`${name} must use http or https.`);
};
export function createConfig(overrides = {}) {
const rootDir = overrides.rootDir || process.cwd();
const dataDir = overrides.dataDir || path.resolve(rootDir, "data");
@@ -86,13 +93,21 @@ export function createConfig(overrides = {}) {
};
const resolved = {...config, ...overrides};
if (process.env.NODE_ENV === "production" && !resolved.testMode) {
// An explicitly named environment is a deployment contract. With no NODE_ENV,
// retain the local-development defaults used by the CLI and test helpers.
const isNonTestDeployment = Boolean(process.env.NODE_ENV) && process.env.NODE_ENV !== "test" && !resolved.testMode;
if (isNonTestDeployment) {
if (resolved.sessionSecret === "change-me-session-secret") {
throw new Error("SESSION_SECRET must be set in production.");
throw new Error("SESSION_SECRET must be set outside test mode.");
}
if (!resolved.cookieSecure && !resolved.allowInsecureCookies) {
throw new Error("COOKIE_SECURE must be true in production unless ALLOW_INSECURE_COOKIES=true is set.");
throw new Error("COOKIE_SECURE must be true outside test mode unless ALLOW_INSECURE_COOKIES=true is set.");
}
if (![true, false].includes(resolved.trustProxy) && !(Number.isInteger(resolved.trustProxy) && resolved.trustProxy >= 0)) {
throw new Error("TRUST_PROXY must be a boolean or non-negative hop count.");
}
validHttpUrl(resolved.modelApiBaseUrl, "MODEL_API_BASE_URL");
validHttpUrl(resolved.searxngUrl, "SEARXNG_URL", {allowEmpty: true});
}
return resolved;
+6
View File
@@ -27,6 +27,12 @@ export function initializeAttachments(db) {
PRIMARY KEY (attachment_id, vision_model, prompt_version, question)
);
CREATE TABLE IF NOT EXISTS upload_deletions (storage_key TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS upload_leases (
token TEXT PRIMARY KEY,
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS upload_leases_user ON upload_leases(username);
CREATE TRIGGER IF NOT EXISTS release_message_attachment AFTER DELETE ON message_attachments
BEGIN
DELETE FROM attachments WHERE id = OLD.attachment_id AND status = 'attached'
+3 -4
View File
@@ -3,16 +3,15 @@ import {requireLogin, requireAdmin} from "../middleware/auth.js";
export function createAdminMarketRouter(repositories) {
const router = express.Router();
router.use(requireLogin, requireAdmin);
router.get("/admin/reports", (_req, res) => res.json({
router.get("/admin/reports", requireLogin, requireAdmin, (_req, res) => res.json({
reports: repositories.listMarketReports(),
personas: repositories.listMarketPersonas({admin: true, username: _req.session.user})
}));
router.post("/admin/reports/:id", (req, res) => {
router.post("/admin/reports/:id", requireLogin, requireAdmin, (req, res) => {
repositories.updateMarketReportStatus(Number(req.params.id), String(req.body?.status || "reviewed").trim() || "reviewed");
return res.json({message: "Report updated"});
});
router.post("/admin/personas/market/:id/moderate", (req, res) => {
router.post("/admin/personas/market/:id/moderate", requireLogin, requireAdmin, (req, res) => {
const status = String(req.body?.status || "approved").trim() || "approved";
repositories.updateMarketModeration(Number(req.params.id), {
status,
+7 -3
View File
@@ -18,6 +18,8 @@ const credentialsValidator = (body) => {
return {value: {username, password, inviteCode}};
};
const DUMMY_PASSWORD_HASH = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
export function createAuthRouter({repositories, authRateLimiters, config}) {
const router = express.Router();
@@ -50,9 +52,11 @@ export function createAuthRouter({repositories, authRateLimiters, config}) {
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
const {username, password} = req.validatedBody;
const user = repositories.getUser(username);
if (!user) return res.status(400).json({error: "User not found"});
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(403).json({error: "Invalid password"});
const valid = await bcrypt.compare(password, user?.password || DUMMY_PASSWORD_HASH);
if (!user || !valid) {
// Keep the message generic and still perform bcrypt work for unknown users.
return res.status(user ? 403 : 400).json({error: "Invalid username or password"});
}
req.session.user = username;
req.session.sessionVersion = Number(user.session_version || 0);
return res.json({message: "Login successful"});
+7 -9
View File
@@ -12,7 +12,6 @@ import {AttachmentError} from "../services/attachmentService.js";
export function createUploadsRouter(service, config) {
const router = express.Router();
mkdirSync(config.uploadTmpDir, {recursive: true});
const activeUploads = new Map();
const upload = multer({storage: multer.diskStorage({destination: config.uploadTmpDir}), limits: {
fileSize: config.uploadMaxSizeMb * 1024 * 1024, files: 1, fields: 0, parts: 1
}}).single("image");
@@ -21,19 +20,18 @@ export function createUploadsRouter(service, config) {
router.get("/uploads/config", requireLogin, (_req, res) => res.json({maxSizeMb: config.uploadMaxSizeMb, maxFiles: config.uploadMaxFiles}));
router.post("/uploads", requireLogin, (req, res) => {
const user = req.session.user;
const active = activeUploads.get(user) || 0;
if (active >= config.uploadMaxFiles) return res.status(429).json({error: "Too many simultaneous uploads. Please wait."});
activeUploads.set(user, active + 1);
res.once("close", () => {
const remaining = (activeUploads.get(user) || 1) - 1;
if (remaining) activeUploads.set(user, remaining); else activeUploads.delete(user);
});
const lease = service.acquireUploadLease(user);
if (!lease) return res.status(429).json({error: "Too many simultaneous uploads. Please wait."});
let released = false;
const release = () => { if (!released) { released = true; service.releaseUploadLease(lease); } };
res.once("close", release);
upload(req, res, async error => {
if (error) { if (req.file?.path) await unlink(req.file.path).catch(() => {}); return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error:
if (error) { release(); if (req.file?.path) await unlink(req.file.path).catch(() => {}); return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error:
error.code === "LIMIT_FILE_SIZE" ? `Image exceeds ${config.uploadMaxSizeMb} MB` : "Upload one PNG, JPEG or WebP image per request"});
}
try { res.status(201).json(await service.upload(req.file, req.session.user)); }
catch (failure) { fail(res, failure); }
finally { release(); }
});
});
router.get("/attachments/:id", requireLogin, asyncHandler(async (req, res) => {
+10 -1
View File
@@ -29,6 +29,15 @@ export function parseVisionAnalysis(text) {
export function createAttachmentService(db, config, modelService, visionDebugService = null) {
const storage = new LocalFileStorage(config.uploadDir);
const acquireUploadLease = (username) => db.transaction(() => {
db.prepare("DELETE FROM upload_leases WHERE created_at < datetime('now', '-15 minutes')").run();
const active = db.prepare("SELECT COUNT(*) AS count FROM upload_leases WHERE username=?").get(username).count;
if (active >= config.uploadMaxFiles) return null;
const token = randomUUID();
db.prepare("INSERT INTO upload_leases(token, username) VALUES(?, ?)").run(token, username);
return token;
})();
const releaseUploadLease = token => { if (token) db.prepare("DELETE FROM upload_leases WHERE token=?").run(token); };
const log = (event, fields) => { if (config.structuredRequestLoggingEnabled) console.log(JSON.stringify({event, ...fields})); };
const get = id => db.prepare("SELECT * FROM attachments WHERE id = ?").get(id);
const rows = messageId => db.prepare("SELECT a.*, m.position FROM attachments a JOIN message_attachments m ON m.attachment_id=a.id WHERE m.message_id=? ORDER BY m.position").all(messageId);
@@ -148,7 +157,7 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
timer.unref();
void cleanup();
return {
storage, cleanup,
storage, cleanup, acquireUploadLease, releaseUploadLease,
async close() { clearInterval(timer); await cleanup(); closed = true; },
list(messageId) { return rows(messageId).map(attachmentMetadata); },
owned(id, user) {