634 lines · 24.1 KB · TypeScript
RawDownload
import Mime from "@effect/platform-node/Mime";import {  AuthOrchestrationOperateScope,  AuthOrchestrationReadScope,  EnvironmentHttpApi,} from "@t3tools/contracts";import { isDevProxiedPath } from "@t3tools/shared/devProxy";import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { cast } from "effect/Function";import {  HttpBody,  HttpClient,  HttpClientResponse,  HttpMiddleware,  HttpRouter,  HttpServerResponse,  HttpServerRequest,  HttpServerRespondable,} from "effect/unstable/http";import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts";import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";import {  ATTACHMENT_UPLOAD_ROUTE_PREFIX,  storeAttachmentUpload,  validateAttachmentUploadToken,} from "./assets/AttachmentUpload.ts";import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";import {  annotateEnvironmentRequest,  failEnvironmentScopeRequired,  failEnvironmentAuthInvalid,  failEnvironmentInternal,} from "./auth/http.ts";import * as ServerEnvironment from "./environment/ServerEnvironment.ts";import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";// HTML previews are agent output, not the app. The sandbox gives the document an// opaque origin: scripts run, but same-origin cookies, storage, and API calls are// out of reach. Relative sibling assets still load through their signed URLs.const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals"; // Types a browser may render as a document if a proxy strips the disposition// header. Downloads of these fall back to octet-stream.const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;const isSafeDownloadMimeType = (mimeType: string): boolean =>  DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&  !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());const isSafeInlineVideoMimeType = (mimeType: string): boolean =>  DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");const isSafeInlineDocumentMimeType = (mimeType: string): boolean =>  mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html"; /** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */export function downloadContentDisposition(fileName?: string): string {  if (fileName === undefined) {    return "attachment";  }  // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.  const sanitized = fileName.toWellFormed().replace(/[\p{Cc}"\\]/gu, "_");  const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");  const needsExtended = asciiFallback !== sanitized;  const extendedName = encodeURIComponent(sanitized).replace(    /['()*]/g,    (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,  );  return `attachment; filename="${asciiFallback}"${    needsExtended ? `; filename*=UTF-8''${extendedName}` : ""  }`;} export function assetResponseHeaders(  filePath: string,  options?: {    readonly download?: boolean;    readonly fileName?: string;    readonly mimeType?: string;  },): Record<string, string> {  const lowerPath = filePath.toLowerCase();  const inlineMimeType = options?.mimeType?.split(";", 1)[0]?.trim();  return {    "Cache-Control": "private, max-age=3600",    "X-Content-Type-Options": "nosniff",    ...(options?.download      ? {          "Content-Disposition": downloadContentDisposition(options.fileName),          "Content-Security-Policy": "default-src 'none'; sandbox",          "Content-Type":            options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType)              ? options.mimeType              : "application/octet-stream",        }      : inlineMimeType !== undefined && isSafeInlineVideoMimeType(inlineMimeType)        ? { "Content-Type": inlineMimeType }        : inlineMimeType !== undefined && isSafeInlineDocumentMimeType(inlineMimeType)          ? {              "Content-Type":                inlineMimeType.toLowerCase() === "text/html"                  ? "text/html; charset=utf-8"                  : "application/pdf",              ...(inlineMimeType.toLowerCase() === "text/html"                ? { "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY }                : {}),            }          : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")            ? {                "Content-Type": "text/html; charset=utf-8",                "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,              }            : {}),    ...(!options?.download && lowerPath.endsWith(".svg")      ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }      : {}),  };} /** A single byte range for native video readers; unsupported range syntax uses the full file. */function assetByteRange(header: string, size: bigint) {  const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());  if (!match || (!match[1] && !match[2])) return null;  const first = match[1] ? BigInt(match[1]) : null;  const last = match[2] ? BigInt(match[2]) : null;  if (first !== null && last !== null && last < first) return null;  if (size === 0n || (first !== null && first >= size) || (first === null && last === 0n)) {    return { _tag: "Unsatisfiable" as const };  }  const start = first ?? (last! >= size ? 0n : size - last!);  const end = first === null || last === null || last >= size ? size - 1n : last;  if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) {    return { _tag: "Unsatisfiable" as const };  }  return {    _tag: "Range" as const,    offset: start,    bytesToRead: end - start + 1n,    contentRange: `bytes ${start}-${end}/${size}`,  };} export const assetFileResponse = Effect.fn("assetFileResponse")(function* (  asset: {    readonly path: string;    readonly download?: boolean;    readonly fileName?: string;    readonly mimeType?: string;    readonly file?: OpenMediaFile;  },  rangeHeader?: string,  ifRangeHeader?: string,  method: "GET" | "HEAD" = "GET",) {  const headers = assetResponseHeaders(asset.path, asset);  const mediaFile = asset.file;  const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined;  const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true;  if (mediaFile && isVideo) {    // Host videos can change in place. Do not invite conditional range requests    // with validators that cannot establish byte-for-byte identity.    headers["Cache-Control"] = "private, no-store";  }  let status = 200;  let offset = 0n;  let bytesToRead: bigint | undefined;  if (isVideo) {    headers["Accept-Ranges"] = "bytes";    // If-Range requires a matching validator. A full response is safe when we cannot validate it.    if (method === "GET" && rangeHeader && ifRangeHeader === undefined) {      const fs = yield* FileSystem.FileSystem;      const info = mediaInfo ?? (yield* fs.stat(asset.path));      const range = assetByteRange(rangeHeader, info.size);      if (range?._tag === "Unsatisfiable") {        return HttpServerResponse.empty({          status: 416,          headers: { ...headers, "Content-Range": `bytes */${info.size}` },        });      }      if (range?._tag === "Range") {        status = 206;        offset = range.offset;        bytesToRead = range.bytesToRead;        headers["Content-Range"] = range.contentRange;      }    }  }  if (mediaFile && mediaInfo) {    const size = bytesToRead ?? mediaInfo.size;    headers["Content-Type"] ??= Mime.getType(asset.path) ?? "application/octet-stream";    headers["Content-Length"] = String(size);    if (!isVideo) {      headers["Last-Modified"] = mediaInfo.mtime.toUTCString();      headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`;    }    if (method === "HEAD" || size === 0n) {      return HttpServerResponse.empty({ status, headers });    }    const body = streamMediaFile(mediaFile, offset, size);    if (!body) {      return HttpServerResponse.text("File is too large to preview.", { status: 413 });    }    return HttpServerResponse.stream(body, {      status,      headers,    });  }  return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers });}); export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {  global: true,}); export const browserApiCorsLayer = Layer.unwrap(  Effect.gen(function* () {    const config = yield* ServerConfig.ServerConfig;    const devOrigin = config.devUrl?.origin;    // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be    // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin.    //    // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second    // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies    // through Vite and is same-origin (no preflight at all), so this is a    // safety net for the desktop renderer and any direct-to-backend caller.    return HttpRouter.cors({      ...(devOrigin        ? {            allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins],            credentials: true,          }        : {}),      allowedMethods: browserApiCorsAllowedMethods,      allowedHeaders: browserApiCorsAllowedHeaders,      maxAge: 600,    });  }),); export function isLoopbackHostname(hostname: string): boolean {  const normalizedHostname = hostname    .trim()    .toLowerCase()    .replace(/^\[(.*)\]$/, "$1");  return LOOPBACK_HOSTNAMES.has(normalizedHostname);} export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {  const redirectUrl = new URL(devUrl.toString());  redirectUrl.pathname = requestUrl.pathname;  redirectUrl.search = requestUrl.search;  redirectUrl.hash = requestUrl.hash;  return redirectUrl.toString();} const authenticateRawRouteWithScope = (  scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,) =>  Effect.gen(function* () {    const request = yield* HttpServerRequest.HttpServerRequest;    const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;    const session = yield* serverAuth.authenticateHttpRequest(request).pipe(      Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>        failEnvironmentAuthInvalid(          EnvironmentAuth.serverAuthCredentialReason(error),          EnvironmentAuth.serverAuthDpopFailureReason(error),        ),      ),      Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>        failEnvironmentInternal("internal_error", error),      ),    );    if (!session.scopes.includes(scope)) {      return yield* failEnvironmentScopeRequired(scope);    }  }); export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(  EnvironmentHttpApi,  "metadata",  Effect.fnUntraced(function* (handlers) {    const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;    return handlers.handle(      "descriptor",      Effect.fn("environment.metadata.descriptor")(function* (args) {        yield* annotateEnvironmentRequest(args.endpoint.name);        return yield* serverEnvironment.getDescriptor;      }, traceRelayRequest),    );  }),); class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{  readonly cause: unknown;  readonly bodyJson: OtlpTracer.TraceData;}> {} export const otlpTracesProxyRouteLayer = HttpRouter.add(  "POST",  OTLP_TRACES_PROXY_PATH,  Effect.gen(function* () {    yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);    const request = yield* HttpServerRequest.HttpServerRequest;    const config = yield* ServerConfig.ServerConfig;    const otlpTracesUrl = config.otlpTracesUrl;    const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;    const httpClient = yield* HttpClient.HttpClient;    const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);     yield* Effect.try({      try: () => decodeOtlpTraceRecords(bodyJson),      catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }),    }).pipe(      Effect.flatMap((records) => browserTraceCollector.record(records)),      Effect.catch((cause) =>        Effect.logWarning("Failed to decode browser OTLP traces", {          cause,          bodyJson,        }),      ),    );     if (otlpTracesUrl === undefined) {      return HttpServerResponse.empty({ status: 204 });    }     return yield* httpClient      .post(otlpTracesUrl, {        body: HttpBody.jsonUnsafe(bodyJson),      })      .pipe(        Effect.flatMap(HttpClientResponse.filterStatusOk),        Effect.as(HttpServerResponse.empty({ status: 204 })),        Effect.tapError((cause) =>          Effect.logWarning("Failed to export browser OTLP traces", {            cause,            otlpTracesUrl,          }),        ),        Effect.orElseSucceed(() =>          HttpServerResponse.text("Trace export failed.", { status: 502 }),        ),      );  }).pipe(    Effect.catchTags({      EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,      EnvironmentInternalError: HttpServerRespondable.toResponse,      EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,    }),  ),); export const assetRouteLayer = HttpRouter.add(  "GET",  `${ASSET_ROUTE_PREFIX}/*`,  Effect.gen(function* () {    const request = yield* HttpServerRequest.HttpServerRequest;    const url = HttpServerRequest.toURL(request);    if (Option.isNone(url)) {      return HttpServerResponse.text("Bad Request", { status: 400 });    }     const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length);    const separatorIndex = suffix.indexOf("/");    if (separatorIndex <= 0) {      return HttpServerResponse.text("Not Found", { status: 404 });    }     const asset = yield* resolveAsset(      suffix.slice(0, separatorIndex),      suffix.slice(separatorIndex + 1),    );    if (!asset) {      return HttpServerResponse.text("Not Found", { status: 404 });    }    return yield* assetFileResponse(      asset,      request.method === "GET" ? request.headers.range : undefined,      request.headers["if-range"],      request.method === "HEAD" ? "HEAD" : "GET",    ).pipe(      Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),    );  }),); export const attachmentUploadRouteLayer = HttpRouter.add(  "POST",  `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`,  Effect.gen(function* () {    const request = yield* HttpServerRequest.HttpServerRequest;    const url = HttpServerRequest.toURL(request);    if (Option.isNone(url)) {      return HttpServerResponse.text("Bad Request", { status: 400 });    }     const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);    if (!token) {      return HttpServerResponse.text("Not Found", { status: 404 });    }    const claims = yield* validateAttachmentUploadToken(token);    if (!claims) {      return HttpServerResponse.text("Not Found", { status: 404 });    }     const contentLengthHeader = request.headers["content-length"];    if (      contentLengthHeader !== undefined &&      (!Number.isInteger(Number(contentLengthHeader)) ||        Number(contentLengthHeader) !== claims.sizeBytes)    ) {      return HttpServerResponse.text("Content-Length must match the upload size.", {        status: 400,      });    }     // Keep the request stream in the route scope until the response is sent.    const bodyPull = yield* Stream.toPull(request.stream);    const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull)));    return stored.ok      ? HttpServerResponse.empty({ status: 204 })      : HttpServerResponse.text(stored.detail, { status: stored.status });  }),); const decodeBuildManifest = Schema.decodeUnknownEffect(  Schema.fromJsonString(    Schema.Record(      Schema.String,      Schema.Struct({        file: Schema.String,        css: Schema.optional(Schema.Array(Schema.String)),        assets: Schema.optional(Schema.Array(Schema.String)),      }),    ),  ),); const loadImmutableBuildAssets = Effect.gen(function* () {  const config = yield* ServerConfig.ServerConfig;  const staticDir =    config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);  if (!staticDir) return new Set<string>();  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  return yield* fileSystem.readFileString(path.join(staticDir, ".vite", "manifest.json")).pipe(    Effect.flatMap(decodeBuildManifest),    Effect.map(      (manifest) =>        new Set(          Object.values(manifest).flatMap((entry) => [            entry.file,            ...(entry.css ?? []),            ...(entry.assets ?? []),          ]),        ),    ),    Effect.orElseSucceed(() => new Set<string>()),  );}); const openStaticFile = Effect.fn("openStaticFile")(function* (filePath: string) {  const fileSystem = yield* FileSystem.FileSystem;  // Reject directories and special files before opening. Response metadata comes from the handle.  const pathInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));  if (pathInfo?.type !== "File") return null;  const file = yield* fileSystem.open(filePath, { flag: "r" });  const info = yield* file.stat;  return info.type === "File" ? { file, info } : null;}); const streamStaticFile = (file: FileSystem.File, size: bigint) =>  Stream.unfold(    0n,    Effect.fnUntraced(function* (offset: bigint) {      if (offset >= size) return;      const remaining = size - offset;      const bytes = yield* file.readAlloc(remaining < 65_536n ? remaining : 65_536n);      if (Option.isNone(bytes)) return;      return [bytes.value, offset + BigInt(bytes.value.byteLength)] as const;    }),  ); const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")(  function* (immutableBuildAssets: ReadonlySet<string>) {

Lines 1–500 of 634.