Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 95 additions & 13 deletions packages/source-control-github/src/server/gitHubMediaFetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>() };
/**
* 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<string>(),
unreadableBy: new Set<string>(),
};
const rendered: Array<GitHubRest.GitHubRestInput> = [];
const lookups: Array<string> = [];
const signedUrls: Array<string> = [];
const requests: Array<{ url: string; authorization?: string; range?: string }> = [];
const credential = (host: string) =>
Expand All @@ -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 =
Expand All @@ -41,7 +73,8 @@ const makeSsoFixture = (uploadUrl: string) => {
truncated: false,
invalidUtf8: false,
};
}),
});
},
}),
Layer.succeed(
HttpClient.HttpClient,
Expand All @@ -57,9 +90,14 @@ const makeSsoFixture = (uploadUrl: string) => {
if (request.url === uploadUrl) {
return HttpClientResponse.fromWeb(
request,
new Response("<html>Sign in with SSO</html>", {
headers: { "content-type": "text/html; charset=utf-8" },
}),
repository === "sso"
? new Response("<html>Sign in with SSO</html>", {
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);
Expand All @@ -72,7 +110,7 @@ const makeSsoFixture = (uploadUrl: string) => {
),
),
);
return { layer, state, rendered, signedUrls, requests };
return { layer, state, rendered, lookups, signedUrls, requests };
};

describe("GitHubMediaFetch", () => {
Expand Down Expand Up @@ -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 };
Expand All @@ -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" });
Expand All @@ -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;
Expand Down
41 changes: 40 additions & 1 deletion packages/source-control-github/src/server/gitHubMediaFetch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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() ?? "";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading