Skip to content
Open
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
193 changes: 192 additions & 1 deletion apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { ThreadId } from "@t3tools/contracts";
import { DEFAULT_SERVER_SETTINGS, ThreadId } from "@t3tools/contracts";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
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 Stream from "effect/Stream";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
Expand All @@ -25,6 +28,8 @@ const testLayer = Layer.mergeAll(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
),
RepositoryIdentityResolver.layer,
ServerSettings.ServerSettingsService.layerTest(),
ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
).pipe(Layer.provideMerge(NodeServices.layer));

Expand Down Expand Up @@ -245,6 +250,191 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(testLayer)),
);

it.effect("issues exact capabilities for configured project icons outside the workspace", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-root-",
});
const iconDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-custom-",
});
const iconPath = path.join(iconDirectory, "custom.svg");
yield* fileSystem.writeFileString(iconPath, "<svg />");
const canonicalIconPath = yield* fileSystem.realPath(iconPath);
const settings = ServerSettings.ServerSettingsService.of({
start: Effect.void,
ready: Effect.void,
getSettings: Effect.succeed({
...DEFAULT_SERVER_SETTINGS,
projectIcons: { [root]: iconPath },
}),
updateSettings: () => Effect.die("not implemented"),
streamChanges: Stream.empty,
});

const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root, revision: iconPath },
}).pipe(Effect.provideService(ServerSettings.ServerSettingsService, settings));
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(
yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
).toEqual({ kind: "file", path: canonicalIconPath });
}).pipe(Effect.provide(testLayer)),
);

it.effect("finds workspace icon settings keyed by the unnormalized request root", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-raw-root-",
});
const requestedRoot = `${root}/.`;
const iconDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-raw-custom-",
});
const iconPath = path.join(iconDirectory, "custom.svg");
yield* fileSystem.writeFileString(iconPath, "<svg />");
const settings = ServerSettings.ServerSettingsService.of({
start: Effect.void,
ready: Effect.void,
getSettings: Effect.succeed({
...DEFAULT_SERVER_SETTINGS,
projectIcons: { [requestedRoot]: iconPath },
}),
updateSettings: () => Effect.die("not implemented"),
streamChanges: Stream.empty,
});

const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: requestedRoot, revision: iconPath },
}).pipe(Effect.provideService(ServerSettings.ServerSettingsService, settings));

expect(result.relativeUrl.endsWith("/custom.svg")).toBe(true);
}).pipe(Effect.provide(testLayer)),
);

it.effect("rejects automatically discovered icon symlinks outside the workspace", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-auto-symlink-root-",
});
const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-auto-symlink-outside-",
});
const outsidePath = path.join(outsideDirectory, "secret.svg");
yield* fileSystem.writeFileString(outsidePath, "<svg>secret</svg>");
yield* fileSystem.symlink(outsidePath, path.join(root, "favicon.svg"));

const error = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root },
}).pipe(Effect.flip);

expect(error._tag).toBe("AssetProjectFaviconNotFoundError");
}).pipe(Effect.provide(testLayer)),
);

it.effect("rejects a configured project icon replaced by a symlink after signing", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-symlink-root-",
});
const iconDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-symlink-custom-",
});
const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-symlink-outside-",
});
const iconPath = path.join(iconDirectory, "custom.svg");
const outsidePath = path.join(outsideDirectory, "secret.svg");
yield* fileSystem.writeFileString(iconPath, "<svg>icon</svg>");
yield* fileSystem.writeFileString(outsidePath, "<svg>secret</svg>");
const settings = ServerSettings.ServerSettingsService.of({
start: Effect.void,
ready: Effect.void,
getSettings: Effect.succeed({
...DEFAULT_SERVER_SETTINGS,
projectIcons: { [root]: iconPath },
}),
updateSettings: () => Effect.die("not implemented"),
streamChanges: Stream.empty,
});

const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root, revision: iconPath },
}).pipe(Effect.provideService(ServerSettings.ServerSettingsService, settings));
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");
const token = suffix.slice(0, separatorIndex);

yield* fileSystem.remove(iconPath);
yield* fileSystem.symlink(outsidePath, iconPath);

expect(yield* resolveAsset(token, suffix.slice(separatorIndex + 1))).toBeNull();
}).pipe(Effect.provide(testLayer)),
);

it.effect("uses a configured git-remote icon across clone paths", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-remote-root-",
});
const iconDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-remote-custom-",
});
const iconPath = path.join(iconDirectory, "custom.svg");
yield* fileSystem.writeFileString(iconPath, "<svg />");
const canonicalIconPath = yield* fileSystem.realPath(iconPath);
const settings = ServerSettings.ServerSettingsService.of({
start: Effect.void,
ready: Effect.void,
getSettings: Effect.succeed({
...DEFAULT_SERVER_SETTINGS,
projectIconsByGitRemote: { "github.com/t3tools/t3code": iconPath },
}),
updateSettings: () => Effect.die("not implemented"),
streamChanges: Stream.empty,
});
const repositoryIdentityResolver = RepositoryIdentityResolver.RepositoryIdentityResolver.of({
resolve: () =>
Effect.succeed({
canonicalKey: "github.com/t3tools/t3code",
locator: {
source: "git-remote",
remoteName: "origin",
remoteUrl: "git@github.com:T3Tools/T3Code.git",
},
}),
});

