fix: address v2 stack review findings

This commit is contained in:
ajnart committed 2026-08-20 13:08:54 +02:00
1 parent 53b518960c
commit 263702210a
48 files changed
+1235 -530

No files matched your search

@@ -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 });
@@ -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<string, unknown> = {};
const status: Record<string, unknown> = {};
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) {
@@ -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<typeof fetchWithTrustedCertificatesAsync>[0],
init as Parameters<typeof fetchWithTrustedCertificatesAsync>[1],
)) as unknown as Response,
includeUsage: true,
transformRequestBody:
configuration.provider === "openrouter"
+264 -103
View File
@@ -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<Uint8Array<ArrayBuffer>> => {
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<number | null> => {
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<Uint8Array>);
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<ZipFile>((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<BackupArchiveEntries>((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<Readable>((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<FormData> => {
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<typeof BetterSqlite3>, importedKeyHex: string) => {
@@ -240,9 +401,13 @@ export async function POST(req: Request) {
let tempDb: InstanceType<typeof BetterSqlite3> | 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 })
@@ -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<void>((resolve) => {
@@ -243,13 +242,25 @@ describe("POST /api/backup/import", () => {
const release = new Promise<void>((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<Uint8Array>({
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();
@@ -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,
@@ -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", () => {
@@ -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;
@@ -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<string | null>(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 (
<Button
variant="default"
size="compact-sm"
leftSection={<IconPhoto size={14} aria-hidden />}
onClick={() => setApprovedExternalSource(externalSource)}
>
{t("loadExternal", { host: new URL(externalSource).host })}
</Button>
);
}
if (!displaySource) return null;
return (
<AssistantImage
{...props}
source={safeSource}
source={displaySource}
alt={alt}
className={classes.markdownImage}
loadingLabel={t("loading")}
@@ -2099,6 +2121,8 @@ const AutoApprovalControl = () => {
width="min(19rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
trapFocus
returnFocus
>
<Popover.Target>
<ActionIcon
@@ -2106,6 +2130,11 @@ const AutoApprovalControl = () => {
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 = () => {
<IconShieldCheck size={17} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Popover.Dropdown
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.stopPropagation();
setOpened(false);
}}
>
<Stack gap="sm">
<Box>
<Text size="sm" fw={700}>
@@ -2124,6 +2159,7 @@ const AutoApprovalControl = () => {
</Text>
</Box>
<Button
data-autofocus
fullWidth
variant={enabled ? "default" : "light"}
color={enabled ? "gray" : "green"}
@@ -1,7 +1,16 @@
"use client";
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
@@ -28,9 +37,12 @@ interface OpenAdvancedFocusOptions {
restoreFocusTarget?: HTMLElement;
}
interface AdvancedFocusContextValue {
interface AdvancedFocusState {
active: ActiveFocus | null;
viewportSize: { width: number; height: number };
}
interface AdvancedFocusActions {
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
close: (restoreFocus?: boolean) => void;
dismiss: (itemId: string) => void;
@@ -38,7 +50,17 @@ interface AdvancedFocusContextValue {
leave: (itemId: string) => void;
}
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
interface AdvancedFocusStore {
getSnapshot: () => AdvancedFocusState;
subscribe: (listener: () => void) => () => void;
}
const AdvancedFocusActionsContext = createContext<AdvancedFocusActions | null>(null);
const AdvancedFocusStoreContext = createContext<AdvancedFocusStore | null>(null);
const INACTIVE_FOCUS_STATE: AdvancedFocusState = {
active: null,
viewportSize: { width: 0, height: 0 },
};
const HOVER_DELAY_MS = 500;
const POINTER_TRANSITION_GRACE_MS = 80;
const CLOSE_DURATION_MS = 180;
@@ -61,6 +83,15 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const holdIndicatorRef = useRef<HTMLDivElement | null>(null);
const pointerPositionRef = useRef({ x: -100, y: -100 });
const pointerFrameRef = useRef<number | null>(null);
const focusStateRef = useRef<AdvancedFocusState>({ active: null, viewportSize });
const focusListenersRef = useRef(new Set<() => void>());
const [focusStore] = useState<AdvancedFocusStore>(() => ({
getSnapshot: () => focusStateRef.current,
subscribe: (listener) => {
focusListenersRef.current.add(listener);
return () => focusListenersRef.current.delete(listener);
},
}));
const cancelHoverTimer = useCallback(() => {
if (hoverTimerRef.current === null) return;
@@ -315,55 +346,83 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
};
}, [active?.activation]);
const value = useMemo(
() => ({ active, viewportSize, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open, viewportSize],
);
useIsomorphicEffect(() => {
focusStateRef.current = { active, viewportSize };
for (const listener of focusListenersRef.current) listener();
}, [active, viewportSize]);
const actions = useMemo(() => ({ open, close, dismiss, hover, leave }), [close, dismiss, hover, leave, open]);
return (
<AdvancedFocusContext.Provider value={value}>
{children}
{isHoldPending && (
<Portal>
<div
ref={holdIndicatorRef}
data-advanced-focus-hold-indicator
className={classes.holdIndicator}
style={
{
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
} as CSSProperties
}
aria-hidden
>
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
</svg>
<span className={classes.holdIndicatorLabel}>&#8679;</span>
</div>
</Portal>
)}
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusContext.Provider>
<AdvancedFocusActionsContext.Provider value={actions}>
<AdvancedFocusStoreContext.Provider value={focusStore}>
{children}
{isHoldPending && (
<Portal>
<div
ref={holdIndicatorRef}
data-advanced-focus-hold-indicator
className={classes.holdIndicator}
style={
{
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
} as CSSProperties
}
aria-hidden
>
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
</svg>
<span className={classes.holdIndicatorLabel}>&#8679;</span>
</div>
</Portal>
)}
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusStoreContext.Provider>
</AdvancedFocusActionsContext.Provider>
);
};
const useAdvancedFocusStore = () => {
const store = useContext(AdvancedFocusStoreContext);
if (!store) throw new Error("Advanced focus state must be used within BoardAdvancedFocusProvider");
return store;
};
export const useAdvancedFocusActions = () => {
const actions = useContext(AdvancedFocusActionsContext);
if (!actions) throw new Error("Advanced focus actions must be used within BoardAdvancedFocusProvider");
return actions;
};
export const useAdvancedFocusItem = (itemId: string | null) => {
const store = useAdvancedFocusStore();
const getItemSnapshot = useCallback(() => {
const snapshot = store.getSnapshot();
if (itemId && snapshot.active?.itemId === itemId) return snapshot;
return INACTIVE_FOCUS_STATE;
}, [itemId, store]);
return useSyncExternalStore(store.subscribe, getItemSnapshot, () => INACTIVE_FOCUS_STATE);
};
export const useAdvancedFocus = () => {
const context = useContext(AdvancedFocusContext);
if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider");
return context;
const store = useAdvancedFocusStore();
const state = useSyncExternalStore(store.subscribe, store.getSnapshot, () => INACTIVE_FOCUS_STATE);
const actions = useAdvancedFocusActions();
return useMemo(() => ({ ...state, ...actions }), [actions, state]);
};
@@ -26,7 +26,7 @@ import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/
import type { SectionItem } from "~/app/[locale]/boards/_types";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { useAdvancedFocusActions, useAdvancedFocusItem } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
@@ -166,13 +166,14 @@ const LoadedBoardItemContent = ({
);
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const focusItemId = supportsAdvancedFocus ? item.id : null;
const { active: activeFocus, viewportSize } = useAdvancedFocusItem(focusItemId);
const { open, close, dismiss, hover, leave } = useAdvancedFocusActions();
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const widgetName = t(`widget.${item.kind}.name`);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
const isAdvanced = activeFocus !== null;
const advancedRect = activeFocus
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
@@ -339,13 +340,7 @@ const LoadedBoardItemContent = ({
return (
<>
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
<WidgetContextMenu
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
>
<WidgetContextMenu item={item} definition={definition} widgetRuntimeRef={widgetRuntimeRef} sourceRef={sourceRef}>
<Box
ref={sourceRef}
w="100%"
@@ -1,6 +1,6 @@
"use client";
import type { MutableRefObject, ReactNode, RefObject } from "react";
import type { ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
@@ -24,7 +24,7 @@ import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } fr
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context";
import { useAdvancedFocusActions } from "../advanced-focus/context";
import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
@@ -33,7 +33,6 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
children: ReactNode;
@@ -46,6 +45,44 @@ export const WidgetContextMenu = ({
sourceRef,
children,
}: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings();
const [menuOpened, setMenuOpened] = useState(false);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
{menuOpened && (
<WidgetContextMenuDropdown
item={item}
definition={definition}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
/>
)}
</Menu>
);
};
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children">;
const WidgetContextMenuDropdown = ({
item,
definition,
widgetRuntimeRef,
sourceRef,
}: WidgetContextMenuDropdownProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
@@ -64,9 +101,8 @@ export const WidgetContextMenu = ({
hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false));
const canOpenAdvancedFocus = supportsAdvancedFocus(definition);
const queryClient = useQueryClient();
const { open: openAdvancedFocus } = useAdvancedFocus();
const { open: openAdvancedFocus } = useAdvancedFocusActions();
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
const [menuOpened, setMenuOpened] = useState(false);
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
@@ -209,104 +245,90 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
</Menu.CheckboxItem>
))}
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
</Menu.CheckboxItem>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
>
{tMenu("settings")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
>
{tMenu("settings")}
</Menu.Item>
</Menu.Dropdown>
);
};
@@ -2,7 +2,8 @@
width: 100%;
max-width: 100%;
min-width: 0;
overflow: clip;
overflow-x: auto;
overflow-y: visible;
}
.viewport[data-board-hydrated="false"] {
@@ -8,6 +8,7 @@ import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
const MIN_READABLE_CANVAS_SCALE = 0.5;
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -15,7 +16,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return availableWidth / logicalWidth;
return Math.max(MIN_READABLE_CANVAS_SCALE, availableWidth / logicalWidth);
};
export const calculateBoardUiScale = (canvasScale: number) => {
@@ -33,8 +34,8 @@ interface ScaledBoardCanvasProps {
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
* fixed logical pixels. Narrow viewports stop scaling at a readable threshold
* and scroll the remaining logical surface horizontally.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
@@ -8,6 +8,7 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
touch-action: none;
}
.board-grid-entry:has([data-board-widget-header]):hover,
@@ -106,8 +106,11 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
export const BoardGridPortalRenderer = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const [isEditMode] = useEditMode();
const { announce, containers, integrations } = useBoardGridPortalHost();
if (!isEditMode) return null;
return (
<>
{Array.from(containers, ([id, container]) => {