diff --git a/package.json b/package.json index 7331293..76aec0d 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "node": ">=22.13.0" }, "scripts": { - "start": "node --env-file-if-exists=.env server.js", - "dev": "node --env-file-if-exists=.env --watch server.js", + "start": "node --env-file-if-exists=.env scripts/start.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", "lint": "eslint .", "test": "npm run test:ci", diff --git a/scripts/dev-server.js b/scripts/dev-server.js new file mode 100644 index 0000000..4d0f08f --- /dev/null +++ b/scripts/dev-server.js @@ -0,0 +1,2 @@ +process.env.NODE_ENV ??= "development"; +await import("../server.js"); diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 0000000..7aea277 --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,2 @@ +process.env.NODE_ENV ??= "production"; +await import("../server.js"); diff --git a/src/app.js b/src/app.js index f3294b5..b8e5571 100644 --- a/src/app.js +++ b/src/app.js @@ -103,7 +103,7 @@ function createObservability(config) { at: new Date().toISOString(), requestId: req?.id || null, method: req?.method || null, - path: req?.originalUrl || req?.url || null, + path: req?.path || null, status, message: String(error?.message || error || "Unknown error").slice(0, 500) }; @@ -129,11 +129,10 @@ function createObservability(config) { type: "request", requestId: req.id, method: req.method, - path: req.originalUrl || req.url, + path: req.path, status, durationMs, slow: durationMs > config.slowRequestWarnMs, - user: req.session?.user || null })); } }); @@ -288,7 +287,7 @@ export function createApp(options = {}) { app.use(observability.middleware); app.use(browserSecurityHeaders(config)); - app.use(createHealthRouter({modelService})); + app.use(createHealthRouter({modelService, config})); app.use(express.json({limit: "1mb"})); app.use(session({ store: sessionStore, @@ -340,8 +339,8 @@ export function createApp(options = {}) { app.use(createSettingsRouter({repositories})); app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService})); app.use(createAdminMarketRouter(repositories)); - app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService})); - app.use(createSystemRouter({modelService, config})); + app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService, db})); + app.use(createSystemRouter({modelService, config, observability})); app.use(notFoundHandler); app.use(errorHandler); diff --git a/src/config.js b/src/config.js index 2a41bc6..a1f4e26 100644 --- a/src/config.js +++ b/src/config.js @@ -12,6 +12,8 @@ const int = (value, fallback) => { const parsed = Number.parseInt(value, 10); 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) => { if (value == null || value === "") return fallback; @@ -33,7 +35,7 @@ export function createConfig(overrides = {}) { const rootDir = overrides.rootDir || process.cwd(); const dataDir = overrides.dataDir || path.resolve(rootDir, "data"); 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 = { rootDir, @@ -52,7 +54,7 @@ export function createConfig(overrides = {}) { cookieSecure: bool(process.env.COOKIE_SECURE, false), allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, 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 || "", sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true), 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), visionDebug: bool(process.env.VISION_DEBUG, false), visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false), - chatLimit: int(process.env.CHAT_LIMIT, 10), - chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20), - chatSummaryUpdateEveryMessages: int(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8), - chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200), - chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5), + chatLimit: positiveInt(process.env.CHAT_LIMIT, 10), + chatHistoryLimit: positiveInt(process.env.CHAT_HISTORY_LIMIT, 20), + chatSummaryUpdateEveryMessages: positiveInt(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8), + chatSummaryMaxChars: positiveInt(process.env.CHAT_SUMMARY_MAX_CHARS, 2200), + chatMaxRetries: nonNegativeInt(process.env.CHAT_MAX_RETRIES, 5), modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api", - modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), - modelConnectTimeoutMs: Math.max(1, int(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))), - modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)), + modelRequestTimeoutMs: positiveInt(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), + modelConnectTimeoutMs: positiveInt(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000), + modelInactivityTimeoutMs: positiveInt(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000)), + modelMaxGenerationMs: positiveInt(process.env.MODEL_MAX_GENERATION_MS, 600000), ollamaKeepAlive: process.env.OLLAMA_KEEP_ALIVE || "30m", searxngUrl: process.env.SEARXNG_URL || "", - webSearchTimeoutMs: Math.max(1, int(process.env.WEB_SEARCH_TIMEOUT_MS, 8000)), - modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000), - modelUnloadAfterMs: int(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000), - modelsCacheTtlMs: int(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000), - authRateLimitWindowMs: int(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000), - authRateLimitMaxPerIp: int(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20), - authRateLimitMaxPerAccount: int(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10), - chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000), - chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60), - chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30), - rateLimitCleanupIntervalMs: int(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000), + webSearchTimeoutMs: positiveInt(process.env.WEB_SEARCH_TIMEOUT_MS, 8000), + modelListTimeoutMs: positiveInt(process.env.MODEL_LIST_TIMEOUT_MS, 4000), + modelUnloadAfterMs: positiveInt(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000), + modelsCacheTtlMs: nonNegativeInt(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000), + authRateLimitWindowMs: positiveInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000), + authRateLimitMaxPerIp: positiveInt(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20), + authRateLimitMaxPerAccount: positiveInt(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10), + chatRateLimitWindowMs: positiveInt(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000), + chatRateLimitMaxPerIp: positiveInt(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60), + chatRateLimitMaxPerAccount: positiveInt(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30), + rateLimitCleanupIntervalMs: positiveInt(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000), 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}; // 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 isProduction = process.env.NODE_ENV === "production" || overrides.production === true; 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") { 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."); } if (![true, false].includes(resolved.trustProxy) && !(Number.isInteger(resolved.trustProxy) && resolved.trustProxy >= 0)) { diff --git a/src/db/accountRepository.js b/src/db/accountRepository.js index 1692311..af22e93 100644 --- a/src/db/accountRepository.js +++ b/src/db/accountRepository.js @@ -2,6 +2,7 @@ export function createAccountStatements(db) { return { insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"), 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 = ?"), updatePassword: db.prepare("UPDATE users SET password = ? 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 { getUser: (username) => statements.getUser.get(username), + getUserCaseInsensitive: (username) => statements.getUserCaseInsensitive.get(username), insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash), updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username), incrementSessionVersion(username) { diff --git a/src/db/attachments.js b/src/db/attachments.js index ace04ec..bea1c77 100644 --- a/src/db/attachments.js +++ b/src/db/attachments.js @@ -33,6 +33,11 @@ export function initializeAttachments(db) { created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); 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 BEGIN DELETE FROM attachments WHERE id = OLD.attachment_id AND status = 'attached' diff --git a/src/db/schema.js b/src/db/schema.js index d9d39b7..37cb887 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -240,7 +240,7 @@ export function initializeSchema(db) { `).run(); } -export function runMigrations(db, {dropLegacyChats = true} = {}) { +export function runMigrations(db, {dropLegacyChats = false} = {}) { db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, 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, "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.exec("CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower ON users (LOWER(username))"); const personaColumns = db.prepare("PRAGMA table_info(personas)").all(); 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) { 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); } diff --git a/src/routes/auth.js b/src/routes/auth.js index 3a99bb7..28c7a9d 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -6,13 +6,14 @@ import {asyncHandler} from "../middleware/asyncHandler.js"; import bcrypt from "bcrypt"; import {validateBody} from "../middleware/validate.js"; import {newPasswordError} from "../services/passwordPolicy.js"; +import {normalizeUsername, usernameError} from "../services/usernamePolicy.js"; const credentialsValidator = (body) => { - const username = String(body?.username || "").trim(); + const username = normalizeUsername(body?.username); const password = String(body?.password || ""); const inviteCode = String(body?.inviteCode || "").trim(); - if (!username) return {error: "Username is required"}; - if (username.length < 3) return {error: "Username must be at least 3 characters"}; + const usernameValidationError = usernameError(username); + if (usernameValidationError) return {error: usernameValidationError}; if (!password) return {error: "Password is required"}; if (password.length < 6) return {error: "Password must be at least 6 characters"}; return {value: {username, password, inviteCode}}; @@ -20,6 +21,14 @@ const credentialsValidator = (body) => { 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}) { const router = express.Router(); @@ -30,7 +39,7 @@ export function createAuthRouter({repositories, authRateLimiters, config}) { if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) { 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"}); } @@ -44,21 +53,19 @@ export function createAuthRouter({repositories, authRateLimiters, config}) { } throw error; } - req.session.user = username; - req.session.sessionVersion = 0; + await regenerateSession(req, {user: username, sessionVersion: 0}); return res.json({message: "Registration successful"}); })); router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => { 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); 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); + await regenerateSession(req, {user: user.username, sessionVersion: Number(user.session_version || 0)}); return res.json({message: "Login successful"}); })); diff --git a/src/routes/settings.js b/src/routes/settings.js index 94626fc..5afc949 100644 --- a/src/routes/settings.js +++ b/src/routes/settings.js @@ -7,12 +7,13 @@ import bcrypt from "bcrypt"; import {requireLogin} from "../middleware/auth.js"; import {validateBody} from "../middleware/validate.js"; import {newPasswordError} from "../services/passwordPolicy.js"; +import {normalizeUsername, usernameError} from "../services/usernamePolicy.js"; const usernameValidator = (body) => { - const username = String(body?.username || "").trim(); + const username = normalizeUsername(body?.username); const password = String(body?.password || ""); - if (!username) return {error: "New username is required"}; - if (username.length < 3) return {error: "Username must be at least 3 characters"}; + const usernameValidationError = usernameError(username, "New username"); + if (usernameValidationError) return {error: usernameValidationError}; if (!password) return {error: "Current password is required"}; return {value: {username, password}}; }; @@ -54,7 +55,7 @@ export function createSettingsRouter({repositories}) { if (!currentUser) return res.status(404).json({error: "User not found"}); const valid = await bcrypt.compare(password, currentUser.password); 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"}); } @@ -66,7 +67,12 @@ export function createSettingsRouter({repositories}) { } throw error; } - req.session.user = nextUsername; + await new Promise((resolve, reject) => req.session.regenerate(error => { + if (error) return reject(error); + req.session.user = nextUsername; + req.session.sessionVersion = 0; + resolve(); + })); return res.json({username: nextUsername}); })); diff --git a/src/routes/system.js b/src/routes/system.js index 309efe5..cc73eb9 100644 --- a/src/routes/system.js +++ b/src/routes/system.js @@ -4,6 +4,7 @@ import express from "express"; import {asyncHandler} from "../middleware/asyncHandler.js"; import {requireLogin, requireAdmin} from "../middleware/auth.js"; +import {createRateLimiter} from "../middleware/rateLimit.js"; function buildConfigWarnings(config) { const warnings = []; @@ -25,18 +26,50 @@ function buildConfigWarnings(config) { return warnings; } -export function createHealthRouter({modelService}) { +export function createHealthRouter({modelService, config}) { 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 model = await modelService.checkHealth(); + const runHealthCheck = () => { + 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 router; } -export function createSystemRouter({modelService, config}) { +export function createSystemRouter({modelService, config, observability}) { const router = express.Router(); 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; } diff --git a/src/routes/workspaceTransfer.js b/src/routes/workspaceTransfer.js index a1b3964..d695bfd 100644 --- a/src/routes/workspaceTransfer.js +++ b/src/routes/workspaceTransfer.js @@ -1,22 +1,51 @@ import express from "express"; import multer from "multer"; +import fs from "node:fs/promises"; +import {randomUUID} from "node:crypto"; import {requireLogin} from "../middleware/auth.js"; import {asyncHandler} from "../middleware/asyncHandler.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(); router.use(requireLogin); - const receiveArchive = multer({storage: multer.memoryStorage(), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive"); - const readArchive = (req, res, next) => receiveArchive(req, res, error => { + 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, async error => { if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: "Invalid or oversized workspace archive"}); try { if (!req.file) throw new Error("Choose a workspace archive"); - req.archive = parseWorkspaceArchive(req.file.buffer); + req.archive = await parseWorkspaceArchiveFile(req.file.path); next(); } 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 images = new Map(); 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/archive", asyncHandler(async (req, res) => { + if (!acquire(req, res)) return; try { 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) { @@ -51,7 +81,7 @@ export function createWorkspaceTransferRouter({repositories, chatService, attach 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)]); } - 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"}); return res.send(archive); } 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}); }); 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)); 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}); diff --git a/src/services/modelService.js b/src/services/modelService.js index d97a5a0..0132bef 100644 --- a/src/services/modelService.js +++ b/src/services/modelService.js @@ -286,20 +286,20 @@ export function createModelService(config, overrides = {}) { scheduleModelUnload(model); } }, - async listModels() { + async listModels(signal = null) { if (modelsCache && Date.now() - modelsCacheAt < config.modelsCacheTtlMs) { return modelsCache; } try { const startedAt = Date.now(); - modelsCache = await withTimeout(config.modelListTimeoutMs, async (signal) => { - const response = await fetchImpl(`${config.modelApiBaseUrl}/tags`, { signal }); + modelsCache = await withTimeout(config.modelListTimeoutMs, async (requestSignal) => { + const response = await fetchImpl(`${config.modelApiBaseUrl}/tags`, { signal: requestSignal }); if (!response.ok) { throw new ModelServiceError("MODEL_LIST_FAILED", "Failed to load models.", 502); } return response.json(); - }); + }, signal); lastListLatencyMs = Date.now() - startedAt; modelsCacheAt = Date.now(); 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); } }, - async checkHealth() { + async checkHealth(signal = null) { const startedAt = Date.now(); try { - await this.listModels(); + await this.listModels(signal); const health = { ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt }; lastHealth = health; return health; diff --git a/src/services/usernamePolicy.js b/src/services/usernamePolicy.js new file mode 100644 index 0000000..30d1d65 --- /dev/null +++ b/src/services/usernamePolicy.js @@ -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; +} diff --git a/src/services/workspaceArchive.js b/src/services/workspaceArchive.js index 4c12099..70a5035 100644 --- a/src/services/workspaceArchive.js +++ b/src/services/workspaceArchive.js @@ -1,5 +1,11 @@ /** Portable workspace archive: a gzip-compressed ustar file with JSON and images. */ 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; @@ -34,9 +40,7 @@ export function createWorkspaceArchive(workspace, images) { return gzipSync(Buffer.concat(chunks, total), {level: 1}); } -export function parseWorkspaceArchive(buffer) { - if (!Buffer.isBuffer(buffer) || buffer.length > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB"); - const tar = gunzipSync(buffer, {maxOutputLength: MAX_ARCHIVE_BYTES}); +function parseTar(tar) { const files = new Map(); for (let offset = 0; offset + 512 <= tar.length;) { const block = tar.subarray(offset, offset + 512); @@ -59,3 +63,86 @@ export function parseWorkspaceArchive(buffer) { catch { throw new Error("Archive has invalid workspace.json"); } 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}; +} diff --git a/test/app.test.js b/test/app.test.js index c901c87..d8594ab 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -263,16 +263,19 @@ test("auth flow validates inputs and persists session", async () => { assert.match(json.error, /at least 3 characters/i); }); - await request(server.baseUrl, "/register", { + const registration = await request(server.baseUrl, "/register", { method: "POST", - body: {username: "alice", password: "password123"} - }).then(({status}) => assert.equal(status, 200)); + body: {username: "alice", password: "password123"}, jar + }); + assert.equal(registration.status, 200); + const registeredSession = jar.cookie; await request(server.baseUrl, "/login", { method: "POST", body: {username: "alice", password: "password123"}, jar }).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}) => { 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"}}); 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); + const sessionBeforeRename = jar.cookie; const chat = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Keep this chat"}, jar}); assert.equal(chat.status, 200); 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); const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar}); 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((await request(server.baseUrl, "/settings/profile", {jar})).json.username, "newowner"); assert.equal((await request(server.baseUrl, "/session", {jar})).json.user, "newowner");