| 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 | } |