fix: address CodeRabbit review comments

API / router (packages/api):
- open-webui: expose serializable procedures as MCP tools via .meta({ mcp })
  and gate knowledge/files/notes reads behind protectedProcedure + interact so
  anonymous board viewers can't enumerate private source content
- integration create: wrap integration + creator-permission + secret writes in
  a single cross-driver transaction so a partial failure can't leave an
  unusable integration
- integration test: scope the permission lookup to the created integration

Integration (packages/integrations):
- open-webui: log only safe metadata (not raw completion chunks) on parse
  failure, flush the final buffered SSE frame before treating the stream as
  truncated, and apply a default request timeout (streaming opts out)

Widget (packages/widgets):
- collapse knowledge rows by default to avoid an N+1 getKnowledgeFiles burst
- strip markdown images from assistant output to block untrusted fetches
- reconcile the selected model when the model list changes
- clear draft attachments when switching to a saved chat
- dedupe async note/chat attaches after the fetch resolves
- discard stale persist responses via a session token so a cleared chat can't
  be rebound to an old Open WebUI chat id
- release the mic stream on mid-start failures and mark the recording cancelled
  on unmount so partial audio isn't transcribed
This commit is contained in:
Thomas Camlong committed 2026-07-11 18:28:04 +02:00
1 parent e1ba193ea3
commit 951e707438
9 files changed
+253 -71

No files matched your search

