import type { AssetResource } from "@t3tools/contracts";import { AssetAccessError } from "@t3tools/contracts";import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,} from "@t3tools/shared/filePreview";import * as Clock from "effect/Clock";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema"; import { base64UrlDecodeUtf8, base64UrlEncode, signPayload, timingSafeEqualBase64Url,} from "../auth/utils.ts";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 WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets";export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; const SIGNING_SECRET_NAME = "asset-access-signing-key";const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, ".css", ".js", ".mjs", ".otf", ".ttf", ".woff", ".woff2",]); const AssetClaimsSchema = Schema.Union([ Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("workspace-file"), workspaceRoot: Schema.String, baseRelativePath: Schema.String, expiresAt: Schema.Number, }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("workspace-file-exact"), workspaceRoot: Schema.String, relativePath: Schema.String, expiresAt: Schema.Number, }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), attachmentId: Schema.String, expiresAt: Schema.Number, }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("project-favicon"), workspaceRoot: Schema.String, relativePath: Schema.NullOr(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 kind: "project-favicon-fallback" }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload))); } catch { return null; }} function decodeRelativePath(value: string): string | null { try { return decodeURIComponent(value); } catch { return null; }} const failAccess = (message: string, cause?: unknown) => new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const resolved = yield* workspacePaths .resolveRelativePathWithinRoot(input) .pipe(Effect.orElseSucceed(() => null)); if (!resolved) return null; const [canonicalRoot, canonicalFile] = yield* Effect.all([ fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)), fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)), ]); if (!canonicalRoot || !canonicalFile) return null; const path = yield* Path.Path; const relative = path.relative(canonicalRoot, canonicalFile); if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null; const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null)); return info?.type === "File" ? canonicalFile : null; },); export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string;}) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { return yield* failAccess("Workspace context was not found."); } const workspaceRoot = yield* workspacePaths .normalizeWorkspaceRoot(input.workspaceRoot) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); const relativePath = path.isAbsolute(input.resource.path) ? path.relative(workspaceRoot, input.resource.path) : input.resource.path; const resolved = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { return yield* failAccess("Only browser documents and images can be previewed."); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: resolved.relativePath, }); if (!canonicalFile) { return yield* failAccess("Workspace asset was not found."); } const canonicalWorkspaceRoot = yield* fileSystem .realPath(workspaceRoot) .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))); claims = isWorkspaceImagePreviewPath(resolved.relativePath) ? { version: 1, kind: "workspace-file-exact", workspaceRoot: canonicalWorkspaceRoot, relativePath: resolved.relativePath, expiresAt, } : { version: 1, kind: "workspace-file", workspaceRoot: canonicalWorkspaceRoot, baseRelativePath: path.dirname(resolved.relativePath), expiresAt, }; fileName = path.basename(resolved.relativePath); break; } case "attachment": { const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { return yield* failAccess("Attachment was not found."); } claims = { version: 1, kind: "attachment", attachmentId: input.resource.attachmentId, expiresAt, }; fileName = path.basename(attachmentPath); break; } case "project-favicon": { const workspaceRoot = yield* workspacePaths .normalizeWorkspaceRoot(input.resource.cwd) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if ( relativePath && !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath })) ) { return yield* failAccess("Project favicon was not found."); } claims = { version: 1, kind: "project-favicon", workspaceRoot: yield* fileSystem .realPath(workspaceRoot) .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), relativePath, expiresAt, }; fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; break; } } const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, };}); export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( token: string, relativePath: string,) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.orElseSucceed(() => null)); if (!signingSecret) return null; if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: claims.attachmentId, }); if (!attachmentPath) return null; const fileSystem = yield* FileSystem.FileSystem; const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null)); return info?.type === "File" ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) : null; } if (claims.kind === "project-favicon") { if (claims.relativePath === null) { return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; } const faviconPath = yield* resolveCanonicalWorkspaceFile({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; } const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); return exactWorkspaceFile ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset) : null; } const segments = decodedPath.split(/[\\/]/); if ( decodedPath.length === 0 || decodedPath.includes("\0") || segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) || !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase()) ) { return null; } const joinedRelativePath = claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); const workspaceFile = yield* resolveCanonicalWorkspaceFile({ workspaceRoot: claims.workspaceRoot, relativePath: joinedRelativePath, }); return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null;});
import type { AssetResource } from "@t3tools/contracts";
import { AssetAccessError } from "@t3tools/contracts";
import {
isWorkspaceImagePreviewPath,
isWorkspacePreviewEntryPath,
WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
} from "@t3tools/shared/filePreview";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import {
base64UrlDecodeUtf8,
base64UrlEncode,
signPayload,
timingSafeEqualBase64Url,
} from "../auth/utils.ts";
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 WorkspacePaths from "../workspace/WorkspacePaths.ts";
export const ASSET_ROUTE_PREFIX = "/api/assets";
export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
const SIGNING_SECRET_NAME = "asset-access-signing-key";
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
const PREVIEW_ASSET_EXTENSIONS = new Set([
...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
".css",
".js",
".mjs",
".otf",
".ttf",
".woff",
".woff2",
]);
const AssetClaimsSchema = Schema.Union([
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("workspace-file"),
workspaceRoot: Schema.String,
baseRelativePath: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("workspace-file-exact"),
workspaceRoot: Schema.String,
relativePath: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("attachment"),
attachmentId: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("project-favicon"),
workspaceRoot: Schema.String,
relativePath: Schema.NullOr(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 kind: "project-favicon-fallback" };
function decodeClaims(encodedPayload: string): AssetClaims | null {
try {
return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload)));
} catch {
return null;
}
}
function decodeRelativePath(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
const failAccess = (message: string, cause?: unknown) =>
new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) });
const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
const fileSystem = yield* FileSystem.FileSystem;
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const resolved = yield* workspacePaths
.resolveRelativePathWithinRoot(input)
.pipe(Effect.orElseSucceed(() => null));
if (!resolved) return null;
const [canonicalRoot, canonicalFile] = yield* Effect.all([
fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)),
fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)),
]);
if (!canonicalRoot || !canonicalFile) return null;
const path = yield* Path.Path;
const relative = path.relative(canonicalRoot, canonicalFile);
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null));
return info?.type === "File" ? canonicalFile : null;
},
);
export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
readonly resource: AssetResource;
readonly workspaceRoot?: string;
}) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
let claims: AssetClaims;
let fileName: string;
switch (input.resource._tag) {
case "workspace-file": {
if (!input.workspaceRoot) {
return yield* failAccess("Workspace context was not found.");
}
const workspaceRoot = yield* workspacePaths
.normalizeWorkspaceRoot(input.workspaceRoot)
.pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
const relativePath = path.isAbsolute(input.resource.path)
? path.relative(workspaceRoot, input.resource.path)
: input.resource.path;
const resolved = yield* workspacePaths
.resolveRelativePathWithinRoot({ workspaceRoot, relativePath })
.pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
return yield* failAccess("Only browser documents and images can be previewed.");
}
const canonicalFile = yield* resolveCanonicalWorkspaceFile({
workspaceRoot,
relativePath: resolved.relativePath,
});
if (!canonicalFile) {
return yield* failAccess("Workspace asset was not found.");
}
const canonicalWorkspaceRoot = yield* fileSystem
.realPath(workspaceRoot)
.pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause)));
claims = isWorkspaceImagePreviewPath(resolved.relativePath)
? {
version: 1,
kind: "workspace-file-exact",
workspaceRoot: canonicalWorkspaceRoot,
relativePath: resolved.relativePath,
expiresAt,
}
: {
version: 1,
kind: "workspace-file",
workspaceRoot: canonicalWorkspaceRoot,
baseRelativePath: path.dirname(resolved.relativePath),
expiresAt,
};
fileName = path.basename(resolved.relativePath);
break;
}
case "attachment": {
const config = yield* ServerConfig.ServerConfig;
const attachmentPath = resolveAttachmentPathById({
attachmentsDir: config.attachmentsDir,
attachmentId: input.resource.attachmentId,
});
if (!attachmentPath) {
return yield* failAccess("Attachment was not found.");
}
claims = {
version: 1,
kind: "attachment",
attachmentId: input.resource.attachmentId,
expiresAt,
};
fileName = path.basename(attachmentPath);
break;
}
case "project-favicon": {
const workspaceRoot = yield* workspacePaths
.normalizeWorkspaceRoot(input.resource.cwd)
.pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot);
const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
if (
relativePath &&
!(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }))
) {
return yield* failAccess("Project favicon was not found.");
}
claims = {
version: 1,
kind: "project-favicon",
workspaceRoot: yield* fileSystem
.realPath(workspaceRoot)
.pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
relativePath,
expiresAt,
};
fileName = relativePath ? path.basename(relativePath) : "favicon.svg";
break;
}
}
const secretStore = yield* ServerSecretStore.ServerSecretStore;
const signingSecret = yield* secretStore
.getOrCreateRandom(SIGNING_SECRET_NAME, 32)
.pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
return {
relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
expiresAt,
};
});
export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
token: string,
relativePath: string,
) {
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) return null;
const secretStore = yield* ServerSecretStore.ServerSecretStore;
const signingSecret = yield* secretStore
.getOrCreateRandom(SIGNING_SECRET_NAME, 32)
.pipe(Effect.orElseSucceed(() => null));
if (!signingSecret) return null;
if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null;
const claims = decodeClaims(encodedPayload);
if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;
if (claims.kind === "attachment") {
const config = yield* ServerConfig.ServerConfig;
const attachmentPath = resolveAttachmentPathById({
attachmentsDir: config.attachmentsDir,
attachmentId: claims.attachmentId,
});
if (!attachmentPath) return null;
const fileSystem = yield* FileSystem.FileSystem;
const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null));
return info?.type === "File"
? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
: null;
}
if (claims.kind === "project-favicon") {
if (claims.relativePath === null) {
return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
}
const faviconPath = yield* resolveCanonicalWorkspaceFile({
workspaceRoot: claims.workspaceRoot,
relativePath: claims.relativePath,
});
return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
}
const decodedPath = decodeRelativePath(relativePath);
if (decodedPath === null) return null;
const path = yield* Path.Path;
if (claims.kind === "workspace-file-exact") {
if (decodedPath !== path.basename(claims.relativePath)) return null;
const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({
workspaceRoot: claims.workspaceRoot,
relativePath: claims.relativePath,
});
return exactWorkspaceFile
? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset)
: null;
}
const segments = decodedPath.split(/[\\/]/);
if (
decodedPath.length === 0 ||
decodedPath.includes("\0") ||
segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) ||
!PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase())
) {
return null;
}
const joinedRelativePath =
claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);
const workspaceFile = yield* resolveCanonicalWorkspaceFile({
workspaceRoot: claims.workspaceRoot,
relativePath: joinedRelativePath,
});
return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null;
});