Protect HttpApi web UI fallback with auth (#25169)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
|
||||
import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Account } from "@/account/account"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Auth } from "@/auth"
|
||||
@@ -38,7 +39,7 @@ import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { isAllowedCorsOrigin } from "@/server/cors"
|
||||
import { UIRoutes } from "@/server/routes/ui"
|
||||
import { serveUIEffect } from "@/server/routes/ui"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||
import { eventRoute } from "./event"
|
||||
@@ -120,18 +121,10 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
|
||||
]),
|
||||
)
|
||||
|
||||
const uiRoutes = lazy(() => UIRoutes())
|
||||
const uiRoute = HttpRouter.add("*", "/*", (request) =>
|
||||
Effect.promise(async () =>
|
||||
uiRoutes().fetch(
|
||||
request.source instanceof Request
|
||||
? request.source
|
||||
: new Request(new URL(request.originalUrl, "http://localhost"), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.map(HttpServerResponse.fromWeb)),
|
||||
serveUIEffect(request).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.provide(FetchHttpClient.layer)),
|
||||
).pipe(
|
||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
|
||||
)
|
||||
|
||||
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe(
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import { getMimeType } from "hono/utils/mime"
|
||||
import { createHash } from "node:crypto"
|
||||
import fs from "node:fs/promises"
|
||||
import { ProxyUtil } from "../proxy-util"
|
||||
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
@@ -12,44 +16,119 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
|
||||
const DEFAULT_CSP =
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||
const UI_UPSTREAM = new URL("https://app.opencode.ai")
|
||||
|
||||
const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
export const UIRoutes = (): Hono =>
|
||||
new Hono().all("/*", async (c) => {
|
||||
const embeddedWebUI = await embeddedUIPromise
|
||||
const path = c.req.path
|
||||
function themePreloadHash(body: string) {
|
||||
return body.match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
}
|
||||
|
||||
function requestBody(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
|
||||
const len = request.headers["content-length"]
|
||||
return HttpBody.stream(
|
||||
request.stream,
|
||||
request.headers["content-type"],
|
||||
len === undefined ? undefined : Number(len),
|
||||
)
|
||||
}
|
||||
|
||||
function proxyResponseHeaders(headers: Record<string, string>) {
|
||||
const result = new Headers(headers)
|
||||
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
|
||||
// transfer metadata makes browsers decode already-decoded assets again.
|
||||
result.delete("content-encoding")
|
||||
result.delete("content-length")
|
||||
return result
|
||||
}
|
||||
|
||||
function upstreamURL(path: string) {
|
||||
return new URL(path, UI_UPSTREAM).toString()
|
||||
}
|
||||
|
||||
function embeddedUI() {
|
||||
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
|
||||
return embeddedUIPromise
|
||||
}
|
||||
|
||||
export async function serveUI(request: Request) {
|
||||
const embeddedWebUI = await embeddedUI()
|
||||
const path = new URL(request.url).pathname
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
|
||||
|
||||
if (await fs.exists(match)) {
|
||||
const mime = getMimeType(match) ?? "text/plain"
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
|
||||
}
|
||||
|
||||
return Response.json({ error: "Not Found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const response = await proxy(upstreamURL(path), {
|
||||
raw: request,
|
||||
headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }),
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? themePreloadHash(await response.clone().text())
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
}
|
||||
|
||||
export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) {
|
||||
return Effect.gen(function* () {
|
||||
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
|
||||
const path = new URL(request.url, "http://localhost").pathname
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
|
||||
|
||||
if (await fs.exists(match)) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
if (yield* fs.existsSafe(match)) {
|
||||
const mime = getMimeType(match) ?? "text/plain"
|
||||
c.header("Content-Type", mime)
|
||||
if (mime.startsWith("text/html")) {
|
||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||
}
|
||||
return c.body(new Uint8Array(await fs.readFile(match)))
|
||||
} else {
|
||||
return c.json({ error: "Not Found" }, 404)
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||
return HttpServerResponse.raw(yield* fs.readFile(match), { headers })
|
||||
}
|
||||
} else {
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
raw: c.req.raw,
|
||||
headers: {
|
||||
...Object.fromEntries(c.req.raw.headers.entries()),
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
|
||||
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const response = yield* HttpClient.execute(
|
||||
HttpClientRequest.make(request.method)(upstreamURL(path), {
|
||||
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
|
||||
body: requestBody(request),
|
||||
}),
|
||||
)
|
||||
const headers = proxyResponseHeaders(response.headers)
|
||||
|
||||
if (response.headers["content-type"]?.includes("text/html")) {
|
||||
const body = yield* response.text
|
||||
const match = themePreloadHash(body)
|
||||
headers.set(
|
||||
"Content-Security-Policy",
|
||||
csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""),
|
||||
)
|
||||
return HttpServerResponse.text(body, { status: response.status, headers })
|
||||
}
|
||||
|
||||
headers.set("Content-Security-Policy", csp())
|
||||
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
|
||||
status: response.status,
|
||||
headers,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
|
||||
|
||||
Reference in New Issue
Block a user