Revamp workspace archive handling with async support, input validation improvements and better session management
This commit is contained in:
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
process.env.NODE_ENV ??= "development";
|
||||||
|
await import("../server.js");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
process.env.NODE_ENV ??= "production";
|
||||||
|
await import("../server.js");
|
||||||
+5
-6
@@ -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
@@ -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,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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user