Revamp workspace archive handling with async support, input validation improvements and better session management
CI / check (22) (push) Failing after 4m51s
CI / check (24) (push) Successful in 9m43s

This commit is contained in:
2026-10-13 18:10:56 +02:00
parent 5489e03138
commit 068843beda
16 changed files with 279 additions and 70 deletions
+2 -2
View File
@@ -7,8 +7,8 @@
"node": ">=22.13.0" "node": ">=22.13.0"
}, },
"scripts": { "scripts": {
"start": "node --env-file-if-exists=.env server.js", "start": "node --env-file-if-exists=.env scripts/start.js",
"dev": "node --env-file-if-exists=.env --watch server.js", "dev": "node --env-file-if-exists=.env --watch scripts/dev-server.js",
"user:role": "node --env-file-if-exists=.env scripts/set-role.js", "user:role": "node --env-file-if-exists=.env scripts/set-role.js",
"lint": "eslint .", "lint": "eslint .",
"test": "npm run test:ci", "test": "npm run test:ci",
+2
View File
@@ -0,0 +1,2 @@
process.env.NODE_ENV ??= "development";
await import("../server.js");
+2
View File
@@ -0,0 +1,2 @@
process.env.NODE_ENV ??= "production";
await import("../server.js");
+5 -6
View File
@@ -103,7 +103,7 @@ function createObservability(config) {
at: new Date().toISOString(), at: new Date().toISOString(),
requestId: req?.id || null, requestId: req?.id || null,
method: req?.method || null, method: req?.method || null,
path: req?.originalUrl || req?.url || null, path: req?.path || null,
status, status,
message: String(error?.message || error || "Unknown error").slice(0, 500) message: String(error?.message || error || "Unknown error").slice(0, 500)
}; };
@@ -129,11 +129,10 @@ function createObservability(config) {
type: "request", type: "request",
requestId: req.id, requestId: req.id,
method: req.method, method: req.method,
path: req.originalUrl || req.url, path: req.path,
status, status,
durationMs, durationMs,
slow: durationMs > config.slowRequestWarnMs, slow: durationMs > config.slowRequestWarnMs,
user: req.session?.user || null
})); }));
} }
}); });
@@ -288,7 +287,7 @@ export function createApp(options = {}) {
app.use(observability.middleware); app.use(observability.middleware);
app.use(browserSecurityHeaders(config)); app.use(browserSecurityHeaders(config));
app.use(createHealthRouter({modelService})); app.use(createHealthRouter({modelService, config}));
app.use(express.json({limit: "1mb"})); app.use(express.json({limit: "1mb"}));
app.use(session({ app.use(session({
store: sessionStore, store: sessionStore,
@@ -340,8 +339,8 @@ export function createApp(options = {}) {
app.use(createSettingsRouter({repositories})); app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService})); app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
app.use(createAdminMarketRouter(repositories)); app.use(createAdminMarketRouter(repositories));
app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService})); app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService, db}));
app.use(createSystemRouter({modelService, config})); app.use(createSystemRouter({modelService, config, observability}));
app.use(notFoundHandler); app.use(notFoundHandler);
app.use(errorHandler); app.use(errorHandler);
+32 -25
View File
@@ -12,6 +12,8 @@ const int = (value, fallback) => {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : fallback; return Number.isInteger(parsed) ? parsed : fallback;
}; };
const positiveInt = (value, fallback) => Math.max(1, int(value, fallback));
const nonNegativeInt = (value, fallback) => Math.max(0, int(value, fallback));
const trustProxy = (value, fallback = false) => { const trustProxy = (value, fallback = false) => {
if (value == null || value === "") return fallback; if (value == null || value === "") return fallback;
@@ -33,7 +35,7 @@ export function createConfig(overrides = {}) {
const rootDir = overrides.rootDir || process.cwd(); const rootDir = overrides.rootDir || process.cwd();
const dataDir = overrides.dataDir || path.resolve(rootDir, "data"); const dataDir = overrides.dataDir || path.resolve(rootDir, "data");
const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions"); const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions");
const testMode = bool(process.env.TEST_MODE, process.env.NODE_ENV === "test"); const testMode = overrides.testMode ?? bool(process.env.TEST_MODE, process.env.NODE_ENV === "test");
const config = { const config = {
rootDir, rootDir,
@@ -52,7 +54,7 @@ export function createConfig(overrides = {}) {
cookieSecure: bool(process.env.COOKIE_SECURE, false), cookieSecure: bool(process.env.COOKIE_SECURE, false),
allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false), allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false),
trustProxy: trustProxy(process.env.TRUST_PROXY, false), trustProxy: trustProxy(process.env.TRUST_PROXY, false),
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000), sessionMaxAgeMs: positiveInt(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "", registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "",
sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true), sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true),
staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0), staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0),
@@ -65,42 +67,47 @@ export function createConfig(overrides = {}) {
structuredRequestLoggingEnabled: bool(process.env.STRUCTURED_REQUEST_LOGGING_ENABLED, !testMode), structuredRequestLoggingEnabled: bool(process.env.STRUCTURED_REQUEST_LOGGING_ENABLED, !testMode),
visionDebug: bool(process.env.VISION_DEBUG, false), visionDebug: bool(process.env.VISION_DEBUG, false),
visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false), visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false),
chatLimit: int(process.env.CHAT_LIMIT, 10), chatLimit: positiveInt(process.env.CHAT_LIMIT, 10),
chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20), chatHistoryLimit: positiveInt(process.env.CHAT_HISTORY_LIMIT, 20),
chatSummaryUpdateEveryMessages: int(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8), chatSummaryUpdateEveryMessages: positiveInt(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8),
chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200), chatSummaryMaxChars: positiveInt(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5), chatMaxRetries: nonNegativeInt(process.env.CHAT_MAX_RETRIES, 5),
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api", modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), modelRequestTimeoutMs: positiveInt(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000),
modelConnectTimeoutMs: Math.max(1, int(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000)), modelConnectTimeoutMs: positiveInt(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000),
modelInactivityTimeoutMs: Math.max(1, int(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000))), modelInactivityTimeoutMs: positiveInt(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000)),
modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)), modelMaxGenerationMs: positiveInt(process.env.MODEL_MAX_GENERATION_MS, 600000),
ollamaKeepAlive: process.env.OLLAMA_KEEP_ALIVE || "30m", ollamaKeepAlive: process.env.OLLAMA_KEEP_ALIVE || "30m",
searxngUrl: process.env.SEARXNG_URL || "", searxngUrl: process.env.SEARXNG_URL || "",
webSearchTimeoutMs: Math.max(1, int(process.env.WEB_SEARCH_TIMEOUT_MS, 8000)), webSearchTimeoutMs: positiveInt(process.env.WEB_SEARCH_TIMEOUT_MS, 8000),
modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000), modelListTimeoutMs: positiveInt(process.env.MODEL_LIST_TIMEOUT_MS, 4000),
modelUnloadAfterMs: int(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000), modelUnloadAfterMs: positiveInt(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000),
modelsCacheTtlMs: int(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000), modelsCacheTtlMs: nonNegativeInt(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000),
authRateLimitWindowMs: int(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000), authRateLimitWindowMs: positiveInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
authRateLimitMaxPerIp: int(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20), authRateLimitMaxPerIp: positiveInt(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20),
authRateLimitMaxPerAccount: int(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10), authRateLimitMaxPerAccount: positiveInt(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10),
chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000), chatRateLimitWindowMs: positiveInt(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000),
chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60), chatRateLimitMaxPerIp: positiveInt(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60),
chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30), chatRateLimitMaxPerAccount: positiveInt(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30),
rateLimitCleanupIntervalMs: int(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000), rateLimitCleanupIntervalMs: positiveInt(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000),
testMode, testMode,
dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, true) dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, false),
healthCacheTtlMs: Math.max(0, int(process.env.HEALTH_CACHE_TTL_MS, testMode ? 0 : 5000)),
healthTimeoutMs: Math.max(100, int(process.env.HEALTH_TIMEOUT_MS, 2000)),
healthRateLimitWindowMs: Math.max(1000, int(process.env.HEALTH_RATE_LIMIT_WINDOW_MS, 60000)),
healthRateLimitMaxPerIp: Math.max(1, int(process.env.HEALTH_RATE_LIMIT_MAX_PER_IP, testMode ? 1000 : 30))
}; };
const resolved = {...config, ...overrides}; const resolved = {...config, ...overrides};
// An explicitly named environment is a deployment contract. With no NODE_ENV, // An explicitly named environment is a deployment contract. With no NODE_ENV,
// retain the local-development defaults used by the CLI and test helpers. // retain the local-development defaults used by the CLI and test helpers.
const isProduction = process.env.NODE_ENV === "production" || overrides.production === true;
const isNonTestDeployment = Boolean(process.env.NODE_ENV) && process.env.NODE_ENV !== "test" && !resolved.testMode; const isNonTestDeployment = Boolean(process.env.NODE_ENV) && process.env.NODE_ENV !== "test" && !resolved.testMode;
if (isNonTestDeployment) { if (isNonTestDeployment || overrides.production === true) {
if (resolved.sessionSecret === "change-me-session-secret") { if (resolved.sessionSecret === "change-me-session-secret") {
throw new Error("SESSION_SECRET must be set outside test mode."); throw new Error("SESSION_SECRET must be set outside test mode.");
} }
if (!resolved.cookieSecure && !resolved.allowInsecureCookies) { if (isProduction && !resolved.cookieSecure && !resolved.allowInsecureCookies) {
throw new Error("COOKIE_SECURE must be true outside test mode 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)) { if (![true, false].includes(resolved.trustProxy) && !(Number.isInteger(resolved.trustProxy) && resolved.trustProxy >= 0)) {
+2
View File
@@ -2,6 +2,7 @@ export function createAccountStatements(db) {
return { return {
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"), insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
getUser: db.prepare("SELECT username, password, role, session_version FROM users WHERE username = ?"), getUser: db.prepare("SELECT username, password, role, session_version FROM users WHERE username = ?"),
getUserCaseInsensitive: db.prepare("SELECT username, password, role, session_version FROM users WHERE LOWER(username) = LOWER(?)"),
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"), updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"), updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"), incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
@@ -68,6 +69,7 @@ const deleteUserTransaction = db.transaction((username) => {
}); });
return { return {
getUser: (username) => statements.getUser.get(username), getUser: (username) => statements.getUser.get(username),
getUserCaseInsensitive: (username) => statements.getUserCaseInsensitive.get(username),
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash), insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username), updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
incrementSessionVersion(username) { incrementSessionVersion(username) {
+5
View File
@@ -33,6 +33,11 @@ export function initializeAttachments(db) {
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS upload_leases_user ON upload_leases(username); CREATE INDEX IF NOT EXISTS upload_leases_user ON upload_leases(username);
CREATE TABLE IF NOT EXISTS workspace_operation_leases (
username TEXT PRIMARY KEY REFERENCES users(username) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER IF NOT EXISTS release_message_attachment AFTER DELETE ON message_attachments CREATE TRIGGER IF NOT EXISTS release_message_attachment AFTER DELETE ON message_attachments
BEGIN BEGIN
DELETE FROM attachments WHERE id = OLD.attachment_id AND status = 'attached' DELETE FROM attachments WHERE id = OLD.attachment_id AND status = 'attached'
+5 -1
View File
@@ -240,7 +240,7 @@ export function initializeSchema(db) {
`).run(); `).run();
} }
export function runMigrations(db, {dropLegacyChats = true} = {}) { export function runMigrations(db, {dropLegacyChats = false} = {}) {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -265,6 +265,7 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
ensureColumn("users", userColumns, "role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'))"); ensureColumn("users", userColumns, "role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'))");
ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0"); ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0");
db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run(); db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run();
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower ON users (LOWER(username))");
const personaColumns = db.prepare("PRAGMA table_info(personas)").all(); const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT"); ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
@@ -500,6 +501,9 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
if (dropLegacyChats) { if (dropLegacyChats) {
db.prepare("DROP TABLE chats").run(); db.prepare("DROP TABLE chats").run();
} else {
const backupName = `chats_legacy_backup_${Date.now()}`;
db.prepare(`ALTER TABLE chats RENAME TO "${backupName}"`).run();
} }
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)").run(1); db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)").run(1);
} }
+16 -9
View File
@@ -6,13 +6,14 @@ import {asyncHandler} from "../middleware/asyncHandler.js";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import {validateBody} from "../middleware/validate.js"; import {validateBody} from "../middleware/validate.js";
import {newPasswordError} from "../services/passwordPolicy.js"; import {newPasswordError} from "../services/passwordPolicy.js";
import {normalizeUsername, usernameError} from "../services/usernamePolicy.js";
const credentialsValidator = (body) => { const credentialsValidator = (body) => {
const username = String(body?.username || "").trim(); const username = normalizeUsername(body?.username);
const password = String(body?.password || ""); const password = String(body?.password || "");
const inviteCode = String(body?.inviteCode || "").trim(); const inviteCode = String(body?.inviteCode || "").trim();
if (!username) return {error: "Username is required"}; const usernameValidationError = usernameError(username);
if (username.length < 3) return {error: "Username must be at least 3 characters"}; if (usernameValidationError) return {error: usernameValidationError};
if (!password) return {error: "Password is required"}; if (!password) return {error: "Password is required"};
if (password.length < 6) return {error: "Password must be at least 6 characters"}; if (password.length < 6) return {error: "Password must be at least 6 characters"};
return {value: {username, password, inviteCode}}; return {value: {username, password, inviteCode}};
@@ -20,6 +21,14 @@ const credentialsValidator = (body) => {
const DUMMY_PASSWORD_HASH = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; const DUMMY_PASSWORD_HASH = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
const regenerateSession = (req, fields) => new Promise((resolve, reject) => {
req.session.regenerate(error => {
if (error) return reject(error);
Object.assign(req.session, fields);
resolve();
});
});
export function createAuthRouter({repositories, authRateLimiters, config}) { export function createAuthRouter({repositories, authRateLimiters, config}) {
const router = express.Router(); const router = express.Router();
@@ -30,7 +39,7 @@ export function createAuthRouter({repositories, authRateLimiters, config}) {
if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) { if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
return res.status(403).json({error: "Valid invite code is required"}); return res.status(403).json({error: "Valid invite code is required"});
} }
if (repositories.getUser(username)) { if (repositories.getUserCaseInsensitive(username)) {
return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"}); return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"});
} }
@@ -44,21 +53,19 @@ export function createAuthRouter({repositories, authRateLimiters, config}) {
} }
throw error; throw error;
} }
req.session.user = username; await regenerateSession(req, {user: username, sessionVersion: 0});
req.session.sessionVersion = 0;
return res.json({message: "Registration successful"}); return res.json({message: "Registration successful"});
})); }));
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => { router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
const {username, password} = req.validatedBody; const {username, password} = req.validatedBody;
const user = repositories.getUser(username); const user = repositories.getUserCaseInsensitive(username);
const valid = await bcrypt.compare(password, user?.password || DUMMY_PASSWORD_HASH); const valid = await bcrypt.compare(password, user?.password || DUMMY_PASSWORD_HASH);
if (!user || !valid) { if (!user || !valid) {
// Keep the message generic and still perform bcrypt work for unknown users. // Keep the message generic and still perform bcrypt work for unknown users.
return res.status(user ? 403 : 400).json({error: "Invalid username or password"}); return res.status(user ? 403 : 400).json({error: "Invalid username or password"});
} }
req.session.user = username; await regenerateSession(req, {user: user.username, sessionVersion: Number(user.session_version || 0)});
req.session.sessionVersion = Number(user.session_version || 0);
return res.json({message: "Login successful"}); return res.json({message: "Login successful"});
})); }));
+10 -4
View File
@@ -7,12 +7,13 @@ import bcrypt from "bcrypt";
import {requireLogin} from "../middleware/auth.js"; import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js"; import {validateBody} from "../middleware/validate.js";
import {newPasswordError} from "../services/passwordPolicy.js"; import {newPasswordError} from "../services/passwordPolicy.js";
import {normalizeUsername, usernameError} from "../services/usernamePolicy.js";
const usernameValidator = (body) => { const usernameValidator = (body) => {
const username = String(body?.username || "").trim(); const username = normalizeUsername(body?.username);
const password = String(body?.password || ""); const password = String(body?.password || "");
if (!username) return {error: "New username is required"}; const usernameValidationError = usernameError(username, "New username");
if (username.length < 3) return {error: "Username must be at least 3 characters"}; if (usernameValidationError) return {error: usernameValidationError};
if (!password) return {error: "Current password is required"}; if (!password) return {error: "Current password is required"};
return {value: {username, password}}; return {value: {username, password}};
}; };
@@ -54,7 +55,7 @@ export function createSettingsRouter({repositories}) {
if (!currentUser) return res.status(404).json({error: "User not found"}); if (!currentUser) return res.status(404).json({error: "User not found"});
const valid = await bcrypt.compare(password, currentUser.password); const valid = await bcrypt.compare(password, currentUser.password);
if (!valid) return res.status(403).json({error: "Current password is incorrect"}); if (!valid) return res.status(403).json({error: "Current password is incorrect"});
if (repositories.getUser(nextUsername)) { if (repositories.getUserCaseInsensitive(nextUsername)) {
return res.status(409).json({error: "Username already exists", code: "USERNAME_TAKEN"}); return res.status(409).json({error: "Username already exists", code: "USERNAME_TAKEN"});
} }
@@ -66,7 +67,12 @@ export function createSettingsRouter({repositories}) {
} }
throw error; throw error;
} }
await new Promise((resolve, reject) => req.session.regenerate(error => {
if (error) return reject(error);
req.session.user = nextUsername; req.session.user = nextUsername;
req.session.sessionVersion = 0;
resolve();
}));
return res.json({username: nextUsername}); return res.json({username: nextUsername});
})); }));
+41 -4
View File
@@ -4,6 +4,7 @@
import express from "express"; import express from "express";
import {asyncHandler} from "../middleware/asyncHandler.js"; import {asyncHandler} from "../middleware/asyncHandler.js";
import {requireLogin, requireAdmin} from "../middleware/auth.js"; import {requireLogin, requireAdmin} from "../middleware/auth.js";
import {createRateLimiter} from "../middleware/rateLimit.js";
function buildConfigWarnings(config) { function buildConfigWarnings(config) {
const warnings = []; const warnings = [];
@@ -25,18 +26,50 @@ function buildConfigWarnings(config) {
return warnings; return warnings;
} }
export function createHealthRouter({modelService}) { export function createHealthRouter({modelService, config}) {
const router = express.Router(); const router = express.Router();
const limiter = createRateLimiter({
windowMs: config.healthRateLimitWindowMs,
max: config.healthRateLimitMaxPerIp,
keyGenerator: req => `health:${req.ip}`,
message: "Too many health checks. Please try again later."
});
let cached = null;
let checkedAt = 0;
let inFlight = null;
router.get("/health", asyncHandler(async (req, res) => { const runHealthCheck = () => {
const model = await modelService.checkHealth(); const controller = new AbortController();
let timer;
const timeout = new Promise(resolve => {
timer = setTimeout(() => {
controller.abort(new Error("Health check timed out"));
resolve({ok: false, timedOut: true});
}, config.healthTimeoutMs);
});
const check = Promise.resolve(modelService.checkHealth(controller.signal));
return Promise.race([check, timeout]).finally(() => clearTimeout(timer));
};
router.get("/health", limiter, asyncHandler(async (req, res) => {
if (cached && Date.now() - checkedAt < config.healthCacheTtlMs) {
return res.status(cached.ok ? 200 : 503).json({ok: Boolean(cached.ok), cached: true});
}
if (!inFlight) {
inFlight = runHealthCheck().then(model => {
cached = model;
checkedAt = Date.now();
return model;
}).finally(() => { inFlight = null; });
}
const model = await inFlight;
return res.status(model.ok ? 200 : 503).json({ok: Boolean(model.ok)}); return res.status(model.ok ? 200 : 503).json({ok: Boolean(model.ok)});
})); }));
return router; return router;
} }
export function createSystemRouter({modelService, config}) { export function createSystemRouter({modelService, config, observability}) {
const router = express.Router(); const router = express.Router();
router.get("/admin/diagnostics", requireLogin, requireAdmin, asyncHandler(async (req, res) => { router.get("/admin/diagnostics", requireLogin, requireAdmin, asyncHandler(async (req, res) => {
@@ -47,5 +80,9 @@ export function createSystemRouter({modelService, config}) {
}); });
})); }));
router.get("/admin/observability", requireLogin, requireAdmin, (req, res) => {
return res.json(observability.getSnapshot());
});
return router; return router;
} }
+37 -6
View File
@@ -1,22 +1,51 @@
import express from "express"; import express from "express";
import multer from "multer"; import multer from "multer";
import fs from "node:fs/promises";
import {randomUUID} from "node:crypto";
import {requireLogin} from "../middleware/auth.js"; import {requireLogin} from "../middleware/auth.js";
import {asyncHandler} from "../middleware/asyncHandler.js"; import {asyncHandler} from "../middleware/asyncHandler.js";
import {validateWorkspaceImport} from "../services/workspaceImportService.js"; import {validateWorkspaceImport} from "../services/workspaceImportService.js";
import {createWorkspaceArchive, parseWorkspaceArchive, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js"; import {createWorkspaceArchiveAsync, parseWorkspaceArchiveFile, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js";
export function createWorkspaceTransferRouter({repositories, chatService, attachmentService}) { export function createWorkspaceTransferRouter({repositories, chatService, attachmentService, config, db}) {
const router = express.Router(); const router = express.Router();
router.use(requireLogin); router.use(requireLogin);
const receiveArchive = multer({storage: multer.memoryStorage(), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive"); const receiveArchive = multer({storage: multer.diskStorage({destination: config.uploadTmpDir, filename: (_req, _file, cb) => cb(null, `workspace-${Date.now()}-${Math.random().toString(16).slice(2)}.tar.gz`)}), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive");
const readArchive = (req, res, next) => receiveArchive(req, res, error => { const readArchive = (req, res, next) => receiveArchive(req, res, async error => {
if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: "Invalid or oversized workspace archive"}); if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: "Invalid or oversized workspace archive"});
try { try {
if (!req.file) throw new Error("Choose a workspace archive"); if (!req.file) throw new Error("Choose a workspace archive");
req.archive = parseWorkspaceArchive(req.file.buffer); req.archive = await parseWorkspaceArchiveFile(req.file.path);
next(); next();
} catch (failure) { res.status(400).json({error: failure.message}); } } catch (failure) { res.status(400).json({error: failure.message}); }
finally { if (req.file?.path) await fs.rm(req.file.path, {force: true}).catch(() => {}); }
}); });
const acquire = (req, res) => {
const key = req.session.user;
const token = randomUUID();
let acquired = false;
try {
acquired = db.transaction(() => {
db.prepare("DELETE FROM workspace_operation_leases WHERE created_at < datetime('now', '-30 minutes')").run();
db.prepare("INSERT INTO workspace_operation_leases(username, token) VALUES(?, ?)").run(key, token);
return true;
})();
} catch (error) {
if (!String(error?.code || "").startsWith("SQLITE_CONSTRAINT")) throw error;
}
if (!acquired) {
res.status(429).json({error: "Another workspace archive operation is already running"});
return false;
}
let released = false;
const release = () => {
if (released) return;
released = true;
db.prepare("DELETE FROM workspace_operation_leases WHERE username = ? AND token = ?").run(key, token);
};
res.once("close", release);
return true;
};
const archiveImages = (workspace, files) => { const archiveImages = (workspace, files) => {
const images = new Map(); const images = new Map();
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) { for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
@@ -44,6 +73,7 @@ export function createWorkspaceTransferRouter({repositories, chatService, attach
}); });
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)})); router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
router.get("/exports/workspace/archive", asyncHandler(async (req, res) => { router.get("/exports/workspace/archive", asyncHandler(async (req, res) => {
if (!acquire(req, res)) return;
try { try {
const workspace = repositories.exportWorkspace(req.session.user), images = new Map(); const workspace = repositories.exportWorkspace(req.session.user), images = new Map();
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) { for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
@@ -51,7 +81,7 @@ export function createWorkspaceTransferRouter({repositories, chatService, attach
const row = attachmentService.owned(attachment.id, req.session.user); const row = attachmentService.owned(attachment.id, req.session.user);
images.set(attachment.id, [`images/${row.id}.${row.mime_type.split("/")[1]}`, await attachmentService.storage.read(row.storage_key)]); images.set(attachment.id, [`images/${row.id}.${row.mime_type.split("/")[1]}`, await attachmentService.storage.read(row.storage_key)]);
} }
const archive = createWorkspaceArchive(workspace, [...images.values()]); const archive = await createWorkspaceArchiveAsync(workspace, [...images.values()]);
res.set({"Content-Type": "application/gzip", "Content-Disposition": 'attachment; filename="workspace-backup.tar.gz"', "Cache-Control": "private, no-store"}); res.set({"Content-Type": "application/gzip", "Content-Disposition": 'attachment; filename="workspace-backup.tar.gz"', "Cache-Control": "private, no-store"});
return res.send(archive); return res.send(archive);
} catch (error) { return res.status(error.message.includes("250 MB") ? 413 : 500).json({error: "Workspace archive could not be created"}); } } catch (error) { return res.status(error.message.includes("250 MB") ? 413 : 500).json({error: "Workspace archive could not be created"}); }
@@ -62,6 +92,7 @@ export function createWorkspaceTransferRouter({repositories, chatService, attach
return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview}); return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview});
}); });
router.post("/imports/workspace/archive", readArchive, asyncHandler(async (req, res) => { router.post("/imports/workspace/archive", readArchive, asyncHandler(async (req, res) => {
if (!acquire(req, res)) return;
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user)); const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors}); if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors});
if (validation.preview.duplicates.length && req.body?.allowDuplicates !== "true") return res.status(409).json({error: "Workspace import contains duplicate records", warnings: validation.warnings, preview: validation.preview}); if (validation.preview.duplicates.length && req.body?.allowDuplicates !== "true") return res.status(409).json({error: "Workspace import contains duplicate records", warnings: validation.warnings, preview: validation.preview});
+6 -6
View File
@@ -286,20 +286,20 @@ export function createModelService(config, overrides = {}) {
scheduleModelUnload(model); scheduleModelUnload(model);
} }
}, },
async listModels() { async listModels(signal = null) {
if (modelsCache && Date.now() - modelsCacheAt < config.modelsCacheTtlMs) { if (modelsCache && Date.now() - modelsCacheAt < config.modelsCacheTtlMs) {
return modelsCache; return modelsCache;
} }
try { try {
const startedAt = Date.now(); const startedAt = Date.now();
modelsCache = await withTimeout(config.modelListTimeoutMs, async (signal) => { modelsCache = await withTimeout(config.modelListTimeoutMs, async (requestSignal) => {
const response = await fetchImpl(`${config.modelApiBaseUrl}/tags`, { signal }); const response = await fetchImpl(`${config.modelApiBaseUrl}/tags`, { signal: requestSignal });
if (!response.ok) { if (!response.ok) {
throw new ModelServiceError("MODEL_LIST_FAILED", "Failed to load models.", 502); throw new ModelServiceError("MODEL_LIST_FAILED", "Failed to load models.", 502);
} }
return response.json(); return response.json();
}); }, signal);
lastListLatencyMs = Date.now() - startedAt; lastListLatencyMs = Date.now() - startedAt;
modelsCacheAt = Date.now(); modelsCacheAt = Date.now();
lastHealth = { ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: lastListLatencyMs }; lastHealth = { ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: lastListLatencyMs };
@@ -317,10 +317,10 @@ export function createModelService(config, overrides = {}) {
throw new ModelServiceError("MODEL_UNAVAILABLE", "Failed to load models.", 502); throw new ModelServiceError("MODEL_UNAVAILABLE", "Failed to load models.", 502);
} }
}, },
async checkHealth() { async checkHealth(signal = null) {
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
await this.listModels(); await this.listModels(signal);
const health = { ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt }; const health = { ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt };
lastHealth = health; lastHealth = health;
return health; return health;
+15
View File
@@ -0,0 +1,15 @@
// Keep legacy printable names working, while excluding whitespace, controls,
// path separators, and URL delimiters that are unsafe/confusing in identifiers.
const USERNAME_PATTERN = /^[^\p{C}\s\\/@?#%]{3,64}$/u;
export function normalizeUsername(value) {
return String(value || "").normalize("NFKC").trim();
}
export function usernameError(username, label = "Username") {
if (!username) return `${label} is required`;
if (username.length < 3) return `${label} must be at least 3 characters`;
if (username.length > 64) return `${label} must be at most 64 characters`;
if (!USERNAME_PATTERN.test(username)) return `${label} may contain letters, numbers, dots, underscores, and hyphens only`;
return null;
}
+90 -3
View File
@@ -1,5 +1,11 @@
/** Portable workspace archive: a gzip-compressed ustar file with JSON and images. */ /** Portable workspace archive: a gzip-compressed ustar file with JSON and images. */
import {gzipSync, gunzipSync} from "node:zlib"; import {gzipSync, gunzipSync} from "node:zlib";
import {promisify} from "node:util";
import {stat} from "node:fs/promises";
import {createReadStream} from "node:fs";
import {gzip, createGunzip} from "node:zlib";
const gzipAsync = promisify(gzip);
export const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024; export const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
@@ -34,9 +40,7 @@ export function createWorkspaceArchive(workspace, images) {
return gzipSync(Buffer.concat(chunks, total), {level: 1}); return gzipSync(Buffer.concat(chunks, total), {level: 1});
} }
export function parseWorkspaceArchive(buffer) { function parseTar(tar) {
if (!Buffer.isBuffer(buffer) || buffer.length > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB");
const tar = gunzipSync(buffer, {maxOutputLength: MAX_ARCHIVE_BYTES});
const files = new Map(); const files = new Map();
for (let offset = 0; offset + 512 <= tar.length;) { for (let offset = 0; offset + 512 <= tar.length;) {
const block = tar.subarray(offset, offset + 512); const block = tar.subarray(offset, offset + 512);
@@ -59,3 +63,86 @@ export function parseWorkspaceArchive(buffer) {
catch { throw new Error("Archive has invalid workspace.json"); } catch { throw new Error("Archive has invalid workspace.json"); }
return {workspace, images: files}; return {workspace, images: files};
} }
export function parseWorkspaceArchive(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB");
return parseTar(gunzipSync(buffer, {maxOutputLength: MAX_ARCHIVE_BYTES}));
}
// Async variants keep archive compression/decompression off the event loop. The
// synchronous exports remain for small local callers and backwards compatibility.
export async function createWorkspaceArchiveAsync(workspace, images) {
const files = [["workspace.json", Buffer.from(JSON.stringify(workspace, null, 2))], ...images];
const chunks = [];
let total = 1024;
for (const [name, body] of files) {
if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name)) throw new Error("Invalid archive entry");
total += 512 + Math.ceil(body.length / 512) * 512;
if (total > MAX_ARCHIVE_BYTES) throw new Error("Workspace archive exceeds 250 MB");
chunks.push(header(name, body.length), body, Buffer.alloc((512 - body.length % 512) % 512));
}
chunks.push(Buffer.alloc(1024));
return gzipAsync(Buffer.concat(chunks, total), {level: 1});
}
export async function parseWorkspaceArchiveFile(filePath) {
const metadata = await stat(filePath);
if (metadata.size > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB");
const files = new Map();
const input = createReadStream(filePath).pipe(createGunzip());
let pending = Buffer.alloc(0);
let current = null;
let total = 0;
let ended = false;
const consumeHeader = block => {
if (block.every(byte => byte === 0)) {
ended = true;
return;
}
const name = block.toString("ascii", 0, 100).split("\0")[0];
if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name) || files.has(name) || block.toString("ascii", 156, 157) !== "0") {
throw new Error("Invalid archive entry");
}
const recorded = Number.parseInt(block.toString("ascii", 148, 156).trim(), 8);
const copy = Buffer.from(block); copy.fill(32, 148, 156);
if (recorded !== copy.reduce((sum, byte) => sum + byte, 0)) throw new Error("Invalid archive checksum");
const size = Number.parseInt(block.toString("ascii", 124, 136).replaceAll("\0", "").trim(), 8);
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_ARCHIVE_BYTES) throw new Error("Invalid archive size");
current = {name, size, remaining: size, chunks: []};
};
for await (const chunk of input) {
total += chunk.length;
if (total > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB");
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
while (!ended) {
if (!current) {
if (pending.length < 512) break;
const block = pending.subarray(0, 512);
pending = pending.subarray(512);
consumeHeader(block);
continue;
}
const bodyLength = Math.min(current.remaining, pending.length);
if (bodyLength) {
current.chunks.push(pending.subarray(0, bodyLength));
pending = pending.subarray(bodyLength);
current.remaining -= bodyLength;
}
if (current.remaining > 0) break;
const padding = (512 - current.size % 512) % 512;
if (pending.length < padding) break;
pending = pending.subarray(padding);
files.set(current.name, Buffer.concat(current.chunks, current.size));
current = null;
}
}
if (current || (!ended && pending.length >= 512)) throw new Error("Invalid archive size");
if (!files.has("workspace.json")) throw new Error("Archive has no workspace.json");
let workspace;
try { workspace = JSON.parse(files.get("workspace.json").toString("utf8")); }
catch { throw new Error("Archive has invalid workspace.json"); }
return {workspace, images: files};
}
+8 -3
View File
@@ -263,16 +263,19 @@ test("auth flow validates inputs and persists session", async () => {
assert.match(json.error, /at least 3 characters/i); assert.match(json.error, /at least 3 characters/i);
}); });
await request(server.baseUrl, "/register", { const registration = await request(server.baseUrl, "/register", {
method: "POST", method: "POST",
body: {username: "alice", password: "password123"} body: {username: "alice", password: "password123"}, jar
}).then(({status}) => assert.equal(status, 200)); });
assert.equal(registration.status, 200);
const registeredSession = jar.cookie;
await request(server.baseUrl, "/login", { await request(server.baseUrl, "/login", {
method: "POST", method: "POST",
body: {username: "alice", password: "password123"}, body: {username: "alice", password: "password123"},
jar jar
}).then(({status}) => assert.equal(status, 200)); }).then(({status}) => assert.equal(status, 200));
assert.notEqual(jar.cookie, registeredSession, "login must regenerate the session id");
await request(server.baseUrl, "/session", {jar}).then(({json}) => { await request(server.baseUrl, "/session", {jar}).then(({json}) => {
assert.equal(json.user, "alice"); assert.equal(json.user, "alice");
@@ -399,6 +402,7 @@ test("username and password changes keep account data and sessions consistent",
await request(server.baseUrl, "/register", {method: "POST", body: {username: "alreadytaken", password: "password123"}}); await request(server.baseUrl, "/register", {method: "POST", body: {username: "alreadytaken", password: "password123"}});
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar})).status, 200); assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar})).status, 200);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar: otherSession})).status, 200); assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar: otherSession})).status, 200);
const sessionBeforeRename = jar.cookie;
const chat = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Keep this chat"}, jar}); const chat = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Keep this chat"}, jar});
assert.equal(chat.status, 200); assert.equal(chat.status, 200);
assert.equal((await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Keeper", personaType: "user"}, jar})).status, 404); assert.equal((await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Keeper", personaType: "user"}, jar})).status, 404);
@@ -411,6 +415,7 @@ test("username and password changes keep account data and sessions consistent",
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 409); assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 409);
const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar}); const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar});
assert.equal(renamed.status, 200); assert.equal(renamed.status, 200);
assert.notEqual(jar.cookie, sessionBeforeRename, "username changes must regenerate the session id");
assert.equal(renamed.json.username, "newowner"); assert.equal(renamed.json.username, "newowner");
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.username, "newowner"); assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.username, "newowner");
assert.equal((await request(server.baseUrl, "/session", {jar})).json.user, "newowner"); assert.equal((await request(server.baseUrl, "/session", {jar})).json.user, "newowner");