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"
},
"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",
+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(),
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);
+32 -25
View File
@@ -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)) {
+2
View File
@@ -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) {
+5
View File
@@ -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'
+5 -1
View File
@@ -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);
}
+16 -9
View File
@@ -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"});
}));
+10 -4
View File
@@ -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;
}
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});
}));
+41 -4
View File
@@ -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;
}
+37 -6
View File
@@ -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});
+6 -6
View File
@@ -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;
+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. */
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};
}
+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);
});
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");