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}