diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index b83b8684432c..a47c927e090f 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -6,6 +6,7 @@ import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -47,6 +48,30 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("signs a Gitea image URL for one asset and expires it", () => + Effect.gen(function* () { + const url = "https://forge.test/attachments/82cde921-c3fc-4c01-85b8-edf737cdaa83"; + const result = yield* issueAssetUrl({ + resource: { _tag: "source-control-image", provider: "gitea", url }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + expect(yield* resolveAsset(token, "image")).toEqual({ kind: "source-control-image", url }); + expect(yield* resolveAsset(token, "other-image")).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, "image")).toBeNull(); + yield* TestClock.adjust("2 hours"); + expect(yield* resolveAsset(token, "image")).toBeNull(); + }).pipe( + Effect.provide( + Layer.merge( + testLayer, + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_GITEA_BASE_URL: "https://forge.test" } }), + ), + ), + ), + ), + ); it.effect("issues exact URLs for media and browser documents outside the workspace", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -228,7 +253,7 @@ describe("AssetAccess", () => { suffix.slice(0, separator), suffix.slice(separator + 1), ); - if (!asset) throw new Error("Expected a resolved media file"); + if (asset?.kind !== "file") throw new Error("Expected a resolved media file"); yield* fs.rename(filePath, savedPath); yield* fs.symlink(secretPath, filePath); @@ -391,7 +416,7 @@ describe("AssetAccess", () => { const name = suffix.slice(separator + 1); yield* fs.writeFileString(filePath, "in-place edit"); const edited = yield* resolveAsset(token, name); - if (!edited) throw new Error("Expected the edited media file"); + if (edited?.kind !== "file") throw new Error("Expected the edited media file"); const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited)); expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit"); @@ -407,7 +432,7 @@ describe("AssetAccess", () => { renewedSuffix.slice(0, renewedSeparator), renewedSuffix.slice(renewedSeparator + 1), ); - if (!renewedAsset) throw new Error("Expected the replacement media file"); + if (renewedAsset?.kind !== "file") throw new Error("Expected the replacement media file"); const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset)); expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement"); yield* fs.remove(filePath); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 956c4ac44211..7431b2955a93 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -6,6 +6,7 @@ import { AssetProjectFaviconNotFoundError, AssetProjectFaviconResolutionError, AssetSigningKeyLoadError, + AssetSourceControlImageError, AssetWorkspaceAssetInspectionError, AssetWorkspaceAssetNotFoundError, AssetWorkspaceContextNotFoundError, @@ -49,6 +50,7 @@ import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +import * as GiteaAttachment from "../sourceControl/GiteaAttachment.ts"; import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -79,6 +81,13 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ ]); const AssetClaimsSchema = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("source-control-image"), + provider: Schema.Literal("gitea"), + url: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("workspace-file"), @@ -272,6 +281,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let imageDimensions: ImageDimensions | null = null; switch (input.resource._tag) { + case "source-control-image": { + const url = yield* GiteaAttachment.validateUrl(input.resource.url).pipe( + Effect.mapError( + () => + new AssetSourceControlImageError({ + resource: input.resource, + detail: "The image is not an attachment on the configured Gitea server.", + }), + ), + ); + claims = { version: 1, kind: "source-control-image", provider: "gitea", url, expiresAt }; + fileName = "image"; + break; + } case "media-file": { let requestedPath = input.resource.path; if (!path.isAbsolute(requestedPath)) { @@ -624,6 +647,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + if (claims.kind === "source-control-image") { + if (relativePath !== "image") return null; + return { kind: "source-control-image" as const, url: claims.url }; + } + if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 290b73b48514..21cc81acd85e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -30,6 +30,7 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import * as GiteaAttachment from "./sourceControl/GiteaAttachment.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, @@ -388,6 +389,11 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } + if (asset.kind === "source-control-image") { + return yield* GiteaAttachment.imageResponse(asset.url).pipe( + Effect.orElseSucceed(() => HttpServerResponse.text("Image unavailable", { status: 502 })), + ); + } return yield* assetFileResponse( asset, request.method === "GET" ? request.headers.range : undefined, diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 49955b555a71..6e52ffd2cc1c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1887,6 +1887,76 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect.each(["", " \n\t "])( + "submits inline change requests without a user-written summary: %j", + (body) => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.make; + yield* api.submitReview({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + verdict: "request-changes", + body, + comments: [ + { + body: "Keep this check.", + path: "src/a.ts", + position: { kind: "deleted", oldLine: 4 }, + }, + { + body: "Handle the empty result.", + path: "src/b.ts", + position: { kind: "added", newLine: 9 }, + }, + ], + }); + + expect(mockedRequest).toHaveBeenCalledTimes(1); + expect(callAt(0)).toMatchObject({ + method: "POST", + path: "/repos/acme/web/pulls/7/reviews", + }); + expect(decodeJson(callAt(0).body ?? "{}")).toEqual({ + event: "REQUEST_CHANGES", + body: "See inline comments.", + comments: [ + { body: "Keep this check.", path: "src/a.ts", old_position: 4 }, + { body: "Handle the empty result.", path: "src/b.ts", new_position: 9 }, + ], + }); + }), + ); + + it.effect.each(["approve", "comment"] as const)( + "keeps the summary empty for an inline %s review", + (verdict) => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.make; + yield* api.submitReview({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + verdict, + body: "", + comments: [ + { + body: "Worth following up separately.", + path: "src/a.ts", + position: { kind: "added", newLine: 4 }, + }, + ], + }); + + expect(decodeJson(callAt(0).body ?? "{}")).toMatchObject({ + event: verdict === "approve" ? "APPROVED" : "COMMENT", + body: "", + }); + }), + ); + it.effect("maps native warning statuses to failing checks", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce( diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index ef79ae1360da..8319f9f72e3e 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -2199,7 +2199,13 @@ export const make = Effect.gen(function* () { : input.verdict === "request-changes" ? "REQUEST_CHANGES" : "COMMENT", - body: input.body, + // Gitea requires a summary for change requests even when inline comments are present. + body: + input.verdict === "request-changes" && + input.body.trim().length === 0 && + input.comments.length > 0 + ? "See inline comments." + : input.body, comments: input.comments.map((comment) => ({ body: comment.body, path: comment.path, diff --git a/apps/server/src/sourceControl/GiteaAttachment.test.ts b/apps/server/src/sourceControl/GiteaAttachment.test.ts new file mode 100644 index 000000000000..070d247875c9 --- /dev/null +++ b/apps/server/src/sourceControl/GiteaAttachment.test.ts @@ -0,0 +1,122 @@ +import { expect, it, vi } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpServerResponse, +} from "effect/unstable/http"; + +import * as GiteaAttachment from "./GiteaAttachment.ts"; + +const url = "https://forge.test/gitea/attachments/82cde921-c3fc-4c01-85b8-edf737cdaa83"; +const config = ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_GITEA_BASE_URL: "https://forge.test/gitea", + T3CODE_GITEA_TOKEN: "private-token", + }, + }), +); + +function httpLayer(response: Response) { + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const requestInit = yield* Effect.serviceOption(FetchHttpClient.RequestInit); + expect(Option.getOrNull(requestInit)?.redirect).toBe("manual"); + return HttpClientResponse.fromWeb(request, response); + }), + ); + return { + execute, + layer: Layer.merge(config, Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + }; +} + +it.effect("streams a private Gitea image using server credentials", () => { + const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const { execute, layer } = httpLayer( + new Response(bytes, { headers: { "Content-Type": "image/png" } }), + ); + return Effect.gen(function* () { + const response = yield* GiteaAttachment.imageResponse(url); + expect(response.status).toBe(200); + expect(execute.mock.calls[0]?.[0]).toMatchObject({ + url, + headers: { authorization: "token private-token" }, + }); + const web = HttpServerResponse.toWeb(response); + expect(new Uint8Array(yield* Effect.promise(() => web.arrayBuffer()))).toEqual(bytes); + expect(web.headers.get("content-type")).toBe("image/png"); + expect(web.headers.get("authorization")).toBeNull(); + expect(web.headers.get("content-security-policy")).toContain("sandbox"); + }).pipe(Effect.scoped, Effect.provide(layer)); +}); + +it.effect("fails a stalled image body and cancels the upstream reader", () => { + const cancel = vi.fn(); + const { layer } = httpLayer( + new Response( + new ReadableStream({ + start: (controller) => controller.enqueue(new Uint8Array([137, 80, 78, 71])), + cancel, + }), + { headers: { "Content-Type": "image/png" } }, + ), + ); + return Effect.gen(function* () { + const response = yield* GiteaAttachment.imageResponse(url); + expect(response.status).toBe(200); + if (response.body._tag !== "Stream") return yield* Effect.die("Expected an image stream"); + const receivedChunk = yield* Deferred.make(); + const read = yield* response.body.stream.pipe( + Stream.tap(() => Deferred.succeed(receivedChunk, undefined)), + Stream.runDrain, + Effect.flip, + Effect.forkScoped, + ); + yield* Deferred.await(receivedChunk); + yield* TestClock.adjust("30 seconds"); + expect(yield* Fiber.join(read)).toMatchObject({ + _tag: "GiteaAttachmentError", + detail: "Gitea image body timed out.", + }); + expect(cancel).toHaveBeenCalledTimes(1); + }).pipe(Effect.scoped, Effect.provide(layer)); +}); + +it.effect.each([ + "https://elsewhere.test/gitea/attachments/82cde921-c3fc-4c01-85b8-edf737cdaa83", + "https://forge.test/api/v1/user", +])("does not send credentials to %s", (source) => { + const { execute, layer } = httpLayer(new Response()); + return Effect.gen(function* () { + const error = yield* GiteaAttachment.imageResponse(source).pipe(Effect.flip); + expect(error._tag).toBe("GiteaAttachmentError"); + expect(execute).not.toHaveBeenCalled(); + }).pipe(Effect.scoped, Effect.provide(layer)); +}); + +it.effect.each([ + { status: 302, headers: { Location: "https://elsewhere.test/image.png" }, expected: 502 }, + { status: 404, headers: {}, expected: 404 }, + { status: 200, headers: { "Content-Type": "text/html" }, expected: 415 }, +])( + "rejects an upstream response with status $status and headers $headers", + ({ status, headers, expected }) => { + const { execute, layer } = httpLayer(new Response("", { status, headers })); + return Effect.gen(function* () { + const response = yield* GiteaAttachment.imageResponse(url); + expect(response.status).toBe(expected); + expect(execute).toHaveBeenCalledTimes(1); + }).pipe(Effect.scoped, Effect.provide(layer)); + }, +); diff --git a/apps/server/src/sourceControl/GiteaAttachment.ts b/apps/server/src/sourceControl/GiteaAttachment.ts new file mode 100644 index 000000000000..a7149ebba788 --- /dev/null +++ b/apps/server/src/sourceControl/GiteaAttachment.ts @@ -0,0 +1,78 @@ +import { resolveGiteaAttachmentUrl } from "@t3tools/shared/giteaAttachments"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpServerResponse, +} from "effect/unstable/http"; + +export class GiteaAttachmentError extends Schema.TaggedError()( + "GiteaAttachmentError", + { detail: Schema.String }, +) {} + +export const validateUrl = Effect.fn("GiteaAttachment.validateUrl")(function* (url: string) { + const configured = yield* Config.string("T3CODE_GITEA_BASE_URL").pipe(Config.option); + const resolved = Option.isSome(configured) + ? resolveGiteaAttachmentUrl(url, configured.value) + : null; + if (resolved === null) { + return yield* new GiteaAttachmentError({ + detail: "The image is not an attachment on the configured Gitea server.", + }); + } + return resolved; +}); + +/** Fetches private images without exposing the host's token in a client URL. */ +export const imageResponse = Effect.fn("GiteaAttachment.imageResponse")(function* (url: string) { + const resolved = yield* validateUrl(url); + const configuredToken = yield* Config.redacted("T3CODE_GITEA_TOKEN").pipe(Config.option); + const token = Option.isSome(configuredToken) ? Redacted.value(configuredToken.value).trim() : ""; + if (token.length === 0) { + return yield* new GiteaAttachmentError({ detail: "Gitea authentication is not configured." }); + } + const client = yield* HttpClient.HttpClient; + const response = yield* HttpClient.withScope(client) + .execute( + HttpClientRequest.get(resolved).pipe( + HttpClientRequest.setHeader("Authorization", `token ${token}`), + HttpClientRequest.setHeader("Accept", "image/*"), + ), + ) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.timeout("30 seconds"), + ); + // Redirects must not forward the server's credentials to another host. + if (response.status !== 200) { + return HttpServerResponse.text("Image unavailable", { + status: response.status === 404 ? 404 : 502, + }); + } + const contentType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase(); + if (!contentType || !/^image\/(?:png|jpeg|gif|webp|avif|bmp|svg\+xml)$/u.test(contentType)) { + return HttpServerResponse.text("Unsupported image type", { status: 415 }); + } + const body = response.stream.pipe( + Stream.timeoutOrElse({ + duration: "30 seconds", + orElse: () => + Stream.fail(new GiteaAttachmentError({ detail: "Gitea image body timed out." })), + }), + ); + return HttpServerResponse.stream(body, { + contentType, + headers: { + "Cache-Control": "private, max-age=300", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'", + }, + }); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f7812b5becf9..144ff8c42cbb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2491,6 +2491,7 @@ const makeWsRpcLayer = ( const path = yield* Path.Path; // An absolute media path can be linked from a thread on another environment. if ( + input.resource._tag === "source-control-image" || input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 21d0d20cc4af..fef08b3cc02a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -178,6 +178,11 @@ import { import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; +type MarkdownImageAssetResource = Extract< + AssetResource, + { readonly _tag: "attachment" | "workspace-file" | "media-file" | "source-control-image" } +>; + interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -199,6 +204,8 @@ interface ChatMarkdownProps { /** Directory that anchors relative links and images; defaults to `cwd`. Set to the file's own directory when rendering a markdown file. */ imageBaseDir?: string | undefined; + /** Host-backed images, such as private uploads in a pull request description. */ + resolveImageAsset?: ((source: string) => MarkdownImageAssetResource | null) | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; } @@ -1519,10 +1526,7 @@ function ChatMarkdownVideo(props: { /** Environment-hosted media loads through an exact-file signed asset URL. */ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract< - AssetResource, - { readonly _tag: "attachment" | "workspace-file" | "media-file" } - >; + readonly resource: MarkdownImageAssetResource; readonly kind?: "image" | "video"; readonly alt: string; readonly copyMarkdown?: string; @@ -1565,7 +1569,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props src, asset: { environmentId: props.environmentId, resource }, ...(reference ? { reference } : {}), - ...(relativePath && resource._tag !== "attachment" + ...(relativePath && (resource._tag === "workspace-file" || resource._tag === "media-file") ? { onOpenFile: () => useRightPanelStore @@ -2157,6 +2161,7 @@ function useChatMarkdownState({ skills = EMPTY_MARKDOWN_SKILLS, onUseArtifactTemplate, imageBaseDir, + resolveImageAsset, onImageExpand, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); @@ -2551,6 +2556,7 @@ function useChatMarkdownState({ expandMedia, fileLinkChip, imageBaseDir, + resolveImageAsset, inlineCodeFileLinkMetaByText, isStreaming, linkTargetPreference, @@ -2578,6 +2584,7 @@ function useChatMarkdownState({ expandMedia, fileLinkChip, imageBaseDir, + resolveImageAsset, inlineCodeFileLinkMetaByText, isStreaming, linkTargetPreference, @@ -2969,7 +2976,9 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { - const { expandMedia, cwd, imageBaseDir, threadRef } = use(ChatMarkdownRendererContext); + const { expandMedia, cwd, imageBaseDir, threadRef, environmentId, resolveImageAsset } = use( + ChatMarkdownRendererContext, + ); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; @@ -2984,6 +2993,20 @@ const CHAT_MARKDOWN_COMPONENTS = { const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); const { className, style: _style, width, height, ...imageProps } = props; const authoredSizeStyle = authoredImageSizeStyle(width, height); + const imageAsset = resolveImageAsset?.(classifiedSrc); + if (imageAsset && environmentId) { + return ( + + ); + } const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; if (imageSource._tag === "Direct") { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 307953577aa7..e565492a931c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -704,7 +704,7 @@ export function PullRequestDetailPanel({ }, [detail?.autoMergeMethod, pullRequestKey]); const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); const markdownContext = useMemo( - () => ({ repositoryUrl: detail?.provider === "github" ? repositoryUrl : null, threadRef }), + () => ({ repositoryUrl, provider: detail?.provider ?? null, threadRef }), [detail?.provider, repositoryUrl, threadRef], ); const authorProfileUrl = diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index edfc1c19f104..603ae891dc5c 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,6 +1,6 @@ import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { createContext, useContext, useMemo } from "react"; +import type { EnvironmentId, ScopedThreadRef, SourceControlProviderKind } from "@t3tools/contracts"; +import { createContext, useCallback, useContext, useMemo } from "react"; import type { Options as ReactMarkdownOptions } from "react-markdown"; import { cn } from "~/lib/utils"; @@ -8,10 +8,15 @@ import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore"; import ChatMarkdown from "../ChatMarkdown"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; -import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { + remarkPullRequestAutolinks, + resolvePullRequestImageAsset, + splitPullRequestBody, +} from "./pullRequestMarkdown.logic"; export const PullRequestMarkdownContext = createContext<{ repositoryUrl: string | null; + provider: SourceControlProviderKind | null; threadRef: ScopedThreadRef | null; } | null>(null); @@ -33,10 +38,18 @@ export function PullRequestMarkdown({ const segments = splitPullRequestBody(text); const context = useContext(PullRequestMarkdownContext); const repositoryUrl = context?.repositoryUrl; + const provider = context?.provider; + const resolveImageAsset = useCallback( + (source: string) => resolvePullRequestImageAsset(source, provider, repositoryUrl), + [provider, repositoryUrl], + ); const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; const extraRemarkPlugins = useMemo>( - () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []), - [repositoryUrl], + () => + provider === "github" && repositoryUrl + ? [[remarkPullRequestAutolinks, { repositoryUrl }]] + : [], + [provider, repositoryUrl], ); return (
@@ -51,6 +64,7 @@ export function PullRequestMarkdown({ pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} extraRemarkPlugins={extraRemarkPlugins} + resolveImageAsset={resolveImageAsset} /> ); } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts index 5779909a6a6f..0c7bc7191978 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts @@ -1,6 +1,36 @@ import { describe, expect, it } from "vite-plus/test"; -import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { resolvePullRequestImageAsset, splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +describe("pull request image assets", () => { + const source = "attachments/82cde921-c3fc-4c01-85b8-edf737cdaa83"; + + it("routes a Gitea upload through the environment's signed image assets", () => { + expect( + resolvePullRequestImageAsset(source, "gitea", "https://forge.test/rtvision/monorepo"), + ).toEqual({ + _tag: "source-control-image", + provider: "gitea", + url: `https://forge.test/${source}`, + }); + }); + + it("retains a Gitea installation's subpath", () => { + expect( + resolvePullRequestImageAsset(source, "gitea", "https://forge.test/gitea/acme/web/")?.url, + ).toBe(`https://forge.test/gitea/${source}`); + }); + + it("keeps workspace and other providers' images on their existing paths", () => { + expect( + resolvePullRequestImageAsset("src/image.png", "gitea", "https://forge.test/acme/web"), + ).toBeNull(); + expect( + resolvePullRequestImageAsset(source, "github", "https://github.com/acme/web"), + ).toBeNull(); + expect(resolvePullRequestImageAsset(source, "gitea", null)).toBeNull(); + }); +}); describe("pull request body segmentation", () => { it("keeps a plain body as a single markdown run", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index 79ae60e928ca..ac9c09cae6cb 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,9 +1,27 @@ +import type { AssetResource, SourceControlProviderKind } from "@t3tools/contracts"; +import { resolveGiteaAttachmentUrl } from "@t3tools/shared/giteaAttachments"; + import { findAndReplaceText, type MarkdownNode, type TextMatch, } from "~/vendor/mdast-find-and-replace"; +export function resolvePullRequestImageAsset( + source: string, + provider: SourceControlProviderKind | null | undefined, + repositoryUrl: string | null | undefined, +): Extract | null { + if (provider !== "gitea" || !repositoryUrl) return null; + try { + const baseUrl = new URL("../../", `${repositoryUrl.replace(/\/+$/u, "")}/`).href; + const url = resolveGiteaAttachmentUrl(source, baseUrl); + return url ? { _tag: "source-control-image", provider: "gitea", url } : null; + } catch { + return null; + } +} + /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = | { readonly id: string; readonly kind: "markdown"; readonly text: string } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 26638832ca05..83420c496ba6 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -12,6 +12,10 @@ import { ToolActivityNativeAppReference } from "./providerRuntime.ts"; const ASSET_PATH_MAX_LENGTH = 1024; export const AssetResource = Schema.Union([ + Schema.TaggedStruct("source-control-image", { + provider: Schema.Literal("gitea"), + url: TrimmedNonEmptyString.check(Schema.isMaxLength(2048)), + }), Schema.TaggedStruct("workspace-file", { threadId: ThreadId, path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), @@ -277,7 +281,17 @@ export class AssetSigningKeyLoadError extends Schema.TaggedError()( + "AssetSourceControlImageError", + { resource: AssetResource, detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + export const AssetAccessError = Schema.Union([ + AssetSourceControlImageError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, AssetWorkspaceRootNormalizationError, diff --git a/packages/shared/package.json b/packages/shared/package.json index 57150ca61ea9..68547acc2d6e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./giteaAttachments": { + "types": "./src/giteaAttachments.ts", + "import": "./src/giteaAttachments.ts" + }, "./releasePackage": { "types": "./src/releasePackage.ts", "import": "./src/releasePackage.ts" diff --git a/packages/shared/src/giteaAttachments.test.ts b/packages/shared/src/giteaAttachments.test.ts new file mode 100644 index 000000000000..f613065ba31c --- /dev/null +++ b/packages/shared/src/giteaAttachments.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveGiteaAttachmentUrl } from "./giteaAttachments.ts"; + +const id = "82cde921-c3fc-4c01-85b8-edf737cdaa83"; + +describe("Gitea attachment URLs", () => { + it.each([ + `attachments/${id}`, + `./attachments/${id}`, + `/attachments/${id}`, + `https://forge.test/attachments/${id}`, + ])("resolves %s against the host web root", (source) => + expect(resolveGiteaAttachmentUrl(source, "https://forge.test")).toBe( + `https://forge.test/attachments/${id}`, + ), + ); + + it.each([ + `attachments/${id}`, + `/gitea/attachments/${id}`, + `https://forge.test/gitea/attachments/${id}`, + ])("preserves the configured proxy subpath for %s", (source) => + expect(resolveGiteaAttachmentUrl(source, "https://forge.test/gitea/")).toBe( + `https://forge.test/gitea/attachments/${id}`, + ), + ); + + it.each([ + `https://elsewhere.test/attachments/${id}`, + `//elsewhere.test/attachments/${id}`, + `https://user:password@forge.test/attachments/${id}`, + `attachments/${id}?token=secret`, + "attachments/../../api/v1/user", + "attachments/not-a-uuid", + "src/screenshot.png", + "file:///attachments/example.png", + ])("rejects non-attachment destinations: %s", (source) => { + expect(resolveGiteaAttachmentUrl(source, "https://forge.test")).toBeNull(); + }); + + it("rejects an attachment outside the configured subpath", () => { + expect(resolveGiteaAttachmentUrl(`/attachments/${id}`, "https://forge.test/gitea")).toBeNull(); + }); +}); diff --git a/packages/shared/src/giteaAttachments.ts b/packages/shared/src/giteaAttachments.ts new file mode 100644 index 000000000000..f34e3ad8a2bb --- /dev/null +++ b/packages/shared/src/giteaAttachments.ts @@ -0,0 +1,27 @@ +const ATTACHMENT_PATH = /^attachments\/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu; + +/** Resolves Gitea uploads against its web root, including installations under a subpath. */ +export function resolveGiteaAttachmentUrl(source: string, baseUrl: string): string | null { + try { + const base = new URL(`${baseUrl.replace(/\/+$/u, "")}/`); + const url = new URL(source, base); + if ( + !["http:", "https:"].includes(base.protocol) || + base.username !== "" || + base.password !== "" || + base.search !== "" || + base.hash !== "" || + url.origin !== base.origin || + url.username !== "" || + url.password !== "" || + url.search !== "" || + !url.pathname.startsWith(base.pathname) || + !ATTACHMENT_PATH.test(url.pathname.slice(base.pathname.length)) + ) + return null; + url.hash = ""; + return url.href; + } catch { + return null; + } +}