Revamp workspace archive handling with async support, input validation improvements and better session management
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
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};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user