From 291280b709e1464146429b6fcf7a0b143a075c6c Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Sat, 13 Jan 2024 18:07:02 +0100 Subject: [PATCH] Switch from Oak to Hono --- cli/plug_run.ts | 9 +- cmd/server.ts | 4 - plugos/hooks/endpoint.test.ts | 9 +- plugos/hooks/endpoint.ts | 34 +- server/deps.ts | 13 +- server/http_server.ts | 735 ++++++++++++++++------------------ server/rpc.ts | 11 - web/syscalls/shell.ts | 3 +- web/syscalls/util.ts | 12 +- 9 files changed, 372 insertions(+), 458 deletions(-) diff --git a/cli/plug_run.ts b/cli/plug_run.ts index efe4cdd..84bdb71 100644 --- a/cli/plug_run.ts +++ b/cli/plug_run.ts @@ -1,13 +1,13 @@ import { DiskSpacePrimitives } from "../common/spaces/disk_space_primitives.ts"; import { AssetBundle } from "../plugos/asset_bundle/bundle.ts"; -import { Application } from "../server/deps.ts"; import { sleep } from "$sb/lib/async.ts"; import { ServerSystem } from "../server/server_system.ts"; import { AssetBundlePlugSpacePrimitives } from "../common/spaces/asset_bundle_space_primitives.ts"; import { determineDatabaseBackend } from "../server/db_backend.ts"; import { EndpointHook } from "../plugos/hooks/endpoint.ts"; import { determineShellBackend } from "../server/shell_backend.ts"; +import { Hono } from "../server/deps.ts"; export async function runPlug( spacePath: string, @@ -18,7 +18,7 @@ export async function runPlug( httpHostname = "127.0.0.1", ) { const serverController = new AbortController(); - const app = new Application(); + const app = new Hono(); const dbBackend = await determineDatabaseBackend(spacePath); @@ -41,12 +41,11 @@ export async function runPlug( app.use((context, next) => { return endpointHook.handleRequest(serverSystem.system!, context, next); }); - - app.listen({ + Deno.serve({ hostname: httpHostname, port: httpServerPort, signal: serverController.signal, - }); + }, app.fetch); if (functionName) { const [plugName, funcName] = functionName.split("."); diff --git a/cmd/server.ts b/cmd/server.ts index 4356acb..d5fb0aa 100644 --- a/cmd/server.ts +++ b/cmd/server.ts @@ -1,4 +1,3 @@ -import { Application } from "../server/deps.ts"; import { HttpServer } from "../server/http_server.ts"; import clientAssetBundle from "../dist/client_asset_bundle.json" assert { type: "json", @@ -46,8 +45,6 @@ export async function serveCommand( console.log("Running in sync-only mode (no backend processing)"); } - const app = new Application(); - if (!folder) { // Didn't get a folder as an argument, check if we got it as an environment variable folder = Deno.env.get("SB_FOLDER"); @@ -90,7 +87,6 @@ export async function serveCommand( }); const httpServer = new HttpServer({ - app, hostname, port, clientAssetBundle: new AssetBundle(clientAssetBundle as AssetJson), diff --git a/plugos/hooks/endpoint.test.ts b/plugos/hooks/endpoint.test.ts index 4bdaa47..b69b249 100644 --- a/plugos/hooks/endpoint.test.ts +++ b/plugos/hooks/endpoint.test.ts @@ -2,10 +2,10 @@ import { createSandbox } from "../environments/deno_sandbox.ts"; import { EndpointHook, EndpointHookT } from "./endpoint.ts"; import { System } from "../system.ts"; -import { Application } from "../../server/deps.ts"; import { assertEquals } from "../../test_deps.ts"; import { compileManifest } from "../compile.ts"; import { esbuild } from "../deps.ts"; +import { Hono } from "../../server/deps.ts"; Deno.test("Run a plugos endpoint server", async () => { const tempDir = await Deno.makeTempDir(); @@ -23,21 +23,20 @@ Deno.test("Run a plugos endpoint server", async () => { createSandbox, ); - const app = new Application(); + const app = new Hono(); const port = 3123; const endpointHook = new EndpointHook("/_/"); - app.use((context, next) => { + app.all("*", (context, next) => { return endpointHook.handleRequest(system, context, next); }); const controller = new AbortController(); - app.listen({ port: port, signal: controller.signal }); + Deno.serve({ port: port, signal: controller.signal }, app.fetch); const res = await fetch(`http://localhost:${port}/_/test/?name=Pete`); assertEquals(res.status, 200); - assertEquals(res.headers.get("Content-type"), "application/json"); assertEquals(await res.json(), [1, 2, 3]); console.log("Aborting"); controller.abort(); diff --git a/plugos/hooks/endpoint.ts b/plugos/hooks/endpoint.ts index 1523f6c..79ca66f 100644 --- a/plugos/hooks/endpoint.ts +++ b/plugos/hooks/endpoint.ts @@ -1,6 +1,6 @@ import { Hook, Manifest } from "../types.ts"; import { System } from "../system.ts"; -import { Application, Context, Next } from "../../server/deps.ts"; +import { Context, Next } from "../../server/deps.ts"; export type EndpointRequest = { method: string; @@ -37,8 +37,9 @@ export class EndpointHook implements Hook { ctx: Context, next: Next, ) { - const req = ctx.request; - const requestPath = ctx.request.url.pathname; + const req = ctx.req; + const url = new URL(req.url); + const requestPath = url.pathname; if (!requestPath.startsWith(this.prefix)) { return next(); } @@ -73,13 +74,13 @@ export class EndpointHook implements Hook { try { const response: EndpointResponse = await plug.invoke(name, [ { - path: req.url.pathname, + path: url.pathname, method: req.method, - body: req.body(), + body: await req.text(), query: Object.fromEntries( - req.url.searchParams.entries(), + url.searchParams.entries(), ), - headers: Object.fromEntries(req.headers.entries()), + headers: req.header(), } as EndpointRequest, ]); if (response.headers) { @@ -88,18 +89,21 @@ export class EndpointHook implements Hook { response.headers, ) ) { - ctx.response.headers.set(key, value); + ctx.header(key, value); } } - ctx.response.status = response.status; - ctx.response.body = response.body; - // console.log("Sent result"); - return; + ctx.status(response.status); + console.log("Going to return", response.body); + if (typeof response.body === "string") { + return ctx.text(response.body); + } else if (response.body instanceof Uint8Array) { + return ctx.body(response.body); + } else { + return ctx.json(response.body); + } } catch (e: any) { console.error("Error executing function", e); - ctx.response.status = 500; - ctx.response.body = e.message; - return; + return ctx.body(e.message, 500); } } } diff --git a/server/deps.ts b/server/deps.ts index 62fa8d2..e7c873b 100644 --- a/server/deps.ts +++ b/server/deps.ts @@ -1,14 +1,4 @@ export * from "../common/deps.ts"; -export type { Next } from "https://deno.land/x/oak@v12.4.0/mod.ts"; -export { - Application, - Context, - Request, - Response, - Router, -} from "https://deno.land/x/oak@v12.4.0/mod.ts"; -export * as etag from "https://deno.land/x/oak@v12.4.0/etag.ts"; -export { oakCors } from "https://deno.land/x/cors@v1.2.2/mod.ts"; export { Hono } from "https://deno.land/x/hono@v3.12.2/mod.ts"; export { @@ -19,6 +9,7 @@ export { export { cors } from "https://deno.land/x/hono@v3.12.2/middleware.ts"; export type { + Context, HonoRequest, - // Next, + Next, } from "https://deno.land/x/hono@v3.12.2/mod.ts"; diff --git a/server/http_server.ts b/server/http_server.ts index c23236f..4ac2587 100644 --- a/server/http_server.ts +++ b/server/http_server.ts @@ -1,25 +1,23 @@ import { - Application, - Context, - Next, - oakCors, - Request, - Router, + cors, + deleteCookie, + getCookie, + Hono, + HonoRequest, + setCookie, } from "./deps.ts"; import { AssetBundle } from "../plugos/asset_bundle/bundle.ts"; import { FileMeta } from "$sb/types.ts"; -import { ShellRequest, SyscallRequest, SyscallResponse } from "./rpc.ts"; +import { ShellRequest } from "./rpc.ts"; import { determineShellBackend } from "./shell_backend.ts"; import { SpaceServer, SpaceServerConfig } from "./instance.ts"; import { KvPrimitives } from "../plugos/lib/kv_primitives.ts"; -import { EndpointHook } from "../plugos/hooks/endpoint.ts"; import { PrefixedKvPrimitives } from "../plugos/lib/prefixed_kv_primitives.ts"; import { base64Encode } from "../plugos/asset_bundle/base64.ts"; const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week export type ServerOptions = { - app: Application; hostname: string; port: number; clientAssetBundle: AssetBundle; @@ -37,7 +35,7 @@ export class HttpServer { plugAssetBundle: AssetBundle; hostname: string; port: number; - app: Application>; + app: Hono; keyFile: string | undefined; certFile: string | undefined; @@ -46,11 +44,11 @@ export class HttpServer { configs: Map; constructor(options: ServerOptions) { + this.app = new Hono(); this.clientAssetBundle = options.clientAssetBundle; this.plugAssetBundle = options.plugAssetBundle; this.hostname = options.hostname; this.port = options.port; - this.app = options.app; this.keyFile = options.keyFile; this.certFile = options.certFile; this.baseKvPrimitives = options.baseKvPrimitives; @@ -71,8 +69,9 @@ export class HttpServer { return spaceServer; } - determineConfig(req: Request): [string, SpaceServerConfig] { - let hostname = req.url.host; // hostname:port + determineConfig(req: HonoRequest): [string, SpaceServerConfig] { + const url = new URL(req.url); + let hostname = url.host; // hostname:port // First try a full match let config = this.configs.get(hostname); @@ -97,7 +96,7 @@ export class HttpServer { throw new Error(`No space server config found for hostname ${hostname}`); } - ensureSpaceServer(req: Request): Promise { + ensureSpaceServer(req: HonoRequest): Promise { const [matchedHostname, config] = this.determineConfig(req); const spaceServer = this.spaceServers.get(matchedHostname); if (spaceServer) { @@ -127,29 +126,17 @@ export class HttpServer { } start() { - // Initialize JWT issuer - // First check if auth string (username:password) has changed // Serve static files (javascript, css, html) - this.app.use(this.serveStatic.bind(this)); - - const endpointHook = new EndpointHook("/_/"); - - this.app.use(async (context, next) => { - const spaceServer = await this.ensureSpaceServer(context.request); - return endpointHook.handleRequest(spaceServer.system!, context, next); - }); - - this.addAuth(this.app); - const fsRouter = this.addFsRoutes(); - this.app.use(fsRouter.routes()); - this.app.use(fsRouter.allowedMethods()); + this.serveStatic(); + this.addAuth(); + this.addFsRoutes(); // Fallback, serve the UI index.html - this.app.use(async ({ request, response }) => { - response.headers.set("Content-type", "text/html"); - response.headers.set("Cache-Control", "no-cache"); - const spaceServer = await this.ensureSpaceServer(request); - response.body = this.renderIndexHtml(spaceServer); + this.app.use("*", async (c) => { + const spaceServer = await this.ensureSpaceServer(c.req); + return c.html(this.renderIndexHtml(spaceServer), 200, { + "Cache-Control": "no-cache", + }); }); this.abortController = new AbortController(); @@ -164,11 +151,10 @@ export class HttpServer { if (this.certFile) { listenOptions.cert = Deno.readTextFileSync(this.certFile); } - this.app.listen(listenOptions) - .catch((e: any) => { - console.log("Server listen error:", e.message); - Deno.exit(1); - }); + + // Start the actual server + Deno.serve(listenOptions, this.app.fetch); + const visibleHostname = this.hostname === "0.0.0.0" ? "localhost" : this.hostname; @@ -177,73 +163,75 @@ export class HttpServer { ); } - async serveStatic( - { request, response }: Context, Record>, - next: Next, - ) { - const spaceServer = await this.ensureSpaceServer(request); - if ( - request.url.pathname === "/" - ) { - // Serve the UI (index.html) - // Note: we're explicitly not setting Last-Modified and If-Modified-Since header here because this page is dynamic - response.headers.set("Content-type", "text/html"); - response.headers.set("Cache-Control", "no-cache"); - response.body = this.renderIndexHtml(spaceServer); - return; - } - try { - const assetName = request.url.pathname.slice(1); + serveStatic() { + this.app.use("*", async (c, next) => { + const req = c.req; + const spaceServer = await this.ensureSpaceServer(req); + const url = new URL(req.url); + // console.log("URL", url); if ( - this.clientAssetBundle.has(assetName) && - request.headers.get("If-Modified-Since") === - utcDateString(this.clientAssetBundle.getMtime(assetName)) && - assetName !== "service_worker.js" + url.pathname === "/" ) { - response.status = 304; - return; + // Serve the UI (index.html) + // Note: we're explicitly not setting Last-Modified and If-Modified-Since header here because this page is dynamic + return c.html(this.renderIndexHtml(spaceServer), 200, { + "Cache-Control": "no-cache", + }); } - response.status = 200; - response.headers.set( - "Content-type", - this.clientAssetBundle.getMimeType(assetName), - ); - let data: Uint8Array | string = this.clientAssetBundle.readFileSync( - assetName, - ); - response.headers.set("Cache-Control", "no-cache"); - response.headers.set("Content-length", "" + data.length); - if (assetName !== "service_worker.js") { - response.headers.set( - "Last-Modified", - utcDateString(this.clientAssetBundle.getMtime(assetName)), + try { + const assetName = url.pathname.slice(1); + if (!this.clientAssetBundle.has(assetName)) { + return next(); + } + console.log("Asset name", assetName); + if ( + this.clientAssetBundle.has(assetName) && + req.header("If-Modified-Since") === + utcDateString(this.clientAssetBundle.getMtime(assetName)) && + assetName !== "service_worker.js" + ) { + return c.body(null, 304); + } + c.status(200); + c.header("Content-type", this.clientAssetBundle.getMimeType(assetName)); + let data: Uint8Array | string = this.clientAssetBundle.readFileSync( + assetName, ); - } - - if (request.method === "GET") { - if (assetName === "service_worker.js") { - const textData = new TextDecoder().decode(data); - // console.log( - // "Swapping out config hash in service worker", - // ); - data = textData.replaceAll( - "{{CONFIG_HASH}}", - base64Encode( - JSON.stringify([ - spaceServer.clientEncryption, - spaceServer.syncOnly, - ]), - ), + c.header("Cache-Control", "no-cache"); + c.header("Content-length", "" + data.length); + if (assetName !== "service_worker.js") { + c.header( + "Last-Modified", + utcDateString(this.clientAssetBundle.getMtime(assetName)), ); } - response.body = data; + // console.log("Serving it now", assetName); + + if (req.method === "GET") { + if (assetName === "service_worker.js") { + const textData = new TextDecoder().decode(data); + // console.log( + // "Swapping out config hash in service worker", + // ); + data = textData.replaceAll( + "{{CONFIG_HASH}}", + base64Encode( + JSON.stringify([ + spaceServer.clientEncryption, + spaceServer.syncOnly, + ]), + ), + ); + } + return c.body(data); + } // else e.g. HEAD, OPTIONS, don't send body + } catch { + return next(); } - } catch { - return next(); - } + }); } - private addAuth(app: Application) { + private addAuth() { const excludedPaths = [ "/manifest.json", "/favicon.png", @@ -252,70 +240,63 @@ export class HttpServer { ]; // Middleware handling the /.auth page and flow - app.use(async ({ request, response, cookies }, next) => { - const host = request.url.host; // e.g. localhost:3000 - if (request.url.pathname === "/.auth") { - if (request.url.search === "?logout") { - await cookies.delete(authCookieName(host)); - // Implicit fallthrough to login page - } - if (request.method === "GET") { - response.headers.set("Content-type", "text/html"); - response.body = this.clientAssetBundle.readTextFileSync( - ".client/auth.html", + this.app.all("/.auth", async (c) => { + const url = new URL(c.req.url); + const req = c.req; + const host = url.host; // e.g. localhost:3000 + if (url.search === "?logout") { + deleteCookie(c, authCookieName(host)); + } + if (req.method === "GET") { + return c.html( + this.clientAssetBundle.readTextFileSync(".client/auth.html"), + ); + } else if (req.method === "POST") { + const values = await c.req.parseBody(); + const username = values["username"]; + const password = values["password"]; + const spaceServer = await this.ensureSpaceServer(req); + const { user: expectedUser, pass: expectedPassword } = spaceServer + .auth!; + if (username === expectedUser && password === expectedPassword) { + // Generate a JWT and set it as a cookie + const jwt = await spaceServer.jwtIssuer.createJWT( + { username }, + authenticationExpirySeconds, ); - return; - } else if (request.method === "POST") { - const values = await request.body({ type: "form" }).value; - const username = values.get("username")!; - const password = values.get("password")!; - const spaceServer = await this.ensureSpaceServer(request); - const { user: expectedUser, pass: expectedPassword } = spaceServer - .auth!; - if (username === expectedUser && password === expectedPassword) { - // Generate a JWT and set it as a cookie - const jwt = await spaceServer.jwtIssuer.createJWT( - { username }, - authenticationExpirySeconds, - ); - await cookies.set( - authCookieName(host), - jwt, - { - expires: new Date( - Date.now() + authenticationExpirySeconds * 1000, - ), // in a week - sameSite: "strict", - }, - ); - response.redirect("/"); - } else { - response.redirect("/.auth?error=1"); - } - return; + console.log("Successful auth"); + setCookie(c, authCookieName(host), jwt, { + expires: new Date( + Date.now() + authenticationExpirySeconds * 1000, + ), // in a week + // sameSite: "Strict", + // httpOnly: true, + }); + return c.redirect("/"); } else { - response.redirect("/.auth"); - return; + return c.redirect("/.auth?error=1"); } } else { - await next(); + return c.redirect("/.auth"); } }); // Check auth - app.use(async ({ request, response, cookies }, next) => { - const spaceServer = await this.ensureSpaceServer(request); + this.app.use("*", async (c, next) => { + const req = c.req; + const spaceServer = await this.ensureSpaceServer(req); if (!spaceServer.auth) { // Auth disabled in this config, skip return next(); } - const host = request.url.host; - if (!excludedPaths.includes(request.url.pathname)) { - const authToken = await cookies.get(authCookieName(host)); + const url = new URL(req.url); + const host = url.host; + if (!excludedPaths.includes(url.pathname)) { + const authCookie = getCookie(c, authCookieName(host)); - if (!authToken && spaceServer.authToken) { + if (!authCookie && spaceServer.authToken) { // Attempt Bearer Authorization based authentication - const authHeader = request.headers.get("Authorization"); + const authHeader = req.header("Authorization"); if (authHeader && authHeader.startsWith("Bearer ")) { const authToken = authHeader.slice("Bearer ".length); if (authToken === spaceServer.authToken) { @@ -325,21 +306,19 @@ export class HttpServer { console.log( "Unauthorized token access, redirecting to auth page", ); - response.status = 401; - response.body = "Unauthorized"; - return; + return c.text("Unauthorized", 401); } } } - if (!authToken) { + if (!authCookie) { console.log("Unauthorized access, redirecting to auth page"); - return response.redirect("/.auth"); + return c.redirect("/.auth"); } const { user: expectedUser } = spaceServer.auth!; try { const verifiedJwt = await spaceServer.jwtIssuer.verifyAndDecodeJWT( - authToken, + authCookie, ); if (verifiedJwt.username !== expectedUser) { throw new Error("Username mismatch"); @@ -349,255 +328,225 @@ export class HttpServer { "Error verifying JWT, redirecting to auth page", e.message, ); - return response.redirect("/.auth"); + return c.redirect("/.auth"); } } return next(); }); } - private addFsRoutes(): Router { - const fsRouter = new Router(); - const corsMiddleware = oakCors({ - allowedHeaders: "*", - exposedHeaders: "*", - methods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], - }); - - fsRouter.use(corsMiddleware); + private addFsRoutes() { + this.app.use( + "*", + cors({ + origin: "*", + allowHeaders: ["*"], + exposeHeaders: ["*"], + allowMethods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + }), + ); // File list - fsRouter.get( + this.app.get( "/index.json", - // corsMiddleware, - async ({ request, response }) => { - const spaceServer = await this.ensureSpaceServer(request); - if (request.headers.has("X-Sync-Mode")) { + async (c) => { + const req = c.req; + const spaceServer = await this.ensureSpaceServer(req); + if (req.header("X-Sync-Mode")) { // Only handle direct requests for a JSON representation of the file list - response.headers.set("Content-type", "application/json"); - response.headers.set("X-Space-Path", spaceServer.pagesPath); const files = await spaceServer.spacePrimitives.fetchFileList(); - response.body = JSON.stringify(files); + return c.json(files, 200, { + "X-Space-Path": spaceServer.pagesPath, + }); } else { // Otherwise, redirect to the UI // The reason to do this is to handle authentication systems like Authelia nicely - response.redirect("/"); + return c.redirect("/"); } }, ); - // RPC - fsRouter.post("/.rpc", async ({ request, response }) => { - const spaceServer = await this.ensureSpaceServer(request); - const body = await request.body({ type: "json" }).value; + // RPC shell + this.app.post("/.rpc/shell", async (c) => { + const req = c.req; + const spaceServer = await this.ensureSpaceServer(req); + const body = await req.json(); try { - switch (body.operation) { - case "shell": { - const shellCommand: ShellRequest = body; - const shellResponse = await spaceServer.shellBackend.handle( - shellCommand, - ); - response.headers.set("Content-Type", "application/json"); - response.body = JSON.stringify(shellResponse); - return; - } - case "syscall": { - if (spaceServer.syncOnly) { - response.headers.set("Content-Type", "text/plain"); - response.status = 400; - response.body = "Unknown operation"; - return; - } - const syscallCommand: SyscallRequest = body; - try { - const plug = spaceServer.system!.loadedPlugs.get( - syscallCommand.ctx, - ); - if (!plug) { - throw new Error(`Plug ${syscallCommand.ctx} not found`); - } - const result = await plug.syscall( - syscallCommand.name, - syscallCommand.args, - ); - response.headers.set("Content-type", "application/json"); - response.status = 200; - response.body = JSON.stringify({ - result: result, - } as SyscallResponse); - } catch (e: any) { - response.headers.set("Content-type", "application/json"); - response.status = 500; - response.body = JSON.stringify({ - error: e.message, - } as SyscallResponse); - } - return; - } - default: - response.headers.set("Content-Type", "text/plain"); - response.status = 400; - response.body = "Unknown operation"; - } + const shellCommand: ShellRequest = body; + const shellResponse = await spaceServer.shellBackend.handle( + shellCommand, + ); + return c.json(shellResponse); } catch (e: any) { - console.log("Error", e); - response.status = 500; - response.body = e.message; - return; + console.log("Shell error", e); + return c.text(e.message, 500); } }); - const filePathRegex = "\/([^!].+\\.[a-zA-Z]+)"; + // RPC syscall + this.app.post("/.rpc/:plug/:syscall", async (c) => { + const req = c.req; + const plugName = req.param("plug")!; + const syscall = req.param("syscall")!; + const spaceServer = await this.ensureSpaceServer(req); + const body = await req.json(); + try { + if (spaceServer.syncOnly) { + return c.text("Sync only mode, no syscalls allowed", 400); + } + const args: string[] = body; + try { + const plug = spaceServer.system!.loadedPlugs.get(plugName); + if (!plug) { + throw new Error(`Plug ${plugName} not found`); + } + const result = await plug.syscall(syscall, args); + return c.json({ + result: result, + }); + } catch (e: any) { + return c.json({ + error: e.message, + }, 500); + } + } catch (e: any) { + console.log("Error", e); + return c.text(e.message, 500); + } + }); - fsRouter - .get( - filePathRegex, - async ({ params, response, request }) => { - const name = params[0]; - const spaceServer = await this.ensureSpaceServer(request); - console.log( - "Requested file", - name, + const filePathRegex = "/:path{[^!].+\\.[a-zA-Z]+}"; + + this.app.get( + filePathRegex, + async (c) => { + const req = c.req; + const name = req.param("path")!; + const spaceServer = await this.ensureSpaceServer(req); + console.log( + "Requested file", + name, + ); + if ( + name.endsWith(".md") && + // This header signififies the requests comes directly from the http_space_primitives client (not the browser) + !req.header("X-Sync-Mode") && + // This Accept header is used by federation to still work with CORS + req.header("Accept") !== + "application/octet-stream" && + req.header("sec-fetch-mode") !== "cors" + ) { + // It can happen that during a sync, authentication expires, this may result in a redirect to the login page and then back to this particular file. This particular file may be an .md file, which isn't great to show so we're redirecting to the associated SB UI page. + console.warn( + "Request was without X-Sync-Mode nor a CORS request, redirecting to page", ); - if ( - name.endsWith(".md") && - // This header signififies the requests comes directly from the http_space_primitives client (not the browser) - !request.headers.has("X-Sync-Mode") && - // This Accept header is used by federation to still work with CORS - request.headers.get("Accept") !== - "application/octet-stream" && - request.headers.get("sec-fetch-mode") !== "cors" - ) { - // It can happen that during a sync, authentication expires, this may result in a redirect to the login page and then back to this particular file. This particular file may be an .md file, which isn't great to show so we're redirecting to the associated SB UI page. - console.warn( - "Request was without X-Sync-Mode nor a CORS request, redirecting to page", - ); - response.redirect(`/${name.slice(0, -3)}`); - return; - } - if (name.startsWith(".")) { - // Don't expose hidden files - response.status = 404; - response.body = "Not exposed"; - return; - } - // Handle federated links through a simple redirect, only used for attachments loads with service workers disabled - if (name.startsWith("!")) { - let url = name.slice(1); - console.log("Handling this as a federated link", url); - if (url.startsWith("localhost")) { - url = `http://${url}`; - } else { - url = `https://${url}`; - } - try { - const req = await fetch(url); - response.status = req.status; - // Override X-Permssion header to always be "ro" - const newHeaders = new Headers(); - for (const [key, value] of req.headers.entries()) { - newHeaders.set(key, value); - } - newHeaders.set("X-Permission", "ro"); - response.headers = newHeaders; - response.body = req.body; - } catch (e: any) { - console.error("Error fetching federated link", e); - response.status = 500; - response.body = e.message; - } - return; - } - try { - if (request.headers.has("X-Get-Meta")) { - // Getting meta via GET request - const fileData = await spaceServer.spacePrimitives.getFileMeta( - name, - ); - response.status = 200; - this.fileMetaToHeaders(response.headers, fileData); - response.body = ""; - return; - } - const fileData = await spaceServer.spacePrimitives.readFile(name); - const lastModifiedHeader = new Date(fileData.meta.lastModified) - .toUTCString(); - if ( - request.headers.get("If-Modified-Since") === lastModifiedHeader - ) { - response.status = 304; - return; - } - response.status = 200; - this.fileMetaToHeaders(response.headers, fileData.meta); - response.headers.set("Last-Modified", lastModifiedHeader); - - response.body = fileData.data; - } catch (e: any) { - console.error("Error GETting file", name, e.message); - response.status = 404; - response.headers.set("Cache-Control", "no-cache"); - response.body = "Not found"; - } - }, - ) - .put( - filePathRegex, - async ({ request, response, params }) => { - const name = params[0]; - const spaceServer = await this.ensureSpaceServer(request); - console.log("Saving file", name); - if (name.startsWith(".")) { - // Don't expose hidden files - response.status = 403; - return; - } - - const body = await request.body({ type: "bytes" }).value; - - try { - const meta = await spaceServer.spacePrimitives.writeFile( - name, - body, - ); - response.status = 200; - this.fileMetaToHeaders(response.headers, meta); - response.body = "OK"; - } catch (err) { - console.error("Write failed", err); - response.status = 500; - response.body = "Write failed"; - } - }, - ) - .delete(filePathRegex, async ({ request, response, params }) => { - const name = params[0]; - const spaceServer = await this.ensureSpaceServer(request); - console.log("Deleting file", name); + return c.redirect(`/${name.slice(0, -3)}`); + } if (name.startsWith(".")) { // Don't expose hidden files - response.status = 403; - return; + return c.notFound(); + } + // Handle federated links through a simple redirect, only used for attachments loads with service workers disabled + if (name.startsWith("!")) { + let url = name.slice(1); + console.log("Handling this as a federated link", url); + if (url.startsWith("localhost")) { + url = `http://${url}`; + } else { + url = `https://${url}`; + } + try { + const req = await fetch(url); + // Override X-Permssion header to always be "ro" + const newHeaders = new Headers(); + for (const [key, value] of req.headers.entries()) { + newHeaders.set(key, value); + } + newHeaders.set("X-Permission", "ro"); + return new Response(req.body, { + status: req.status, + headers: newHeaders, + }); + } catch (e: any) { + console.error("Error fetching federated link", e); + return c.text(e.message, 500); + } } try { - await spaceServer.spacePrimitives.deleteFile(name); - response.status = 200; - response.body = "OK"; + if (req.header("X-Get-Meta")) { + // Getting meta via GET request + const fileData = await spaceServer.spacePrimitives.getFileMeta( + name, + ); + return c.text("", 200, this.fileMetaToHeaders(fileData)); + } + const fileData = await spaceServer.spacePrimitives.readFile(name); + const lastModifiedHeader = new Date(fileData.meta.lastModified) + .toUTCString(); + if ( + req.header("If-Modified-Since") === lastModifiedHeader + ) { + return c.body(null, 304); + } + return c.body(fileData.data, 200, { + ...this.fileMetaToHeaders(fileData.meta), + "Last-Modified": lastModifiedHeader, + }); } catch (e: any) { - console.error("Error deleting attachment", e); - response.status = 500; - response.body = e.message; + console.error("Error GETting file", name, e.message); + return c.notFound(); } - }) - .options(filePathRegex, corsMiddleware); + }, + ).put( + async (c) => { + const req = c.req; + const name = req.param("path")!; + const spaceServer = await this.ensureSpaceServer(req); + console.log("Saving file", name); + if (name.startsWith(".")) { + // Don't expose hidden files + return c.text("Forbidden", 403); + } + + const body = await req.arrayBuffer(); + + try { + const meta = await spaceServer.spacePrimitives.writeFile( + name, + new Uint8Array(body), + ); + return c.text("OK", 200, this.fileMetaToHeaders(meta)); + } catch (err) { + console.error("Write failed", err); + return c.text("Write failed", 500); + } + }, + ).delete(async (c) => { + const req = c.req; + const name = req.param("path")!; + const spaceServer = await this.ensureSpaceServer(req); + console.log("Deleting file", name); + if (name.startsWith(".")) { + // Don't expose hidden files + return c.text("Forbidden", 403); + } + try { + await spaceServer.spacePrimitives.deleteFile(name); + return c.text("OK"); + } catch (e: any) { + console.error("Error deleting attachment", e); + return c.text(e.message, 500); + } + }).options(); // Federation proxy - const proxyPathRegex = "\/!(.+)"; - fsRouter.all( + const proxyPathRegex = "/:uri{!.+}"; + this.app.all( proxyPathRegex, - async ({ params, response, request }, next) => { - let url = params[0]; - if (!request.headers.has("X-Proxy-Request")) { + async (c, next) => { + const req = c.req; + let url = req.param("uri")!.slice(1); + if (!req.header("X-Proxy-Request")) { // Direct browser request, not explicity fetch proxy request if (!/\.[a-zA-Z0-9]+$/.test(url)) { console.log("Directly loading federation page via URL:", url); @@ -615,47 +564,41 @@ export class HttpServer { for ( const headerName of ["Authorization", "Accept", "Content-Type"] ) { - if (request.headers.has(headerName)) { + if (req.header(headerName)) { safeRequestHeaders.set( headerName, - request.headers.get(headerName)!, + req.header(headerName)!, ); } } - const req = await fetch(url, { - method: request.method, + const body = await req.arrayBuffer(); + const fetchReq = await fetch(url, { + method: req.method, headers: safeRequestHeaders, - body: request.hasBody - ? request.body({ type: "stream" }).value - : undefined, + body: body.byteLength > 0 ? body : undefined, }); - response.status = req.status; - response.headers = req.headers; - response.body = req.body; + const responseHeaders: Record = {}; + for (const [key, value] of fetchReq.headers.entries()) { + responseHeaders[key] = value; + } + return c.body(fetchReq.body, fetchReq.status, responseHeaders); } catch (e: any) { console.error("Error fetching federated link", e); - response.status = 500; - response.body = e.message; + return c.text(e.message, 500); } - return; }, ); - return fsRouter; } - private fileMetaToHeaders(headers: Headers, fileMeta: FileMeta) { - headers.set("Content-Type", fileMeta.contentType); - headers.set( - "X-Last-Modified", - "" + fileMeta.lastModified, - ); - headers.set( - "X-Created", - "" + fileMeta.created, - ); - headers.set("Cache-Control", "no-cache"); - headers.set("X-Permission", fileMeta.perm); - headers.set("X-Content-Length", "" + fileMeta.size); + private fileMetaToHeaders(fileMeta: FileMeta) { + return { + "Content-Type": fileMeta.contentType, + "X-Last-Modified": "" + fileMeta.lastModified, + "X-Created": "" + fileMeta.created, + "Cache-Control": "no-cache", + "X-Permission": fileMeta.perm, + "X-Content-Length": "" + fileMeta.size, + }; } stop() { @@ -671,5 +614,5 @@ function utcDateString(mtime: number): string { } function authCookieName(host: string) { - return `auth:${host}`; + return `auth_${host.replaceAll(/\W/g, "_")}`; } diff --git a/server/rpc.ts b/server/rpc.ts index c52321b..62c8a53 100644 --- a/server/rpc.ts +++ b/server/rpc.ts @@ -8,14 +8,3 @@ export type ShellResponse = { stderr: string; code: number; }; - -export type SyscallRequest = { - ctx: string; // Plug name requesting - name: string; - args: any[]; -}; - -export type SyscallResponse = { - result?: any; - error?: string; -}; diff --git a/web/syscalls/shell.ts b/web/syscalls/shell.ts index 6585c0a..cf35586 100644 --- a/web/syscalls/shell.ts +++ b/web/syscalls/shell.ts @@ -14,11 +14,10 @@ export function shellSyscalls( throw new Error("Not supported in fully local mode"); } const resp = client.httpSpacePrimitives.authenticatedFetch( - `${client.httpSpacePrimitives.url}/.rpc`, + `${client.httpSpacePrimitives.url}/.rpc/shell`, { method: "POST", body: JSON.stringify({ - operation: "shell", cmd, args, }), diff --git a/web/syscalls/util.ts b/web/syscalls/util.ts index 26a4ce9..ac6878b 100644 --- a/web/syscalls/util.ts +++ b/web/syscalls/util.ts @@ -1,6 +1,5 @@ import { HttpSpacePrimitives } from "../../common/spaces/http_space_primitives.ts"; import { SyscallContext, SysCallMapping } from "../../plugos/system.ts"; -import { SyscallResponse } from "../../server/rpc.ts"; import { Client } from "../client.ts"; export function proxySyscalls(client: Client, names: string[]): SysCallMapping { @@ -20,18 +19,13 @@ export async function proxySyscall( args: any[], ): Promise { const resp = await httpSpacePrimitives.authenticatedFetch( - `${httpSpacePrimitives.url}/.rpc`, + `${httpSpacePrimitives.url}/.rpc/${ctx.plug.name}/${name}`, { method: "POST", - body: JSON.stringify({ - ctx: ctx.plug.name, - operation: "syscall", - name, - args, - }), + body: JSON.stringify(args), }, ); - const result: SyscallResponse = await resp.json(); + const result = await resp.json(); if (result.error) { console.error("Remote syscall error", result.error); throw new Error(result.error);