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.
This commit is contained in:
+18
-3
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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"});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user