@@ -345,32 +345,66 @@ export const integrationRouter = createTRPCRouter({
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: ctx.session.user.id,
});
// Auto-grant the creator "full" permission so they can immediately use
// and manage the integration they just created.
await ctx.db.insert(integrationUserPermissions).values({
const userId = ctx.session.user.id;
const secretValues = input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
userId: ctx.session.user.id,
permission: "full",
});
}));
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
// Create the integration, its creator permission and secrets atomically so
// a failure part-way through can't leave a committed integration the
// creator can neither use nor manage.
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (transaction) => {
await transaction.insert(schema.integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
});
// Auto-grant the creator "full" permission so they can immediately
// use and manage the integration they just created.
await transaction.insert(schema.integrationUserPermissions).values({
integrationId,
userId,
permission: "full",
});
if (secretValues.length >= 1) {
await transaction.insert(schema.integrationSecrets).values(secretValues);
}
});
},
handleSync: (db) => {
db.transaction((transaction) => {
transaction
.insert(integrations)
.values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
})
.run();
transaction
.insert(integrationUserPermissions)
.values({
integrationId,
userId,
permission: "full",
})
.run();
if (secretValues.length >= 1) {
transaction.insert(integrationSecrets).values(secretValues).run();
}
});
},
});
logger.info("Created integration", {
id: integrationId,
@@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { and, eq } from "@homarr/db";
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -321,7 +322,12 @@ describe("create should create a new integration", () => {
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst({
where: and(
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
eq(integrationUserPermissions.userId, defaultUserId),
),
});
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
+89 -30
View File
@@ -122,16 +122,30 @@ const groundMessagesAsync = async (
};
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getModels: publicProcedure
.meta({
mcp: {
enabled: true,
description: "List the available Open WebUI chat models for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
// Chat history exposes private user data, so we require "interact" access
// (not just "query") and verify the caller is the integration's creator.
getChats: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the saved Open WebUI chat conversations for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -140,42 +154,81 @@ export const openWebUiRouter = createTRPCRouter({
return data;
}),
getKnowledge: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
// Knowledge bases, files and notes expose private source content/metadata, so
// gate them like the interactive chat paths (protected + "interact" access,
// creator-only) instead of leaving them reachable by anonymous board viewers.
getKnowledge: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the Open WebUI knowledge bases available for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getFiles: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getFiles: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the files stored in Open WebUI for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getNotes: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getNotes: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the notes stored in Open WebUI for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getNote: publicProcedure
getNote: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "Get the full contents of a single Open WebUI note by id.",
},
})
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getKnowledgeFiles: publicProcedure
getKnowledgeFiles: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the files belonging to an Open WebUI knowledge base.",
},
})
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
knowledgeId: input.knowledgeId,
});
@@ -186,6 +239,12 @@ export const openWebUiRouter = createTRPCRouter({
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "Ingest a web page into Open WebUI so its content can ground later chat messages.",
},
})
.input(z.object({ url: z.string().url() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
@@ -35,6 +35,10 @@ import {
const logger = createLogger({ module: "open-webui-integration" });
// Default per-request timeout so a stalled upstream can't hang model/file/note/
// chat calls indefinitely. Streaming completions opt out (they are long-lived).
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
// The (undici) request options accepted by the shared fetch helper. Derived so
// the request helpers stay in sync with the underlying fetch types.
type RequestOptions = NonNullable<Parameters<typeof fetchWithTrustedCertificatesAsync>[1]>;
@@ -220,6 +224,10 @@ export class OpenWebUiIntegration extends Integration {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }),
// Long-lived stream: opt out of the default request timeout and rely on the
// caller's abort signal instead (the timeout path would also replace this
// signal, breaking cancellation).
timeout: undefined,
signal,
});
@@ -248,8 +256,13 @@ export class OpenWebUiIntegration extends Integration {
// Some providers end the stream with a finish_reason chunk and no
// explicit [DONE] sentinel; treat that as a clean completion too.
if (choice?.finish_reason) return true;
} catch {
logger.debug("Failed to parse Open WebUI completion chunk", { line: trimmed });
} catch (error) {
// The chunk can contain model output or grounded user data, so log only
// safe metadata rather than the raw payload.
logger.debug("Failed to parse Open WebUI completion chunk", {
error: error instanceof Error ? error.message : "unknown",
payloadLength: payload.length,
});
}
return false;
};
@@ -258,7 +271,15 @@ export class OpenWebUiIntegration extends Integration {
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
if (done) {
// The server may close after the final data: event without a trailing
// newline; parse whatever is left so a valid completion isn't treated
// as a truncated stream.
if (buffer.trim() !== "" && processLine(buffer)) {
completed = true;
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
@@ -301,6 +322,10 @@ export class OpenWebUiIntegration extends Integration {
init?: Omit<RequestOptions, "headers"> & { headers?: Record<string, string> },
) {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
// Apply a default timeout unless the caller opts out (e.g. streaming
// completions pass `timeout: undefined` to stay open indefinitely). A
// spread after this key lets an explicit caller value win.
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
...init,
headers: { ...this.getAuthHeaders(), ...init?.headers },
});
@@ -213,8 +213,10 @@ export function KnowledgeRow({
onToggleFile: (file: FileAttachment) => void;
emptyFilesLabel: string;
}) {
// Default to expanded so a base's documents are visible without an extra click.
const [expanded, setExpanded] = useState(true);
// Start collapsed so opening the picker doesn't fire getKnowledgeFiles for
// every knowledge base at once (an N+1 burst on large accounts). Files load
// only when a specific row is expanded.
const [expanded, setExpanded] = useState(false);
const { data: files = [], isLoading } = clientApi.widget.openWebUi.getKnowledgeFiles.useQuery(
{ integrationId, knowledgeId },
{ enabled: expanded && Boolean(integrationId), refetchOnWindowFocus: false, retry: false },
@@ -577,7 +579,12 @@ export function MessageBubble({
</Text>
) : (
<div className={classes.markdown}>
<ReactMarkdown skipHtml>{content}</ReactMarkdown>
{/* skipHtml only strips raw HTML; drop markdown images too so
untrusted model output can't trigger arbitrary browser fetches
on render. */}
<ReactMarkdown skipHtml components={{ img: () => null }}>
{content}
</ReactMarkdown>
</div>
)
) : null}
+18 -4
View File
@@ -112,10 +112,18 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
captureErrorMessage: t("widget.openWebUi.captureError"),
});
// Default to the first available model once the list loads.
// Keep the selection reconciled with the available models: default to the
// first one, and reset if the current id disappears (integration change or a
// model removed/renamed upstream) so sends don't use a stale id.
useEffect(() => {
if (model !== null || models.length === 0) return;
setModel(models[0]?.id ?? null);
if (models.length === 0) {
if (model !== null) setModel(null);
return;
}
if (model === null || !models.some((item) => item.id === model)) {
setModel(models[0]?.id ?? null);
}
}, [model, models]);
// Track the floating composer's height so messages can pad below it.
@@ -505,7 +513,13 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
key={chat.id}
active={chat.id === stream.activeChatId}
label={chat.title ?? chat.id}
onClick={() => stream.setSelectedChatId(chat.id)}
onClick={() => {
// Drop the draft's grounding so a loaded conversation
// isn't answered against the previous draft's attachments.
attachments.reset();
setError(null);
stream.setSelectedChatId(chat.id);
}}
/>
))
)}
@@ -128,7 +128,13 @@ export function useAttachments({ integrationId, onError, errorMessages }: UseAtt
}
try {
const full = await utils.widget.openWebUi.getNote.fetch({ integrationId, noteId: note.id });
setNoteItems((previous) => [...previous, { id: full.id, title: full.title, content: full.content }]);
// A double-click can start two fetches before state updates; dedupe on the
// resolved id so the note isn't attached (and grounded) twice.
setNoteItems((previous) =>
previous.some((item) => item.id === full.id)
? previous
: [...previous, { id: full.id, title: full.title, content: full.content }],
);
} catch {
onError(errorMessages.note);
}
@@ -150,7 +156,13 @@ export function useAttachments({ integrationId, onError, errorMessages }: UseAtt
const transcript = extractChatMessages(full.chat)
.map((message) => `${message.role}: ${message.content}`)
.join("\n");
setChatItems((previous) => [...previous, { id: chat.id, title: chat.title ?? chat.id, transcript }]);
// Dedupe on the resolved id so a double-click can't attach the same chat
// (and its transcript) twice.
setChatItems((previous) =>
previous.some((item) => item.id === chat.id)
? previous
: [...previous, { id: chat.id, title: chat.title ?? chat.id, transcript }],
);
} catch {
onError(errorMessages.chat);
}
@@ -45,6 +45,10 @@ export function useChatStream({
// Read inside the serialized persist loop so a burst of turns reuses the chat
// created by the first write instead of racing on the not-yet-committed state.
const activeChatIdRef = useRef<string | null>(null);
// Bumped whenever the conversation is replaced (history load / new chat) so a
// detached persist that started against an old conversation can be discarded
// instead of rebinding the cleared chat to a stale Open WebUI chat id.
const sessionRef = useRef(0);
const persistInFlightRef = useRef(false);
const pendingPersistRef = useRef<ChatMessage[] | null>(null);
@@ -60,6 +64,7 @@ export function useChatStream({
// Hydrate the conversation when a history chat finishes loading.
useEffect(() => {
if (!loadedChat) return;
sessionRef.current += 1;
const loadedMessages = extractChatMessages(loadedChat.chat);
setMessages(loadedMessages);
messagesRef.current = loadedMessages;
@@ -69,7 +74,7 @@ export function useChatStream({
onChatLoaded();
}, [loadedChat, setModel, onChatLoaded]);
const runPersist = async (finalMessages: ChatMessage[]) => {
const runPersist = async (finalMessages: ChatMessage[], session: number) => {
if (!integrationId || !model) return;
// Native history stores plain text; image data URLs are dropped on persist.
const title = finalMessages.find((message) => message.role === "user")?.content.slice(0, 80) ?? newChatTitle;
@@ -84,9 +89,13 @@ export function useChatStream({
await updateChat.mutateAsync({ integrationId, chatId, chat: payload });
} else {
const chat = await createChat.mutateAsync({ integrationId, ...payload });
// The conversation was cleared/replaced while this create was in flight;
// don't rebind the now-stale chat id onto the current conversation.
if (session !== sessionRef.current) return;
activeChatIdRef.current = chat.id;
setActiveChatId(chat.id);
}
if (session !== sessionRef.current) return;
await utils.widget.openWebUi.getChats.invalidate({ integrationId });
} catch {
// Persistence is best-effort; the conversation remains usable in-memory.
@@ -98,6 +107,7 @@ export function useChatStream({
// a single trailing save of the latest messages so turns don't stack
// concurrent POSTs (or create duplicate chats on the first save).
const persistChat = async (finalMessages: ChatMessage[]) => {
const session = sessionRef.current;
if (persistInFlightRef.current) {
pendingPersistRef.current = finalMessages;
return;
@@ -107,7 +117,7 @@ export function useChatStream({
let next: ChatMessage[] | null = finalMessages;
while (next) {
pendingPersistRef.current = null;
await runPersist(next);
await runPersist(next, session);
next = pendingPersistRef.current;
}
} finally {
@@ -207,6 +217,7 @@ export function useChatStream({
};
const reset = () => {
sessionRef.current += 1;
setMessages([]);
messagesRef.current = [];
setActiveChatId(null);
@@ -107,6 +107,12 @@ export function useVoiceRecording({
recordingTimerRef.current = window.setInterval(() => setRecordingSeconds((value) => value + 1), 1000);
setIsRecording(true);
} catch {
// getUserMedia may have succeeded before AudioContext/MediaRecorder setup
// threw; release the mic stream and context so it isn't left running.
releaseAudioResources();
mediaRecorderRef.current = null;
setAnalyser(null);
setIsRecording(false);
onError(micErrorMessage);
}
};
@@ -121,8 +127,16 @@ export function useVoiceRecording({
mediaRecorderRef.current?.stop();
};
// Tear down the microphone stream and timer on unmount.
useEffect(() => () => releaseAudioResources(), []);
// Tear down the microphone stream and timer on unmount. Mark the session
// cancelled first so a recorder "stop" fired while stopping the tracks doesn't
// upload/transcribe partial audio after the component is gone.
useEffect(
() => () => {
recordingCancelledRef.current = true;
releaseAudioResources();
},
[],
);
return {
isRecording,