| author | |
| committer | |
| log | 32e8b2584556c0c55ce0d1d8f72b506cb771d60d |
| tree | bd5ea61160ad8c1b7c4e9f13a3dab60e305242a9 |
| parent | 384de0858d1f59ca2ad7c660544a5290fd9138cb |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; |
| 4 | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; | |
| 5 | import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; | |
| 6 | 6 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 7 | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | 8 | import * as Crypto from "effect/Crypto"; |
| ... | ... | @@ -11,8 +11,10 @@ import * as FileSystem from "effect/FileSystem"; |
| 11 | 11 | import * as Layer from "effect/Layer"; |
| 12 | 12 | import * as Path from "effect/Path"; |
| 13 | 13 | import * as PlatformError from "effect/PlatformError"; |
| 14 | import * as Schema from "effect/Schema"; | |
| 14 | 15 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | import { HttpServerResponse } from "effect/unstable/http"; | |
| 16 | import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/unstable/http"; | |
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 16 | 18 | import { vi } from "vite-plus/test"; |
| 17 | 19 | |
| 18 | 20 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| ... | ... | @@ -25,6 +27,8 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t |
| 25 | 27 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; |
| 26 | 28 | import { openMediaFile } from "./MediaFile.ts"; |
| 27 | 29 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 30 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 31 | import { githubMediaResponse } from "./GitHubMediaFetch.ts"; | |
| 28 | 32 | |
| 29 | 33 | vi.mock("node:fs/promises", async (importOriginal) => { |
| 30 | 34 | const actual = await importOriginal<typeof NodeFSP>(); |
| ... | ... | @@ -47,6 +51,52 @@ const testLayer = Layer.mergeAll( |
| 47 | 51 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 48 | 52 | |
| 49 | 53 | describe("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 | 100 | it.effect("issues exact URLs for media and browser documents outside the workspace", () => |
| 51 | 101 | Effect.gen(function* () { |
| 52 | 102 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -228,7 +278,7 @@ describe("AssetAccess", () => { |
| 228 | 278 | suffix.slice(0, separator), |
| 229 | 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"); | |
| 232 | 282 | |
| 233 | 283 | yield* fs.rename(filePath, savedPath); |
| 234 | 284 | yield* fs.symlink(secretPath, filePath); |
| ... | ... | @@ -391,7 +441,7 @@ describe("AssetAccess", () => { |
| 391 | 441 | const name = suffix.slice(separator + 1); |
| 392 | 442 | yield* fs.writeFileString(filePath, "in-place edit"); |
| 393 | 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 | 445 | const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited)); |
| 396 | 446 | expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit"); |
| 397 | 447 | |
| ... | ... | @@ -407,7 +457,7 @@ describe("AssetAccess", () => { |
| 407 | 457 | renewedSuffix.slice(0, renewedSeparator), |
| 408 | 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 | 461 | const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset)); |
| 412 | 462 | expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement"); |
| 413 | 463 | yield* fs.remove(filePath); |
| ... | ... | @@ -1062,4 +1112,60 @@ describe("AssetAccess", () => { |
| 1062 | 1112 | expect(error.cause).toBe(resolutionCause); |
| 1063 | 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 | 1 | import type { AssetResource } from "@t3tools/contracts"; |
| 2 | 2 | import { |
| 3 | 3 | AssetAttachmentNotFoundError, |
| 4 | AssetGitHubMediaUrlValidationError, | |
| 4 | 5 | AssetPreviewTypeValidationError, |
| 5 | 6 | AssetProjectFaviconInspectionError, |
| 6 | 7 | AssetProjectFaviconNotFoundError, |
| ... | ... | @@ -27,6 +28,7 @@ import { |
| 27 | 28 | readImageDimensions, |
| 28 | 29 | type ImageDimensions, |
| 29 | 30 | } from "@t3tools/shared/imageDimensions"; |
| 31 | import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; | |
| 30 | 32 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 31 | 33 | import * as Clock from "effect/Clock"; |
| 32 | 34 | import * as Crypto from "effect/Crypto"; |
| ... | ... | @@ -135,6 +137,14 @@ const AssetClaimsSchema = Schema.Union([ |
| 135 | 137 | app: ToolActivityNativeAppReference, |
| 136 | 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 | ]); |
| 139 | 149 | type AssetClaims = typeof AssetClaimsSchema.Type; |
| 140 | 150 | |
| ... | ... | @@ -142,14 +152,23 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); |
| 142 | 152 | const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); |
| 143 | 153 | const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); |
| 144 | 154 | |
| 145 | export type ResolvedAsset = { | |
| 146 | readonly kind: "file"; | |
| 147 | readonly path: string; | |
| 148 | readonly download?: boolean; | |
| 149 | readonly fileName?: string; | |
| 150 | readonly mimeType?: string; | |
| 151 | readonly file?: OpenMediaFile; | |
| 152 | }; | |
| 155 | export type ResolvedAsset = | |
| 156 | | { | |
| 157 | readonly kind: "file"; | |
| 158 | readonly path: string; | |
| 159 | readonly download?: boolean; | |
| 160 | readonly fileName?: string; | |
| 161 | readonly mimeType?: string; | |
| 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 | }; | |
| 153 | 172 | |
| 154 | 173 | function decodeClaims(encodedPayload: string): AssetClaims | null { |
| 155 | 174 | try { |
| ... | ... | @@ -657,6 +676,21 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 657 | 676 | fileName = "native-app-icon.png"; |
| 658 | 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 | } |
| 661 | 695 | |
| 662 | 696 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| ... | ... | @@ -757,6 +791,15 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 757 | 791 | : null; |
| 758 | 792 | } |
| 759 | 793 | |
| 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 | 803 | if (claims.kind === "native-app-icon") { |
| 761 | 804 | const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver; |
| 762 | 805 | const iconPath = yield* nativeAppIconResolver.resolve(claims.app); |
apps/server/src/assets/GitHubMediaFetch.ts created+196| ... | ... | @@ -0,0 +1,196 @@ |
| 1 | import Mime from "@effect/platform-node/Mime"; | |
| 2 | import { githubMediaFileName } from "@t3tools/shared/githubMedia"; | |
| 3 | import * as Clock from "effect/Clock"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Redacted from "effect/Redacted"; | |
| 6 | import { | |
| 7 | FetchHttpClient, | |
| 8 | HttpClient, | |
| 9 | HttpClientRequest, | |
| 10 | HttpServerResponse, | |
| 11 | type HttpClientResponse, | |
| 12 | } from "effect/unstable/http"; | |
| 13 | ||
| 14 | import * 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 | */ | |
| 21 | const CREDENTIALED_HOSTS = new Set([ | |
| 22 | "github.com", | |
| 23 | "www.github.com", | |
| 24 | "raw.githubusercontent.com", | |
| 25 | "media.githubusercontent.com", | |
| 26 | ]); | |
| 27 | const 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. */ | |
| 36 | const MAX_REDIRECTS = 3; | |
| 37 | /** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */ | |
| 38 | const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; | |
| 39 | const TOKEN_CACHE_TTL_MS = 5 * 60_000; | |
| 40 | const TOKEN_CACHE_MAX_ENTRIES = 32; | |
| 41 | /** Passed through so a seek in a long video costs one upstream range request, not a full download. */ | |
| 42 | const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; | |
| 43 | const 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. */ | |
| 52 | const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i; | |
| 53 | const 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. | |
| 56 | const 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 | */ | |
| 63 | const tokenCache = new Map<string, { readonly at: number; readonly token: Redacted.Redacted }>(); | |
| 64 | ||
| 65 | const 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 | */ | |
| 104 | const 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 | */ | |
| 143 | export 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 | 29 | |
| 30 | 30 | import * as ServerConfig from "./config.ts"; |
| 31 | 31 | import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; |
| 32 | import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; | |
| 32 | 33 | import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; |
| 33 | 34 | import { |
| 34 | 35 | ATTACHMENT_UPLOAD_ROUTE_PREFIX, |
| ... | ... | @@ -391,6 +392,19 @@ export const assetRouteLayer = HttpRouter.add( |
| 391 | 392 | if (!asset) { |
| 392 | 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 | 408 | return yield* assetFileResponse( |
| 395 | 409 | asset, |
| 396 | 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 | 169 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 170 | 170 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 171 | 171 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 172 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | |
| 172 | 173 | import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 173 | 174 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 174 | 175 | import * as ReviewService from "./review/ReviewService.ts"; |
| ... | ... | @@ -1210,7 +1211,7 @@ const buildAppUnderTest = (options?: { |
| 1210 | 1211 | Layer.provideMerge(ServerSecretStore.layer), |
| 1211 | 1212 | Layer.provide(workspaceAndProjectServicesLayer), |
| 1212 | 1213 | Layer.provideMerge(FetchHttpClient.layer), |
| 1213 | Layer.provide(VcsProcess.layer), | |
| 1214 | Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))), | |
| 1214 | 1215 | Layer.provide(layerConfig), |
| 1215 | 1216 | ); |
| 1216 | 1217 |
apps/server/src/server.ts+3-1| ... | ... | @@ -485,8 +485,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 485 | 485 | // Core Services |
| 486 | 486 | Layer.provideMerge(ServerSettingsLayerLive), |
| 487 | 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 | 490 | Layer.provideMerge( |
| 489 | Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), | |
| 491 | Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive, GitHubCli.layer), | |
| 490 | 492 | ), |
| 491 | 493 | Layer.provideMerge(GitLayerLive), |
| 492 | 494 | Layer.provideMerge(VcsLayerLive), |
apps/server/src/ws.ts+2| ... | ... | @@ -3083,6 +3083,8 @@ const makeWsRpcLayer = ( |
| 3083 | 3083 | if ( |
| 3084 | 3084 | input.resource._tag === "attachment" || |
| 3085 | 3085 | input.resource._tag === "native-app-icon" || |
| 3086 | // GitHub media names the repository it authenticates through itself. | |
| 3087 | input.resource._tag === "github-media" || | |
| 3086 | 3088 | (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) |
| 3087 | 3089 | ) { |
| 3088 | 3090 | return yield* issueAssetUrl({ resource: input.resource }); |
apps/web/src/components/ChatMarkdown.tsx+88-12| ... | ... | @@ -34,6 +34,7 @@ import type { |
| 34 | 34 | ThreadPullRequestKey, |
| 35 | 35 | } from "@t3tools/contracts"; |
| 36 | 36 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; |
| 37 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; | |
| 37 | 38 | import { |
| 38 | 39 | isAtomCommandInterrupted, |
| 39 | 40 | squashAtomCommandFailure, |
| ... | ... | @@ -220,6 +221,9 @@ interface ChatMarkdownProps { |
| 220 | 221 | extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>; |
| 221 | 222 | /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */ |
| 222 | 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 | 227 | /** Levels added to each markdown heading in the accessibility tree so the |
| 224 | 228 | text nests under the heading that introduces it, such as a chat message's |
| 225 | 229 | author. Rendered tags and their styling are unchanged. */ |
| ... | ... | @@ -1573,7 +1577,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1573 | 1577 | readonly environmentId: EnvironmentId; |
| 1574 | 1578 | readonly resource: Extract< |
| 1575 | 1579 | AssetResource, |
| 1576 | { readonly _tag: "attachment" | "workspace-file" | "media-file" } | |
| 1580 | { readonly _tag: "attachment" | "workspace-file" | "media-file" | "github-media" } | |
| 1577 | 1581 | >; |
| 1578 | 1582 | readonly kind?: "image" | "video"; |
| 1579 | 1583 | readonly alt: string; |
| ... | ... | @@ -1584,6 +1588,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1584 | 1588 | /** Caps the box height in rem while keeping the image's ratio; 30 by default. */ |
| 1585 | 1589 | readonly maxHeightRem?: number | undefined; |
| 1586 | 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 | 1603 | readonly workspaceRoot?: string | undefined; |
| 1588 | 1604 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 1589 | 1605 | }) { |
| ... | ... | @@ -1596,9 +1612,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1596 | 1612 | : resource._tag === "workspace-file" && props.workspaceRoot |
| 1597 | 1613 | ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` |
| 1598 | 1614 | : undefined; |
| 1599 | const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; | |
| 1600 | const relativePath = reference?.relativePath; | |
| 1601 | const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; | |
| 1615 | const reference = path | |
| 1616 | ? mediaFileReference(path, props.workspaceRoot) | |
| 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 | 1628 | // The server reads the pixel size from the file header, so the slot can be |
| 1603 | 1629 | // the image's final box instead of a 16:9 guess. An authored size wins; a |
| 1604 | 1630 | // caller's height cap shrinks the box while keeping the ratio. |
| ... | ... | @@ -1615,9 +1641,11 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1615 | 1641 | kind: props.kind ?? "image", |
| 1616 | 1642 | name: props.alt || (props.kind ?? "image"), |
| 1617 | 1643 | src, |
| 1618 | asset: { environmentId: props.environmentId, resource }, | |
| 1644 | ...(fallbackSrc === undefined | |
| 1645 | ? { asset: { environmentId: props.environmentId, resource } } | |
| 1646 | : {}), | |
| 1619 | 1647 | ...(reference ? { reference } : {}), |
| 1620 | ...(relativePath && resource._tag !== "attachment" | |
| 1648 | ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") | |
| 1621 | 1649 | ? { |
| 1622 | 1650 | onOpenFile: () => |
| 1623 | 1651 | useRightPanelStore |
| ... | ... | @@ -1634,9 +1662,10 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1634 | 1662 | return ( |
| 1635 | 1663 | <ChatMarkdownVideo |
| 1636 | 1664 | src={src} |
| 1637 | sourceFailed={assetUrl._tag === "Failure"} | |
| 1665 | sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined} | |
| 1638 | 1666 | alt={props.alt} |
| 1639 | 1667 | copyMarkdown={props.copyMarkdown} |
| 1668 | originalUrl={props.originalUrl} | |
| 1640 | 1669 | style={props.style} |
| 1641 | 1670 | mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} |
| 1642 | 1671 | onRetry={refreshAssetUrl} |
| ... | ... | @@ -1649,13 +1678,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1649 | 1678 | <ChatMarkdownImage |
| 1650 | 1679 | key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} |
| 1651 | 1680 | src={src} |
| 1652 | sourceFailed={assetUrl._tag === "Failure"} | |
| 1681 | sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined} | |
| 1653 | 1682 | alt={props.alt} |
| 1654 | 1683 | copyMarkdown={props.copyMarkdown} |
| 1655 | 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 | 1689 | style={style} |
| 1690 | imageProps={props.imageProps} | |
| 1658 | 1691 | actionsSource={actionsSource} |
| 1692 | originalUrl={props.originalUrl} | |
| 1659 | 1693 | onImageExpand={props.onImageExpand} |
| 1660 | 1694 | /> |
| 1661 | 1695 | ); |
| ... | ... | @@ -2226,6 +2260,7 @@ function useChatMarkdownState({ |
| 2226 | 2260 | onImageExpand, |
| 2227 | 2261 | renderContextReference, |
| 2228 | 2262 | headingLevelOffset = 0, |
| 2263 | githubMedia = false, | |
| 2229 | 2264 | }: ChatMarkdownProps) { |
| 2230 | 2265 | const { resolvedTheme } = useTheme(); |
| 2231 | 2266 | const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null); |
| ... | ... | @@ -2623,6 +2658,7 @@ function useChatMarkdownState({ |
| 2623 | 2658 | environmentId, |
| 2624 | 2659 | expandMedia, |
| 2625 | 2660 | fileLinkChip, |
| 2661 | githubMedia, | |
| 2626 | 2662 | renderContextReference, |
| 2627 | 2663 | headingLevelOffset, |
| 2628 | 2664 | imageBaseDir, |
| ... | ... | @@ -2652,6 +2688,7 @@ function useChatMarkdownState({ |
| 2652 | 2688 | environmentId, |
| 2653 | 2689 | expandMedia, |
| 2654 | 2690 | fileLinkChip, |
| 2691 | githubMedia, | |
| 2655 | 2692 | renderContextReference, |
| 2656 | 2693 | headingLevelOffset, |
| 2657 | 2694 | imageBaseDir, |
| ... | ... | @@ -3081,9 +3118,15 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3081 | 3118 | ); |
| 3082 | 3119 | }, |
| 3083 | 3120 | img: function MarkdownImage({ node, title, src, alt, ...props }) { |
| 3084 | const { expandMedia, cwd, imageBaseDir, threadRef, renderContextReference } = use( | |
| 3085 | ChatMarkdownRendererContext, | |
| 3086 | ); | |
| 3121 | const { | |
| 3122 | expandMedia, | |
| 3123 | cwd, | |
| 3124 | environmentId, | |
| 3125 | githubMedia, | |
| 3126 | imageBaseDir, | |
| 3127 | threadRef, | |
| 3128 | renderContextReference, | |
| 3129 | } = use(ChatMarkdownRendererContext); | |
| 3087 | 3130 | const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; |
| 3088 | 3131 | const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null; |
| 3089 | 3132 | if (contextReference) { |
| ... | ... | @@ -3109,6 +3152,39 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3109 | 3152 | const authoredSizeStyle = authoredImageSizeStyle(width, height); |
| 3110 | 3153 | const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); |
| 3111 | 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 | 3188 | if (imageSource._tag === "Direct") { |
| 3113 | 3189 | const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); |
| 3114 | 3190 | const originalUrl = |
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+57-1| ... | ... | @@ -1,8 +1,11 @@ |
| 1 | 1 | import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; |
| 2 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images"; | |
| 3 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; | |
| 4 | import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | 5 | import { createContext, useContext, useMemo } from "react"; |
| 4 | 6 | import type { Options as ReactMarkdownOptions } from "react-markdown"; |
| 5 | 7 | |
| 8 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; | |
| 6 | 9 | import { cn } from "~/lib/utils"; |
| 7 | 10 | import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore"; |
| 8 | 11 | |
| ... | ... | @@ -15,6 +18,46 @@ export const PullRequestMarkdownContext = createContext<{ |
| 15 | 18 | threadRef: ScopedThreadRef | null; |
| 16 | 19 | } | null>(null); |
| 17 | 20 | |
| 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 | */ | |
| 26 | function 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 | 61 | /** Renders PR uploads inline, with retry and an original link when video playback fails. */ |
| 19 | 62 | export function PullRequestMarkdown({ |
| 20 | 63 | text, |
| ... | ... | @@ -57,6 +100,19 @@ export function PullRequestMarkdown({ |
| 57 | 100 | pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} |
| 58 | 101 | environmentId={environmentId} |
| 59 | 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 | 50 | Schema.TaggedStruct("native-app-icon", { |
| 51 | 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 | ]); |
| 54 | 61 | export type AssetResource = typeof AssetResource.Type; |
| 55 | 62 | |
| ... | ... | @@ -285,6 +292,15 @@ export class AssetSigningKeyLoadError extends Schema.TaggedError<AssetSigningKey |
| 285 | 292 | } |
| 286 | 293 | } |
| 287 | 294 | |
| 295 | export 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 | ||
| 288 | 304 | export const AssetAccessError = Schema.Union([ |
| 289 | 305 | AssetWorkspaceContextNotFoundError, |
| 290 | 306 | AssetWorkspaceContextResolutionError, |
| ... | ... | @@ -298,6 +314,7 @@ export const AssetAccessError = Schema.Union([ |
| 298 | 314 | AssetProjectFaviconResolutionError, |
| 299 | 315 | AssetProjectFaviconInspectionError, |
| 300 | 316 | AssetProjectFaviconNotFoundError, |
| 317 | AssetGitHubMediaUrlValidationError, | |
| 301 | 318 | AssetSigningKeyLoadError, |
| 302 | 319 | ]); |
| 303 | 320 | export type AssetAccessError = typeof AssetAccessError.Type; |
packages/shared/package.json+4| ... | ... | @@ -43,6 +43,10 @@ |
| 43 | 43 | "types": "./src/git.ts", |
| 44 | 44 | "import": "./src/git.ts" |
| 45 | 45 | }, |
| 46 | "./githubMedia": { | |
| 47 | "types": "./src/githubMedia.ts", | |
| 48 | "import": "./src/githubMedia.ts" | |
| 49 | }, | |
| 46 | 50 | "./sourceControl": { |
| 47 | 51 | "types": "./src/sourceControl.ts", |
| 48 | 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 | ||
| 14 | const RAW_HOST = "raw.githubusercontent.com"; | |
| 15 | /** Git LFS pointers resolve here, which is where an LFS-tracked screenshot's bytes live. */ | |
| 16 | const LFS_HOST = "media.githubusercontent.com"; | |
| 17 | const ATTACHMENT_PATH_PATTERN = /^\/user-attachments\/assets\/[\w-]+$/u; | |
| 18 | /** What GitHub wrote into a body before `user-attachments`; older descriptions still carry it. */ | |
| 19 | const 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. */ | |
| 21 | const REPOSITORY_FILE_PATTERN = /^\/([^/]+)\/([^/]+)\/(?:raw|blob)\/(.*[^/])$/u; | |
| 22 | ||
| 23 | /** Port, userinfo, and fragment say nothing about which bytes GitHub will serve. */ | |
| 24 | function 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 | */ | |
| 32 | export 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 | */ | |
| 60 | export 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 | } |