1import * as Mime from "effect/http/Mime";
2import { githubMediaFileName } from "@t3tools/shared/githubMedia";
3import * as Clock from "effect/Clock";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import {
8 FetchHttpClient,
9 HttpClient,
10 HttpClientRequest,
11 HttpServerResponse,
12 type HttpClientResponse,
13} from "effect/http";
14
15import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
16
17/**
18 * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned
19 * object stores GitHub hands assets off to — authorizes with its own signature, and some of
20 * them reject a request that also carries a bearer token.
21 */
22const CREDENTIALED_HOSTS = new Set([
23 "github.com",
24 "www.github.com",
25 "raw.githubusercontent.com",
26 "media.githubusercontent.com",
27]);
28const isCredentialedHost = (url: string) => {
29 try {
30 return CREDENTIALED_HOSTS.has(new URL(url).hostname.toLowerCase());
31 } catch {
32 return false;
33 }
34};
35
36/** GitHub answers an asset request with a 302 to a signed object URL that needs no credential. */
37const MAX_REDIRECTS = 3;
38/** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */
39const MANUAL_REDIRECT: RequestInit = { redirect: "manual" };
40/** Passed through so a seek in a long video costs one upstream range request, not a full download. */
41const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const;
42const FORWARDED_RESPONSE_HEADERS = [
43 "content-type",
44 "content-length",
45 "content-range",
46 "accept-ranges",
47 "etag",
48 "last-modified",
49] as const;
50/** A pull request embeds pictures and recordings. Anything else is not served from our origin. */
51const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i;
52const SVG_CONTENT_TYPE = "image/svg+xml";
53// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a
54// body cannot run script against this origin.
55const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
56
57/**
58 * The github.com credential, or null without one: a public asset still loads, and a private one
59 * fails the way it does in a browser that is not signed in.
60 */
61const githubToken = GitHubCredentials.GitHubCredentials.pipe(
62 Effect.flatMap((credentials) => credentials.get("github.com")),
63 Effect.map((credential) => credential.token),
64 Effect.orElseSucceed(() => null),
65);
66
67/**
68 * Follows GitHub's redirect to the signed object itself, and never carries the credential off
69 * GitHub: the object URL authorizes with its own signature, and the store it lives in has no
70 * business seeing a token.
71 */
72const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedirects")(function* (
73 url: string,
74 headers: Record<string, string>,
75 token: Redacted.Redacted | null,
76) {
77 const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient);
78 let target = url;
79 for (let hop = 0; ; hop += 1) {
80 // The credential rides only on a request to GitHub itself. A redirect leads to a signed
81 // object URL that authorizes on its own, and the store it lives in has no business seeing
82 // a token — deciding that from the target, not from the hop count, is what makes it so.
83 const authorization =
84 token !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(token)}` : null;
85 const response: HttpClientResponse.HttpClientResponse = yield* httpClient
86 .execute(
87 HttpClientRequest.get(target).pipe(
88 HttpClientRequest.setHeaders({
89 ...headers,
90 // The bytes are streamed straight through, so never let an encoding layer in.
91 "accept-encoding": "identity",
92 ...(authorization === null ? {} : { authorization }),
93 }),
94 ),
95 )
96 .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT));
97 const location = response.headers.location;
98 if (response.status < 300 || response.status >= 400) return response;
99 // A chain this long is not GitHub answering with bytes, and its body is not the media.
100 if (!location || hop >= MAX_REDIRECTS) return null;
101 const next = new URL(location, target);
102 if (next.protocol !== "https:") return null;
103 target = next.toString();
104 }
105});
106
107/**
108 * Serves media a pull request body points at on GitHub through the `gh` credential, which is the
109 * only thing that distinguishes a readable private attachment from a 404.
110 */
111export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaResponse")(function* (
112 asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number },
113 requestHeaders: Record<string, string | undefined>,
114) {
115 // Both media hosts are served by github.com's account, which is the host `gh` stores it under.
116 const token = yield* githubToken;
117 const forwarded: Record<string, string> = {};
118 for (const name of FORWARDED_REQUEST_HEADERS) {
119 const value = requestHeaders[name];
120 if (value !== undefined) forwarded[name] = value;
121 }
122 const response = yield* fetchFollowingRedirects(asset.url, forwarded, token);
123 // An upload GitHub hosts never changes under its URL, so the only thing a cached copy must
124 // not outlive is the signed URL that granted it — which is the same bound the URL itself has.
125 const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000);
126 const headers: Record<string, string> = {
127 "cache-control":
128 remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store",
129 "x-content-type-options": "nosniff",
130 };
131 if (response === null) return HttpServerResponse.empty({ status: 502, headers });
132 // An upstream refusal is the client's answer, not this server's fault; only a broken hop is.
133 // It carries none of the upstream entity headers: a `content-length` with no body behind it
134 // holds the connection open until the browser gives up on it.
135 if (response.status >= 400) {
136 return HttpServerResponse.empty({
137 status: response.status >= 500 ? 502 : response.status,
138 headers,
139 });
140 }
141 // Only pictures and recordings leave this origin, and never on GitHub's word alone: the raw
142 // host labels every committed binary `application/octet-stream`, so the name decides those.
143 const upstreamType =
144 response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
145 const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType)
146 ? upstreamType
147 : Option.getOrElse(Mime.getType(githubMediaFileName(asset.url)), () => "").toLowerCase();
148 if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) {
149 return HttpServerResponse.empty({ status: 415, headers });
150 }
151 for (const name of FORWARDED_RESPONSE_HEADERS) {
152 const value = response.headers[name];
153 if (value !== undefined) headers[name] = value;
154 }
155 headers["content-type"] = contentType;
156 if (contentType === SVG_CONTENT_TYPE) {
157 headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY;
158 }
159 return HttpServerResponse.stream(response.stream, {
160 status: response.status,
161 headers,
162 contentType,
163 });
164});