Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions server/web-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { existsSync, mkdirSync, realpathSync } from "node:fs";
import { mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
Expand Down Expand Up @@ -416,12 +416,28 @@ const servesFrontend = !["api", "api-only", "backend", "backend-only"].includes(
const authToken = process.env.OPENGUI_AUTH_TOKEN?.trim() || "";
const allowedCorsOrigin = process.env.OPENGUI_CORS_ORIGIN?.trim() || "*";

const uploadDirectory = join(tmpdir(), "opengui-uploads");
mkdirSync(uploadDirectory, { recursive: true });

function addCanonicalRootVariants(root: string) {
const resolved = resolve(root);
try {
const real = realpathSync(resolved);
return real === resolved ? [resolved] : [resolved, real];
} catch {
return [resolved];
}
}

function parseAllowedRoots() {
const raw = process.env.OPENGUI_ALLOWED_ROOTS || homedir();
return raw
.split(",")
.map((entry) => resolve(entry.trim()))
.filter(Boolean);
return Array.from(
new Set(
[...raw.split(",").map((entry) => entry.trim()), uploadDirectory]
.filter(Boolean)
.flatMap(addCanonicalRootVariants),
),
);
}

const allowedRoots = parseAllowedRoots();
Expand Down Expand Up @@ -1575,8 +1591,7 @@ app.get("/api/fs/file", async (c) => {
? inputPath
: join(await resolveSafeDirectory(directory), inputPath);
const actual = await realpath(requestedPath);
const allowed = allowedRoots.some((root) => actual === root || actual.startsWith(`${root}/`));
if (!allowed) throw new Error("Path outside OPENGUI_ALLOWED_ROOTS");
if (!isWithinAllowedRoot(actual)) throw new Error("Path outside OPENGUI_ALLOWED_ROOTS");
const info = await stat(actual);
if (!info.isFile()) throw new Error("Path is not a file");
return new Response(await readFile(actual), {
Expand Down Expand Up @@ -1615,16 +1630,15 @@ app.post("/api/fs/upload", async (c) => {
if (file.size > uploadMaxFileBytes) throw new Error("File exceeds size limit");
}

const dir = join(tmpdir(), "opengui-uploads");
await mkdir(dir, { recursive: true });
await mkdir(uploadDirectory, { recursive: true });

const uploaded: string[] = [];
for (const file of files) {
const originalName = typeof file.name === "string" ? basename(file.name) : "file";
const extension = extname(originalName)
.replace(/[^a-zA-Z0-9.]/g, "")
.slice(0, 24);
const filePath = join(dir, `${randomUUID()}${extension}`);
const filePath = join(uploadDirectory, `${randomUUID()}${extension}`);
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
uploaded.push(filePath);
}
Expand Down
15 changes: 13 additions & 2 deletions src/components/ImageMentionPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ function ImageLightbox({
image,
serverUrl,
baseDirectory,
authToken,
onClose,
}: {
image: ImageMention;
serverUrl?: string | null;
baseDirectory?: string | null;
authToken?: string | null;
onClose: () => void;
}) {
React.useEffect(() => {
Expand All @@ -28,7 +30,7 @@ function ImageLightbox({
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);

const src = resolveAttachmentImageSrc(image.path, serverUrl, baseDirectory);
const src = resolveAttachmentImageSrc(image.path, serverUrl, baseDirectory, authToken);

return createPortal(
<div
Expand Down Expand Up @@ -64,6 +66,7 @@ export function ImageMentionToken({
image,
serverUrl,
baseDirectory,
authToken,
active,
onHover,
onOpen,
Expand All @@ -72,6 +75,7 @@ export function ImageMentionToken({
image: ImageMention;
serverUrl?: string | null;
baseDirectory?: string | null;
authToken?: string | null;
active?: boolean;
onHover?: (path: string | null) => void;
onOpen?: (image: ImageMention) => void;
Expand Down Expand Up @@ -99,6 +103,7 @@ export function ImageMentionToken({
image={localOpenImage}
serverUrl={serverUrl}
baseDirectory={baseDirectory}
authToken={authToken}
onClose={() => setLocalOpenImage(null)}
/>
)}
Expand All @@ -110,6 +115,7 @@ export function ImageMentionThumbnails({
images,
serverUrl,
baseDirectory,
authToken,
activePath,
onHover,
onOpen,
Expand All @@ -118,6 +124,7 @@ export function ImageMentionThumbnails({
images: ImageMention[];
serverUrl?: string | null;
baseDirectory?: string | null;
authToken?: string | null;
activePath?: string | null;
onHover?: (path: string | null) => void;
onOpen?: (image: ImageMention) => void;
Expand All @@ -130,7 +137,7 @@ export function ImageMentionThumbnails({
<>
<div className={cn("flex flex-wrap gap-2", className)}>
{images.map((image) => {
const src = resolveAttachmentImageSrc(image.path, serverUrl, baseDirectory);
const src = resolveAttachmentImageSrc(image.path, serverUrl, baseDirectory, authToken);
const active = activePath === image.path;
return (
<button
Expand All @@ -157,6 +164,7 @@ export function ImageMentionThumbnails({
image={localOpenImage}
serverUrl={serverUrl}
baseDirectory={baseDirectory}
authToken={authToken}
onClose={() => setLocalOpenImage(null)}
/>
)}
Expand All @@ -168,11 +176,13 @@ export function ImageMentionLightbox({
image,
serverUrl,
baseDirectory,
authToken,
onClose,
}: {
image: ImageMention | null;
serverUrl?: string | null;
baseDirectory?: string | null;
authToken?: string | null;
onClose: () => void;
}) {
if (!image) return null;
Expand All @@ -181,6 +191,7 @@ export function ImageMentionLightbox({
image={image}
serverUrl={serverUrl}
baseDirectory={baseDirectory}
authToken={authToken}
onClose={onClose}
/>
);
Expand Down
19 changes: 15 additions & 4 deletions src/components/PromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,25 @@ export const PromptBox = React.forwardRef<HTMLTextAreaElement, PromptBoxProps>(
clearSessionDraft,
});

const promptImageServerUrl =
window.electronAPI?.kind === "electron" && activeWorkspace?.isLocal
? null
: activeWorkspace?.serverUrl;
const browserOrigin =
window.location.protocol === "http:" || window.location.protocol === "https:"
? window.location.origin
: null;
const localBackendUrl =
window.electronAPI?.kind === "electron" ? window.electronAPI.backendUrl : null;
const promptImageServerUrl = activeWorkspace?.isLocal
? (localBackendUrl ?? activeWorkspace.serverUrl ?? browserOrigin)
: (activeWorkspace?.serverUrl ?? null);
const promptImageAuthToken =
activeWorkspace?.isLocal && window.electronAPI?.kind === "electron"
? window.electronAPI.backendToken
: (activeWorkspace?.authToken ?? null);
const promptFiles = usePromptFiles({
disabled: isDisabled,
value,
setValue,
serverUrl: promptImageServerUrl,
authToken: promptImageAuthToken,
textareaRef: internalTextareaRef,
});
const promptImages = usePromptImages(value);
Expand Down Expand Up @@ -303,6 +313,7 @@ export const PromptBox = React.forwardRef<HTMLTextAreaElement, PromptBoxProps>(
images={promptImages}
serverUrl={promptImageServerUrl}
baseDirectory={projectDir ?? activeTargetDirectory ?? null}
authToken={promptImageAuthToken}
/>
<textarea
ref={internalTextareaRef}
Expand Down
4 changes: 4 additions & 0 deletions src/components/PromptImageMentions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ export function PromptImageMentions({
images,
serverUrl,
baseDirectory,
authToken,
}: {
images: ImageMention[];
serverUrl: string | null | undefined;
baseDirectory: string | null;
authToken?: string | null;
}) {
const [activeImagePath, setActiveImagePath] = React.useState<string | null>(null);
const [openImage, setOpenImage] = React.useState<ImageMention | null>(null);
Expand All @@ -38,6 +40,7 @@ export function PromptImageMentions({
images={images}
serverUrl={serverUrl}
baseDirectory={baseDirectory}
authToken={authToken}
activePath={activeImagePath}
onHover={setActiveImagePath}
onOpen={setOpenImage}
Expand All @@ -48,6 +51,7 @@ export function PromptImageMentions({
image={openImage}
serverUrl={serverUrl}
baseDirectory={baseDirectory}
authToken={authToken}
onClose={() => setOpenImage(null)}
/>
</>
Expand Down
14 changes: 10 additions & 4 deletions src/components/message-list/FilePartView.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { FilePart } from "@opencode-ai/sdk/v2/client";
import { useConnectionState } from "@/hooks/use-agent-state";
import { resolveAttachmentImageSrc } from "@/lib/attachment-src";

export function FilePartView({ part }: { part: FilePart }) {
const { workspaceServerUrl } = useConnectionState();
export function FilePartView({
part,
imageServerUrl,
imageAuthToken,
}: {
part: FilePart;
imageServerUrl?: string | null;
imageAuthToken?: string | null;
}) {
const isImage = (part.mime ?? "").toLowerCase().startsWith("image/");
const src = resolveAttachmentImageSrc(part.url, workspaceServerUrl);
const src = resolveAttachmentImageSrc(part.url, imageServerUrl, null, imageAuthToken);

if (isImage) {
return (
Expand Down
20 changes: 18 additions & 2 deletions src/components/message-list/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,21 @@ export const MessageBubble = memo(function MessageBubble({
onToggleToolPart?: (partId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const { isLocalWorkspace, workspaceServerUrl } = useConnectionState();
const { activeWorkspace, isLocalWorkspace, workspaceServerUrl } = useConnectionState();
const { sessions, activeTargetDirectory } = useSessionState();
const imageServerUrl = isLocalWorkspace ? null : workspaceServerUrl;
const browserOrigin =
window.location.protocol === "http:" || window.location.protocol === "https:"
? window.location.origin
: null;
const localBackendUrl =
window.electronAPI?.kind === "electron" ? window.electronAPI.backendUrl : null;
const imageServerUrl = isLocalWorkspace
? (localBackendUrl ?? activeWorkspace?.serverUrl ?? browserOrigin)
: workspaceServerUrl;
const imageAuthToken =
isLocalWorkspace && window.electronAPI?.kind === "electron"
? window.electronAPI.backendToken
: (activeWorkspace?.authToken ?? null);
const { info, parts } = entry;
const imageBaseDirectory =
sessions.find((session) => session.id === info.sessionID)?._projectDir ??
Expand Down Expand Up @@ -123,6 +135,7 @@ export const MessageBubble = memo(function MessageBubble({
onOpen={setOpenImage}
serverUrl={imageServerUrl}
baseDirectory={imageBaseDirectory}
authToken={imageAuthToken}
className="mb-2"
/>
)}
Expand All @@ -146,6 +159,8 @@ export const MessageBubble = memo(function MessageBubble({
onImageHover={setActiveImagePath}
onImageOpen={setOpenImage}
imageBaseDirectory={imageBaseDirectory}
imageServerUrl={imageServerUrl}
imageAuthToken={imageAuthToken}
/>
))}
</div>
Expand Down Expand Up @@ -178,6 +193,7 @@ export const MessageBubble = memo(function MessageBubble({
image={openImage}
serverUrl={imageServerUrl}
baseDirectory={imageBaseDirectory}
authToken={imageAuthToken}
onClose={() => setOpenImage(null)}
/>
{info.role === "assistant" && turnFooter && (
Expand Down
12 changes: 11 additions & 1 deletion src/components/message-list/PartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const PartView = memo(function PartView({
onImageHover,
onImageOpen,
imageBaseDirectory,
imageServerUrl,
imageAuthToken,
}: {
part: Part;
isUser?: boolean;
Expand All @@ -26,6 +28,8 @@ export const PartView = memo(function PartView({
onImageHover?: (path: string | null) => void;
onImageOpen?: (image: ImageMention) => void;
imageBaseDirectory?: string | null;
imageServerUrl?: string | null;
imageAuthToken?: string | null;
}) {
switch (part.type) {
case "text":
Expand All @@ -37,10 +41,14 @@ export const PartView = memo(function PartView({
onImageHover={onImageHover}
onImageOpen={onImageOpen}
imageBaseDirectory={imageBaseDirectory}
imageServerUrl={imageServerUrl}
imageAuthToken={imageAuthToken}
/>
);
case "file":
return <FilePartView part={part} />;
return (
<FilePartView part={part} imageServerUrl={imageServerUrl} imageAuthToken={imageAuthToken} />
);
case "reasoning":
return <ReasoningPartView part={part} isLastReasoning={part.id === lastReasoningPartId} />;
case "tool":
Expand All @@ -49,6 +57,8 @@ export const PartView = memo(function PartView({
part={part}
expandedToolParts={expandedToolParts}
onToggleToolPart={onToggleToolPart}
imageServerUrl={imageServerUrl}
imageAuthToken={imageAuthToken}
/>
);
case "step-start":
Expand Down
9 changes: 6 additions & 3 deletions src/components/message-list/TextPartView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { TextPart } from "@opencode-ai/sdk/v2/client";
import { ImageMentionToken, type ImageMention } from "@/components/ImageMentionPreview";
import { MarkdownRenderer } from "@/components/MarkdownRenderer";
import { useConnectionState } from "@/hooks/use-agent-state";
import { splitImageMentions } from "@/lib/image-mentions";

export function TextPartView({
Expand All @@ -11,15 +10,18 @@ export function TextPartView({
onImageHover,
onImageOpen,
imageBaseDirectory,
imageServerUrl,
imageAuthToken,
}: {
part: TextPart;
isUser?: boolean;
activeImagePath?: string | null;
onImageHover?: (path: string | null) => void;
onImageOpen?: (image: ImageMention) => void;
imageBaseDirectory?: string | null;
imageServerUrl?: string | null;
imageAuthToken?: string | null;
}) {
const { isLocalWorkspace, workspaceServerUrl } = useConnectionState();
if (!part.text) return null;

if (isUser) {
Expand All @@ -34,8 +36,9 @@ export function TextPartView({
key={`${segment.path}-${index}`}
token={segment.token}
image={{ path: segment.path, filename: segment.filename }}
serverUrl={isLocalWorkspace ? null : workspaceServerUrl}
serverUrl={imageServerUrl}
baseDirectory={imageBaseDirectory}
authToken={imageAuthToken}
active={activeImagePath === segment.path}
onHover={onImageHover}
onOpen={onImageOpen}
Expand Down
Loading
Loading