revert: trim optional review fixes

This commit is contained in:
ajnart committed 2026-08-22 08:37:13 +02:00
1 parent 5522378ef2
commit dfa32c6add
13 files changed
+245 -638

No files matched your search

+2 -6
View File
@@ -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.
-4
View File
@@ -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:"
@@ -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<typeof fetchWithTrustedCertificatesAsync>[0],
init as Parameters<typeof fetchWithTrustedCertificatesAsync>[1],
)) as unknown as Response,
includeUsage: true,
transformRequestBody:
configuration.provider === "openrouter"
+103 -383
View File
@@ -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<Uint8Array<ArrayBuffer>> => {
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<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 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<Uint8Array>);
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<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;
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<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 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<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");
}
return Buffer.concat(chunks, totalBytes);
};
const reEncryptSecrets = (tempDb: InstanceType<typeof BetterSqlite3>, importedKeyHex: string) => {
@@ -502,84 +240,66 @@ export async function POST(req: Request) {
let tempDb: InstanceType<typeof BetterSqlite3> | 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 })
@@ -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<void>((resolve) => {
@@ -242,25 +243,13 @@ describe("POST /api/backup/import", () => {
const release = new Promise<void>((resolve) => {
releaseFirstRequest = resolve;
});
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();
},
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();
@@ -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", () => {
@@ -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;
@@ -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<string | null>(null);
const safeSource = getSafeAssistantMarkdownImageSource(src);
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;
if (!safeSource) return null;
return (
<AssistantImage
{...props}
source={displaySource}
source={safeSource}
alt={alt}
className={classes.markdownImage}
loadingLabel={t("loading")}
@@ -2309,8 +2287,6 @@ const AutoApprovalControl = () => {
width="min(19rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
trapFocus
returnFocus
>
<Popover.Target>
<ActionIcon
@@ -2318,11 +2294,6 @@ 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")}
@@ -2330,13 +2301,7 @@ const AutoApprovalControl = () => {
<IconShieldCheck size={17} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.stopPropagation();
setOpened(false);
}}
>
<Popover.Dropdown>
<Stack gap="sm">
<Box>
<Text size="sm" fw={700}>
@@ -2347,7 +2312,6 @@ const AutoApprovalControl = () => {
</Text>
</Box>
<Button
data-autofocus
fullWidth
variant={enabled ? "default" : "light"}
color={enabled ? "gray" : "green"}
+4 -5
View File
@@ -5,7 +5,6 @@ import { z } from "zod/v4";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { and, asc, desc, eq, handleTransactionsAsync, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import {
@@ -34,7 +33,7 @@ import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation
import { env } from "../env";
import { orderMessagesByParent } from "../assistant-message-order";
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
import { createTRPCRouter, isDemoMode, isDemoReadOnly, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { boardRouter, getHomeIdBoardAsync } from "./board";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
@@ -207,9 +206,9 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
}
const endpoint = `${configuration.baseUrl}${configuration.modelDiscoveryPath}`;
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
let response: Response;
try {
response = await fetchWithTrustedCertificatesAsync(endpoint, {
response = await fetch(endpoint, {
headers: getProviderHeaders(configuration),
signal: AbortSignal.timeout(15_000),
});
@@ -455,7 +454,7 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
export const assistantRouter = createTRPCRouter({
getAvailability: protectedProcedure.query(async ({ ctx }) => {
if (isDemoMode) {
return { enabled: !isDemoReadOnly };
return { enabled: true };
}
const configuration = await getConfigurationAsync(ctx.db);
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
+118 -98
View File
@@ -1,6 +1,6 @@
// @vitest-environment node
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { parse } from "superjson";
import type { Session } from "@homarr/auth";
@@ -34,14 +34,6 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
vi.mock("@homarr/core/infrastructure/db/env", () => ({
dbEnv: { DRIVER: "better-sqlite3" },
}));
const mocks = vi.hoisted(() => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: mocks.fetchWithTrustedCertificatesAsync,
}));
afterEach(() => {
mocks.fetchWithTrustedCertificatesAsync.mockReset();
});
const adminSession = {
user: {
@@ -177,23 +169,30 @@ describe("assistantRouter.updateConfiguration", () => {
.set({ encryptedHeaders: null })
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
Response.json({
data: [
{
id: "deepseek/deepseek-v4-pro",
name: "DeepSeek: DeepSeek V4 Pro",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "deepseek/deepseek-v4-pro",
name: "DeepSeek: DeepSeek V4 Pro",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
await caller.updateConfiguration({
enabled: true,
modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
});
try {
await caller.updateConfiguration({
enabled: true,
modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
});
} finally {
vi.unstubAllGlobals();
}
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.modelId).toBe("deepseek/deepseek-v4-pro");
@@ -222,24 +221,31 @@ describe("assistantRouter.updateConfiguration", () => {
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
Response.json({
data: [
{
id: "homarr/model",
name: "Homarr model",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "homarr/model",
name: "Homarr model",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
await caller.updateConfiguration({
enabled: true,
modelId: "homarr/model",
webSearchEnabled: true,
});
try {
await caller.updateConfiguration({
enabled: true,
modelId: "homarr/model",
webSearchEnabled: true,
});
} finally {
vi.unstubAllGlobals();
}
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.webSearchEnabled).toBe(true);
@@ -271,49 +277,56 @@ describe("assistant conversation features", () => {
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Example Model",
supported_parameters: ["tools"],
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
},
{
id: "example/no-tools",
name: "No tools",
supported_parameters: [],
architecture: { output_modalities: ["text"] },
},
{
id: "example/unnamed",
name: "",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Example Model",
supported_parameters: ["tools"],
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
},
{
id: "example/no-tools",
name: "No tools",
supported_parameters: [],
architecture: { output_modalities: ["text"] },
},
{
id: "example/unnamed",
name: "",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
const options = await caller.getRuntimeOptions();
try {
const options = await caller.getRuntimeOptions();
expect(options).toEqual({
provider: "openrouter",
defaultModelId: "example/model",
models: [
expect.objectContaining({
id: "example/model",
name: "Example Model",
inputModalities: ["text", "image"],
}),
expect.objectContaining({
id: "example/unnamed",
name: "example/unnamed",
}),
],
});
expect(JSON.stringify(options)).not.toContain("encrypted");
expect(options).toEqual({
provider: "openrouter",
defaultModelId: "example/model",
models: [
expect.objectContaining({
id: "example/model",
name: "Example Model",
inputModalities: ["text", "image"],
}),
expect.objectContaining({
id: "example/unnamed",
name: "example/unnamed",
}),
],
});
expect(JSON.stringify(options)).not.toContain("encrypted");
} finally {
vi.unstubAllGlobals();
}
});
test("returns permission-checked entities for composer mentions", async () => {
@@ -370,26 +383,33 @@ describe("assistant conversation features", () => {
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const thread = await caller.createThread();
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Default",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
{
id: "example/reasoning",
name: "Reasoning",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Default",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
{
id: "example/reasoning",
name: "Reasoning",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
try {
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
} finally {
vi.unstubAllGlobals();
}
const storedThread = await db.query.assistantThreads.findFirst({
where: eq(assistantThreads.id, thread.id),
+1 -2
View File
@@ -7510,8 +7510,7 @@
"image": {
"loading": "Loading image",
"failed": "This image could not be loaded.",
"retry": "Try image again",
"loadExternal": "Load external image from {host}"
"retry": "Try image again"
},
"quoteSelection": "Quote",
"dismissQuote": "Remove quote",
-65
View File
@@ -285,9 +285,6 @@ 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
@@ -333,9 +330,6 @@ 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
@@ -378,9 +372,6 @@ 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
@@ -672,9 +663,6 @@ 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
@@ -1190,9 +1178,6 @@ importers:
better-sqlite3:
specifier: 'catalog:'
version: 12.11.1
busboy:
specifier: 'catalog:'
version: 1.6.0
chroma-js:
specifier: 'catalog:'
version: 3.2.0
@@ -1283,9 +1268,6 @@ importers:
yaml:
specifier: 'catalog:'
version: 2.9.0
yauzl:
specifier: 'catalog:'
version: 3.4.0
zod:
specifier: 'catalog:'
version: 4.4.3
@@ -1299,9 +1281,6 @@ 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
@@ -1317,9 +1296,6 @@ 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
@@ -9236,9 +9212,6 @@ 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==}
@@ -9577,9 +9550,6 @@ 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'}
@@ -10393,10 +10363,6 @@ 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'}
@@ -14178,9 +14144,6 @@ 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==}
@@ -15787,10 +15750,6 @@ 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==}
@@ -16977,10 +16936,6 @@ 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'}
@@ -24355,10 +24310,6 @@ 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':
@@ -24745,10 +24696,6 @@ 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
@@ -25617,10 +25564,6 @@ snapshots:
dependencies:
run-applescript: 7.1.0
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
byline@5.0.0: {}
bytes@3.0.0: {}
@@ -29943,8 +29886,6 @@ snapshots:
pathe@2.0.3: {}
pend@1.2.0: {}
pg-cloudflare@1.4.0:
optional: true
@@ -32148,8 +32089,6 @@ snapshots:
duplexer2: 0.1.4
readable-stream: 2.3.8
streamsearch@1.1.0: {}
streamx@2.22.0:
dependencies:
fast-fifo: 1.3.2
@@ -33495,10 +33434,6 @@ 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
-4
View File
@@ -91,7 +91,6 @@ 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
@@ -109,7 +108,6 @@ 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
@@ -126,7 +124,6 @@ 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
@@ -216,7 +213,6 @@ 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