diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 225910e38426e1a65acafbb9283225484120be12..71a2c769ef4ee98ee771c2344f40df419aaa7b0b 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; -import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; +import { AssetAccessError, 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"; @@ -11,8 +11,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { HttpServerResponse } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -25,6 +27,8 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile } from "./MediaFile.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { githubMediaResponse } from "./GitHubMediaFetch.ts"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); @@ -47,6 +51,52 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("loads private media immediately after login and reuses the found credential", () => { + let lookups = 0; + const authorizations: Array = []; + return Effect.gen(function* () { + const asset = { + url: "https://raw.githubusercontent.com/owner/repo/main/shot.png", + cwd: "/repo", + expiresAt: Number.MAX_SAFE_INTEGER, + }; + expect((yield* githubMediaResponse(asset, {})).status).toBe(404); + expect((yield* githubMediaResponse(asset, {})).status).toBe(200); + expect((yield* githubMediaResponse(asset, {})).status).toBe(200); + expect(lookups).toBe(2); + expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); + }).pipe( + Effect.provide( + Layer.mock(GitHubCli.GitHubCli)({ + execute: () => + Effect.sync(() => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: ++lookups === 1 ? "" : "signed-in", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + })), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + authorizations.push(request.headers.authorization); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: request.headers.authorization ? 200 : 404, + headers: { "content-type": "image/png" }, + }), + ), + ); + }), + ), + Effect.scoped, + ); + }); + it.effect("issues exact URLs for media and browser documents outside the workspace", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -228,7 +278,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 +441,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 +457,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); @@ -1062,4 +1112,60 @@ describe("AssetAccess", () => { expect(error.cause).toBe(resolutionCause); }).pipe(Effect.provide(testLayer)), ); + + it.effect("serves GitHub-hosted pull request media through the repository's credential", () => + Effect.gen(function* () { + const resolve = (relativeUrl: string) => { + const suffix = relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + return resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + }; + const issue = (url: string) => + issueAssetUrl({ resource: { _tag: "github-media", cwd: "/repo", url } }); + + const attachment = yield* issue( + "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", + ); + expect(attachment.relativeUrl.endsWith("/1a1842fb-6383-492f-873c-57aa0033fa6c")).toBe(true); + expect(yield* resolve(attachment.relativeUrl)).toEqual({ + kind: "github-media", + url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", + cwd: "/repo", + // The signed URL's own expiry, which is how long a client may keep the bytes. + expiresAt: attachment.expiresAt, + }); + + // A `blob` link addresses the page; only the raw host answers a credential with bytes. + const committed = yield* issue("https://github.com/owner/repo/blob/main/docs/shot.png"); + expect(yield* resolve(committed.relativeUrl)).toMatchObject({ + url: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png", + }); + + // The pre-`user-attachments` form, Git LFS bytes, and a name no `decodeURIComponent` + // accepts all arrive from real bodies. + const legacy = yield* issue("https://github.com/owner/repo/assets/45952064/1a1842fb"); + expect(yield* resolve(legacy.relativeUrl)).toMatchObject({ + url: "https://github.com/owner/repo/assets/45952064/1a1842fb", + }); + const lfs = yield* issue("https://media.githubusercontent.com/media/owner/repo/main/a.mp4"); + expect(yield* resolve(lfs.relativeUrl)).toMatchObject({ + url: "https://media.githubusercontent.com/media/owner/repo/main/a.mp4", + }); + const awkward = yield* issue("https://raw.githubusercontent.com/o/r/main/100%.png"); + expect(awkward.relativeUrl.endsWith("/100%25.png")).toBe(true); + + for (const url of [ + "https://example.com/shot.png", + "https://example.com/shot.png?token=private-media-token", + "http://github.com/user-attachments/assets/1a1842fb", + "https://github.com/owner/repo/pull/1", + "https://github.com/owner/repo/blob/main/", + ]) { + const error = yield* issue(url).pipe(Effect.flip); + expect(error._tag).toBe("AssetGitHubMediaUrlValidationError"); + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error); + expect(encoded).not.toContain(url); + } + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 700aeacb19a5acbd8e1501060ba29a27c13004ff..e1f6633fbecae1e29bb36772479b014d75cb2f4f 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,6 +1,7 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, + AssetGitHubMediaUrlValidationError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -27,6 +28,7 @@ import { readImageDimensions, type ImageDimensions, } from "@t3tools/shared/imageDimensions"; +import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -135,6 +137,14 @@ const AssetClaimsSchema = Schema.Union([ app: ToolActivityNativeAppReference, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("github-media"), + /** Already narrowed to a GitHub media host at mint time; the signature is what keeps it there. */ + url: Schema.String, + cwd: Schema.String, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -142,14 +152,23 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { - readonly kind: "file"; - readonly path: string; - readonly download?: boolean; - readonly fileName?: string; - readonly mimeType?: string; - readonly file?: OpenMediaFile; -}; +export type ResolvedAsset = + | { + readonly kind: "file"; + readonly path: string; + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; + readonly file?: OpenMediaFile; + } + | { + readonly kind: "github-media"; + readonly url: string; + readonly cwd: string; + /** When the signed URL that granted this stops working, which bounds how long a client + may keep the bytes it fetched with it. */ + readonly expiresAt: number; + }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -657,6 +676,21 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = "native-app-icon.png"; break; } + case "github-media": { + const fetchUrl = githubMediaFetchUrl(input.resource.url); + if (fetchUrl === null) { + return yield* new AssetGitHubMediaUrlValidationError({}); + } + claims = { + version: 1, + kind: "github-media", + url: fetchUrl, + cwd: input.resource.cwd, + expiresAt, + }; + fileName = githubMediaFileName(fetchUrl); + break; + } } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -757,6 +791,15 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( : null; } + if (claims.kind === "github-media") { + return { + kind: "github-media", + url: claims.url, + cwd: claims.cwd, + expiresAt: claims.expiresAt, + } satisfies ResolvedAsset; + } + if (claims.kind === "native-app-icon") { const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver; const iconPath = yield* nativeAppIconResolver.resolve(claims.app); diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc2bb8c3e6de40631b347bc82742a0f43a26d1c7 --- /dev/null +++ b/apps/server/src/assets/GitHubMediaFetch.ts @@ -0,0 +1,196 @@ +import Mime from "@effect/platform-node/Mime"; +import { githubMediaFileName } from "@t3tools/shared/githubMedia"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpServerResponse, + type HttpClientResponse, +} from "effect/unstable/http"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; + +/** + * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned + * object stores GitHub hands assets off to — authorizes with its own signature, and some of + * them reject a request that also carries a bearer token. + */ +const CREDENTIALED_HOSTS = new Set([ + "github.com", + "www.github.com", + "raw.githubusercontent.com", + "media.githubusercontent.com", +]); +const isCredentialedHost = (url: string) => { + try { + return CREDENTIALED_HOSTS.has(new URL(url).hostname.toLowerCase()); + } catch { + return false; + } +}; + +/** GitHub answers an asset request with a 302 to a signed object URL that needs no credential. */ +const MAX_REDIRECTS = 3; +/** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */ +const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; +const TOKEN_CACHE_TTL_MS = 5 * 60_000; +const TOKEN_CACHE_MAX_ENTRIES = 32; +/** Passed through so a seek in a long video costs one upstream range request, not a full download. */ +const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; +const FORWARDED_RESPONSE_HEADERS = [ + "content-type", + "content-length", + "content-range", + "accept-ranges", + "etag", + "last-modified", +] as const; +/** A pull request embeds pictures and recordings. Anything else is not served from our origin. */ +const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i; +const SVG_CONTENT_TYPE = "image/svg+xml"; +// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a +// body cannot run script against this origin. +const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +/** + * A media request per image and one per video seek, each of which would otherwise spawn `gh`. + * The token is what `gh auth token` would print again on the next call, and it is held no longer + * than a signed asset URL lives. + */ +const tokenCache = new Map(); + +const githubToken = Effect.fn("GitHubMediaFetch.githubToken")(function* (input: { + readonly cwd: string; + readonly host: string; +}) { + // `gh` stores a token per host, not per repository, so the directory it runs in is not part + // of the answer and must not fragment the cache a client could otherwise churn. This route + // pins no credential; if it ever does, the pin belongs in this key. + const key = input.host; + const now = yield* Clock.currentTimeMillis; + const cached = tokenCache.get(key); + if (cached !== undefined && now - cached.at < TOKEN_CACHE_TTL_MS) return cached.token; + const github = yield* GitHubCli.GitHubCli; + // No credential is a normal state: a public asset still loads, and a private one fails the way + // it does in a browser that is not signed in. + const token = yield* github + .execute({ + cwd: input.cwd, + args: ["auth", "token", "--hostname", input.host], + env: { GH_DEBUG: "" }, + }) + .pipe( + Effect.map((output) => output.stdout.trim()), + Effect.orElseSucceed(() => ""), + ); + // A login or recovered CLI failure must take effect on the next media request. + if (token.length === 0) return null; + if (tokenCache.size >= TOKEN_CACHE_MAX_ENTRIES) { + tokenCache.delete(tokenCache.keys().next().value!); + } + const redacted = Redacted.make(token); + tokenCache.set(key, { at: now, token: redacted }); + return redacted; +}); + +/** + * Follows GitHub's redirect to the signed object itself, and never carries the credential off + * GitHub: the object URL authorizes with its own signature, and the store it lives in has no + * business seeing a token. + */ +const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedirects")(function* ( + url: string, + headers: Record, + token: Redacted.Redacted | null, +) { + const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); + let target = url; + for (let hop = 0; ; hop += 1) { + // The credential rides only on a request to GitHub itself. A redirect leads to a signed + // object URL that authorizes on its own, and the store it lives in has no business seeing + // a token — deciding that from the target, not from the hop count, is what makes it so. + const authorization = + token !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(token)}` : null; + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + HttpClientRequest.get(target).pipe( + HttpClientRequest.setHeaders({ + ...headers, + // The bytes are streamed straight through, so never let an encoding layer in. + "accept-encoding": "identity", + ...(authorization === null ? {} : { authorization }), + }), + ), + ) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT)); + const location = response.headers.location; + if (response.status < 300 || response.status >= 400) return response; + // A chain this long is not GitHub answering with bytes, and its body is not the media. + if (!location || hop >= MAX_REDIRECTS) return null; + const next = new URL(location, target); + if (next.protocol !== "https:") return null; + target = next.toString(); + } +}); + +/** + * Serves media a pull request body points at on GitHub through the `gh` credential, which is the + * only thing that distinguishes a readable private attachment from a 404. + */ +export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaResponse")(function* ( + asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number }, + requestHeaders: Record, +) { + // Both media hosts are served by github.com's account, which is the host `gh` stores it under. + const token = yield* githubToken({ cwd: asset.cwd, host: "github.com" }); + const forwarded: Record = {}; + for (const name of FORWARDED_REQUEST_HEADERS) { + const value = requestHeaders[name]; + if (value !== undefined) forwarded[name] = value; + } + const response = yield* fetchFollowingRedirects(asset.url, forwarded, token); + // An upload GitHub hosts never changes under its URL, so the only thing a cached copy must + // not outlive is the signed URL that granted it — which is the same bound the URL itself has. + const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000); + const headers: Record = { + "cache-control": + remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store", + "x-content-type-options": "nosniff", + }; + if (response === null) return HttpServerResponse.empty({ status: 502, headers }); + // An upstream refusal is the client's answer, not this server's fault; only a broken hop is. + // It carries none of the upstream entity headers: a `content-length` with no body behind it + // holds the connection open until the browser gives up on it. + if (response.status >= 400) { + return HttpServerResponse.empty({ + status: response.status >= 500 ? 502 : response.status, + headers, + }); + } + // Only pictures and recordings leave this origin, and never on GitHub's word alone: the raw + // host labels every committed binary `application/octet-stream`, so the name decides those. + const upstreamType = + response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType) + ? upstreamType + : (Mime.getType(githubMediaFileName(asset.url))?.toLowerCase() ?? ""); + if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) { + return HttpServerResponse.empty({ status: 415, headers }); + } + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = response.headers[name]; + if (value !== undefined) headers[name] = value; + } + headers["content-type"] = contentType; + if (contentType === SVG_CONTENT_TYPE) { + headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY; + } + return HttpServerResponse.stream(response.stream, { + status: response.status, + headers, + contentType, + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c564201785522aaafd6fad4edf3aca1478493975..e5ff8961f5fa9d361314888694f2ac5b9977196f 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -29,6 +29,7 @@ import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, @@ -391,6 +392,19 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } + if (asset.kind === "github-media") { + return yield* githubMediaResponse(asset, request.headers).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }), + ), + Effect.orElseSucceed(() => + HttpServerResponse.empty({ + status: 502, + headers: { "cache-control": "private, no-store", "x-content-type-options": "nosniff" }, + }), + ), + ); + } return yield* assetFileResponse( asset, request.method === "GET" ? request.headers.range : undefined, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 10dad8244845288e8e25a5b9c1af0e41aab3070d..2a16bbfe00749b8ef5d4a1aff79156572c628948 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -169,6 +169,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; @@ -1210,7 +1211,7 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), - Layer.provide(VcsProcess.layer), + Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))), Layer.provide(layerConfig), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0aeecb3f7b86505df0754896e442b9a72d50564..51c6cb1b7df89f300a0c17817991d939505c983b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -485,8 +485,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), + // `GitHubCli` is the registry's own instance, exposed because the asset route fetches + // GitHub-hosted pull request media with the repository's credential. Layer.provideMerge( - Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), + Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive, GitHubCli.layer), ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 16a77c957085bee6097e72dd15d8714732bbe8bb..f31e62162b52e3e1a01ce5aaba6133881ca43faf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3083,6 +3083,8 @@ const makeWsRpcLayer = ( if ( input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || + // GitHub media names the repository it authenticates through itself. + input.resource._tag === "github-media" || (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) ) { return yield* issueAssetUrl({ resource: input.resource }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b0209754b0d0b0216e05c65790ea5f27c8d91841..4e80768b315afc05f3400dd86f4057fce3956888 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -34,6 +34,7 @@ import type { ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; +import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -220,6 +221,9 @@ interface ChatMarkdownProps { extraRemarkPlugins?: NonNullable; /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */ renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; + /** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's + uploads need; without it those images and videos load unauthenticated and 404. */ + githubMedia?: boolean | undefined; /** Levels added to each markdown heading in the accessibility tree so the text nests under the heading that introduces it, such as a chat message's author. Rendered tags and their styling are unchanged. */ @@ -1573,7 +1577,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly environmentId: EnvironmentId; readonly resource: Extract< AssetResource, - { readonly _tag: "attachment" | "workspace-file" | "media-file" } + { readonly _tag: "attachment" | "workspace-file" | "media-file" | "github-media" } >; readonly kind?: "image" | "video"; readonly alt: string; @@ -1584,6 +1588,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props /** Caps the box height in rem while keeping the image's ratio; 30 by default. */ readonly maxHeightRem?: number | undefined; readonly style?: CSSProperties | undefined; + readonly className?: string | undefined; + /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ + readonly imageProps?: + | Omit, "src" | "alt" | "className" | "style"> + | undefined; + /** Where the media also lives on the web, for the failure state's escape hatch. */ + readonly originalUrl?: string | undefined; + /** The workspace media frame, on by default; off for media that keeps the author's own box. */ + readonly framed?: boolean | undefined; + /** Loaded instead of the failure state when no URL can be signed, such as against a server + too old to know this resource. Only safe when the client can reach it directly. */ + readonly fallbackSrc?: string | undefined; readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { @@ -1596,9 +1612,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props : resource._tag === "workspace-file" && props.workspaceRoot ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` : undefined; - const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; - const relativePath = reference?.relativePath; - const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const reference = path + ? mediaFileReference(path, props.workspaceRoot) + : props.originalUrl + ? mediaUrlReference(props.originalUrl) + : undefined; + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; + const src = + assetUrl._tag === "Success" + ? assetUrl.url + (props.srcFragment ?? "") + : fallbackSrc === undefined + ? null + : fallbackSrc + (props.srcFragment ?? ""); // The server reads the pixel size from the file header, so the slot can be // the image's final box instead of a 16:9 guess. An authored size wins; a // caller's height cap shrinks the box while keeping the ratio. @@ -1615,9 +1641,11 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props kind: props.kind ?? "image", name: props.alt || (props.kind ?? "image"), src, - asset: { environmentId: props.environmentId, resource }, + ...(fallbackSrc === undefined + ? { asset: { environmentId: props.environmentId, resource } } + : {}), ...(reference ? { reference } : {}), - ...(relativePath && resource._tag !== "attachment" + ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") ? { onOpenFile: () => useRightPanelStore @@ -1634,9 +1662,10 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props return ( ); @@ -2226,6 +2260,7 @@ function useChatMarkdownState({ onImageExpand, renderContextReference, headingLevelOffset = 0, + githubMedia = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); @@ -2623,6 +2658,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, + githubMedia, renderContextReference, headingLevelOffset, imageBaseDir, @@ -2652,6 +2688,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, + githubMedia, renderContextReference, headingLevelOffset, imageBaseDir, @@ -3081,9 +3118,15 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { - const { expandMedia, cwd, imageBaseDir, threadRef, renderContextReference } = use( - ChatMarkdownRendererContext, - ); + const { + expandMedia, + cwd, + environmentId, + githubMedia, + imageBaseDir, + threadRef, + renderContextReference, + } = use(ChatMarkdownRendererContext); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null; if (contextReference) { @@ -3109,6 +3152,39 @@ const CHAT_MARKDOWN_COMPONENTS = { const authoredSizeStyle = authoredImageSizeStyle(width, height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; + const directUri = imageSource._tag === "Direct" ? imageSource.uri : null; + const githubMediaUrl = + directUri === null ? null : githubMediaFetchUrl(resolveProtocolRelativeMediaUrl(directUri)); + if ( + githubMedia && + cwd !== undefined && + environmentId !== null && + directUri !== null && + githubMediaUrl !== null + ) { + return ( + + ); + } if (imageSource._tag === "Direct") { const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); const originalUrl = diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 9a4b63044ad2d47ddac1ce35a956c59c515552cc..6ffb54569ef3e9c74323cea9215dc35a29bab396 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,8 +1,11 @@ import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images"; +import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; +import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { createContext, useContext, useMemo } from "react"; import type { Options as ReactMarkdownOptions } from "react-markdown"; +import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { cn } from "~/lib/utils"; import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore"; @@ -15,6 +18,46 @@ export const PullRequestMarkdownContext = createContext<{ threadRef: ScopedThreadRef | null; } | null>(null); +/** + * A video GitHub hosts for the repository. It plays through a signed asset URL the server + * fetches with the repository's GitHub credential, which is what a private repository's + * uploads need; the URL is re-signed on retry, so a stale one recovers without a reload. + */ +function PullRequestGitHubVideo({ + environmentId, + cwd, + url, + fetchUrl, +}: { + environmentId: EnvironmentId; + cwd: string; + /** What the body authored, which is what "Open original" should reach. */ + url: string; + /** The canonical GitHub media URL: a `blob` link addresses the page, not the bytes. */ + fetchUrl: string; +}) { + const resource = useMemo( + () => ({ _tag: "github-media", cwd, url: fetchUrl }), + [cwd, fetchUrl], + ); + const assetUrl = useAssetUrlState(environmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(environmentId, resource); + // A server too old to sign this resource, or one with no route to GitHub, still leaves a + // public repository's video playing exactly as it did before. + const src = + assetUrl._tag === "Success" ? assetUrl.url : assetUrl._tag === "Failure" ? fetchUrl : null; + return ( + + ); +} + /** Renders PR uploads inline, with retry and an original link when video playback fails. */ export function PullRequestMarkdown({ text, @@ -57,6 +100,19 @@ export function PullRequestMarkdown({ pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} extraRemarkPlugins={extraRemarkPlugins} + githubMedia + /> + ); + } + const githubMediaUrl = segment.media === "video" ? githubMediaFetchUrl(segment.url) : null; + if (githubMediaUrl !== null) { + return ( + ); } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 6447aab22585a422d7e390bf4f1ae6011153f242..cd5d1757301aeaf69c3b44b64a0d74c728deb645 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -50,6 +50,13 @@ export const AssetResource = Schema.Union([ Schema.TaggedStruct("native-app-icon", { app: ToolActivityNativeAppReference, }), + // An upload a pull request body points at on GitHub. A private repository serves these only + // to a request that carries a credential, which the client has none of, so the server fetches + // them with the `gh` credential the repository at `cwd` authenticates with. + Schema.TaggedStruct("github-media", { + cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + url: TrimmedNonEmptyString.check(Schema.isMaxLength(2048)), + }), ]); export type AssetResource = typeof AssetResource.Type; @@ -285,6 +292,15 @@ export class AssetSigningKeyLoadError extends Schema.TaggedError()( + "AssetGitHubMediaUrlValidationError", + {}, +) { + override get message(): string { + return "Only media hosted by GitHub can be fetched with a GitHub credential."; + } +} + export const AssetAccessError = Schema.Union([ AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, @@ -298,6 +314,7 @@ export const AssetAccessError = Schema.Union([ AssetProjectFaviconResolutionError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, + AssetGitHubMediaUrlValidationError, AssetSigningKeyLoadError, ]); export type AssetAccessError = typeof AssetAccessError.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 579a0cd3b1135be77eae0bf75015b9bac2cad479..2a69848937657f2f7a75db1a2d8975a7afedab2d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -43,6 +43,10 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, + "./githubMedia": { + "types": "./src/githubMedia.ts", + "import": "./src/githubMedia.ts" + }, "./sourceControl": { "types": "./src/sourceControl.ts", "import": "./src/sourceControl.ts" diff --git a/packages/shared/src/githubMedia.ts b/packages/shared/src/githubMedia.ts new file mode 100644 index 0000000000000000000000000000000000000000..4226db8250350f76c52b0a5eb932722eb200042d --- /dev/null +++ b/packages/shared/src/githubMedia.ts @@ -0,0 +1,70 @@ +/** + * Media a pull request body points at on GitHub's own hosts. In a private repository GitHub + * answers an unauthenticated request for one with 404 — a screenshot dropped into a description + * becomes `github.com/user-attachments/assets/`, and a file committed alongside the code + * becomes a `raw.githubusercontent.com` path — so the renderer, which carries no GitHub session, + * draws a broken image where the reviewer expects the evidence. The server can fetch these with + * the `gh` credential and hand the bytes back. + * + * Only hosts where a credential is what decides the answer belong here. `objects.githubusercontent.com` + * and the `private-user-images` links GitHub's own HTML carries are already signed and load on their + * own, and a token does nothing for them once that signature expires. + */ + +const RAW_HOST = "raw.githubusercontent.com"; +/** Git LFS pointers resolve here, which is where an LFS-tracked screenshot's bytes live. */ +const LFS_HOST = "media.githubusercontent.com"; +const ATTACHMENT_PATH_PATTERN = /^\/user-attachments\/assets\/[\w-]+$/u; +/** What GitHub wrote into a body before `user-attachments`; older descriptions still carry it. */ +const LEGACY_ATTACHMENT_PATH_PATTERN = /^\/[^/]+\/[^/]+\/assets\/\d+\/[\w-]+$/u; +/** `blob` and `raw` both address file bytes; `raw` is the one the token is honoured on. */ +const REPOSITORY_FILE_PATTERN = /^\/([^/]+)\/([^/]+)\/(?:raw|blob)\/(.*[^/])$/u; + +/** Port, userinfo, and fragment say nothing about which bytes GitHub will serve. */ +function canonicalUrl(host: string, url: URL): string { + return `https://${host}${url.pathname}${url.search}`; +} + +/** + * The URL to fetch with a GitHub credential for `source`, or null when the source is not + * GitHub-hosted media — those keep loading directly, exactly as they do today. + */ +export function githubMediaFetchUrl(source: string): string | null { + let url: URL; + try { + url = new URL(source); + } catch { + return null; + } + if (url.protocol !== "https:") return null; + const host = url.hostname.toLowerCase(); + if (host === RAW_HOST || host === LFS_HOST) return canonicalUrl(host, url); + if (host !== "github.com" && host !== "www.github.com") return null; + if ( + ATTACHMENT_PATH_PATTERN.test(url.pathname) || + LEGACY_ATTACHMENT_PATH_PATTERN.test(url.pathname) + ) { + return `https://github.com${url.pathname}`; + } + const repositoryFile = REPOSITORY_FILE_PATTERN.exec(url.pathname); + // `?raw=true` is how the web UI spells "the bytes, not the page"; the raw host needs no query. + return repositoryFile + ? `https://${RAW_HOST}/${repositoryFile[1]}/${repositoryFile[2]}/${repositoryFile[3]}` + : null; +} + +/** + * Last path segment, for the signed URL's display name. A percent sequence GitHub accepts but + * `decodeURIComponent` rejects is left encoded rather than failing the whole asset. + */ +export function githubMediaFileName(fetchUrl: string): string { + const segment = new URL(fetchUrl).pathname.split("/").pop() ?? ""; + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + decoded = segment; + } + const name = decoded.replace(/[\p{Cc}\\/]/gu, ""); + return name.length > 0 ? name : "github-media"; +}