import Mime from "@effect/platform-node/Mime"; import { Data, Effect, FileSystem, Option, Path } from "effect"; import { cast } from "effect/Function"; import { HttpBody, HttpClient, HttpClientResponse, HttpRouter, HttpServerResponse, HttpServerRequest, } from "effect/unstable/http"; import { OtlpTracer } from "effect/unstable/observability"; import { ATTACHMENTS_ROUTE_PREFIX, normalizeAttachmentRelativePath, resolveAttachmentRelativePath, } from "./attachmentPaths"; import { resolveAttachmentPathById } from "./attachmentStore"; import { resolveStaticDir, ServerConfig } from "./config"; import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { respondToAuthError } from "./auth/http.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const FALLBACK_PROJECT_FAVICON_SVG = ``; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); export const browserApiCorsLayer = HttpRouter.cors({ allowedMethods: ["GET", "POST", "OPTIONS"], allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], 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 requireAuthenticatedRequest = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* ServerAuth; yield* serverAuth.authenticateHttpRequest(request); }); export const serverEnvironmentRouteLayer = HttpRouter.add( "GET", "/.well-known/t3/environment", Effect.gen(function* () { const descriptor = yield* Effect.service(ServerEnvironment).pipe( Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor), ); return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 }); }), ); 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* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; const browserTraceCollector = yield* BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; const bodyJson = cast(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.catch(() => Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), ), ); }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const attachmentsRouteLayer = HttpRouter.add( "GET", `${ATTACHMENTS_ROUTE_PREFIX}/*`, Effect.gen(function* () { yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } const config = yield* ServerConfig; const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); if (!normalizedRelativePath) { return HttpServerResponse.text("Invalid attachment path", { status: 400 }); } const isIdLookup = !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); const filePath = isIdLookup ? resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: normalizedRelativePath, }) : resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, relativePath: normalizedRelativePath, }); if (!filePath) { return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", { status: isIdLookup ? 404 : 400, }); } const fileSystem = yield* FileSystem.FileSystem; const fileInfo = yield* fileSystem .stat(filePath) .pipe(Effect.catch(() => Effect.succeed(null))); if (!fileInfo || fileInfo.type !== "File") { return HttpServerResponse.text("Not Found", { status: 404 }); } return yield* HttpServerResponse.file(filePath, { status: 200, headers: { "Cache-Control": "public, max-age=31536000, immutable", }, }).pipe( Effect.catch(() => Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const projectFaviconRouteLayer = HttpRouter.add( "GET", "/api/project-favicon", Effect.gen(function* () { yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } const projectCwd = url.value.searchParams.get("cwd"); if (!projectCwd) { return HttpServerResponse.text("Missing cwd parameter", { status: 400 }); } const faviconResolver = yield* ProjectFaviconResolver; const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd); if (!faviconFilePath) { return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { status: 200, contentType: "image/svg+xml", headers: { "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, }, }); } return yield* HttpServerResponse.file(faviconFilePath, { status: 200, headers: { "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, }, }).pipe( Effect.catch(() => Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", 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 config = yield* ServerConfig; if (config.devUrl && isLoopbackHostname(url.value.hostname)) { return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302, }); } const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); if (!staticDir) { return HttpServerResponse.text("No static directory configured and no dev URL set.", { status: 503, }); } const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const staticRoot = path.resolve(staticDir); const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); const hasPathTraversalSegment = staticRelativePath.startsWith(".."); if ( staticRelativePath.length === 0 || hasRawLeadingParentSegment || hasPathTraversalSegment || staticRelativePath.includes("\0") ) { return HttpServerResponse.text("Invalid static file path", { status: 400 }); } const isWithinStaticRoot = (candidate: string) => candidate === staticRoot || candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`); let filePath = path.resolve(staticRoot, staticRelativePath); if (!isWithinStaticRoot(filePath)) { return HttpServerResponse.text("Invalid static file path", { status: 400 }); } const ext = path.extname(filePath); if (!ext) { filePath = path.resolve(filePath, "index.html"); if (!isWithinStaticRoot(filePath)) { return HttpServerResponse.text("Invalid static file path", { status: 400 }); } } const fileInfo = yield* fileSystem .stat(filePath) .pipe(Effect.catch(() => Effect.succeed(null))); if (!fileInfo || fileInfo.type !== "File") { const indexPath = path.resolve(staticRoot, "index.html"); const indexData = yield* fileSystem .readFile(indexPath) .pipe(Effect.catch(() => Effect.succeed(null))); if (!indexData) { return HttpServerResponse.text("Not Found", { status: 404 }); } return HttpServerResponse.uint8Array(indexData, { status: 200, contentType: "text/html; charset=utf-8", }); } const contentType = Mime.getType(filePath) ?? "application/octet-stream"; const data = yield* fileSystem .readFile(filePath) .pipe(Effect.catch(() => Effect.succeed(null))); if (!data) { return HttpServerResponse.text("Internal Server Error", { status: 500 }); } return HttpServerResponse.uint8Array(data, { status: 200, contentType, }); }), );