311 lines · 11.6 KB · TypeScript
RawDownload
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;});