1
0

Switch from Oak to Hono

This commit is contained in:
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 { 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(".");

View File

@ -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),

View File

@ -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();

View File

@ -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<EndpointHookT> {
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<EndpointHookT> {
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<EndpointHookT> {
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);
}
}
}

View File

@ -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";

View File

@ -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<Record<string, any>>;
app: Hono;
keyFile: string | undefined;
certFile: string | undefined;
@ -46,11 +44,11 @@ export class HttpServer {
configs: Map<string, SpaceServerConfig>;
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<SpaceServer> {
ensureSpaceServer(req: HonoRequest): Promise<SpaceServer> {
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<string, any>, Record<string, any>>,
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<string, any> = {};
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, "_")}`;
}

View File

@ -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;
};

View File

@ -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,
}),

View File

@ -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<any> {
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);