Switch from Oak to Hono

pull/632/head
Zef Hemel 2024-01-13 18:07:02 +01:00
parent bf1eb03129
commit 291280b709
9 changed files with 372 additions and 458 deletions

View File

@ -1,13 +1,13 @@
import { DiskSpacePrimitives } from "../common/spaces/disk_space_primitives.ts"; import { DiskSpacePrimitives } from "../common/spaces/disk_space_primitives.ts";
import { AssetBundle } from "../plugos/asset_bundle/bundle.ts"; import { AssetBundle } from "../plugos/asset_bundle/bundle.ts";
import { Application } from "../server/deps.ts";
import { sleep } from "$sb/lib/async.ts"; import { sleep } from "$sb/lib/async.ts";
import { ServerSystem } from "../server/server_system.ts"; import { ServerSystem } from "../server/server_system.ts";
import { AssetBundlePlugSpacePrimitives } from "../common/spaces/asset_bundle_space_primitives.ts"; import { AssetBundlePlugSpacePrimitives } from "../common/spaces/asset_bundle_space_primitives.ts";
import { determineDatabaseBackend } from "../server/db_backend.ts"; import { determineDatabaseBackend } from "../server/db_backend.ts";
import { EndpointHook } from "../plugos/hooks/endpoint.ts"; import { EndpointHook } from "../plugos/hooks/endpoint.ts";
import { determineShellBackend } from "../server/shell_backend.ts"; import { determineShellBackend } from "../server/shell_backend.ts";
import { Hono } from "../server/deps.ts";
export async function runPlug( export async function runPlug(
spacePath: string, spacePath: string,
@ -18,7 +18,7 @@ export async function runPlug(
httpHostname = "127.0.0.1", httpHostname = "127.0.0.1",
) { ) {
const serverController = new AbortController(); const serverController = new AbortController();
const app = new Application(); const app = new Hono();
const dbBackend = await determineDatabaseBackend(spacePath); const dbBackend = await determineDatabaseBackend(spacePath);
@ -41,12 +41,11 @@ export async function runPlug(
app.use((context, next) => { app.use((context, next) => {
return endpointHook.handleRequest(serverSystem.system!, context, next); return endpointHook.handleRequest(serverSystem.system!, context, next);
}); });
Deno.serve({
app.listen({
hostname: httpHostname, hostname: httpHostname,
port: httpServerPort, port: httpServerPort,
signal: serverController.signal, signal: serverController.signal,
}); }, app.fetch);
if (functionName) { if (functionName) {
const [plugName, funcName] = functionName.split("."); const [plugName, funcName] = functionName.split(".");

View File

@ -1,4 +1,3 @@
import { Application } from "../server/deps.ts";
import { HttpServer } from "../server/http_server.ts"; import { HttpServer } from "../server/http_server.ts";
import clientAssetBundle from "../dist/client_asset_bundle.json" assert { import clientAssetBundle from "../dist/client_asset_bundle.json" assert {
type: "json", type: "json",
@ -46,8 +45,6 @@ export async function serveCommand(
console.log("Running in sync-only mode (no backend processing)"); console.log("Running in sync-only mode (no backend processing)");
} }
const app = new Application();
if (!folder) { if (!folder) {
// Didn't get a folder as an argument, check if we got it as an environment variable // Didn't get a folder as an argument, check if we got it as an environment variable
folder = Deno.env.get("SB_FOLDER"); folder = Deno.env.get("SB_FOLDER");
@ -90,7 +87,6 @@ export async function serveCommand(
}); });
const httpServer = new HttpServer({ const httpServer = new HttpServer({
app,
hostname, hostname,
port, port,
clientAssetBundle: new AssetBundle(clientAssetBundle as AssetJson), clientAssetBundle: new AssetBundle(clientAssetBundle as AssetJson),

View File

@ -2,10 +2,10 @@ import { createSandbox } from "../environments/deno_sandbox.ts";
import { EndpointHook, EndpointHookT } from "./endpoint.ts"; import { EndpointHook, EndpointHookT } from "./endpoint.ts";
import { System } from "../system.ts"; import { System } from "../system.ts";
import { Application } from "../../server/deps.ts";
import { assertEquals } from "../../test_deps.ts"; import { assertEquals } from "../../test_deps.ts";
import { compileManifest } from "../compile.ts"; import { compileManifest } from "../compile.ts";
import { esbuild } from "../deps.ts"; import { esbuild } from "../deps.ts";
import { Hono } from "../../server/deps.ts";
Deno.test("Run a plugos endpoint server", async () => { Deno.test("Run a plugos endpoint server", async () => {
const tempDir = await Deno.makeTempDir(); const tempDir = await Deno.makeTempDir();
@ -23,21 +23,20 @@ Deno.test("Run a plugos endpoint server", async () => {
createSandbox, createSandbox,
); );
const app = new Application(); const app = new Hono();
const port = 3123; const port = 3123;
const endpointHook = new EndpointHook("/_/"); const endpointHook = new EndpointHook("/_/");
app.use((context, next) => { app.all("*", (context, next) => {
return endpointHook.handleRequest(system, context, next); return endpointHook.handleRequest(system, context, next);
}); });
const controller = new AbortController(); 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`); const res = await fetch(`http://localhost:${port}/_/test/?name=Pete`);
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.headers.get("Content-type"), "application/json");
assertEquals(await res.json(), [1, 2, 3]); assertEquals(await res.json(), [1, 2, 3]);
console.log("Aborting"); console.log("Aborting");
controller.abort(); controller.abort();

View File

@ -1,6 +1,6 @@
import { Hook, Manifest } from "../types.ts"; import { Hook, Manifest } from "../types.ts";
import { System } from "../system.ts"; import { System } from "../system.ts";
import { Application, Context, Next } from "../../server/deps.ts"; import { Context, Next } from "../../server/deps.ts";
export type EndpointRequest = { export type EndpointRequest = {
method: string; method: string;
@ -37,8 +37,9 @@ export class EndpointHook implements Hook<EndpointHookT> {
ctx: Context, ctx: Context,
next: Next, next: Next,
) { ) {
const req = ctx.request; const req = ctx.req;
const requestPath = ctx.request.url.pathname; const url = new URL(req.url);
const requestPath = url.pathname;
if (!requestPath.startsWith(this.prefix)) { if (!requestPath.startsWith(this.prefix)) {
return next(); return next();
} }
@ -73,13 +74,13 @@ export class EndpointHook implements Hook<EndpointHookT> {
try { try {
const response: EndpointResponse = await plug.invoke(name, [ const response: EndpointResponse = await plug.invoke(name, [
{ {
path: req.url.pathname, path: url.pathname,
method: req.method, method: req.method,
body: req.body(), body: await req.text(),
query: Object.fromEntries( query: Object.fromEntries(
req.url.searchParams.entries(), url.searchParams.entries(),
), ),
headers: Object.fromEntries(req.headers.entries()), headers: req.header(),
} as EndpointRequest, } as EndpointRequest,
]); ]);
if (response.headers) { if (response.headers) {
@ -88,18 +89,21 @@ export class EndpointHook implements Hook<EndpointHookT> {
response.headers, response.headers,
) )
) { ) {
ctx.response.headers.set(key, value); ctx.header(key, value);
} }
} }
ctx.response.status = response.status; ctx.status(response.status);
ctx.response.body = response.body; console.log("Going to return", response.body);
// console.log("Sent result"); if (typeof response.body === "string") {
return; 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) { } catch (e: any) {
console.error("Error executing function", e); console.error("Error executing function", e);
ctx.response.status = 500; return ctx.body(e.message, 500);
ctx.response.body = e.message;
return;
} }
} }
} }

View File

@ -1,14 +1,4 @@
export * from "../common/deps.ts"; 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 { Hono } from "https://deno.land/x/hono@v3.12.2/mod.ts";
export { export {
@ -19,6 +9,7 @@ export {
export { cors } from "https://deno.land/x/hono@v3.12.2/middleware.ts"; export { cors } from "https://deno.land/x/hono@v3.12.2/middleware.ts";
export type { export type {
Context,
HonoRequest, HonoRequest,
// Next, Next,
} from "https://deno.land/x/hono@v3.12.2/mod.ts"; } from "https://deno.land/x/hono@v3.12.2/mod.ts";

View File

@ -1,25 +1,23 @@
import { import {
Application, cors,
Context, deleteCookie,
Next, getCookie,
oakCors, Hono,
Request, HonoRequest,
Router, setCookie,
} from "./deps.ts"; } from "./deps.ts";
import { AssetBundle } from "../plugos/asset_bundle/bundle.ts"; import { AssetBundle } from "../plugos/asset_bundle/bundle.ts";
import { FileMeta } from "$sb/types.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 { determineShellBackend } from "./shell_backend.ts";
import { SpaceServer, SpaceServerConfig } from "./instance.ts"; import { SpaceServer, SpaceServerConfig } from "./instance.ts";
import { KvPrimitives } from "../plugos/lib/kv_primitives.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 { PrefixedKvPrimitives } from "../plugos/lib/prefixed_kv_primitives.ts";
import { base64Encode } from "../plugos/asset_bundle/base64.ts"; import { base64Encode } from "../plugos/asset_bundle/base64.ts";
const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week
export type ServerOptions = { export type ServerOptions = {
app: Application;
hostname: string; hostname: string;
port: number; port: number;
clientAssetBundle: AssetBundle; clientAssetBundle: AssetBundle;
@ -37,7 +35,7 @@ export class HttpServer {
plugAssetBundle: AssetBundle; plugAssetBundle: AssetBundle;
hostname: string; hostname: string;
port: number; port: number;
app: Application<Record<string, any>>; app: Hono;
keyFile: string | undefined; keyFile: string | undefined;
certFile: string | undefined; certFile: string | undefined;
@ -46,11 +44,11 @@ export class HttpServer {
configs: Map<string, SpaceServerConfig>; configs: Map<string, SpaceServerConfig>;
constructor(options: ServerOptions) { constructor(options: ServerOptions) {
this.app = new Hono();
this.clientAssetBundle = options.clientAssetBundle; this.clientAssetBundle = options.clientAssetBundle;
this.plugAssetBundle = options.plugAssetBundle; this.plugAssetBundle = options.plugAssetBundle;
this.hostname = options.hostname; this.hostname = options.hostname;
this.port = options.port; this.port = options.port;
this.app = options.app;
this.keyFile = options.keyFile; this.keyFile = options.keyFile;
this.certFile = options.certFile; this.certFile = options.certFile;
this.baseKvPrimitives = options.baseKvPrimitives; this.baseKvPrimitives = options.baseKvPrimitives;
@ -71,8 +69,9 @@ export class HttpServer {
return spaceServer; return spaceServer;
} }
determineConfig(req: Request): [string, SpaceServerConfig] { determineConfig(req: HonoRequest): [string, SpaceServerConfig] {
let hostname = req.url.host; // hostname:port const url = new URL(req.url);
let hostname = url.host; // hostname:port
// First try a full match // First try a full match
let config = this.configs.get(hostname); let config = this.configs.get(hostname);
@ -97,7 +96,7 @@ export class HttpServer {
throw new Error(`No space server config found for hostname ${hostname}`); throw new Error(`No space server config found for hostname ${hostname}`);
} }
ensureSpaceServer(req: Request): Promise<SpaceServer> { ensureSpaceServer(req: HonoRequest): Promise<SpaceServer> {
const [matchedHostname, config] = this.determineConfig(req); const [matchedHostname, config] = this.determineConfig(req);
const spaceServer = this.spaceServers.get(matchedHostname); const spaceServer = this.spaceServers.get(matchedHostname);
if (spaceServer) { if (spaceServer) {
@ -127,29 +126,17 @@ export class HttpServer {
} }
start() { start() {
// Initialize JWT issuer
// First check if auth string (username:password) has changed
// Serve static files (javascript, css, html) // Serve static files (javascript, css, html)
this.app.use(this.serveStatic.bind(this)); this.serveStatic();
this.addAuth();
const endpointHook = new EndpointHook("/_/"); this.addFsRoutes();
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());
// Fallback, serve the UI index.html // Fallback, serve the UI index.html
this.app.use(async ({ request, response }) => { this.app.use("*", async (c) => {
response.headers.set("Content-type", "text/html"); const spaceServer = await this.ensureSpaceServer(c.req);
response.headers.set("Cache-Control", "no-cache"); return c.html(this.renderIndexHtml(spaceServer), 200, {
const spaceServer = await this.ensureSpaceServer(request); "Cache-Control": "no-cache",
response.body = this.renderIndexHtml(spaceServer); });
}); });
this.abortController = new AbortController(); this.abortController = new AbortController();
@ -164,11 +151,10 @@ export class HttpServer {
if (this.certFile) { if (this.certFile) {
listenOptions.cert = Deno.readTextFileSync(this.certFile); listenOptions.cert = Deno.readTextFileSync(this.certFile);
} }
this.app.listen(listenOptions)
.catch((e: any) => { // Start the actual server
console.log("Server listen error:", e.message); Deno.serve(listenOptions, this.app.fetch);
Deno.exit(1);
});
const visibleHostname = this.hostname === "0.0.0.0" const visibleHostname = this.hostname === "0.0.0.0"
? "localhost" ? "localhost"
: this.hostname; : this.hostname;
@ -177,50 +163,51 @@ export class HttpServer {
); );
} }
async serveStatic( serveStatic() {
{ request, response }: Context<Record<string, any>, Record<string, any>>, this.app.use("*", async (c, next) => {
next: Next, const req = c.req;
) { const spaceServer = await this.ensureSpaceServer(req);
const spaceServer = await this.ensureSpaceServer(request); const url = new URL(req.url);
// console.log("URL", url);
if ( if (
request.url.pathname === "/" url.pathname === "/"
) { ) {
// Serve the UI (index.html) // Serve the UI (index.html)
// Note: we're explicitly not setting Last-Modified and If-Modified-Since header here because this page is dynamic // 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"); return c.html(this.renderIndexHtml(spaceServer), 200, {
response.headers.set("Cache-Control", "no-cache"); "Cache-Control": "no-cache",
response.body = this.renderIndexHtml(spaceServer); });
return;
} }
try { try {
const assetName = request.url.pathname.slice(1); const assetName = url.pathname.slice(1);
if (!this.clientAssetBundle.has(assetName)) {
return next();
}
console.log("Asset name", assetName);
if ( if (
this.clientAssetBundle.has(assetName) && this.clientAssetBundle.has(assetName) &&
request.headers.get("If-Modified-Since") === req.header("If-Modified-Since") ===
utcDateString(this.clientAssetBundle.getMtime(assetName)) && utcDateString(this.clientAssetBundle.getMtime(assetName)) &&
assetName !== "service_worker.js" assetName !== "service_worker.js"
) { ) {
response.status = 304; return c.body(null, 304);
return;
} }
response.status = 200; c.status(200);
response.headers.set( c.header("Content-type", this.clientAssetBundle.getMimeType(assetName));
"Content-type",
this.clientAssetBundle.getMimeType(assetName),
);
let data: Uint8Array | string = this.clientAssetBundle.readFileSync( let data: Uint8Array | string = this.clientAssetBundle.readFileSync(
assetName, assetName,
); );
response.headers.set("Cache-Control", "no-cache"); c.header("Cache-Control", "no-cache");
response.headers.set("Content-length", "" + data.length); c.header("Content-length", "" + data.length);
if (assetName !== "service_worker.js") { if (assetName !== "service_worker.js") {
response.headers.set( c.header(
"Last-Modified", "Last-Modified",
utcDateString(this.clientAssetBundle.getMtime(assetName)), utcDateString(this.clientAssetBundle.getMtime(assetName)),
); );
} }
// console.log("Serving it now", assetName);
if (request.method === "GET") { if (req.method === "GET") {
if (assetName === "service_worker.js") { if (assetName === "service_worker.js") {
const textData = new TextDecoder().decode(data); const textData = new TextDecoder().decode(data);
// console.log( // console.log(
@ -236,14 +223,15 @@ export class HttpServer {
), ),
); );
} }
response.body = data; return c.body(data);
} } // else e.g. HEAD, OPTIONS, don't send body
} catch { } catch {
return next(); return next();
} }
});
} }
private addAuth(app: Application) { private addAuth() {
const excludedPaths = [ const excludedPaths = [
"/manifest.json", "/manifest.json",
"/favicon.png", "/favicon.png",
@ -252,24 +240,22 @@ export class HttpServer {
]; ];
// Middleware handling the /.auth page and flow // Middleware handling the /.auth page and flow
app.use(async ({ request, response, cookies }, next) => { this.app.all("/.auth", async (c) => {
const host = request.url.host; // e.g. localhost:3000 const url = new URL(c.req.url);
if (request.url.pathname === "/.auth") { const req = c.req;
if (request.url.search === "?logout") { const host = url.host; // e.g. localhost:3000
await cookies.delete(authCookieName(host)); if (url.search === "?logout") {
// Implicit fallthrough to login page deleteCookie(c, authCookieName(host));
} }
if (request.method === "GET") { if (req.method === "GET") {
response.headers.set("Content-type", "text/html"); return c.html(
response.body = this.clientAssetBundle.readTextFileSync( this.clientAssetBundle.readTextFileSync(".client/auth.html"),
".client/auth.html",
); );
return; } else if (req.method === "POST") {
} else if (request.method === "POST") { const values = await c.req.parseBody();
const values = await request.body({ type: "form" }).value; const username = values["username"];
const username = values.get("username")!; const password = values["password"];
const password = values.get("password")!; const spaceServer = await this.ensureSpaceServer(req);
const spaceServer = await this.ensureSpaceServer(request);
const { user: expectedUser, pass: expectedPassword } = spaceServer const { user: expectedUser, pass: expectedPassword } = spaceServer
.auth!; .auth!;
if (username === expectedUser && password === expectedPassword) { if (username === expectedUser && password === expectedPassword) {
@ -278,44 +264,39 @@ export class HttpServer {
{ username }, { username },
authenticationExpirySeconds, authenticationExpirySeconds,
); );
await cookies.set( console.log("Successful auth");
authCookieName(host), setCookie(c, authCookieName(host), jwt, {
jwt,
{
expires: new Date( expires: new Date(
Date.now() + authenticationExpirySeconds * 1000, Date.now() + authenticationExpirySeconds * 1000,
), // in a week ), // in a week
sameSite: "strict", // sameSite: "Strict",
}, // httpOnly: true,
); });
response.redirect("/"); return c.redirect("/");
} else { } else {
response.redirect("/.auth?error=1"); return c.redirect("/.auth?error=1");
}
return;
} else {
response.redirect("/.auth");
return;
} }
} else { } else {
await next(); return c.redirect("/.auth");
} }
}); });
// Check auth // Check auth
app.use(async ({ request, response, cookies }, next) => { this.app.use("*", async (c, next) => {
const spaceServer = await this.ensureSpaceServer(request); const req = c.req;
const spaceServer = await this.ensureSpaceServer(req);
if (!spaceServer.auth) { if (!spaceServer.auth) {
// Auth disabled in this config, skip // Auth disabled in this config, skip
return next(); return next();
} }
const host = request.url.host; const url = new URL(req.url);
if (!excludedPaths.includes(request.url.pathname)) { const host = url.host;
const authToken = await cookies.get(authCookieName(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 // Attempt Bearer Authorization based authentication
const authHeader = request.headers.get("Authorization"); const authHeader = req.header("Authorization");
if (authHeader && authHeader.startsWith("Bearer ")) { if (authHeader && authHeader.startsWith("Bearer ")) {
const authToken = authHeader.slice("Bearer ".length); const authToken = authHeader.slice("Bearer ".length);
if (authToken === spaceServer.authToken) { if (authToken === spaceServer.authToken) {
@ -325,21 +306,19 @@ export class HttpServer {
console.log( console.log(
"Unauthorized token access, redirecting to auth page", "Unauthorized token access, redirecting to auth page",
); );
response.status = 401; return c.text("Unauthorized", 401);
response.body = "Unauthorized";
return;
} }
} }
} }
if (!authToken) { if (!authCookie) {
console.log("Unauthorized access, redirecting to auth page"); console.log("Unauthorized access, redirecting to auth page");
return response.redirect("/.auth"); return c.redirect("/.auth");
} }
const { user: expectedUser } = spaceServer.auth!; const { user: expectedUser } = spaceServer.auth!;
try { try {
const verifiedJwt = await spaceServer.jwtIssuer.verifyAndDecodeJWT( const verifiedJwt = await spaceServer.jwtIssuer.verifyAndDecodeJWT(
authToken, authCookie,
); );
if (verifiedJwt.username !== expectedUser) { if (verifiedJwt.username !== expectedUser) {
throw new Error("Username mismatch"); throw new Error("Username mismatch");
@ -349,112 +328,101 @@ export class HttpServer {
"Error verifying JWT, redirecting to auth page", "Error verifying JWT, redirecting to auth page",
e.message, e.message,
); );
return response.redirect("/.auth"); return c.redirect("/.auth");
} }
} }
return next(); return next();
}); });
} }
private addFsRoutes(): Router { private addFsRoutes() {
const fsRouter = new Router(); this.app.use(
const corsMiddleware = oakCors({ "*",
allowedHeaders: "*", cors({
exposedHeaders: "*", origin: "*",
methods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], allowHeaders: ["*"],
}); exposeHeaders: ["*"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"],
fsRouter.use(corsMiddleware); }),
);
// File list // File list
fsRouter.get( this.app.get(
"/index.json", "/index.json",
// corsMiddleware, async (c) => {
async ({ request, response }) => { const req = c.req;
const spaceServer = await this.ensureSpaceServer(request); const spaceServer = await this.ensureSpaceServer(req);
if (request.headers.has("X-Sync-Mode")) { if (req.header("X-Sync-Mode")) {
// Only handle direct requests for a JSON representation of the file list // 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(); const files = await spaceServer.spacePrimitives.fetchFileList();
response.body = JSON.stringify(files); return c.json(files, 200, {
"X-Space-Path": spaceServer.pagesPath,
});
} else { } else {
// Otherwise, redirect to the UI // Otherwise, redirect to the UI
// The reason to do this is to handle authentication systems like Authelia nicely // The reason to do this is to handle authentication systems like Authelia nicely
response.redirect("/"); return c.redirect("/");
} }
}, },
); );
// RPC // RPC shell
fsRouter.post("/.rpc", async ({ request, response }) => { this.app.post("/.rpc/shell", async (c) => {
const spaceServer = await this.ensureSpaceServer(request); const req = c.req;
const body = await request.body({ type: "json" }).value; const spaceServer = await this.ensureSpaceServer(req);
const body = await req.json();
try { try {
switch (body.operation) {
case "shell": {
const shellCommand: ShellRequest = body; const shellCommand: ShellRequest = body;
const shellResponse = await spaceServer.shellBackend.handle( const shellResponse = await spaceServer.shellBackend.handle(
shellCommand, shellCommand,
); );
response.headers.set("Content-Type", "application/json"); return c.json(shellResponse);
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) { } catch (e: any) {
response.headers.set("Content-type", "application/json"); console.log("Shell error", e);
response.status = 500; return c.text(e.message, 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";
}
} catch (e: any) {
console.log("Error", e);
response.status = 500;
response.body = e.message;
return;
} }
}); });
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 const filePathRegex = "/:path{[^!].+\\.[a-zA-Z]+}";
.get(
this.app.get(
filePathRegex, filePathRegex,
async ({ params, response, request }) => { async (c) => {
const name = params[0]; const req = c.req;
const spaceServer = await this.ensureSpaceServer(request); const name = req.param("path")!;
const spaceServer = await this.ensureSpaceServer(req);
console.log( console.log(
"Requested file", "Requested file",
name, name,
@ -462,24 +430,21 @@ export class HttpServer {
if ( if (
name.endsWith(".md") && name.endsWith(".md") &&
// This header signififies the requests comes directly from the http_space_primitives client (not the browser) // This header signififies the requests comes directly from the http_space_primitives client (not the browser)
!request.headers.has("X-Sync-Mode") && !req.header("X-Sync-Mode") &&
// This Accept header is used by federation to still work with CORS // This Accept header is used by federation to still work with CORS
request.headers.get("Accept") !== req.header("Accept") !==
"application/octet-stream" && "application/octet-stream" &&
request.headers.get("sec-fetch-mode") !== "cors" 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. // 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( console.warn(
"Request was without X-Sync-Mode nor a CORS request, redirecting to page", "Request was without X-Sync-Mode nor a CORS request, redirecting to page",
); );
response.redirect(`/${name.slice(0, -3)}`); return c.redirect(`/${name.slice(0, -3)}`);
return;
} }
if (name.startsWith(".")) { if (name.startsWith(".")) {
// Don't expose hidden files // Don't expose hidden files
response.status = 404; return c.notFound();
response.body = "Not exposed";
return;
} }
// Handle federated links through a simple redirect, only used for attachments loads with service workers disabled // Handle federated links through a simple redirect, only used for attachments loads with service workers disabled
if (name.startsWith("!")) { if (name.startsWith("!")) {
@ -492,112 +457,96 @@ export class HttpServer {
} }
try { try {
const req = await fetch(url); const req = await fetch(url);
response.status = req.status;
// Override X-Permssion header to always be "ro" // Override X-Permssion header to always be "ro"
const newHeaders = new Headers(); const newHeaders = new Headers();
for (const [key, value] of req.headers.entries()) { for (const [key, value] of req.headers.entries()) {
newHeaders.set(key, value); newHeaders.set(key, value);
} }
newHeaders.set("X-Permission", "ro"); newHeaders.set("X-Permission", "ro");
response.headers = newHeaders; return new Response(req.body, {
response.body = req.body; status: req.status,
headers: newHeaders,
});
} catch (e: any) { } catch (e: any) {
console.error("Error fetching federated link", e); console.error("Error fetching federated link", e);
response.status = 500; return c.text(e.message, 500);
response.body = e.message;
} }
return;
} }
try { try {
if (request.headers.has("X-Get-Meta")) { if (req.header("X-Get-Meta")) {
// Getting meta via GET request // Getting meta via GET request
const fileData = await spaceServer.spacePrimitives.getFileMeta( const fileData = await spaceServer.spacePrimitives.getFileMeta(
name, name,
); );
response.status = 200; return c.text("", 200, this.fileMetaToHeaders(fileData));
this.fileMetaToHeaders(response.headers, fileData);
response.body = "";
return;
} }
const fileData = await spaceServer.spacePrimitives.readFile(name); const fileData = await spaceServer.spacePrimitives.readFile(name);
const lastModifiedHeader = new Date(fileData.meta.lastModified) const lastModifiedHeader = new Date(fileData.meta.lastModified)
.toUTCString(); .toUTCString();
if ( if (
request.headers.get("If-Modified-Since") === lastModifiedHeader req.header("If-Modified-Since") === lastModifiedHeader
) { ) {
response.status = 304; return c.body(null, 304);
return;
} }
response.status = 200; return c.body(fileData.data, 200, {
this.fileMetaToHeaders(response.headers, fileData.meta); ...this.fileMetaToHeaders(fileData.meta),
response.headers.set("Last-Modified", lastModifiedHeader); "Last-Modified": lastModifiedHeader,
});
response.body = fileData.data;
} catch (e: any) { } catch (e: any) {
console.error("Error GETting file", name, e.message); console.error("Error GETting file", name, e.message);
response.status = 404; return c.notFound();
response.headers.set("Cache-Control", "no-cache");
response.body = "Not found";
} }
}, },
) ).put(
.put( async (c) => {
filePathRegex, const req = c.req;
async ({ request, response, params }) => { const name = req.param("path")!;
const name = params[0]; const spaceServer = await this.ensureSpaceServer(req);
const spaceServer = await this.ensureSpaceServer(request);
console.log("Saving file", name); console.log("Saving file", name);
if (name.startsWith(".")) { if (name.startsWith(".")) {
// Don't expose hidden files // Don't expose hidden files
response.status = 403; return c.text("Forbidden", 403);
return;
} }
const body = await request.body({ type: "bytes" }).value; const body = await req.arrayBuffer();
try { try {
const meta = await spaceServer.spacePrimitives.writeFile( const meta = await spaceServer.spacePrimitives.writeFile(
name, name,
body, new Uint8Array(body),
); );
response.status = 200; return c.text("OK", 200, this.fileMetaToHeaders(meta));
this.fileMetaToHeaders(response.headers, meta);
response.body = "OK";
} catch (err) { } catch (err) {
console.error("Write failed", err); console.error("Write failed", err);
response.status = 500; return c.text("Write failed", 500);
response.body = "Write failed";
} }
}, },
) ).delete(async (c) => {
.delete(filePathRegex, async ({ request, response, params }) => { const req = c.req;
const name = params[0]; const name = req.param("path")!;
const spaceServer = await this.ensureSpaceServer(request); const spaceServer = await this.ensureSpaceServer(req);
console.log("Deleting file", name); console.log("Deleting file", name);
if (name.startsWith(".")) { if (name.startsWith(".")) {
// Don't expose hidden files // Don't expose hidden files
response.status = 403; return c.text("Forbidden", 403);
return;
} }
try { try {
await spaceServer.spacePrimitives.deleteFile(name); await spaceServer.spacePrimitives.deleteFile(name);
response.status = 200; return c.text("OK");
response.body = "OK";
} catch (e: any) { } catch (e: any) {
console.error("Error deleting attachment", e); console.error("Error deleting attachment", e);
response.status = 500; return c.text(e.message, 500);
response.body = e.message;
} }
}) }).options();
.options(filePathRegex, corsMiddleware);
// Federation proxy // Federation proxy
const proxyPathRegex = "\/!(.+)"; const proxyPathRegex = "/:uri{!.+}";
fsRouter.all( this.app.all(
proxyPathRegex, proxyPathRegex,
async ({ params, response, request }, next) => { async (c, next) => {
let url = params[0]; const req = c.req;
if (!request.headers.has("X-Proxy-Request")) { let url = req.param("uri")!.slice(1);
if (!req.header("X-Proxy-Request")) {
// Direct browser request, not explicity fetch proxy request // Direct browser request, not explicity fetch proxy request
if (!/\.[a-zA-Z0-9]+$/.test(url)) { if (!/\.[a-zA-Z0-9]+$/.test(url)) {
console.log("Directly loading federation page via URL:", url); console.log("Directly loading federation page via URL:", url);
@ -615,47 +564,41 @@ export class HttpServer {
for ( for (
const headerName of ["Authorization", "Accept", "Content-Type"] const headerName of ["Authorization", "Accept", "Content-Type"]
) { ) {
if (request.headers.has(headerName)) { if (req.header(headerName)) {
safeRequestHeaders.set( safeRequestHeaders.set(
headerName, headerName,
request.headers.get(headerName)!, req.header(headerName)!,
); );
} }
} }
const req = await fetch(url, { const body = await req.arrayBuffer();
method: request.method, const fetchReq = await fetch(url, {
method: req.method,
headers: safeRequestHeaders, headers: safeRequestHeaders,
body: request.hasBody body: body.byteLength > 0 ? body : undefined,
? request.body({ type: "stream" }).value
: undefined,
}); });
response.status = req.status; const responseHeaders: Record<string, any> = {};
response.headers = req.headers; for (const [key, value] of fetchReq.headers.entries()) {
response.body = req.body; responseHeaders[key] = value;
}
return c.body(fetchReq.body, fetchReq.status, responseHeaders);
} catch (e: any) { } catch (e: any) {
console.error("Error fetching federated link", e); console.error("Error fetching federated link", e);
response.status = 500; return c.text(e.message, 500);
response.body = e.message;
} }
return;
}, },
); );
return fsRouter;
} }
private fileMetaToHeaders(headers: Headers, fileMeta: FileMeta) { private fileMetaToHeaders(fileMeta: FileMeta) {
headers.set("Content-Type", fileMeta.contentType); return {
headers.set( "Content-Type": fileMeta.contentType,
"X-Last-Modified", "X-Last-Modified": "" + fileMeta.lastModified,
"" + fileMeta.lastModified, "X-Created": "" + fileMeta.created,
); "Cache-Control": "no-cache",
headers.set( "X-Permission": fileMeta.perm,
"X-Created", "X-Content-Length": "" + fileMeta.size,
"" + fileMeta.created, };
);
headers.set("Cache-Control", "no-cache");
headers.set("X-Permission", fileMeta.perm);
headers.set("X-Content-Length", "" + fileMeta.size);
} }
stop() { stop() {
@ -671,5 +614,5 @@ function utcDateString(mtime: number): string {
} }
function authCookieName(host: string) { function authCookieName(host: string) {
return `auth:${host}`; return `auth_${host.replaceAll(/\W/g, "_")}`;
} }

View File

@ -8,14 +8,3 @@ export type ShellResponse = {
stderr: string; stderr: string;
code: number; code: number;
}; };
export type SyscallRequest = {
ctx: string; // Plug name requesting
name: string;
args: any[];
};
export type SyscallResponse = {
result?: any;
error?: string;
};

View File

@ -14,11 +14,10 @@ export function shellSyscalls(
throw new Error("Not supported in fully local mode"); throw new Error("Not supported in fully local mode");
} }
const resp = client.httpSpacePrimitives.authenticatedFetch( const resp = client.httpSpacePrimitives.authenticatedFetch(
`${client.httpSpacePrimitives.url}/.rpc`, `${client.httpSpacePrimitives.url}/.rpc/shell`,
{ {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
operation: "shell",
cmd, cmd,
args, args,
}), }),

View File

@ -1,6 +1,5 @@
import { HttpSpacePrimitives } from "../../common/spaces/http_space_primitives.ts"; import { HttpSpacePrimitives } from "../../common/spaces/http_space_primitives.ts";
import { SyscallContext, SysCallMapping } from "../../plugos/system.ts"; import { SyscallContext, SysCallMapping } from "../../plugos/system.ts";
import { SyscallResponse } from "../../server/rpc.ts";
import { Client } from "../client.ts"; import { Client } from "../client.ts";
export function proxySyscalls(client: Client, names: string[]): SysCallMapping { export function proxySyscalls(client: Client, names: string[]): SysCallMapping {
@ -20,18 +19,13 @@ export async function proxySyscall(
args: any[], args: any[],
): Promise<any> { ): Promise<any> {
const resp = await httpSpacePrimitives.authenticatedFetch( const resp = await httpSpacePrimitives.authenticatedFetch(
`${httpSpacePrimitives.url}/.rpc`, `${httpSpacePrimitives.url}/.rpc/${ctx.plug.name}/${name}`,
{ {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify(args),
ctx: ctx.plug.name,
operation: "syscall",
name,
args,
}),
}, },
); );
const result: SyscallResponse = await resp.json(); const result = await resp.json();
if (result.error) { if (result.error) {
console.error("Remote syscall error", result.error); console.error("Remote syscall error", result.error);
throw new Error(result.error); throw new Error(result.error);