authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 18:08:41-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 18:08:41-03:00
log32e8b2584556c0c55ce0d1d8f72b506cb771d60d
treebd5ea61160ad8c1b7c4e9f13a3dab60e305242a9
parent384de0858d1f59ca2ad7c660544a5290fd9138cb
signature Signed by PGP key B5690EEEBB952194

fix(web): show private repository media in pull request tabs (#11706)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

12 files changed, 615 insertions(+), 28 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+111-5
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
5import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";5import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
6import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";6import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
7import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
8import * as Crypto from "effect/Crypto";8import * as Crypto from "effect/Crypto";
...@@ -11,8 +11,10 @@ import * as FileSystem from "effect/FileSystem";...@@ -11,8 +11,10 @@ import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";12import * as Path from "effect/Path";
13import * as PlatformError from "effect/PlatformError";13import * as PlatformError from "effect/PlatformError";
14import * as Schema from "effect/Schema";
14import * as TestClock from "effect/testing/TestClock";15import * as TestClock from "effect/testing/TestClock";
15import { HttpServerResponse } from "effect/unstable/http";16import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/unstable/http";
17import { ChildProcessSpawner } from "effect/unstable/process";
16import { vi } from "vite-plus/test";18import { vi } from "vite-plus/test";
1719
18import * as ServerSecretStore from "../auth/ServerSecretStore.ts";20import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
...@@ -25,6 +27,8 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t...@@ -25,6 +27,8 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t
25import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";27import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
26import { openMediaFile } from "./MediaFile.ts";28import { openMediaFile } from "./MediaFile.ts";
27import { symlinksSupported } from "@t3tools/shared/testing/symlinks";29import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
30import * as GitHubCli from "../sourceControl/GitHubCli.ts";
31import { githubMediaResponse } from "./GitHubMediaFetch.ts";
2832
29vi.mock("node:fs/promises", async (importOriginal) => {33vi.mock("node:fs/promises", async (importOriginal) => {
30 const actual = await importOriginal<typeof NodeFSP>();34 const actual = await importOriginal<typeof NodeFSP>();
...@@ -47,6 +51,52 @@ const testLayer = Layer.mergeAll(...@@ -47,6 +51,52 @@ const testLayer = Layer.mergeAll(
47).pipe(Layer.provideMerge(NodeServices.layer));51).pipe(Layer.provideMerge(NodeServices.layer));
4852
49describe("AssetAccess", () => {53describe("AssetAccess", () => {
54 it.effect("loads private media immediately after login and reuses the found credential", () => {
55 let lookups = 0;
56 const authorizations: Array<string | undefined> = [];
57 return Effect.gen(function* () {
58 const asset = {
59 url: "https://raw.githubusercontent.com/owner/repo/main/shot.png",
60 cwd: "/repo",
61 expiresAt: Number.MAX_SAFE_INTEGER,
62 };
63 expect((yield* githubMediaResponse(asset, {})).status).toBe(404);
64 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
65 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
66 expect(lookups).toBe(2);
67 expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]);
68 }).pipe(
69 Effect.provide(
70 Layer.mock(GitHubCli.GitHubCli)({
71 execute: () =>
72 Effect.sync(() => ({
73 exitCode: ChildProcessSpawner.ExitCode(0),
74 stdout: ++lookups === 1 ? "" : "signed-in",
75 stderr: "",
76 stdoutTruncated: false,
77 stderrTruncated: false,
78 })),
79 }),
80 ),
81 Effect.provideService(
82 HttpClient.HttpClient,
83 HttpClient.make((request) => {
84 authorizations.push(request.headers.authorization);
85 return Effect.succeed(
86 HttpClientResponse.fromWeb(
87 request,
88 new Response(null, {
89 status: request.headers.authorization ? 200 : 404,
90 headers: { "content-type": "image/png" },
91 }),
92 ),
93 );
94 }),
95 ),
96 Effect.scoped,
97 );
98 });
99
50 it.effect("issues exact URLs for media and browser documents outside the workspace", () =>100 it.effect("issues exact URLs for media and browser documents outside the workspace", () =>
51 Effect.gen(function* () {101 Effect.gen(function* () {
52 const fs = yield* FileSystem.FileSystem;102 const fs = yield* FileSystem.FileSystem;
...@@ -228,7 +278,7 @@ describe("AssetAccess", () => {...@@ -228,7 +278,7 @@ describe("AssetAccess", () => {
228 suffix.slice(0, separator),278 suffix.slice(0, separator),
229 suffix.slice(separator + 1),279 suffix.slice(separator + 1),
230 );280 );
231 if (!asset) throw new Error("Expected a resolved media file");281 if (asset?.kind !== "file") throw new Error("Expected a resolved media file");
232282
233 yield* fs.rename(filePath, savedPath);283 yield* fs.rename(filePath, savedPath);
234 yield* fs.symlink(secretPath, filePath);284 yield* fs.symlink(secretPath, filePath);
...@@ -391,7 +441,7 @@ describe("AssetAccess", () => {...@@ -391,7 +441,7 @@ describe("AssetAccess", () => {
391 const name = suffix.slice(separator + 1);441 const name = suffix.slice(separator + 1);
392 yield* fs.writeFileString(filePath, "in-place edit");442 yield* fs.writeFileString(filePath, "in-place edit");
393 const edited = yield* resolveAsset(token, name);443 const edited = yield* resolveAsset(token, name);
394 if (!edited) throw new Error("Expected the edited media file");444 if (edited?.kind !== "file") throw new Error("Expected the edited media file");
395 const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited));445 const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited));
396 expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit");446 expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit");
397447
...@@ -407,7 +457,7 @@ describe("AssetAccess", () => {...@@ -407,7 +457,7 @@ describe("AssetAccess", () => {
407 renewedSuffix.slice(0, renewedSeparator),457 renewedSuffix.slice(0, renewedSeparator),
408 renewedSuffix.slice(renewedSeparator + 1),458 renewedSuffix.slice(renewedSeparator + 1),
409 );459 );
410 if (!renewedAsset) throw new Error("Expected the replacement media file");460 if (renewedAsset?.kind !== "file") throw new Error("Expected the replacement media file");
411 const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset));461 const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset));
412 expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement");462 expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement");
413 yield* fs.remove(filePath);463 yield* fs.remove(filePath);
...@@ -1062,4 +1112,60 @@ describe("AssetAccess", () => {...@@ -1062,4 +1112,60 @@ describe("AssetAccess", () => {
1062 expect(error.cause).toBe(resolutionCause);1112 expect(error.cause).toBe(resolutionCause);
1063 }).pipe(Effect.provide(testLayer)),1113 }).pipe(Effect.provide(testLayer)),
1064 );1114 );
1115
1116 it.effect("serves GitHub-hosted pull request media through the repository's credential", () =>
1117 Effect.gen(function* () {
1118 const resolve = (relativeUrl: string) => {
1119 const suffix = relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1120 const separator = suffix.indexOf("/");
1121 return resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1));
1122 };
1123 const issue = (url: string) =>
1124 issueAssetUrl({ resource: { _tag: "github-media", cwd: "/repo", url } });
1125
1126 const attachment = yield* issue(
1127 "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c",
1128 );
1129 expect(attachment.relativeUrl.endsWith("/1a1842fb-6383-492f-873c-57aa0033fa6c")).toBe(true);
1130 expect(yield* resolve(attachment.relativeUrl)).toEqual({
1131 kind: "github-media",
1132 url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c",
1133 cwd: "/repo",
1134 // The signed URL's own expiry, which is how long a client may keep the bytes.
1135 expiresAt: attachment.expiresAt,
1136 });
1137
1138 // A `blob` link addresses the page; only the raw host answers a credential with bytes.
1139 const committed = yield* issue("https://github.com/owner/repo/blob/main/docs/shot.png");
1140 expect(yield* resolve(committed.relativeUrl)).toMatchObject({
1141 url: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png",
1142 });
1143
1144 // The pre-`user-attachments` form, Git LFS bytes, and a name no `decodeURIComponent`
1145 // accepts all arrive from real bodies.
1146 const legacy = yield* issue("https://github.com/owner/repo/assets/45952064/1a1842fb");
1147 expect(yield* resolve(legacy.relativeUrl)).toMatchObject({
1148 url: "https://github.com/owner/repo/assets/45952064/1a1842fb",
1149 });
1150 const lfs = yield* issue("https://media.githubusercontent.com/media/owner/repo/main/a.mp4");
1151 expect(yield* resolve(lfs.relativeUrl)).toMatchObject({
1152 url: "https://media.githubusercontent.com/media/owner/repo/main/a.mp4",
1153 });
1154 const awkward = yield* issue("https://raw.githubusercontent.com/o/r/main/100%.png");
1155 expect(awkward.relativeUrl.endsWith("/100%25.png")).toBe(true);
1156
1157 for (const url of [
1158 "https://example.com/shot.png",
1159 "https://example.com/shot.png?token=private-media-token",
1160 "http://github.com/user-attachments/assets/1a1842fb",
1161 "https://github.com/owner/repo/pull/1",
1162 "https://github.com/owner/repo/blob/main/",
1163 ]) {
1164 const error = yield* issue(url).pipe(Effect.flip);
1165 expect(error._tag).toBe("AssetGitHubMediaUrlValidationError");
1166 const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error);
1167 expect(encoded).not.toContain(url);
1168 }
1169 }).pipe(Effect.provide(testLayer)),
1170 );
1065});1171});
apps/server/src/assets/AssetAccess.ts+51-8
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { AssetResource } from "@t3tools/contracts";1import type { AssetResource } from "@t3tools/contracts";
2import {2import {
3 AssetAttachmentNotFoundError,3 AssetAttachmentNotFoundError,
4 AssetGitHubMediaUrlValidationError,
4 AssetPreviewTypeValidationError,5 AssetPreviewTypeValidationError,
5 AssetProjectFaviconInspectionError,6 AssetProjectFaviconInspectionError,
6 AssetProjectFaviconNotFoundError,7 AssetProjectFaviconNotFoundError,
...@@ -27,6 +28,7 @@ import {...@@ -27,6 +28,7 @@ import {
27 readImageDimensions,28 readImageDimensions,
28 type ImageDimensions,29 type ImageDimensions,
29} from "@t3tools/shared/imageDimensions";30} from "@t3tools/shared/imageDimensions";
31import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
30import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";32import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
31import * as Clock from "effect/Clock";33import * as Clock from "effect/Clock";
32import * as Crypto from "effect/Crypto";34import * as Crypto from "effect/Crypto";
...@@ -135,6 +137,14 @@ const AssetClaimsSchema = Schema.Union([...@@ -135,6 +137,14 @@ const AssetClaimsSchema = Schema.Union([
135 app: ToolActivityNativeAppReference,137 app: ToolActivityNativeAppReference,
136 expiresAt: Schema.Number,138 expiresAt: Schema.Number,
137 }),139 }),
140 Schema.Struct({
141 version: Schema.Literal(1),
142 kind: Schema.Literal("github-media"),
143 /** Already narrowed to a GitHub media host at mint time; the signature is what keeps it there. */
144 url: Schema.String,
145 cwd: Schema.String,
146 expiresAt: Schema.Number,
147 }),
138]);148]);
139type AssetClaims = typeof AssetClaimsSchema.Type;149type AssetClaims = typeof AssetClaimsSchema.Type;
140150
...@@ -142,14 +152,23 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);...@@ -142,14 +152,23 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
142const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);152const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
143const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);153const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
144154
145export type ResolvedAsset = {155export type ResolvedAsset =
146 readonly kind: "file";156 | {
147 readonly path: string;157 readonly kind: "file";
148 readonly download?: boolean;158 readonly path: string;
149 readonly fileName?: string;159 readonly download?: boolean;
150 readonly mimeType?: string;160 readonly fileName?: string;
151 readonly file?: OpenMediaFile;161 readonly mimeType?: string;
152};162 readonly file?: OpenMediaFile;
163 }
164 | {
165 readonly kind: "github-media";
166 readonly url: string;
167 readonly cwd: string;
168 /** When the signed URL that granted this stops working, which bounds how long a client
169 may keep the bytes it fetched with it. */
170 readonly expiresAt: number;
171 };
153172
154function decodeClaims(encodedPayload: string): AssetClaims | null {173function decodeClaims(encodedPayload: string): AssetClaims | null {
155 try {174 try {
...@@ -657,6 +676,21 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -657,6 +676,21 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
657 fileName = "native-app-icon.png";676 fileName = "native-app-icon.png";
658 break;677 break;
659 }678 }
679 case "github-media": {
680 const fetchUrl = githubMediaFetchUrl(input.resource.url);
681 if (fetchUrl === null) {
682 return yield* new AssetGitHubMediaUrlValidationError({});
683 }
684 claims = {
685 version: 1,
686 kind: "github-media",
687 url: fetchUrl,
688 cwd: input.resource.cwd,
689 expiresAt,
690 };
691 fileName = githubMediaFileName(fetchUrl);
692 break;
693 }
660 }694 }
661695
662 const secretStore = yield* ServerSecretStore.ServerSecretStore;696 const secretStore = yield* ServerSecretStore.ServerSecretStore;
...@@ -757,6 +791,15 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -757,6 +791,15 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
757 : null;791 : null;
758 }792 }
759793
794 if (claims.kind === "github-media") {
795 return {
796 kind: "github-media",
797 url: claims.url,
798 cwd: claims.cwd,
799 expiresAt: claims.expiresAt,
800 } satisfies ResolvedAsset;
801 }
802
760 if (claims.kind === "native-app-icon") {803 if (claims.kind === "native-app-icon") {
761 const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver;804 const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver;
762 const iconPath = yield* nativeAppIconResolver.resolve(claims.app);805 const iconPath = yield* nativeAppIconResolver.resolve(claims.app);
apps/server/src/assets/GitHubMediaFetch.ts created+196
...@@ -0,0 +1,196 @@
1import Mime from "@effect/platform-node/Mime";
2import { githubMediaFileName } from "@t3tools/shared/githubMedia";
3import * as Clock from "effect/Clock";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import {
7 FetchHttpClient,
8 HttpClient,
9 HttpClientRequest,
10 HttpServerResponse,
11 type HttpClientResponse,
12} from "effect/unstable/http";
13
14import * as GitHubCli from "../sourceControl/GitHubCli.ts";
15
16/**
17 * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned
18 * object stores GitHub hands assets off to — authorizes with its own signature, and some of
19 * them reject a request that also carries a bearer token.
20 */
21const CREDENTIALED_HOSTS = new Set([
22 "github.com",
23 "www.github.com",
24 "raw.githubusercontent.com",
25 "media.githubusercontent.com",
26]);
27const isCredentialedHost = (url: string) => {
28 try {
29 return CREDENTIALED_HOSTS.has(new URL(url).hostname.toLowerCase());
30 } catch {
31 return false;
32 }
33};
34
35/** GitHub answers an asset request with a 302 to a signed object URL that needs no credential. */
36const MAX_REDIRECTS = 3;
37/** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */
38const MANUAL_REDIRECT: RequestInit = { redirect: "manual" };
39const TOKEN_CACHE_TTL_MS = 5 * 60_000;
40const TOKEN_CACHE_MAX_ENTRIES = 32;
41/** Passed through so a seek in a long video costs one upstream range request, not a full download. */
42const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const;
43const FORWARDED_RESPONSE_HEADERS = [
44 "content-type",
45 "content-length",
46 "content-range",
47 "accept-ranges",
48 "etag",
49 "last-modified",
50] as const;
51/** A pull request embeds pictures and recordings. Anything else is not served from our origin. */
52const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i;
53const SVG_CONTENT_TYPE = "image/svg+xml";
54// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a
55// body cannot run script against this origin.
56const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
57
58/**
59 * A media request per image and one per video seek, each of which would otherwise spawn `gh`.
60 * The token is what `gh auth token` would print again on the next call, and it is held no longer
61 * than a signed asset URL lives.
62 */
63const tokenCache = new Map<string, { readonly at: number; readonly token: Redacted.Redacted }>();
64
65const githubToken = Effect.fn("GitHubMediaFetch.githubToken")(function* (input: {
66 readonly cwd: string;
67 readonly host: string;
68}) {
69 // `gh` stores a token per host, not per repository, so the directory it runs in is not part
70 // of the answer and must not fragment the cache a client could otherwise churn. This route
71 // pins no credential; if it ever does, the pin belongs in this key.
72 const key = input.host;
73 const now = yield* Clock.currentTimeMillis;
74 const cached = tokenCache.get(key);
75 if (cached !== undefined && now - cached.at < TOKEN_CACHE_TTL_MS) return cached.token;
76 const github = yield* GitHubCli.GitHubCli;
77 // No credential is a normal state: a public asset still loads, and a private one fails the way
78 // it does in a browser that is not signed in.
79 const token = yield* github
80 .execute({
81 cwd: input.cwd,
82 args: ["auth", "token", "--hostname", input.host],
83 env: { GH_DEBUG: "" },
84 })
85 .pipe(
86 Effect.map((output) => output.stdout.trim()),
87 Effect.orElseSucceed(() => ""),
88 );
89 // A login or recovered CLI failure must take effect on the next media request.
90 if (token.length === 0) return null;
91 if (tokenCache.size >= TOKEN_CACHE_MAX_ENTRIES) {
92 tokenCache.delete(tokenCache.keys().next().value!);
93 }
94 const redacted = Redacted.make(token);
95 tokenCache.set(key, { at: now, token: redacted });
96 return redacted;
97});
98
99/**
100 * Follows GitHub's redirect to the signed object itself, and never carries the credential off
101 * GitHub: the object URL authorizes with its own signature, and the store it lives in has no
102 * business seeing a token.
103 */
104const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedirects")(function* (
105 url: string,
106 headers: Record<string, string>,
107 token: Redacted.Redacted | null,
108) {
109 const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient);
110 let target = url;
111 for (let hop = 0; ; hop += 1) {
112 // The credential rides only on a request to GitHub itself. A redirect leads to a signed
113 // object URL that authorizes on its own, and the store it lives in has no business seeing
114 // a token — deciding that from the target, not from the hop count, is what makes it so.
115 const authorization =
116 token !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(token)}` : null;
117 const response: HttpClientResponse.HttpClientResponse = yield* httpClient
118 .execute(
119 HttpClientRequest.get(target).pipe(
120 HttpClientRequest.setHeaders({
121 ...headers,
122 // The bytes are streamed straight through, so never let an encoding layer in.
123 "accept-encoding": "identity",
124 ...(authorization === null ? {} : { authorization }),
125 }),
126 ),
127 )
128 .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT));
129 const location = response.headers.location;
130 if (response.status < 300 || response.status >= 400) return response;
131 // A chain this long is not GitHub answering with bytes, and its body is not the media.
132 if (!location || hop >= MAX_REDIRECTS) return null;
133 const next = new URL(location, target);
134 if (next.protocol !== "https:") return null;
135 target = next.toString();
136 }
137});
138
139/**
140 * Serves media a pull request body points at on GitHub through the `gh` credential, which is the
141 * only thing that distinguishes a readable private attachment from a 404.
142 */
143export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaResponse")(function* (
144 asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number },
145 requestHeaders: Record<string, string | undefined>,
146) {
147 // Both media hosts are served by github.com's account, which is the host `gh` stores it under.
148 const token = yield* githubToken({ cwd: asset.cwd, host: "github.com" });
149 const forwarded: Record<string, string> = {};
150 for (const name of FORWARDED_REQUEST_HEADERS) {
151 const value = requestHeaders[name];
152 if (value !== undefined) forwarded[name] = value;
153 }
154 const response = yield* fetchFollowingRedirects(asset.url, forwarded, token);
155 // An upload GitHub hosts never changes under its URL, so the only thing a cached copy must
156 // not outlive is the signed URL that granted it — which is the same bound the URL itself has.
157 const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000);
158 const headers: Record<string, string> = {
159 "cache-control":
160 remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store",
161 "x-content-type-options": "nosniff",
162 };
163 if (response === null) return HttpServerResponse.empty({ status: 502, headers });
164 // An upstream refusal is the client's answer, not this server's fault; only a broken hop is.
165 // It carries none of the upstream entity headers: a `content-length` with no body behind it
166 // holds the connection open until the browser gives up on it.
167 if (response.status >= 400) {
168 return HttpServerResponse.empty({
169 status: response.status >= 500 ? 502 : response.status,
170 headers,
171 });
172 }
173 // Only pictures and recordings leave this origin, and never on GitHub's word alone: the raw
174 // host labels every committed binary `application/octet-stream`, so the name decides those.
175 const upstreamType =
176 response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
177 const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType)
178 ? upstreamType
179 : (Mime.getType(githubMediaFileName(asset.url))?.toLowerCase() ?? "");
180 if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) {
181 return HttpServerResponse.empty({ status: 415, headers });
182 }
183 for (const name of FORWARDED_RESPONSE_HEADERS) {
184 const value = response.headers[name];
185 if (value !== undefined) headers[name] = value;
186 }
187 headers["content-type"] = contentType;
188 if (contentType === SVG_CONTENT_TYPE) {
189 headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY;
190 }
191 return HttpServerResponse.stream(response.stream, {
192 status: response.status,
193 headers,
194 contentType,
195 });
196});
apps/server/src/http.ts+14
...@@ -29,6 +29,7 @@ import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability";...@@ -29,6 +29,7 @@ import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability";
2929
30import * as ServerConfig from "./config.ts";30import * as ServerConfig from "./config.ts";
31import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";31import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
32import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts";
32import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";33import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";
33import {34import {
34 ATTACHMENT_UPLOAD_ROUTE_PREFIX,35 ATTACHMENT_UPLOAD_ROUTE_PREFIX,
...@@ -391,6 +392,19 @@ export const assetRouteLayer = HttpRouter.add(...@@ -391,6 +392,19 @@ export const assetRouteLayer = HttpRouter.add(
391 if (!asset) {392 if (!asset) {
392 return HttpServerResponse.text("Not Found", { status: 404 });393 return HttpServerResponse.text("Not Found", { status: 404 });
393 }394 }
395 if (asset.kind === "github-media") {
396 return yield* githubMediaResponse(asset, request.headers).pipe(
397 Effect.tapError((cause) =>
398 Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }),
399 ),
400 Effect.orElseSucceed(() =>
401 HttpServerResponse.empty({
402 status: 502,
403 headers: { "cache-control": "private, no-store", "x-content-type-options": "nosniff" },
404 }),
405 ),
406 );
407 }
394 return yield* assetFileResponse(408 return yield* assetFileResponse(
395 asset,409 asset,
396 request.method === "GET" ? request.headers.range : undefined,410 request.method === "GET" ? request.headers.range : undefined,
apps/server/src/server.test.ts+2-1
...@@ -169,6 +169,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts";...@@ -169,6 +169,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts";
169import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";169import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
170import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";170import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
171import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";171import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
172import * as GitHubCli from "./sourceControl/GitHubCli.ts";
172import * as VcsProcess from "./vcs/VcsProcess.ts";173import * as VcsProcess from "./vcs/VcsProcess.ts";
173import * as GitWorkflowService from "./git/GitWorkflowService.ts";174import * as GitWorkflowService from "./git/GitWorkflowService.ts";
174import * as ReviewService from "./review/ReviewService.ts";175import * as ReviewService from "./review/ReviewService.ts";
...@@ -1210,7 +1211,7 @@ const buildAppUnderTest = (options?: {...@@ -1210,7 +1211,7 @@ const buildAppUnderTest = (options?: {
1210 Layer.provideMerge(ServerSecretStore.layer),1211 Layer.provideMerge(ServerSecretStore.layer),
1211 Layer.provide(workspaceAndProjectServicesLayer),1212 Layer.provide(workspaceAndProjectServicesLayer),
1212 Layer.provideMerge(FetchHttpClient.layer),1213 Layer.provideMerge(FetchHttpClient.layer),
1213 Layer.provide(VcsProcess.layer),1214 Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))),
1214 Layer.provide(layerConfig),1215 Layer.provide(layerConfig),
1215 );1216 );
12161217
apps/server/src/server.ts+3-1
...@@ -485,8 +485,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -485,8 +485,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
485 // Core Services485 // Core Services
486 Layer.provideMerge(ServerSettingsLayerLive),486 Layer.provideMerge(ServerSettingsLayerLive),
487 Layer.provideMerge(CheckpointingLayerLive),487 Layer.provideMerge(CheckpointingLayerLive),
488 // `GitHubCli` is the registry's own instance, exposed because the asset route fetches
489 // GitHub-hosted pull request media with the repository's credential.
488 Layer.provideMerge(490 Layer.provideMerge(
489 Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive),491 Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive, GitHubCli.layer),
490 ),492 ),
491 Layer.provideMerge(GitLayerLive),493 Layer.provideMerge(GitLayerLive),
492 Layer.provideMerge(VcsLayerLive),494 Layer.provideMerge(VcsLayerLive),
apps/server/src/ws.ts+2
...@@ -3083,6 +3083,8 @@ const makeWsRpcLayer = (...@@ -3083,6 +3083,8 @@ const makeWsRpcLayer = (
3083 if (3083 if (
3084 input.resource._tag === "attachment" ||3084 input.resource._tag === "attachment" ||
3085 input.resource._tag === "native-app-icon" ||3085 input.resource._tag === "native-app-icon" ||
3086 // GitHub media names the repository it authenticates through itself.
3087 input.resource._tag === "github-media" ||
3086 (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path))3088 (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path))
3087 ) {3089 ) {
3088 return yield* issueAssetUrl({ resource: input.resource });3090 return yield* issueAssetUrl({ resource: input.resource });
apps/web/src/components/ChatMarkdown.tsx+88-12
...@@ -34,6 +34,7 @@ import type {...@@ -34,6 +34,7 @@ import type {
34 ThreadPullRequestKey,34 ThreadPullRequestKey,
35} from "@t3tools/contracts";35} from "@t3tools/contracts";
36import { faviconUrlForOrigin } from "@t3tools/shared/favicon";36import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
37import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
37import {38import {
38 isAtomCommandInterrupted,39 isAtomCommandInterrupted,
39 squashAtomCommandFailure,40 squashAtomCommandFailure,
...@@ -220,6 +221,9 @@ interface ChatMarkdownProps {...@@ -220,6 +221,9 @@ interface ChatMarkdownProps {
220 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;221 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
221 /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */222 /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */
222 renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;223 renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;
224 /** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's
225 uploads need; without it those images and videos load unauthenticated and 404. */
226 githubMedia?: boolean | undefined;
223 /** Levels added to each markdown heading in the accessibility tree so the227 /** Levels added to each markdown heading in the accessibility tree so the
224 text nests under the heading that introduces it, such as a chat message's228 text nests under the heading that introduces it, such as a chat message's
225 author. Rendered tags and their styling are unchanged. */229 author. Rendered tags and their styling are unchanged. */
...@@ -1573,7 +1577,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1573,7 +1577,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1573 readonly environmentId: EnvironmentId;1577 readonly environmentId: EnvironmentId;
1574 readonly resource: Extract<1578 readonly resource: Extract<
1575 AssetResource,1579 AssetResource,
1576 { readonly _tag: "attachment" | "workspace-file" | "media-file" }1580 { readonly _tag: "attachment" | "workspace-file" | "media-file" | "github-media" }
1577 >;1581 >;
1578 readonly kind?: "image" | "video";1582 readonly kind?: "image" | "video";
1579 readonly alt: string;1583 readonly alt: string;
...@@ -1584,6 +1588,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1584,6 +1588,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1584 /** Caps the box height in rem while keeping the image's ratio; 30 by default. */1588 /** Caps the box height in rem while keeping the image's ratio; 30 by default. */
1585 readonly maxHeightRem?: number | undefined;1589 readonly maxHeightRem?: number | undefined;
1586 readonly style?: CSSProperties | undefined;1590 readonly style?: CSSProperties | undefined;
1591 readonly className?: string | undefined;
1592 /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */
1593 readonly imageProps?:
1594 | Omit<ComponentProps<"img">, "src" | "alt" | "className" | "style">
1595 | undefined;
1596 /** Where the media also lives on the web, for the failure state's escape hatch. */
1597 readonly originalUrl?: string | undefined;
1598 /** The workspace media frame, on by default; off for media that keeps the author's own box. */
1599 readonly framed?: boolean | undefined;
1600 /** Loaded instead of the failure state when no URL can be signed, such as against a server
1601 too old to know this resource. Only safe when the client can reach it directly. */
1602 readonly fallbackSrc?: string | undefined;
1587 readonly workspaceRoot?: string | undefined;1603 readonly workspaceRoot?: string | undefined;
1588 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;1604 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1589}) {1605}) {
...@@ -1596,9 +1612,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1596,9 +1612,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1596 : resource._tag === "workspace-file" && props.workspaceRoot1612 : resource._tag === "workspace-file" && props.workspaceRoot
1597 ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}`1613 ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}`
1598 : undefined;1614 : undefined;
1599 const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined;1615 const reference = path
1600 const relativePath = reference?.relativePath;1616 ? mediaFileReference(path, props.workspaceRoot)
1601 const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null;1617 : props.originalUrl
1618 ? mediaUrlReference(props.originalUrl)
1619 : undefined;
1620 const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
1621 const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined;
1622 const src =
1623 assetUrl._tag === "Success"
1624 ? assetUrl.url + (props.srcFragment ?? "")
1625 : fallbackSrc === undefined
1626 ? null
1627 : fallbackSrc + (props.srcFragment ?? "");
1602 // The server reads the pixel size from the file header, so the slot can be1628 // The server reads the pixel size from the file header, so the slot can be
1603 // the image's final box instead of a 16:9 guess. An authored size wins; a1629 // the image's final box instead of a 16:9 guess. An authored size wins; a
1604 // caller's height cap shrinks the box while keeping the ratio.1630 // caller's height cap shrinks the box while keeping the ratio.
...@@ -1615,9 +1641,11 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1615,9 +1641,11 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1615 kind: props.kind ?? "image",1641 kind: props.kind ?? "image",
1616 name: props.alt || (props.kind ?? "image"),1642 name: props.alt || (props.kind ?? "image"),
1617 src,1643 src,
1618 asset: { environmentId: props.environmentId, resource },1644 ...(fallbackSrc === undefined
1645 ? { asset: { environmentId: props.environmentId, resource } }
1646 : {}),
1619 ...(reference ? { reference } : {}),1647 ...(reference ? { reference } : {}),
1620 ...(relativePath && resource._tag !== "attachment"1648 ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file")
1621 ? {1649 ? {
1622 onOpenFile: () =>1650 onOpenFile: () =>
1623 useRightPanelStore1651 useRightPanelStore
...@@ -1634,9 +1662,10 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1634,9 +1662,10 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1634 return (1662 return (
1635 <ChatMarkdownVideo1663 <ChatMarkdownVideo
1636 src={src}1664 src={src}
1637 sourceFailed={assetUrl._tag === "Failure"}1665 sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined}
1638 alt={props.alt}1666 alt={props.alt}
1639 copyMarkdown={props.copyMarkdown}1667 copyMarkdown={props.copyMarkdown}
1668 originalUrl={props.originalUrl}
1640 style={props.style}1669 style={props.style}
1641 mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}1670 mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
1642 onRetry={refreshAssetUrl}1671 onRetry={refreshAssetUrl}
...@@ -1649,13 +1678,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1649,13 +1678,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1649 <ChatMarkdownImage1678 <ChatMarkdownImage
1650 key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}1679 key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
1651 src={src}1680 src={src}
1652 sourceFailed={assetUrl._tag === "Failure"}1681 sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined}
1653 alt={props.alt}1682 alt={props.alt}
1654 copyMarkdown={props.copyMarkdown}1683 copyMarkdown={props.copyMarkdown}
1655 standalone={props.standalone ?? true}1684 standalone={props.standalone ?? true}
1656 className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}1685 className={cn(
1686 props.framed === false ? undefined : CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,
1687 props.className,
1688 )}
1657 style={style}1689 style={style}
1690 imageProps={props.imageProps}
1658 actionsSource={actionsSource}1691 actionsSource={actionsSource}
1692 originalUrl={props.originalUrl}
1659 onImageExpand={props.onImageExpand}1693 onImageExpand={props.onImageExpand}
1660 />1694 />
1661 );1695 );
...@@ -2226,6 +2260,7 @@ function useChatMarkdownState({...@@ -2226,6 +2260,7 @@ function useChatMarkdownState({
2226 onImageExpand,2260 onImageExpand,
2227 renderContextReference,2261 renderContextReference,
2228 headingLevelOffset = 0,2262 headingLevelOffset = 0,
2263 githubMedia = false,
2229}: ChatMarkdownProps) {2264}: ChatMarkdownProps) {
2230 const { resolvedTheme } = useTheme();2265 const { resolvedTheme } = useTheme();
2231 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);2266 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
...@@ -2623,6 +2658,7 @@ function useChatMarkdownState({...@@ -2623,6 +2658,7 @@ function useChatMarkdownState({
2623 environmentId,2658 environmentId,
2624 expandMedia,2659 expandMedia,
2625 fileLinkChip,2660 fileLinkChip,
2661 githubMedia,
2626 renderContextReference,2662 renderContextReference,
2627 headingLevelOffset,2663 headingLevelOffset,
2628 imageBaseDir,2664 imageBaseDir,
...@@ -2652,6 +2688,7 @@ function useChatMarkdownState({...@@ -2652,6 +2688,7 @@ function useChatMarkdownState({
2652 environmentId,2688 environmentId,
2653 expandMedia,2689 expandMedia,
2654 fileLinkChip,2690 fileLinkChip,
2691 githubMedia,
2655 renderContextReference,2692 renderContextReference,
2656 headingLevelOffset,2693 headingLevelOffset,
2657 imageBaseDir,2694 imageBaseDir,
...@@ -3081,9 +3118,15 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3081,9 +3118,15 @@ const CHAT_MARKDOWN_COMPONENTS = {
3081 );3118 );
3082 },3119 },
3083 img: function MarkdownImage({ node, title, src, alt, ...props }) {3120 img: function MarkdownImage({ node, title, src, alt, ...props }) {
3084 const { expandMedia, cwd, imageBaseDir, threadRef, renderContextReference } = use(3121 const {
3085 ChatMarkdownRendererContext,3122 expandMedia,
3086 );3123 cwd,
3124 environmentId,
3125 githubMedia,
3126 imageBaseDir,
3127 threadRef,
3128 renderContextReference,
3129 } = use(ChatMarkdownRendererContext);
3087 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;3130 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
3088 const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null;3131 const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null;
3089 if (contextReference) {3132 if (contextReference) {
...@@ -3109,6 +3152,39 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3109,6 +3152,39 @@ const CHAT_MARKDOWN_COMPONENTS = {
3109 const authoredSizeStyle = authoredImageSizeStyle(width, height);3152 const authoredSizeStyle = authoredImageSizeStyle(width, height);
3110 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);3153 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
3111 const kind = mediaKindFromPath(classifiedSrc) ?? "image";3154 const kind = mediaKindFromPath(classifiedSrc) ?? "image";
3155 const directUri = imageSource._tag === "Direct" ? imageSource.uri : null;
3156 const githubMediaUrl =
3157 directUri === null ? null : githubMediaFetchUrl(resolveProtocolRelativeMediaUrl(directUri));
3158 if (
3159 githubMedia &&
3160 cwd !== undefined &&
3161 environmentId !== null &&
3162 directUri !== null &&
3163 githubMediaUrl !== null
3164 ) {
3165 return (
3166 <ChatMarkdownAssetImage
3167 environmentId={environmentId}
3168 resource={{ _tag: "github-media", cwd, url: githubMediaUrl }}
3169 alt={altText}
3170 kind={kind}
3171 copyMarkdown={copyMarkdown}
3172 standalone={standalone}
3173 className={className}
3174 style={authoredSizeStyle}
3175 imageProps={imageProps}
3176 srcFragment={markdownImageSourceFragment(classifiedSrc)}
3177 originalUrl={resolveProtocolRelativeMediaUrl(directUri)}
3178 // A pull request body draws its own boxes; keep the author's, not the workspace frame.
3179 framed={false}
3180 // A server too old to sign this resource, or one with no route to GitHub, still leaves
3181 // the public half of these working exactly as it did before. The canonical URL, not the
3182 // authored one: a `blob` link addresses the page, and only the raw host has the bytes.
3183 fallbackSrc={githubMediaUrl}
3184 onImageExpand={imageExpand}
3185 />
3186 );
3187 }
3112 if (imageSource._tag === "Direct") {3188 if (imageSource._tag === "Direct") {
3113 const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri);3189 const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri);
3114 const originalUrl =3190 const originalUrl =
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+57-1
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import { ExternalLinkIcon, PaperclipIcon } from "lucide-react";1import { ExternalLinkIcon, PaperclipIcon } from "lucide-react";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";2import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images";
3import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
4import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import { createContext, useContext, useMemo } from "react";5import { createContext, useContext, useMemo } from "react";
4import type { Options as ReactMarkdownOptions } from "react-markdown";6import type { Options as ReactMarkdownOptions } from "react-markdown";
57
8import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
6import { cn } from "~/lib/utils";9import { cn } from "~/lib/utils";
7import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore";10import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore";
811
...@@ -15,6 +18,46 @@ export const PullRequestMarkdownContext = createContext<{...@@ -15,6 +18,46 @@ export const PullRequestMarkdownContext = createContext<{
15 threadRef: ScopedThreadRef | null;18 threadRef: ScopedThreadRef | null;
16} | null>(null);19} | null>(null);
1720
21/**
22 * A video GitHub hosts for the repository. It plays through a signed asset URL the server
23 * fetches with the repository's GitHub credential, which is what a private repository's
24 * uploads need; the URL is re-signed on retry, so a stale one recovers without a reload.
25 */
26function PullRequestGitHubVideo({
27 environmentId,
28 cwd,
29 url,
30 fetchUrl,
31}: {
32 environmentId: EnvironmentId;
33 cwd: string;
34 /** What the body authored, which is what "Open original" should reach. */
35 url: string;
36 /** The canonical GitHub media URL: a `blob` link addresses the page, not the bytes. */
37 fetchUrl: string;
38}) {
39 const resource = useMemo<AssetResource>(
40 () => ({ _tag: "github-media", cwd, url: fetchUrl }),
41 [cwd, fetchUrl],
42 );
43 const assetUrl = useAssetUrlState(environmentId, resource);
44 const refreshAssetUrl = useAssetUrlRefresh(environmentId, resource);
45 // A server too old to sign this resource, or one with no route to GitHub, still leaves a
46 // public repository's video playing exactly as it did before.
47 const src =
48 assetUrl._tag === "Success" ? assetUrl.url : assetUrl._tag === "Failure" ? fetchUrl : null;
49 return (
50 <MediaVideoPlayer
51 src={src === null ? null : src + markdownImageSourceFragment(url)}
52 originalUrl={url}
53 label="Pull request video"
54 className="w-full"
55 videoClassName="rounded-lg border border-border/60"
56 onRetry={refreshAssetUrl}
57 />
58 );
59}
60
18/** Renders PR uploads inline, with retry and an original link when video playback fails. */61/** Renders PR uploads inline, with retry and an original link when video playback fails. */
19export function PullRequestMarkdown({62export function PullRequestMarkdown({
20 text,63 text,
...@@ -57,6 +100,19 @@ export function PullRequestMarkdown({...@@ -57,6 +100,19 @@ export function PullRequestMarkdown({
57 pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF}100 pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF}
58 environmentId={environmentId}101 environmentId={environmentId}
59 extraRemarkPlugins={extraRemarkPlugins}102 extraRemarkPlugins={extraRemarkPlugins}
103 githubMedia
104 />
105 );
106 }
107 const githubMediaUrl = segment.media === "video" ? githubMediaFetchUrl(segment.url) : null;
108 if (githubMediaUrl !== null) {
109 return (
110 <PullRequestGitHubVideo
111 key={`${segment.id}:${segment.url}`}
112 environmentId={environmentId}
113 cwd={cwd}
114 url={segment.url}
115 fetchUrl={githubMediaUrl}
60 />116 />
61 );117 );
62 }118 }
packages/contracts/src/assets.ts+17
...@@ -50,6 +50,13 @@ export const AssetResource = Schema.Union([...@@ -50,6 +50,13 @@ export const AssetResource = Schema.Union([
50 Schema.TaggedStruct("native-app-icon", {50 Schema.TaggedStruct("native-app-icon", {
51 app: ToolActivityNativeAppReference,51 app: ToolActivityNativeAppReference,
52 }),52 }),
53 // An upload a pull request body points at on GitHub. A private repository serves these only
54 // to a request that carries a credential, which the client has none of, so the server fetches
55 // them with the `gh` credential the repository at `cwd` authenticates with.
56 Schema.TaggedStruct("github-media", {
57 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
58 url: TrimmedNonEmptyString.check(Schema.isMaxLength(2048)),
59 }),
53]);60]);
54export type AssetResource = typeof AssetResource.Type;61export type AssetResource = typeof AssetResource.Type;
5562
...@@ -285,6 +292,15 @@ export class AssetSigningKeyLoadError extends Schema.TaggedError<AssetSigningKey...@@ -285,6 +292,15 @@ export class AssetSigningKeyLoadError extends Schema.TaggedError<AssetSigningKey
285 }292 }
286}293}
287294
295export class AssetGitHubMediaUrlValidationError extends Schema.TaggedError<AssetGitHubMediaUrlValidationError>()(
296 "AssetGitHubMediaUrlValidationError",
297 {},
298) {
299 override get message(): string {
300 return "Only media hosted by GitHub can be fetched with a GitHub credential.";
301 }
302}
303
288export const AssetAccessError = Schema.Union([304export const AssetAccessError = Schema.Union([
289 AssetWorkspaceContextNotFoundError,305 AssetWorkspaceContextNotFoundError,
290 AssetWorkspaceContextResolutionError,306 AssetWorkspaceContextResolutionError,
...@@ -298,6 +314,7 @@ export const AssetAccessError = Schema.Union([...@@ -298,6 +314,7 @@ export const AssetAccessError = Schema.Union([
298 AssetProjectFaviconResolutionError,314 AssetProjectFaviconResolutionError,
299 AssetProjectFaviconInspectionError,315 AssetProjectFaviconInspectionError,
300 AssetProjectFaviconNotFoundError,316 AssetProjectFaviconNotFoundError,
317 AssetGitHubMediaUrlValidationError,
301 AssetSigningKeyLoadError,318 AssetSigningKeyLoadError,
302]);319]);
303export type AssetAccessError = typeof AssetAccessError.Type;320export type AssetAccessError = typeof AssetAccessError.Type;
packages/shared/package.json+4
...@@ -43,6 +43,10 @@...@@ -43,6 +43,10 @@
43 "types": "./src/git.ts",43 "types": "./src/git.ts",
44 "import": "./src/git.ts"44 "import": "./src/git.ts"
45 },45 },
46 "./githubMedia": {
47 "types": "./src/githubMedia.ts",
48 "import": "./src/githubMedia.ts"
49 },
46 "./sourceControl": {50 "./sourceControl": {
47 "types": "./src/sourceControl.ts",51 "types": "./src/sourceControl.ts",
48 "import": "./src/sourceControl.ts"52 "import": "./src/sourceControl.ts"
packages/shared/src/githubMedia.ts created+70
...@@ -0,0 +1,70 @@
1/**
2 * Media a pull request body points at on GitHub's own hosts. In a private repository GitHub
3 * answers an unauthenticated request for one with 404 — a screenshot dropped into a description
4 * becomes `github.com/user-attachments/assets/<id>`, and a file committed alongside the code
5 * becomes a `raw.githubusercontent.com` path — so the renderer, which carries no GitHub session,
6 * draws a broken image where the reviewer expects the evidence. The server can fetch these with
7 * the `gh` credential and hand the bytes back.
8 *
9 * Only hosts where a credential is what decides the answer belong here. `objects.githubusercontent.com`
10 * and the `private-user-images` links GitHub's own HTML carries are already signed and load on their
11 * own, and a token does nothing for them once that signature expires.
12 */
13
14const RAW_HOST = "raw.githubusercontent.com";
15/** Git LFS pointers resolve here, which is where an LFS-tracked screenshot's bytes live. */
16const LFS_HOST = "media.githubusercontent.com";
17const ATTACHMENT_PATH_PATTERN = /^\/user-attachments\/assets\/[\w-]+$/u;
18/** What GitHub wrote into a body before `user-attachments`; older descriptions still carry it. */
19const LEGACY_ATTACHMENT_PATH_PATTERN = /^\/[^/]+\/[^/]+\/assets\/\d+\/[\w-]+$/u;
20/** `blob` and `raw` both address file bytes; `raw` is the one the token is honoured on. */
21const REPOSITORY_FILE_PATTERN = /^\/([^/]+)\/([^/]+)\/(?:raw|blob)\/(.*[^/])$/u;
22
23/** Port, userinfo, and fragment say nothing about which bytes GitHub will serve. */
24function canonicalUrl(host: string, url: URL): string {
25 return `https://${host}${url.pathname}${url.search}`;
26}
27
28/**
29 * The URL to fetch with a GitHub credential for `source`, or null when the source is not
30 * GitHub-hosted media — those keep loading directly, exactly as they do today.
31 */
32export function githubMediaFetchUrl(source: string): string | null {
33 let url: URL;
34 try {
35 url = new URL(source);
36 } catch {
37 return null;
38 }
39 if (url.protocol !== "https:") return null;
40 const host = url.hostname.toLowerCase();
41 if (host === RAW_HOST || host === LFS_HOST) return canonicalUrl(host, url);
42 if (host !== "github.com" && host !== "www.github.com") return null;
43 if (
44 ATTACHMENT_PATH_PATTERN.test(url.pathname) ||
45 LEGACY_ATTACHMENT_PATH_PATTERN.test(url.pathname)
46 ) {
47 return `https://github.com${url.pathname}`;
48 }
49 const repositoryFile = REPOSITORY_FILE_PATTERN.exec(url.pathname);
50 // `?raw=true` is how the web UI spells "the bytes, not the page"; the raw host needs no query.
51 return repositoryFile
52 ? `https://${RAW_HOST}/${repositoryFile[1]}/${repositoryFile[2]}/${repositoryFile[3]}`
53 : null;
54}
55
56/**
57 * Last path segment, for the signed URL's display name. A percent sequence GitHub accepts but
58 * `decodeURIComponent` rejects is left encoded rather than failing the whole asset.
59 */
60export function githubMediaFileName(fetchUrl: string): string {
61 const segment = new URL(fetchUrl).pathname.split("/").pop() ?? "";
62 let decoded: string;
63 try {
64 decoded = decodeURIComponent(segment);
65 } catch {
66 decoded = segment;
67 }
68 const name = decoded.replace(/[\p{Cc}\\/]/gu, "");
69 return name.length > 0 ? name : "github-media";
70}