const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root, revision: iconPath },
}).pipe(
Effect.provideService(ServerSettings.ServerSettingsService, settings),
Effect.provideService(
RepositoryIdentityResolver.RepositoryIdentityResolver,
repositoryIdentityResolver,
),
);
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(
yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
).toEqual({ kind: "file", path: canonicalIconPath });
}).pipe(Effect.provide(testLayer)),
);

it.effect("preserves structured project favicon resolution causes", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -263,6 +453,7 @@ describe("AssetAccess", () => {
cause: platformCause,
});
const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({
resolve: () => Effect.fail(resolutionCause),
resolvePath: () => Effect.fail(resolutionCause),
});

Expand Down
112 changes: 87 additions & 25 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { resolveAttachmentPathById } from "../attachmentStore.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";

export const ASSET_ROUTE_PREFIX = "/api/assets";
Expand Down Expand Up @@ -84,6 +86,12 @@ const AssetClaimsSchema = Schema.Union([
relativePath: Schema.NullOr(Schema.String),
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(2),
kind: Schema.Literal("project-icon"),
absolutePath: Schema.NullOr(Schema.String),
expiresAt: Schema.Number,
}),
]);
type AssetClaims = typeof AssetClaimsSchema.Type;

Expand Down Expand Up @@ -282,8 +290,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
}),
),
);
const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe(
const settings = yield* ServerSettings.ServerSettingsService;
const serverSettings = yield* settings.getSettings.pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconResolutionError({
Expand All @@ -292,39 +300,67 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
}),
),
);
const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
if (
relativePath &&
!(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const repositoryIdentityResolver =
yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot);
const customIconPaths = [
serverSettings.projectIcons[workspaceRoot] ??
serverSettings.projectIcons[input.resource.cwd],
...(repositoryIdentity
? [serverSettings.projectIconsByGitRemote[repositoryIdentity.canonicalKey]]
: []),
].filter((iconPath): iconPath is string => iconPath !== undefined);
Comment thread
colonelpanic8 marked this conversation as resolved.
const resolvedFavicon = yield* faviconResolver
.resolve(workspaceRoot, customIconPaths.length === 0 ? undefined : { customIconPaths })
.pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconInspectionError({
new AssetProjectFaviconResolutionError({
resource: input.resource,
cause,
}),
),
))
) {
return yield* new AssetProjectFaviconNotFoundError({
resource: input.resource,
});
);
const canonicalFaviconPath = resolvedFavicon
? resolvedFavicon.source === "custom-setting"
? yield* optionOnNotFound(fileSystem.realPath(resolvedFavicon.path)).pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconInspectionError({
resource: input.resource,
cause,
}),
),
)
: yield* Effect.gen(function* () {
const canonicalPath = yield* resolveCanonicalWorkspaceFile({
workspaceRoot,
relativePath: path.relative(workspaceRoot, resolvedFavicon.path),
}).pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconInspectionError({
resource: input.resource,
cause,
}),
),
);
return canonicalPath === null ? Option.none<string>() : Option.some(canonicalPath);
})
: Option.none<string>();
if (resolvedFavicon && Option.isNone(canonicalFaviconPath)) {
return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource });
}
claims = {
version: 1,
kind: "project-favicon",
workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
Effect.mapError(
(cause) =>
new AssetWorkspaceResolutionError({
resource: input.resource,
cause,
}),
),
),
relativePath,
version: 2,
kind: "project-icon",
absolutePath: Option.getOrNull(canonicalFaviconPath),
expiresAt,
};
fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER;
fileName = resolvedFavicon
? path.basename(resolvedFavicon.path)
: PROJECT_FAVICON_FALLBACK_MARKER;
break;
}
}
Expand Down Expand Up @@ -396,6 +432,32 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
});
return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
}
if (claims.kind === "project-icon") {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (claims.absolutePath === null) return null;
const fileSystem = yield* FileSystem.FileSystem;
const canonicalPath = yield* optionOnNotFound(fileSystem.realPath(claims.absolutePath)).pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to canonicalize configured project icon.", {
path: claims.absolutePath,
cause,
}),
),
Effect.orElseSucceed(() => Option.none()),
);
if (Option.isNone(canonicalPath) || canonicalPath.value !== claims.absolutePath) return null;
const info = yield* optionOnNotFound(fileSystem.stat(canonicalPath.value)).pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to inspect configured project icon.", {
path: canonicalPath.value,
cause,
}),
),
Effect.orElseSucceed(() => Option.none()),
);
return Option.isSome(info) && info.value.type === "File"
? ({ kind: "file", path: canonicalPath.value } satisfies ResolvedAsset)
: null;
Comment thread
colonelpanic8 marked this conversation as resolved.
}

const decodedPath = decodeRelativePath(relativePath);
if (decodedPath === null) return null;
Expand Down
Loading
Loading