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
31 changes: 28 additions & 3 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { 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";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -47,6 +48,30 @@ const testLayer = Layer.mergeAll(
).pipe(Layer.provideMerge(NodeServices.layer));

describe("AssetAccess", () => {
it.effect("signs a Gitea image URL for one asset and expires it", () =>
Effect.gen(function* () {
const url = "https://forge.test/attachments/82cde921-c3fc-4c01-85b8-edf737cdaa83";
const result = yield* issueAssetUrl({
resource: { _tag: "source-control-image", provider: "gitea", url },
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const token = suffix.slice(0, suffix.indexOf("/"));
expect(yield* resolveAsset(token, "image")).toEqual({ kind: "source-control-image", url });
expect(yield* resolveAsset(token, "other-image")).toBeNull();
expect(yield* resolveAsset(`${token}tampered`, "image")).toBeNull();
yield* TestClock.adjust("2 hours");
expect(yield* resolveAsset(token, "image")).toBeNull();
}).pipe(
Effect.provide(
Layer.merge(
testLayer,
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { T3CODE_GITEA_BASE_URL: "https://forge.test" } }),
),
),
),
),
);
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 +253,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 +416,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 +432,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
28 changes: 28 additions & 0 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AssetProjectFaviconNotFoundError,
AssetProjectFaviconResolutionError,
AssetSigningKeyLoadError,
AssetSourceControlImageError,
AssetWorkspaceAssetInspectionError,
AssetWorkspaceAssetNotFoundError,
AssetWorkspaceContextNotFoundError,
Expand Down Expand Up @@ -49,6 +50,7 @@ import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
import * as GiteaAttachment from "../sourceControl/GiteaAttachment.ts";
import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";

export const ASSET_ROUTE_PREFIX = "/api/assets";
Expand Down Expand Up @@ -79,6 +81,13 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([
]);

const AssetClaimsSchema = Schema.Union([
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("source-control-image"),
provider: Schema.Literal("gitea"),
url: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("workspace-file"),
Expand Down Expand Up @@ -272,6 +281,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
let imageDimensions: ImageDimensions | null = null;

switch (input.resource._tag) {
case "source-control-image": {
const url = yield* GiteaAttachment.validateUrl(input.resource.url).pipe(
Effect.mapError(
() =>
new AssetSourceControlImageError({
resource: input.resource,
detail: "The image is not an attachment on the configured Gitea server.",
}),
),
);
claims = { version: 1, kind: "source-control-image", provider: "gitea", url, expiresAt };
fileName = "image";
break;
}
case "media-file": {
let requestedPath = input.resource.path;
if (!path.isAbsolute(requestedPath)) {
Expand Down Expand Up @@ -624,6 +647,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
const claims = decodeClaims(encodedPayload);
if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;

if (claims.kind === "source-control-image") {
if (relativePath !== "image") return null;
return { kind: "source-control-image" as const, url: claims.url };
}

if (claims.kind === "attachment") {
const config = yield* ServerConfig.ServerConfig;
const attachmentPath = resolveAttachmentPathById({
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { OtlpTracer } from "effect/unstable/observability";

import * as ServerConfig from "./config.ts";
import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
import * as GiteaAttachment from "./sourceControl/GiteaAttachment.ts";
import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";
import {
ATTACHMENT_UPLOAD_ROUTE_PREFIX,
Expand Down Expand Up @@ -388,6 +389,11 @@ export const assetRouteLayer = HttpRouter.add(
if (!asset) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
if (asset.kind === "source-control-image") {
return yield* GiteaAttachment.imageResponse(asset.url).pipe(
Effect.orElseSucceed(() => HttpServerResponse.text("Image unavailable", { status: 502 })),
);
}
return yield* assetFileResponse(
asset,
request.method === "GET" ? request.headers.range : undefined,
Expand Down
70 changes: 70 additions & 0 deletions apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1887,6 +1887,76 @@ layer("GiteaPullRequestApi", (it) => {
}),
);

it.effect.each(["", " \n\t "])(
"submits inline change requests without a user-written summary: %j",
(body) =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(Effect.succeed(response({})));
const api = yield* GiteaPullRequestApi.make;
yield* api.submitReview({
host: "forge.example.test",
repository: "acme/web",
number: 7,
verdict: "request-changes",
body,
comments: [
{
body: "Keep this check.",
path: "src/a.ts",
position: { kind: "deleted", oldLine: 4 },
},
{
body: "Handle the empty result.",
path: "src/b.ts",
position: { kind: "added", newLine: 9 },
},
],
});

expect(mockedRequest).toHaveBeenCalledTimes(1);
expect(callAt(0)).toMatchObject({
method: "POST",
path: "/repos/acme/web/pulls/7/reviews",
});
expect(decodeJson(callAt(0).body ?? "{}")).toEqual({
event: "REQUEST_CHANGES",
body: "See inline comments.",
comments: [
{ body: "Keep this check.", path: "src/a.ts", old_position: 4 },
{ body: "Handle the empty result.", path: "src/b.ts", new_position: 9 },
],
});
}),
);

it.effect.each(["approve", "comment"] as const)(
"keeps the summary empty for an inline %s review",
(verdict) =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(Effect.succeed(response({})));
const api = yield* GiteaPullRequestApi.make;
yield* api.submitReview({
host: "forge.example.test",
repository: "acme/web",
number: 7,
verdict,
body: "",
comments: [
{
body: "Worth following up separately.",
path: "src/a.ts",
position: { kind: "added", newLine: 4 },
},
],
});

expect(decodeJson(callAt(0).body ?? "{}")).toMatchObject({
event: verdict === "approve" ? "APPROVED" : "COMMENT",
body: "",
});
}),
);

it.effect("maps native warning statuses to failing checks", () =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/pullRequest/GiteaPullRequestApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2199,7 +2199,13 @@ export const make = Effect.gen(function* () {
: input.verdict === "request-changes"
? "REQUEST_CHANGES"
: "COMMENT",
body: input.body,
// Gitea requires a summary for change requests even when inline comments are present.
body:
input.verdict === "request-changes" &&
input.body.trim().length === 0 &&
input.comments.length > 0
? "See inline comments."
: input.body,
comments: input.comments.map((comment) => ({
body: comment.body,
path: comment.path,
Expand Down
122 changes: 122 additions & 0 deletions apps/server/src/sourceControl/GiteaAttachment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { expect, it, vi } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpClientResponse,
HttpServerResponse,
} from "effect/unstable/http";

import * as GiteaAttachment from "./GiteaAttachment.ts";

const url = "https://forge.test/gitea/attachments/82cde921-c3fc-4c01-85b8-edf737cdaa83";
const config = ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
T3CODE_GITEA_BASE_URL: "https://forge.test/gitea",
T3CODE_GITEA_TOKEN: "private-token",
},
}),
);

function httpLayer(response: Response) {
const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const requestInit = yield* Effect.serviceOption(FetchHttpClient.RequestInit);
expect(Option.getOrNull(requestInit)?.redirect).toBe("manual");
return HttpClientResponse.fromWeb(request, response);
}),
);
return {
execute,
layer: Layer.merge(config, Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))),
};
}

it.effect("streams a private Gitea image using server credentials", () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
const { execute, layer } = httpLayer(
new Response(bytes, { headers: { "Content-Type": "image/png" } }),
);
return Effect.gen(function* () {
const response = yield* GiteaAttachment.imageResponse(url);
expect(response.status).toBe(200);
expect(execute.mock.calls[0]?.[0]).toMatchObject({
url,
headers: { authorization: "token private-token" },
});
const web = HttpServerResponse.toWeb(response);
expect(new Uint8Array(yield* Effect.promise(() => web.arrayBuffer()))).toEqual(bytes);
expect(web.headers.get("content-type")).toBe("image/png");
expect(web.headers.get("authorization")).toBeNull();
expect(web.headers.get("content-security-policy")).toContain("sandbox");
}).pipe(Effect.scoped, Effect.provide(layer));
});

it.effect("fails a stalled image body and cancels the upstream reader", () => {
const cancel = vi.fn();
const { layer } = httpLayer(
new Response(
new ReadableStream<Uint8Array>({
start: (controller) => controller.enqueue(new Uint8Array([137, 80, 78, 71])),
cancel,
}),
{ headers: { "Content-Type": "image/png" } },
),
);
return Effect.gen(function* () {
const response = yield* GiteaAttachment.imageResponse(url);
expect(response.status).toBe(200);
if (response.body._tag !== "Stream") return yield* Effect.die("Expected an image stream");
const receivedChunk = yield* Deferred.make<void>();
const read = yield* response.body.stream.pipe(
Stream.tap(() => Deferred.succeed(receivedChunk, undefined)),
Stream.runDrain,
Effect.flip,
Effect.forkScoped,
);
yield* Deferred.await(receivedChunk);
yield* TestClock.adjust("30 seconds");
expect(yield* Fiber.join(read)).toMatchObject({
_tag: "GiteaAttachmentError",
detail: "Gitea image body timed out.",
});
expect(cancel).toHaveBeenCalledTimes(1);
}).pipe(Effect.scoped, Effect.provide(layer));
});

it.effect.each([
"https://elsewhere.test/gitea/attachments/82cde921-c3fc-4c01-85b8-edf737cdaa83",
"https://forge.test/api/v1/user",
])("does not send credentials to %s", (source) => {
const { execute, layer } = httpLayer(new Response());
return Effect.gen(function* () {
const error = yield* GiteaAttachment.imageResponse(source).pipe(Effect.flip);
expect(error._tag).toBe("GiteaAttachmentError");
expect(execute).not.toHaveBeenCalled();
}).pipe(Effect.scoped, Effect.provide(layer));
});

it.effect.each([
{ status: 302, headers: { Location: "https://elsewhere.test/image.png" }, expected: 502 },
{ status: 404, headers: {}, expected: 404 },
{ status: 200, headers: { "Content-Type": "text/html" }, expected: 415 },
])(
"rejects an upstream response with status $status and headers $headers",
({ status, headers, expected }) => {
const { execute, layer } = httpLayer(new Response("", { status, headers }));
return Effect.gen(function* () {
const response = yield* GiteaAttachment.imageResponse(url);
expect(response.status).toBe(expected);
expect(execute).toHaveBeenCalledTimes(1);
}).pipe(Effect.scoped, Effect.provide(layer));
},
);
Loading
Loading