From dfa32c6addb62038c1600b148785748afe57eb51 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 08:37:13 +0200 Subject: [PATCH] revert: trim optional review fixes --- apps/docs/docs/management/assistant.mdx | 8 +- apps/nextjs/package.json | 4 - .../src/app/api/assistant/chat/route.ts | 6 - .../nextjs/src/app/api/backup/import/route.ts | 486 ++++-------------- .../app/api/backup/test/import-route.spec.ts | 25 +- .../assistant-markdown-image.spec.ts | 6 +- .../assistant/assistant-markdown-image.ts | 5 +- .../components/assistant/assistant-panel.tsx | 46 +- packages/api/src/router/assistant.ts | 9 +- .../api/src/router/test/assistant.spec.ts | 216 ++++---- packages/translation/src/lang/en.json | 3 +- pnpm-lock.yaml | 65 --- pnpm-workspace.yaml | 4 - 13 files changed, 245 insertions(+), 638 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index f397a53c3..aec1bde4e 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -51,15 +51,11 @@ summary of accessible Homarr resources. Live status is fetched only when needed. ## Permissions and privacy -- Read-only actions can run automatically. Changes require approval by default. -- **Automatic approvals** can be enabled for one conversation. While enabled, protected changes and prepared forms run - without another approval click, but questions still wait for your answer. It turns off when you switch conversations; - Homarr permissions still apply to every action. +- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the + current conversation, turn off when you switch conversations, and never answer questions for you. - Homarr permissions apply to every tool call and mentioned item. - API keys and custom headers are encrypted and never returned to the browser. - Conversations are stored per user and can be exported or deleted. -- Model-generated images from external websites are not loaded automatically. Homarr shows the destination host and - waits for you to approve each image URL, preventing a response from silently contacting another server from your browser. Prompts, attachments, tool definitions, and tool results are sent to the selected model provider. Review that provider's privacy policy before enabling the assistant. diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 7f919a549..9b8c3305d 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -88,7 +88,6 @@ "assistant-stream": "catalog:", "babel-plugin-react-compiler": "catalog:", "better-sqlite3": "catalog:", - "busboy": "catalog:", "chroma-js": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", @@ -119,20 +118,17 @@ "superjson": "catalog:", "use-deep-compare-effect": "catalog:", "yaml": "catalog:", - "yauzl": "catalog:", "zod": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", "@types/adm-zip": "catalog:", "@types/better-sqlite3": "catalog:", - "@types/busboy": "catalog:", "@types/chroma-js": "catalog:", "@types/node": "catalog:", "@types/prismjs": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "@types/yauzl": "catalog:", "concurrently": "catalog:", "node-loader": "catalog:", "typescript": "catalog:" diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 8632a3ac7..d59733da0 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -15,7 +15,6 @@ import { import { auth } from "@homarr/auth/next"; import { env } from "@homarr/common/env"; import { decryptSecret } from "@homarr/common/server"; -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { and, eq } from "@homarr/db"; import { db } from "@homarr/db"; @@ -616,11 +615,6 @@ export async function POST(request: Request) { baseURL: configuration.baseUrl, apiKey: providerApiKey, headers: providerHeaders, - fetch: async (input, init) => - (await fetchWithTrustedCertificatesAsync( - input as Parameters[0], - init as Parameters[1], - )) as unknown as Response, includeUsage: true, transformRequestBody: configuration.provider === "openrouter" diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts index d72067ce5..db9b15f22 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -1,17 +1,12 @@ import crypto from "crypto"; import fs from "fs"; import path from "path"; -import { Readable, Transform } from "stream"; -import { pipeline } from "stream/promises"; -import type { ReadableStream as NodeReadableStream } from "stream/web"; import { NextResponse } from "next/server"; +import AdmZip from "adm-zip"; import BetterSqlite3 from "better-sqlite3"; -import busboy from "busboy"; import { drizzle } from "drizzle-orm/better-sqlite3"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import yauzl from "yauzl"; -import type { Entry as ZipEntry, ZipFile } from "yauzl"; import { getOnboardingClaimTokenFromCookieHeader, @@ -39,350 +34,93 @@ const MAX_UNCOMPRESSED_DATABASE_BYTES = 512 * MEBIBYTE; const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEBIBYTE; const MAX_METADATA_BYTES = MEBIBYTE; const MAX_ENCRYPTION_KEY_BYTES = 1024; -const MAX_ARCHIVE_ENTRIES = 1024; -const UPLOAD_IDLE_TIMEOUT_MS = 60_000; const RESTART_DELAY_MS = 500; let restoreInProgress = false; class BackupTooLargeError extends Error {} -class BackupUploadTimeoutError extends Error {} -class BackupInfrastructureError extends Error { - public readonly cause: unknown; - - constructor(message: string, cause: unknown) { - super(message); - this.cause = cause; - } -} - -const FILE_SYSTEM_ERROR_CODES = new Set([ - "EACCES", - "EDQUOT", - "EEXIST", - "EIO", - "EISDIR", - "EMFILE", - "ENFILE", - "ENOENT", - "ENOMEM", - "ENOSPC", - "ENOTDIR", - "EPERM", - "EROFS", -]); - -const isInfrastructureError = (error: unknown) => { - if (error instanceof BackupInfrastructureError) return true; - if (!(error instanceof Error)) return false; - - const code = (error as NodeJS.ErrnoException).code; - return typeof code === "string" && FILE_SYSTEM_ERROR_CODES.has(code); -}; - -const pipeToNewFileAsync = async (source: Readable, outputPath: string, failureMessage: string) => { - let destination: fs.WriteStream; - try { - destination = fs.createWriteStream(outputPath, { flags: "wx" }); - } catch (error) { - throw new BackupInfrastructureError(failureMessage, error); - } - - const errors: { origin: "source" | "destination"; error: unknown }[] = []; - let sourceEnded = false; - let sourceClosedPrematurely = false; - source.once("end", () => { - sourceEnded = true; - }); - source.once("close", () => { - sourceClosedPrematurely = !sourceEnded; - }); - source.once("error", (error) => { - errors.push({ origin: "source", error }); - }); - destination.once("error", (error) => { - if (errors.length === 0 && sourceClosedPrematurely) { - errors.push({ origin: "source", error }); - return; - } - errors.push({ origin: "destination", error }); - }); - - try { - await pipeline(source, destination); - } catch (error) { - const firstError = errors[0]; - if (!firstError) throw error; - if (firstError.origin === "source") throw firstError.error; - throw new BackupInfrastructureError(failureMessage, firstError.error); - } -}; - -const assertDeclaredRequestSize = (request: Request) => { +const readBoundedRequestBodyAsync = async (request: Request): Promise> => { const declaredLength = request.headers.get("content-length"); - if (declaredLength === null) return; - - const parsedLength = Number(declaredLength); - if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) { - throw new Error("Invalid Content-Length header"); - } - if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) { - throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit"); - } -}; - -const createRequestSizeLimiter = () => { - let totalBytes = 0; - return new Transform({ - transform(chunk: Buffer, _encoding, callback) { - totalBytes += chunk.byteLength; - if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) { - callback(new BackupTooLargeError("Backup upload exceeds the 256 MB limit")); - return; - } - callback(null, chunk); - }, - }); -}; - -const createUploadIdleTimeout = () => { - let timeout: NodeJS.Timeout | undefined; - const stream = new Transform({ - transform(chunk: Buffer, _encoding, callback) { - resetTimeout(); - callback(null, chunk); - }, - }); - const resetTimeout = () => { - if (timeout) clearTimeout(timeout); - timeout = setTimeout( - () => stream.destroy(new BackupUploadTimeoutError("Backup upload timed out")), - UPLOAD_IDLE_TIMEOUT_MS, - ); - timeout.unref(); - }; - resetTimeout(); - stream.on("close", () => { - if (timeout) clearTimeout(timeout); - }); - return stream; -}; - -const streamUploadedBackupAsync = async (request: Request, uploadPath: string): Promise => { - assertDeclaredRequestSize(request); - if (!request.body) return null; - - const parser = busboy({ - headers: Object.fromEntries(request.headers.entries()), - preservePath: false, - limits: { - fileSize: MAX_COMPRESSED_BACKUP_BYTES, - files: 1, - fields: 0, - parts: 2, - headerPairs: 200, - }, - }); - let uploadSize = 0; - let uploadFound = false; - let uploadWrite = Promise.resolve(); - let uploadWriteError: unknown; - let parseError: Error | null = null; - - parser.on("file", (fieldName, file) => { - if (fieldName !== "file" || uploadFound) { - parseError = new Error("Unexpected file field"); - file.resume(); - return; + if (declaredLength !== null) { + const parsedLength = Number(declaredLength); + if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) { + throw new Error("Invalid Content-Length header"); } - uploadFound = true; + if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) { + throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit"); + } + } - file.on("data", (chunk: Buffer) => { - uploadSize += chunk.byteLength; - }); - file.on("limit", () => { - parseError = new BackupTooLargeError("Backup upload exceeds the 256 MB limit"); - }); + if (!request.body) return new Uint8Array(); - uploadWrite = pipeToNewFileAsync(file, uploadPath, "Failed to store backup upload"); - void uploadWrite.catch((error: unknown) => { - uploadWriteError = error; - parser.destroy(error instanceof Error ? error : new Error("Failed to store backup upload")); - }); - }); - parser.on("field", () => { - parseError = new Error("Unexpected form field"); - }); - parser.on("filesLimit", () => { - parseError = new Error("Too many files"); - }); - parser.on("fieldsLimit", () => { - parseError = new Error("Unexpected form field"); - }); - parser.on("partsLimit", () => { - parseError = new Error("Too many form parts"); - }); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; - const requestStream = Readable.fromWeb(request.body as NodeReadableStream); try { - await pipeline(requestStream, createUploadIdleTimeout(), createRequestSizeLimiter(), parser, { - signal: request.signal, - }); - await uploadWrite; - } catch (error) { - await uploadWrite.catch(() => undefined); - if (uploadWriteError instanceof BackupInfrastructureError) throw uploadWriteError; - throw error; - } + while (true) { + const { done, value } = await reader.read(); + if (done) break; - if (parseError) throw parseError; - if (!uploadFound) return null; - return uploadSize; -}; - -interface BackupArchiveEntries { - database: ZipEntry; - metadata: ZipEntry; - encryptionKey: ZipEntry | null; -} - -const openArchiveAsync = (archivePath: string) => - new Promise((resolve, reject) => { - yauzl.open( - archivePath, - { lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true }, - (error, zip) => { - if (error) { - reject(error); - return; - } - if (!zip) { - reject(new Error("ZIP archive could not be opened")); - return; - } - resolve(zip); - }, - ); - }); - -const inspectArchiveAsync = (zip: ZipFile) => - new Promise((resolve, reject) => { - let database: ZipEntry | null = null; - let metadata: ZipEntry | null = null; - let encryptionKey: ZipEntry | null = null; - let totalSize = 0; - let entryCount = 0; - let settled = false; - - const fail = (error: Error) => { - if (settled) return; - settled = true; - reject(error); - }; - - zip.once("error", fail); - zip.on("entry", (entry: ZipEntry) => { - entryCount += 1; - if (entryCount > MAX_ARCHIVE_ENTRIES) { - fail(new BackupTooLargeError(`Backup archive contains more than ${MAX_ARCHIVE_ENTRIES} entries`)); - return; + totalBytes += value.byteLength; + if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) { + await reader.cancel(); + throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit"); } - - const entrySize = entry.uncompressedSize; - if (!Number.isSafeInteger(entrySize) || entrySize < 0) { - fail(new Error(`Invalid uncompressed size for ${entry.fileName}`)); - return; - } - totalSize += entrySize; - if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) { - fail(new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit")); - return; - } - - if (entry.fileName === "db.sqlite") { - if (database) { - fail(new Error("Invalid backup: duplicate db.sqlite entry")); - return; - } - if (entrySize > MAX_UNCOMPRESSED_DATABASE_BYTES) { - fail(new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit")); - return; - } - database = entry; - } else if (entry.fileName === "metadata.json") { - if (metadata) { - fail(new Error("Invalid backup: duplicate metadata.json entry")); - return; - } - if (entrySize > MAX_METADATA_BYTES) { - fail(new BackupTooLargeError("Backup metadata exceeds the 1 MB limit")); - return; - } - metadata = entry; - } else if (entry.fileName === "encryption-key.txt") { - if (encryptionKey) { - fail(new Error("Invalid backup: duplicate encryption-key.txt entry")); - return; - } - if (entrySize > MAX_ENCRYPTION_KEY_BYTES) { - fail(new BackupTooLargeError("Backup encryption key entry is too large")); - return; - } - encryptionKey = entry; - } - - zip.readEntry(); - }); - zip.once("end", () => { - if (settled) return; - settled = true; - if (!database || !metadata) { - const presentNames = [database ? "db.sqlite" : null, metadata ? "metadata.json" : null]; - const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !presentNames.includes(name)); - reject(new Error(`Invalid backup: missing ${missingEntries.join(", ")}`)); - return; - } - resolve({ database, metadata, encryptionKey }); - }); - zip.readEntry(); - }); - -const openEntryStreamAsync = (zip: ZipFile, entry: ZipEntry) => - new Promise((resolve, reject) => { - zip.openReadStream(entry, (error, stream) => { - if (error) { - reject(error); - return; - } - if (!stream) { - reject(new Error(`Could not read ${entry.fileName}`)); - return; - } - resolve(stream); - }); - }); - -const extractEntryToFileAsync = async (zip: ZipFile, entry: ZipEntry, outputPath: string) => { - const source = await openEntryStreamAsync(zip, entry); - await pipeToNewFileAsync(source, outputPath, `Failed to extract ${entry.fileName}`); -}; - -const readBoundedEntryAsync = async (zip: ZipFile, entry: ZipEntry, maximumBytes: number) => { - const source = await openEntryStreamAsync(zip, entry); - const chunks: Buffer[] = []; - let totalBytes = 0; - - for await (const chunk of source) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - totalBytes += buffer.byteLength; - if (totalBytes > maximumBytes) { - source.destroy(); - throw new BackupTooLargeError(`${entry.fileName} exceeds its size limit`); + chunks.push(value); } - chunks.push(buffer); + } finally { + reader.releaseLock(); + } + + const body = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +}; + +const parseBoundedFormDataAsync = async (request: Request): Promise => { + const contentType = request.headers.get("content-type"); + if (!contentType) throw new Error("Missing Content-Type header"); + + const body = await readBoundedRequestBodyAsync(request); + return new Response(body, { headers: { "content-type": contentType } }).formData(); +}; + +const assertArchiveSizeLimits = (zip: AdmZip) => { + const entries = zip.getEntries(); + let totalSize = 0; + + for (const entry of entries) { + if (entry.isDirectory) continue; + const entrySize = entry.header.size; + if (!Number.isSafeInteger(entrySize) || entrySize < 0) { + throw new Error(`Invalid uncompressed size for ${entry.entryName}`); + } + totalSize += entrySize; + if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) { + throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit"); + } + } + + const dbEntry = zip.getEntry("db.sqlite"); + if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) { + throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit"); + } + + const metadataEntry = zip.getEntry("metadata.json"); + if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) { + throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit"); + } + + const encryptionKeyEntry = zip.getEntry("encryption-key.txt"); + if (encryptionKeyEntry && encryptionKeyEntry.header.size > MAX_ENCRYPTION_KEY_BYTES) { + throw new BackupTooLargeError("Backup encryption key entry is too large"); } - return Buffer.concat(chunks, totalBytes); }; const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: string) => { @@ -502,84 +240,66 @@ export async function POST(req: Request) { let tempDb: InstanceType | null = null; try { - tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-")); - const uploadPath = path.join(tempDirectory, "backup.zip"); - const tempPath = path.join(tempDirectory, "db.sqlite"); - - let uploadSize: number | null; + let formData: FormData; try { - uploadSize = await streamUploadedBackupAsync(req, uploadPath); + formData = await parseBoundedFormDataAsync(req); } catch (error) { if (error instanceof BackupTooLargeError) { return NextResponse.json({ error: error.message }, { status: 413 }); } - if (error instanceof BackupUploadTimeoutError) { - return NextResponse.json({ error: error.message }, { status: 408 }); - } - if (isInfrastructureError(error)) throw error; return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); } - if (uploadSize === null) { + const file = formData.get("file"); + if (!(file instanceof File)) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } - if (uploadSize > MAX_COMPRESSED_BACKUP_BYTES) { + if (file.size > MAX_COMPRESSED_BACKUP_BYTES) { return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 }); } - let zip: ZipFile; + let zip: AdmZip; try { - zip = await openArchiveAsync(uploadPath); - } catch (error) { - if (isInfrastructureError(error)) throw error; + zip = new AdmZip(Buffer.from(await file.arrayBuffer())); + } catch { return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 }); } - let metadata: { encryptionKey?: unknown }; - let archivedEncryptionKey: string | undefined; + const entries = zip.getEntries().map((entry) => entry.entryName); + const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name)); + if (missingEntries.length > 0) { + return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 }); + } + try { - const entries = await inspectArchiveAsync(zip); - await extractEntryToFileAsync(zip, entries.database, tempPath); - const metadataBuffer = await readBoundedEntryAsync(zip, entries.metadata, MAX_METADATA_BYTES); - let parsedMetadata: unknown; - try { - parsedMetadata = JSON.parse(metadataBuffer.toString()); - } catch { - // Without this the archive itself gets blamed for a readable ZIP that - // simply carries malformed metadata. - throw new Error("Invalid backup: metadata.json is not valid JSON"); - } - if (typeof parsedMetadata !== "object" || parsedMetadata === null || Array.isArray(parsedMetadata)) { - throw new Error("Invalid backup: metadata.json must contain an object"); - } - metadata = parsedMetadata as { encryptionKey?: unknown }; - if (entries.encryptionKey) { - const keyBuffer = await readBoundedEntryAsync(zip, entries.encryptionKey, MAX_ENCRYPTION_KEY_BYTES); - archivedEncryptionKey = keyBuffer.toString().trim(); - } + assertArchiveSizeLimits(zip); } catch (error) { if (error instanceof BackupTooLargeError) { return NextResponse.json({ error: error.message }, { status: 413 }); } - if (isInfrastructureError(error)) throw error; - const message = - error instanceof Error && error.message.startsWith("Invalid backup:") ? error.message : "Invalid ZIP file"; - return NextResponse.json({ error: message }, { status: 400 }); - } finally { - zip.close(); - try { - fs.rmSync(uploadPath, { force: true }); - } catch { - // The outer restore-directory cleanup retries this path. - } + throw error; } + tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-")); + const tempPath = path.join(tempDirectory, "db.sqlite"); + const dbEntry = zip.getEntry("db.sqlite"); + if (!dbEntry) { + return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 }); + } + fs.writeFileSync(tempPath, dbEntry.getData()); + tempDb = new BetterSqlite3(tempPath); const drizzleDb = drizzle(tempDb, { casing: DB_CASING, schema }); migrate(drizzleDb, { migrationsFolder }); await applyCustomMigrationsAsync(drizzleDb as unknown as Database); - const rawKey = metadata.encryptionKey ?? archivedEncryptionKey; + const metadataEntry = zip.getEntry("metadata.json"); + if (!metadataEntry) { + return NextResponse.json({ error: "Invalid backup: missing metadata.json" }, { status: 400 }); + } + const metadata = JSON.parse(metadataEntry.getData().toString()); + + const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim(); const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined; const secretCount = (tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }) diff --git a/apps/nextjs/src/app/api/backup/test/import-route.spec.ts b/apps/nextjs/src/app/api/backup/test/import-route.spec.ts index 062d8db95..a2784b14f 100644 --- a/apps/nextjs/src/app/api/backup/test/import-route.spec.ts +++ b/apps/nextjs/src/app/api/backup/test/import-route.spec.ts @@ -101,7 +101,7 @@ describe("POST /api/backup/import", () => { let activeDatabasePath: string; beforeEach(() => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + vi.useFakeTimers(); temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-import-route-test-")); activeDatabasePath = path.join(temporaryDirectory, "db.sqlite"); routeMocks.dbEnv.DRIVER = "better-sqlite3"; @@ -234,6 +234,7 @@ describe("POST /api/backup/import", () => { it("rejects a concurrent restore while the first request owns the restore lock", async () => { createMigratedDatabase(activeDatabasePath, "board-current", "Current board"); const backup = createBackup(temporaryDirectory, "concurrent", "Concurrent restore"); + const originalArrayBuffer = File.prototype.arrayBuffer; let signalStarted!: () => void; let releaseFirstRequest!: () => void; const started = new Promise((resolve) => { @@ -242,25 +243,13 @@ describe("POST /api/backup/import", () => { const release = new Promise((resolve) => { releaseFirstRequest = resolve; }); - const originalRequest = createImportRequest(backup); - const requestBody = await originalRequest.arrayBuffer(); - const delayedBody = new ReadableStream({ - async pull(controller) { - signalStarted(); - await release; - controller.enqueue(new Uint8Array(requestBody)); - controller.close(); - }, + vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) { + signalStarted(); + await release; + return originalArrayBuffer.call(this); }); - const delayedRequestInit: RequestInit & { duplex: "half" } = { - method: "POST", - headers: originalRequest.headers, - body: delayedBody, - duplex: "half", - }; - const delayedRequest = new Request(originalRequest.url, delayedRequestInit); - const firstRestore = POST(delayedRequest); + const firstRestore = POST(createImportRequest(backup)); await started; const concurrentResponse = await POST(createImportRequest(backup)); releaseFirstRequest(); diff --git a/apps/nextjs/src/components/assistant/assistant-markdown-image.spec.ts b/apps/nextjs/src/components/assistant/assistant-markdown-image.spec.ts index fb90bbc4e..73d52a0bd 100644 --- a/apps/nextjs/src/components/assistant/assistant-markdown-image.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-markdown-image.spec.ts @@ -3,9 +3,11 @@ import { describe, expect, test } from "vitest"; import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; describe("getSafeAssistantMarkdownImageSource", () => { - test("allows Homarr-relative images without automatically loading remote images", () => { + test("allows Homarr-relative and ordinary remote images", () => { expect(getSafeAssistantMarkdownImageSource("/logo/logo.png")).toBe("/logo/logo.png"); - expect(getSafeAssistantMarkdownImageSource("https://cdn.example.com/icons/discord.svg")).toBeNull(); + expect(getSafeAssistantMarkdownImageSource("https://cdn.example.com/icons/discord.svg")).toBe( + "https://cdn.example.com/icons/discord.svg", + ); }); test("rejects unsafe protocols, protocol-relative URLs, and embedded credentials", () => { diff --git a/apps/nextjs/src/components/assistant/assistant-markdown-image.ts b/apps/nextjs/src/components/assistant/assistant-markdown-image.ts index 27b84b091..eba911817 100644 --- a/apps/nextjs/src/components/assistant/assistant-markdown-image.ts +++ b/apps/nextjs/src/components/assistant/assistant-markdown-image.ts @@ -4,12 +4,9 @@ export const getSafeAssistantMarkdownImageSource = (source: string | Blob | unde if (typeof source !== "string" || !source) return null; if (source.startsWith("/") && !source.startsWith("//")) return source; - return null; + return getSafeAssistantHttpUrl(source); }; -export const getSafeAssistantExternalMarkdownImageSource = (source: string | Blob | undefined) => - getSafeAssistantHttpUrl(source); - const safeAttachmentDataUrl = /^data:image\/(?:gif|jpeg|png|webp);base64,(?:[a-z\d+/]{4})*(?:[a-z\d+/]{2}==|[a-z\d+/]{3}=)?$/iu; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 509cf4066..bdeb8ed45 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -148,11 +148,7 @@ import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from ". import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives"; import type { AssistantDirectiveEntity } from "./assistant-markdown-directives"; import { normalizeAssistantMarkdown } from "./assistant-markdown"; -import { - getSafeAssistantAttachmentImageSource, - getSafeAssistantExternalMarkdownImageSource, - getSafeAssistantMarkdownImageSource, -} from "./assistant-markdown-image"; +import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; import { getAssistantContextBreakdown, getAssistantConversationUsage, @@ -224,8 +220,7 @@ const ContextDirectiveChip = ({ }: DirectiveChipProps & { iconUrl?: string }) => { const t = useI18n("assistant"); const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt; - const safeIconUrl = - getSafeAssistantMarkdownImageSource(iconUrl) ?? getSafeAssistantExternalMarkdownImageSource(iconUrl); + const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl); const translationKey = getAssistantDirectiveTranslationKey(directiveType); const typeLabel = t(`mentions.${translationKey}`); @@ -297,30 +292,13 @@ const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table"> const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => { const t = useI18n("assistant.image"); - const [approvedExternalSource, setApprovedExternalSource] = useState(null); const safeSource = getSafeAssistantMarkdownImageSource(src); - const externalSource = getSafeAssistantExternalMarkdownImageSource(src); - const approvedSource = externalSource === approvedExternalSource ? externalSource : null; - const displaySource = safeSource ?? approvedSource; - - if (!displaySource && externalSource) { - return ( - - ); - } - if (!displaySource) return null; + if (!safeSource) return null; return ( { width="min(19rem, calc(100vw - 1rem))" shadow="md" withinPortal - trapFocus - returnFocus > { variant={enabled ? "light" : "subtle"} color={enabled ? "green" : "gray"} onClick={() => setOpened((current) => !current)} - onKeyDown={(event) => { - if (event.key !== "Escape" || !opened) return; - event.stopPropagation(); - setOpened(false); - }} aria-label={t("label")} aria-pressed={enabled} title={enabled ? t("enabled") : t("label")} @@ -2330,13 +2301,7 @@ const AutoApprovalControl = () => { - { - if (event.key !== "Escape") return; - event.stopPropagation(); - setOpened(false); - }} - > + @@ -2347,7 +2312,6 @@ const AutoApprovalControl = () => {