revert: trim optional review fixes
This commit is contained in:
1 parent
5522378ef2
commit
dfa32c6add
13 files changed
+245
-638
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-65
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user