diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.test.ts b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts index 8248a76e4b04d..058ed642a47c9 100644 --- a/packages/source-control-github/src/server/gitHubMediaFetch.test.ts +++ b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts @@ -7,14 +7,26 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubMediaFetch from "./gitHubMediaFetch.ts"; +import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as GitHubRest from "./GitHubRest.ts"; const PR_REPOSITORY = "enterprise/private"; -/** An SSO organization: the upload URL answers with a sign-in page, a rendered signed URL with bytes. */ -const makeSsoFixture = (uploadUrl: string) => { - const state = { account: "account-a", expired: new Set() }; +/** + * The upload URL answers with an SSO organization's sign-in page, or with the plain 404 a token gets + * in a private repository, a public one, or one the account cannot see. A signed URL has bytes. + */ +const makeUploadFixture = ( + uploadUrl: string, + repository: "sso" | "private" | "public" | "hidden" = "sso", +) => { + const state = { + account: "account-a", + expired: new Set(), + unreadableBy: new Set(), + }; const rendered: Array = []; + const lookups: Array = []; const signedUrls: Array = []; const requests: Array<{ url: string; authorization?: string; range?: string }> = []; const credential = (host: string) => @@ -27,8 +39,28 @@ const makeSsoFixture = (uploadUrl: string) => { const layer = Layer.mergeAll( Layer.mock(GitHubCredentials.GitHubCredentials)({ get: credential }), Layer.mock(GitHubRest.GitHubRest)({ - rest: (input) => - Effect.sync(() => { + rest: (input) => { + if (input.path !== "markdown") { + lookups.push(input.path); + return repository === "hidden" || state.unreadableBy.has(state.account) + ? Effect.fail( + new GitHubRequestPolicy.GitHubApiNotFoundError({ + host: input.host, + operation: input.operation, + }), + ) + : Effect.succeed({ + status: 200, + headers: {}, + body: JSON.stringify({ + full_name: PR_REPOSITORY, + private: repository !== "public", + }), + truncated: false, + invalidUtf8: false, + }); + } + return Effect.sync(() => { rendered.push(input); const signed = `https://private-user-images.githubusercontent.com/1/upload.mp4?jwt=${state.account}-${rendered.length}&x=1`; const inRepository = @@ -41,7 +73,8 @@ const makeSsoFixture = (uploadUrl: string) => { truncated: false, invalidUtf8: false, }; - }), + }); + }, }), Layer.succeed( HttpClient.HttpClient, @@ -57,9 +90,14 @@ const makeSsoFixture = (uploadUrl: string) => { if (request.url === uploadUrl) { return HttpClientResponse.fromWeb( request, - new Response("Sign in with SSO", { - headers: { "content-type": "text/html; charset=utf-8" }, - }), + repository === "sso" + ? new Response("Sign in with SSO", { + headers: { "content-type": "text/html; charset=utf-8" }, + }) + : new Response("Not Found", { + status: 404, + headers: { "content-type": "text/plain; charset=utf-8" }, + }), ); } expect(signedUrls).toContain(request.url); @@ -72,7 +110,7 @@ const makeSsoFixture = (uploadUrl: string) => { ), ), ); - return { layer, state, rendered, signedUrls, requests }; + return { layer, state, rendered, lookups, signedUrls, requests }; }; describe("GitHubMediaFetch", () => { @@ -180,7 +218,7 @@ describe("GitHubMediaFetch", () => { "renders SSO uploads in the PR repository and reuses the signed URL for a range", () => { const url = "https://github.com/user-attachments/assets/explicit-pr-context"; - const fixture = makeSsoFixture(url); + const fixture = makeUploadFixture(url); return Effect.gen(function* () { const fetchMedia = yield* GitHubMediaFetch.make; const request = { url, headers: {}, repository: PR_REPOSITORY }; @@ -205,9 +243,53 @@ describe("GitHubMediaFetch", () => { }, ); + it.effect( + "renders an upload in a private repository, where it is a plain 404 to any token", + () => { + const url = "https://github.com/user-attachments/assets/private-repository"; + const fixture = makeUploadFixture(url, "private"); + const request = { url, headers: {}, repository: PR_REPOSITORY }; + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + expect((yield* fetchMedia(request))?.status).toBe(200); + yield* TestClock.adjust("6 minutes"); + expect((yield* fetchMedia(request))?.status).toBe(200); + expect(fixture.rendered).toHaveLength(2); + // The repository's visibility outlives a signed URL. + expect(fixture.lookups).toEqual([`repos/${PR_REPOSITORY}`]); + + // It is not another account's to reuse: one that cannot read the repository renders nothing. + fixture.state.account = "account-b"; + fixture.state.unreadableBy.add("account-b"); + expect((yield* fetchMedia(request))?.status).toBe(404); + expect(fixture.rendered).toHaveLength(2); + expect(fixture.lookups).toHaveLength(2); + }).pipe(Effect.provide(fixture.layer), Effect.scoped); + }, + ); + + it.effect("leaves a 404 alone unless the repository is known to be private", () => + Effect.gen(function* () { + for (const [repository, request] of [ + ["public", { repository: PR_REPOSITORY }], + ["hidden", { repository: PR_REPOSITORY }], + ["private", {}], + ] as const) { + const url = "https://github.com/user-attachments/assets/missing"; + const fixture = makeUploadFixture(url, repository); + const response = yield* Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + return yield* fetchMedia({ url, headers: {}, ...request }); + }).pipe(Effect.provide(fixture.layer)); + expect(response?.status).toBe(404); + expect(fixture.rendered).toEqual([]); + } + }).pipe(Effect.scoped), + ); + it.effect("renders a legacy upload in the repository its path names", () => { const url = `https://github.com/${PR_REPOSITORY}/assets/123/legacy`; - const fixture = makeSsoFixture(url); + const fixture = makeUploadFixture(url); return Effect.gen(function* () { const fetchMedia = yield* GitHubMediaFetch.make; const response = yield* fetchMedia({ url, headers: {}, repository: "personal/unrelated" }); @@ -220,7 +302,7 @@ describe("GitHubMediaFetch", () => { it.effect("renders a fresh signed URL after an account switch, early expiry, or its TTL", () => { const url = "https://github.com/user-attachments/assets/cache-lifecycle"; - const fixture = makeSsoFixture(url); + const fixture = makeUploadFixture(url); const request = { url, headers: {}, repository: PR_REPOSITORY }; return Effect.gen(function* () { const fetchMedia = yield* GitHubMediaFetch.make; diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.ts b/packages/source-control-github/src/server/gitHubMediaFetch.ts index b03c0d327a6ce..b845d79a7511f 100644 --- a/packages/source-control-github/src/server/gitHubMediaFetch.ts +++ b/packages/source-control-github/src/server/gitHubMediaFetch.ts @@ -1,7 +1,12 @@ import type { SourceControlMediaRequest } from "@t3tools/source-control-core/server/driver"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { FetchHttpClient, HttpClient, @@ -39,6 +44,7 @@ const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u; // GitHub's signed upload links last about five minutes. const SIGNED_URL_CACHE_TTL_MS = 4 * 60_000; const SIGNED_URL_CACHE_MAX_ENTRIES = 64; +const decodeVisibility = decodeJsonResult(Schema.Struct({ private: Schema.Boolean })); const contentTypeOf = (response: HttpClientResponse.HttpClientResponse) => response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; @@ -112,6 +118,28 @@ export const make = Effect.gen(function* () { } }); + // Only a private repository's uploads are worth rendering after a 404; in a public one the file + // is gone. Whether an account can read the repository is part of the answer. + const privateRepositories = yield* Cache.makeWith( + Effect.fnUntraced(function* (key: { + readonly fingerprint: string; + readonly repository: string; + }) { + const [owner = "", name = ""] = key.repository.split("/"); + const response = yield* rest.rest({ + host: "github.com", + operation: "getRepository", + path: `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, + }); + const decoded = decodeVisibility(response.body); + return Result.isSuccess(decoded) && decoded.success.private; + }), + { + capacity: SIGNED_URL_CACHE_MAX_ENTRIES, + timeToLive: (exit) => (Exit.isSuccess(exit) ? "1 hour" : 0), + }, + ); + // An organization with SAML SSO answers a token-authenticated upload request with its sign-in // page. Rendering the link as markdown in the repository's context hands back a signed URL. const renderSignedUrl = Effect.fnUntraced(function* (url: string, repository: string) { @@ -164,7 +192,18 @@ export const make = Effect.gen(function* () { request.headers, signedIn?.token ?? null, ); - if (response === null || contentTypeOf(response) !== "text/html") return response; + if (response === null) return response; + if (contentTypeOf(response) !== "text/html") { + // A private repository's upload is a plain 404 to any token. + if (response.status !== 404 || repository === undefined || signedIn === null) { + return response; + } + const isPrivate = yield* Cache.get(privateRepositories, { + fingerprint: signedIn.fingerprint, + repository, + }).pipe(Effect.orElseSucceed(() => false)); + if (!isPrivate) return response; + } } if (cacheKey === null || repository === undefined || signedIn === null) return response; const signed = yield* renderSignedUrl(request.url, repository);