diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 2ac43a19c..cb9f7fb13 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -49,7 +49,10 @@ summary of accessible Homarr resources. Live status is fetched only when needed. ## Permissions and privacy -- Read-only actions can run automatically. Changes require approval. +- Read-only actions can run automatically. Changes require approval by default. +- **Automatic approvals** can be enabled for one conversation. While enabled, protected changes, submitted form details, + and affirmative answers run without another approval click. It turns off when you switch conversations; Homarr + permissions still apply to every action. - 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. diff --git a/apps/docs/docs/management/custom-widgets/custom-jsx.mdx b/apps/docs/docs/management/custom-widgets/custom-jsx.mdx index b1f8e6aff..653112bdf 100644 --- a/apps/docs/docs/management/custom-widgets/custom-jsx.mdx +++ b/apps/docs/docs/management/custom-widgets/custom-jsx.mdx @@ -61,8 +61,8 @@ than one variable quantifier are rejected. - `ActionButton` and `ToggleSwitch` run actions only when the widget is mounted outside edit mode. A toggle needs both enabled and disabled request parameters and rolls back its visual state if the action fails. - `RefreshButton` renders a compact, accessible icon control that invalidates the client's load-query cache so active load - queries can refetch; it does not execute a request itself. It is disabled when the widget has no active item or preview - session, or while the board is in edit mode. + queries can refetch; it does not execute a request itself. It is disabled for anonymous viewers, when the widget has no + active item or preview session, or while the board is in edit mode. - `Icon`/`TablerIcon` resolves installed safe icons. Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile. diff --git a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx index f06362c8e..35d5415be 100644 --- a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx +++ b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx @@ -60,8 +60,8 @@ Queries default to view permission and actions to modify. DELETE always requires Management and runtime are intentionally separate. A Homarr administrator with board-modify permission places and configures the widget. After placement, query and action authorization follows the request's board permission. Public boards may execute queries for anonymous viewers, but mutations stay authenticated. This includes shared-cache -invalidation: `RefreshButton` is disabled only without an active item or preview session, or in edit mode, while ordinary -scheduled query rendering remains available to anonymous viewers. +invalidation: `RefreshButton` is disabled for anonymous viewers, without an active item or preview session, and in edit +mode, while ordinary scheduled query rendering remains available to anonymous viewers. Responses use bounded, versioned shared-Redis cache keys. Action invalidation advances the shared version so every Homarr replica stops serving the previous response without scanning or deleting a key prefix. diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 71e31402c..812596cd3 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -46,7 +46,7 @@ const nextConfig: NextConfig = { serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], - preloadEntriesOnStart: true, + preloadEntriesOnStart: false, turbopackFileSystemCacheForBuild: true, useTypeScriptCli: true, }, diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 9b8c3305d..7f919a549 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -88,6 +88,7 @@ "assistant-stream": "catalog:", "babel-plugin-react-compiler": "catalog:", "better-sqlite3": "catalog:", + "busboy": "catalog:", "chroma-js": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", @@ -118,17 +119,20 @@ "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/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index cd25ddd12..bc8e92b5d 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -91,8 +91,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync setResettingLayoutId(layout.id); void (async () => { try { - const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values; - const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined; + const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id); if (!canonicalLayout) return; await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts index eba48aa67..5824751a4 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts @@ -1,4 +1,5 @@ import { fetchApi } from "@homarr/api/client"; +import { mapWithConcurrency } from "@homarr/common"; import { customWidgetDefinitionSchema, getCustomWidgetDefaultOptions, @@ -7,6 +8,7 @@ import { validateCustomWidgetOptions, } from "@homarr/custom-widgets/core"; import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; +import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/server"; import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench"; import type { UseFormReturnType } from "@mantine/form"; @@ -102,25 +104,26 @@ export function isRuntimeParams( } export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) { - const data: Record = {}; - const status: Record = {}; - for (const [requestId] of Object.entries(definition.requests).filter( + const requests = Object.entries(definition.requests).filter( ([, entry]) => entry.kind === "query" && entry.trigger === "load", - )) { + ); + const entries = await mapWithConcurrency(requests, CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT, async ([requestId]) => { try { const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} }); - data[requestId] = result.data; - status[requestId] = { loading: false, ...result }; + return [requestId, result.data, { loading: false, ...result }] as const; } catch (error) { - data[requestId] = null; - status[requestId] = { + const failedStatus = { loading: false, ok: false, error: error instanceof Error ? error.message : "Request failed", }; + return [requestId, null, failedStatus] as const; } - } - return { data, status }; + }); + return { + data: Object.fromEntries(entries.map(([requestId, data]) => [requestId, data])), + status: Object.fromEntries(entries.map(([requestId, , status]) => [requestId, status])), + }; } export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) { diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index d59733da0..8632a3ac7 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -15,6 +15,7 @@ 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"; @@ -615,6 +616,11 @@ 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 db9b15f22..eff0b822c 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -1,12 +1,17 @@ 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, @@ -34,93 +39,249 @@ 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 RESTART_DELAY_MS = 500; let restoreInProgress = false; class BackupTooLargeError extends Error {} -const readBoundedRequestBodyAsync = async (request: Request): Promise> => { +const assertDeclaredRequestSize = (request: Request) => { const declaredLength = request.headers.get("content-length"); - if (declaredLength !== null) { - 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"); + 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 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 parseError: Error | null = null; + + parser.on("file", (fieldName, file) => { + if (fieldName !== "file" || uploadFound) { + parseError = new Error("Unexpected file field"); + file.resume(); + return; } + uploadFound = true; + + file.on("data", (chunk: Buffer) => { + uploadSize += chunk.byteLength; + }); + file.on("limit", () => { + parseError = new BackupTooLargeError("Backup upload exceeds the 256 MB limit"); + }); + + uploadWrite = pipeline(file, fs.createWriteStream(uploadPath, { flags: "wx" })); + void uploadWrite.catch((error: unknown) => { + 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 requestStream = Readable.fromWeb(request.body as NodeReadableStream); + try { + await pipeline(requestStream, createRequestSizeLimiter(), parser); + await uploadWrite; + } catch (error) { + await uploadWrite.catch(() => undefined); + throw error; } - if (!request.body) return new Uint8Array(); + if (parseError) throw parseError; + if (!uploadFound) return null; + return uploadSize; +}; - const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; +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; + } + + 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 pipeline(source, fs.createWriteStream(outputPath, { flags: "wx" })); +}; + +const readBoundedEntryAsync = async (zip: ZipFile, entry: ZipEntry, maximumBytes: number) => { + const source = await openEntryStreamAsync(zip, entry); + const chunks: Buffer[] = []; let totalBytes = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - totalBytes += value.byteLength; - if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) { - await reader.cancel(); - throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit"); - } - chunks.push(value); + 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`); } - } 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"); + chunks.push(buffer); } + return Buffer.concat(chunks, totalBytes); }; const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: string) => { @@ -240,9 +401,13 @@ export async function POST(req: Request) { let tempDb: InstanceType | null = null; try { - let formData: FormData; + 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; try { - formData = await parseBoundedFormDataAsync(req); + uploadSize = await streamUploadedBackupAsync(req, uploadPath); } catch (error) { if (error instanceof BackupTooLargeError) { return NextResponse.json({ error: error.message }, { status: 413 }); @@ -250,56 +415,52 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); } - const file = formData.get("file"); - if (!(file instanceof File)) { + if (uploadSize === null) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } - if (file.size > MAX_COMPRESSED_BACKUP_BYTES) { + if (uploadSize > MAX_COMPRESSED_BACKUP_BYTES) { return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 }); } - let zip: AdmZip; + let zip: ZipFile; try { - zip = new AdmZip(Buffer.from(await file.arrayBuffer())); + zip = await openArchiveAsync(uploadPath); } catch { return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 }); } - 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 }); - } - + let metadata: { encryptionKey?: unknown }; + let archivedEncryptionKey: string | undefined; try { - assertArchiveSizeLimits(zip); + const entries = await inspectArchiveAsync(zip); + await extractEntryToFileAsync(zip, entries.database, tempPath); + const metadataBuffer = await readBoundedEntryAsync(zip, entries.metadata, MAX_METADATA_BYTES); + const parsedMetadata: unknown = JSON.parse(metadataBuffer.toString()); + 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(); + } } catch (error) { if (error instanceof BackupTooLargeError) { return NextResponse.json({ error: error.message }, { status: 413 }); } - 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(); } - 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 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 rawKey = metadata.encryptionKey ?? archivedEncryptionKey; 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 a2784b14f..a08ac3f54 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 @@ -234,7 +234,6 @@ 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) => { @@ -243,13 +242,25 @@ describe("POST /api/backup/import", () => { const release = new Promise((resolve) => { releaseFirstRequest = resolve; }); - vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) { - signalStarted(); - await release; - return originalArrayBuffer.call(this); + 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(); + }, }); + const delayedRequestInit: RequestInit & { duplex: "half" } = { + method: "POST", + headers: originalRequest.headers, + body: delayedBody, + duplex: "half", + }; + const delayedRequest = new Request(originalRequest.url, delayedRequestInit); - const firstRestore = POST(createImportRequest(backup)); + const firstRestore = POST(delayedRequest); await started; const concurrentResponse = await POST(createImportRequest(backup)); releaseFirstRequest(); diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts index 2e60d4b33..ca1ac8a29 100644 --- a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts @@ -7,10 +7,10 @@ import { configureCustomWidgetSourceFromRequest, configurePreviewSessionSource, getCustomWidgetConfigurationRequest, + invalidateCustomWidgetResponseCache, releaseCustomWidgetConfigurationRequest, } from "@homarr/api/custom-widget-configuration"; import { customWidgetSourceSchema } from "@homarr/custom-widgets/core"; -import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server"; import { db } from "@homarr/db"; import { adminRoute } from "../../admin"; @@ -90,7 +90,7 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route sourceResult.data, secrets, ); - invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]); + await invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]); } else { const result = await configureCustomWidgetSourceFromRequest(db, { definitionId: claimed.target.id, 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 73d52a0bd..fb90bbc4e 100644 --- a/apps/nextjs/src/components/assistant/assistant-markdown-image.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-markdown-image.spec.ts @@ -3,11 +3,9 @@ import { describe, expect, test } from "vitest"; import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; describe("getSafeAssistantMarkdownImageSource", () => { - test("allows Homarr-relative and ordinary remote images", () => { + test("allows Homarr-relative images without automatically loading remote images", () => { expect(getSafeAssistantMarkdownImageSource("/logo/logo.png")).toBe("/logo/logo.png"); - expect(getSafeAssistantMarkdownImageSource("https://cdn.example.com/icons/discord.svg")).toBe( - "https://cdn.example.com/icons/discord.svg", - ); + expect(getSafeAssistantMarkdownImageSource("https://cdn.example.com/icons/discord.svg")).toBeNull(); }); 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 eba911817..27b84b091 100644 --- a/apps/nextjs/src/components/assistant/assistant-markdown-image.ts +++ b/apps/nextjs/src/components/assistant/assistant-markdown-image.ts @@ -4,9 +4,12 @@ export const getSafeAssistantMarkdownImageSource = (source: string | Blob | unde if (typeof source !== "string" || !source) return null; if (source.startsWith("/") && !source.startsWith("//")) return source; - return getSafeAssistantHttpUrl(source); + return null; }; +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 9ee5cfafa..81cbffbe5 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -141,7 +141,11 @@ 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, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; +import { + getSafeAssistantAttachmentImageSource, + getSafeAssistantExternalMarkdownImageSource, + getSafeAssistantMarkdownImageSource, +} from "./assistant-markdown-image"; import { getAssistantContextBreakdown, getAssistantConversationUsage, @@ -212,7 +216,8 @@ const ContextDirectiveChip = ({ }: DirectiveChipProps & { iconUrl?: string }) => { const t = useScopedI18n("common.assistant"); const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt; - const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl); + const safeIconUrl = + getSafeAssistantMarkdownImageSource(iconUrl) ?? getSafeAssistantExternalMarkdownImageSource(iconUrl); const translationKey = getAssistantDirectiveTranslationKey(directiveType); const typeLabel = t(`mentions.${translationKey}`); @@ -284,13 +289,30 @@ const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table"> const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => { const t = useScopedI18n("common.assistant.image"); + const [approvedExternalSource, setApprovedExternalSource] = useState(null); const safeSource = getSafeAssistantMarkdownImageSource(src); - if (!safeSource) return null; + const externalSource = getSafeAssistantExternalMarkdownImageSource(src); + const approvedSource = externalSource === approvedExternalSource ? externalSource : null; + const displaySource = safeSource ?? approvedSource; + + if (!displaySource && externalSource) { + return ( + + ); + } + if (!displaySource) 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")} @@ -2113,7 +2142,13 @@ const AutoApprovalControl = () => { - + { + if (event.key !== "Escape") return; + event.stopPropagation(); + setOpened(false); + }} + > @@ -2124,6 +2159,7 @@ const AutoApprovalControl = () => { + + + + +
+ +
+ + + - -
- - -
-
- -
- - - - {activeSection === "review" ? ( - - ) : ( - - {activeSection === "connect" && hasIncompleteIntegrations ? ( - setIncompleteIntegrationConfirmationOpened(false)} - transitionProps={{ duration: 0 }} - position="top-end" - width={320} - shadow="md" - withArrow - trapFocus - > - - - - - - {t("connect.incompleteConfirmation")} - - - - - - - - ) : ( + + + {t(`navigation.${section.id}`)} + + + {index + 1} / {sectionDefinitions.length} + + + + } + /> + ); + })} + + + +
+
+ +
+ + + + {activeSection === "review" ? ( + + ) : ( + - )} - - )} - -
-
-
-
-
+ + )} + + + + + + + ); }; @@ -807,6 +818,7 @@ interface StudioSectionProps { setRightSidebar: (value: boolean) => void; applyProgress: number; applyMessage: string; + applyError: string | null; appError: string | null; appErrorSourceId: string | null; } @@ -1920,6 +1932,11 @@ const Review = (props: StudioSectionProps) => { ) : null} + {props.applyError ? ( + } title={t("errorTitle")} role="alert"> + {props.applyError} + + ) : null} ); }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index cff0aa9b6..73e49e7dd 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2416,7 +2416,8 @@ "image": { "loading": "Loading image", "failed": "This image could not be loaded.", - "retry": "Try image again" + "retry": "Try image again", + "loadExternal": "Load external image from {host}" }, "quoteSelection": "Quote", "dismissQuote": "Remove quote", diff --git a/packages/ui/src/components/integration-marquee.tsx b/packages/ui/src/components/integration-marquee.tsx index f413b298a..3c895cf96 100644 --- a/packages/ui/src/components/integration-marquee.tsx +++ b/packages/ui/src/components/integration-marquee.tsx @@ -1,16 +1,24 @@ import { Image, Marquee, SimpleGrid } from "@mantine/core"; import { splitToNChunks } from "@homarr/common"; -import { integrationDefs } from "@homarr/definitions"; import classes from "./integration-marquee.module.css"; -const iconGroups = splitToNChunks( - Object.values(integrationDefs) - .filter((integration) => integration.name !== "Mock") - .map((integration) => integration.iconUrl), - 3, -); +const localIconPaths = [ + "/images/apps/sonarr.svg", + "/images/apps/radarr.svg", + "/images/apps/lidarr.svg", + "/images/apps/readarr.svg", + "/images/apps/nextcloud.svg", + "/images/apps/truenas.svg", + "/images/apps/unraid-alt.svg", + "/images/apps/imdb.svg", + "/images/apps/lastfm.svg", + "/images/apps/tmdb.svg", + "/images/apps/the-tvdb.svg", + "/images/apps/vgmdb.svg", +]; +const iconGroups = splitToNChunks(localIconPaths, 3); const animationDuration = iconGroups.flat().length * 2; export const IntegrationMarquee = ({ className }: { className?: string }) => ( @@ -36,7 +44,16 @@ export const IntegrationMarquee = ({ className }: { className?: string }) => ( classNames={{ content: classes.content }} > {icons.map((icon) => ( - + ))} ))} diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx index 7037c7272..480bc4183 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.tsx +++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx @@ -69,6 +69,7 @@ export default function CustomJsxDisplay({ data }: { data: Record(showMissing ? "missing" : "queued"); + const tabScrollPositionsRef = useRef>({ + missing: { x: 0, y: 0 }, + queued: { x: 0, y: 0 }, + }); const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued); useEffect(() => { @@ -92,10 +96,18 @@ export default function MediaMissingWidget({ const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`); const renderPanel = ( + tab: MediaMissingTab, entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string, ) => ( - + { + tabScrollPositionsRef.current[tab] = position; + }} + > {entries.length === 0 ? ( @@ -146,7 +158,7 @@ export default function MediaMissingWidget({ {tabLabel(t("tab.missing"), missing.length, missingCount)} - {renderPanel(missing, t("empty.missing"))} + {renderPanel("missing", missing, t("empty.missing"))} )} {showQueued && ( @@ -157,7 +169,7 @@ export default function MediaMissingWidget({ {tabLabel(t("tab.queued"), queued.length, queuedCount)} - {renderPanel(queued, t("empty.queued"))} + {renderPanel("queued", queued, t("empty.queued"))} )} @@ -181,12 +193,20 @@ export default function MediaMissingWidget({ {showMissing && ( - }> + } + > {tabLabel(t("tab.missing"), missing.length, missingCount)} )} {showQueued && ( - }> + } + > {tabLabel(t("tab.queued"), queued.length, queuedCount)} )} @@ -194,12 +214,12 @@ export default function MediaMissingWidget({ {showMissing && ( - {activeTab === "missing" && renderPanel(missing, t("empty.missing"))} + {activeTab === "missing" && renderPanel("missing", missing, t("empty.missing"))} )} {showQueued && ( - {activeTab === "queued" && renderPanel(queued, t("empty.queued"))} + {activeTab === "queued" && renderPanel("queued", queued, t("empty.queued"))} )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a17aa075..6a9b9c739 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,6 +285,9 @@ catalogs: '@types/better-sqlite3': specifier: 7.6.13 version: 7.6.13 + '@types/busboy': + specifier: 1.5.4 + version: 1.5.4 '@types/canvas-confetti': specifier: ^1.9.0 version: 1.9.0 @@ -330,6 +333,9 @@ catalogs: '@types/xml2js': specifier: ^0.4.14 version: 0.4.14 + '@types/yauzl': + specifier: 3.4.0 + version: 3.4.0 '@vitejs/plugin-react': specifier: ^6.0.3 version: 6.0.3 @@ -372,6 +378,9 @@ catalogs: better-sqlite3: specifier: ^12.11.1 version: 12.11.1 + busboy: + specifier: 1.6.0 + version: 1.6.0 canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -663,6 +672,9 @@ catalogs: yaml: specifier: 2.9.0 version: 2.9.0 + yauzl: + specifier: 3.4.0 + version: 3.4.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -1177,6 +1189,9 @@ importers: better-sqlite3: specifier: 'catalog:' version: 12.11.1 + busboy: + specifier: 'catalog:' + version: 1.6.0 chroma-js: specifier: 'catalog:' version: 3.2.0 @@ -1267,6 +1282,9 @@ importers: yaml: specifier: 'catalog:' version: 2.9.0 + yauzl: + specifier: 'catalog:' + version: 3.4.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -1280,6 +1298,9 @@ importers: '@types/better-sqlite3': specifier: 'catalog:' version: 7.6.13 + '@types/busboy': + specifier: 'catalog:' + version: 1.5.4 '@types/chroma-js': specifier: 'catalog:' version: 3.1.2 @@ -1295,6 +1316,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.17) + '@types/yauzl': + specifier: 'catalog:' + version: 3.4.0 concurrently: specifier: 'catalog:' version: 10.0.3 @@ -9205,6 +9229,9 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + '@types/busboy@1.5.4': + resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} + '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} @@ -9543,6 +9570,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@3.4.0': + resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -10356,6 +10386,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + byline@5.0.0: resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} engines: {node: '>=0.10.0'} @@ -14137,6 +14171,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -15743,6 +15780,10 @@ packages: stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + streamx@2.22.0: resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==} @@ -16929,6 +16970,10 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yjs@13.6.32: resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -24303,6 +24348,10 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@types/busboy@1.5.4': + dependencies: + '@types/node': 24.13.3 + '@types/canvas-confetti@1.9.0': {} '@types/chai@5.2.2': @@ -24689,6 +24738,10 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/yauzl@3.4.0': + dependencies: + '@types/node': 24.13.3 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -25557,6 +25610,10 @@ snapshots: dependencies: run-applescript: 7.1.0 + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + byline@5.0.0: {} bytes@3.0.0: {} @@ -29879,6 +29936,8 @@ snapshots: pathe@2.0.3: {} + pend@1.2.0: {} + pg-cloudflare@1.4.0: optional: true @@ -32082,6 +32141,8 @@ snapshots: duplexer2: 0.1.4 readable-stream: 2.3.8 + streamsearch@1.1.0: {} + streamx@2.22.0: dependencies: fast-fifo: 1.3.2 @@ -33427,6 +33488,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yjs@13.6.32: dependencies: lib0: 0.2.117 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 122cd2544..a1ccbc5ef 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -91,6 +91,7 @@ catalog: "@types/adm-zip": 0.5.8 "@types/bcrypt": 6.0.0 "@types/better-sqlite3": 7.6.13 + "@types/busboy": 1.5.4 "@types/chroma-js": 3.1.2 "@types/cookies": 0.9.2 "@types/crypto-js": ^4.2.2 @@ -108,6 +109,7 @@ catalog: "@types/ws": ^8.18.1 "@uiw/react-codemirror": ^4.25.11 "@types/xml2js": ^0.4.14 + "@types/yauzl": 3.4.0 posthog-js: ^1.399.1 posthog-node: ^5.40.0 "@vitejs/plugin-react": ^6.0.3 @@ -124,6 +126,7 @@ catalog: babel-plugin-react-compiler: ^1.0.0 bcrypt: ^6.0.0 better-sqlite3: ^12.11.1 + busboy: 1.6.0 chroma-js: ^3.2.0 clsx: ^2.1.1 codemirror: ^6.0.2 @@ -213,6 +216,7 @@ catalog: ws: ^8.21.0 xml2js: ^0.6.2 yaml: 2.9.0 + yauzl: 3.4.0 zod: ^4.4.3 zod-form-data: ^3.0.2 zod-validation-error: ^5.0.0 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index f4d7c9fa4..fbfe8756d 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -7,14 +7,31 @@ export PGID=${PGID:-0} echo "Starting with UID='$PUID', GID='$PGID'" if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then - # The below command will change the owner of all files in the /app directory (except node_modules) to the new UID and GID - echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..." - find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} + - chown -R $PUID:$PGID /var/cache/nginx - chown -R $PUID:$PGID /var/log/nginx - chown -R $PUID:$PGID /var/lib/nginx - chown -R $PUID:$PGID /run/nginx/nginx.pid - chown -R $PUID:$PGID /etc/nginx + # Only chown the paths the non-root user must actually write to at runtime. + # Recursively chowning the whole /app tree is extremely slow on network- or + # HDD-backed storage (e.g. TrueNAS Apps), because the Next.js standalone + # bundle and its node_modules contain tens of thousands of tiny files. This + # turned a ~10s step into 15+ minutes for some users (#2190). Application + # code, migrations and native modules are only read/executed, so they stay + # owned by root and do not need chowning. + echo "Changing owner to $PUID:$PGID..." + # Application data volume (db, redis dumps, trusted certificates). + # Ensure the known subdirectories exist and migrate ownership of any + # existing content, so a persistent volume with stale or root-owned + # entries stays writable after a PUID/PGID change. These trees are small + # (a single sqlite db, a redis dump, a few certs), unlike the /app tree. + mkdir -p /appdata/db /appdata/redis /appdata/trusted-certificates + chown "${PUID}:${PGID}" /appdata + chown -R "${PUID}:${PGID}" /appdata/db /appdata/redis /appdata/trusted-certificates + # Next.js runtime cache (image optimization, fetch cache, ...) + mkdir -p /app/apps/nextjs/.next/cache + chown -R "${PUID}:${PGID}" /app/apps/nextjs/.next/cache + # nginx runtime directories and the rendered configuration + chown -R "${PUID}:${PGID}" /var/cache/nginx + chown -R "${PUID}:${PGID}" /var/log/nginx + chown -R "${PUID}:${PGID}" /var/lib/nginx + chown -R "${PUID}:${PGID}" /run/nginx/nginx.pid + chown -R "${PUID}:${PGID}" /etc/nginx echo "Changing owner to $PUID:$PGID, done." fi