1
0
silverbullet/server/http_server.ts

362 lines
12 KiB
TypeScript

import { Application, Router } from "./deps.ts";
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import { AssetBundle } from "../plugos/asset_bundle/bundle.ts";
import { base64Decode } from "../plugos/asset_bundle/base64.ts";
import { ensureSettingsAndIndex } from "../common/util.ts";
import { performLocalFetch } from "../common/proxy_fetch.ts";
export type ServerOptions = {
hostname: string;
port: number;
pagesPath: string;
clientAssetBundle: AssetBundle;
user?: string;
pass?: string;
certFile?: string;
keyFile?: string;
maxFileSizeMB?: number;
};
export class HttpServer {
app: Application;
private hostname: string;
private port: number;
user?: string;
settings: { [key: string]: any } = {};
abortController?: AbortController;
clientAssetBundle: AssetBundle;
constructor(
private spacePrimitives: SpacePrimitives,
private options: ServerOptions,
) {
this.hostname = options.hostname;
this.port = options.port;
this.app = new Application();
this.user = options.user ?? Deno.env.get("SB_USER");
this.clientAssetBundle = options.clientAssetBundle;
}
// Replaces some template variables in index.html in a rather ad-hoc manner, but YOLO
renderIndexHtml() {
return this.clientAssetBundle.readTextFileSync(".client/index.html")
.replaceAll(
"{{SPACE_PATH}}",
this.options.pagesPath.replaceAll("\\", "\\\\"),
).replaceAll(
"{{SYNC_ENDPOINT}}",
"/.fs",
);
}
async start() {
await ensureSettingsAndIndex(this.spacePrimitives);
// Serve static files (javascript, css, html)
this.app.use(async ({ request, response }, next) => {
if (request.url.pathname === "/") {
// 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.body = this.renderIndexHtml();
return;
}
try {
const assetName = request.url.pathname.slice(1);
if (
this.clientAssetBundle.has(assetName) &&
request.headers.get("If-Modified-Since") ===
utcDateString(this.clientAssetBundle.getMtime(assetName))
) {
response.status = 304;
return;
}
response.status = 200;
response.headers.set(
"Content-type",
this.clientAssetBundle.getMimeType(assetName),
);
const data = this.clientAssetBundle.readFileSync(
assetName,
);
response.headers.set("Cache-Control", "no-cache");
response.headers.set("Content-length", "" + data.length);
response.headers.set(
"Last-Modified",
utcDateString(this.clientAssetBundle.getMtime(assetName)),
);
if (request.method === "GET") {
response.body = data;
}
} catch {
await next();
}
});
// Fallback, serve index.html
this.app.use(({ request, response }, next) => {
if (
!request.url.pathname.startsWith("/.fs") &&
request.url.pathname !== "/.auth"
) {
response.headers.set("Content-type", "text/html");
response.body = this.renderIndexHtml();
} else {
return next();
}
});
// Pages API
const fsRouter = this.buildFsRouter(this.spacePrimitives);
this.addPasswordAuth(this.app);
this.app.use(fsRouter.routes());
this.app.use(fsRouter.allowedMethods());
this.abortController = new AbortController();
const listenOptions: any = {
hostname: this.hostname,
port: this.port,
signal: this.abortController.signal,
};
if (this.options.keyFile) {
listenOptions.key = Deno.readTextFileSync(this.options.keyFile);
}
if (this.options.certFile) {
listenOptions.cert = Deno.readTextFileSync(this.options.certFile);
}
this.app.listen(listenOptions)
.catch((e: any) => {
console.log("Server listen error:", e.message);
Deno.exit(1);
});
const visibleHostname = this.hostname === "0.0.0.0"
? "localhost"
: this.hostname;
console.log(
`SilverBullet is now running: http://${visibleHostname}:${this.port}`,
);
}
private addPasswordAuth(app: Application) {
const excludedPaths = [
"/manifest.json",
"/favicon.png",
"/logo.png",
"/.auth",
];
if (this.user) {
const b64User = btoa(this.user);
app.use(async ({ request, response, cookies }, next) => {
if (!excludedPaths.includes(request.url.pathname)) {
const authCookie = await cookies.get("auth");
if (!authCookie || authCookie !== b64User) {
response.status = 401;
response.body = "Unauthorized, please authenticate";
return;
}
}
if (request.url.pathname === "/.auth") {
if (request.method === "GET") {
response.headers.set("Content-type", "text/html");
response.body = this.clientAssetBundle.readTextFileSync(
".client/auth.html",
);
return;
} else if (request.method === "POST") {
const values = await request.body({ type: "form" }).value;
const username = values.get("username"),
password = values.get("password"),
refer = values.get("refer");
if (this.user === `${username}:${password}`) {
await cookies.set("auth", b64User, {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // in a week
sameSite: "strict",
});
response.redirect(refer || "/");
// console.log("All headers", request.headers);
} else {
response.redirect("/.auth?error=1");
}
return;
} else {
response.status = 401;
response.body = "Unauthorized";
return;
}
} else {
// Unauthenticated access to excluded paths
await next();
}
});
}
}
private buildFsRouter(spacePrimitives: SpacePrimitives): Router {
const fsRouter = new Router();
// File list
fsRouter.get("/", async ({ response }) => {
response.headers.set("Content-type", "application/json");
response.headers.set("X-Space-Path", this.options.pagesPath);
const files = await spacePrimitives.fetchFileList();
response.body = JSON.stringify(files);
});
// RPC
fsRouter.post("/", async ({ request, response }) => {
const body = await request.body({ type: "json" }).value;
try {
switch (body.operation) {
case "fetch": {
const result = await performLocalFetch(body.url, body.options);
response.headers.set("Content-Type", "application/json");
response.body = JSON.stringify(result);
return;
}
case "shell": {
// TODO: Have a nicer way to do this
if (this.options.pagesPath.startsWith("s3://")) {
response.status = 500;
response.body = JSON.stringify({
stdout: "",
stderr: "Cannot run shell commands with S3 backend",
code: 500,
});
return;
}
const p = new Deno.Command(body.cmd, {
args: body.args,
cwd: this.options.pagesPath,
stdout: "piped",
stderr: "piped",
});
const output = await p.output();
const stdout = new TextDecoder().decode(output.stdout);
const stderr = new TextDecoder().decode(output.stderr);
response.headers.set("Content-Type", "application/json");
response.body = JSON.stringify({
stdout,
stderr,
code: output.code,
});
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;
}
});
fsRouter
.get("\/(.+)", async ({ params, response, request }) => {
const name = params[0];
console.log("Loading file", name);
try {
const attachmentData = await spacePrimitives.readFile(
name,
);
const lastModifiedHeader = new Date(attachmentData.meta.lastModified)
.toUTCString();
if (request.headers.get("If-Modified-Since") === lastModifiedHeader) {
response.status = 304;
return;
}
response.status = 200;
response.headers.set(
"X-Last-Modified",
"" + attachmentData.meta.lastModified,
);
response.headers.set("Cache-Control", "no-cache");
response.headers.set("X-Permission", attachmentData.meta.perm);
response.headers.set(
"Last-Modified",
lastModifiedHeader,
);
response.headers.set("Content-Type", attachmentData.meta.contentType);
response.body = attachmentData.data;
} catch {
// console.error("Error in main router", e);
response.status = 404;
response.body = "";
}
})
.put("\/(.+)", async ({ request, response, params }) => {
const name = params[0];
console.log("Saving file", name);
let body: Uint8Array;
if (
request.headers.get("X-Content-Base64")
) {
const content = await request.body({ type: "text" }).value;
body = base64Decode(content);
} else {
body = await request.body({ type: "bytes" }).value;
}
try {
const meta = await spacePrimitives.writeFile(
name,
body,
);
response.status = 200;
response.headers.set("Content-Type", meta.contentType);
response.headers.set("X-Last-Modified", "" + meta.lastModified);
response.headers.set("X-Content-Length", "" + meta.size);
response.headers.set("X-Permission", meta.perm);
response.body = "OK";
} catch (err) {
response.status = 500;
response.body = "Write failed";
console.error("Pipeline failed", err);
}
})
.options("\/(.+)", async ({ response, params }) => {
const name = params[0];
try {
const meta = await spacePrimitives.getFileMeta(name);
response.status = 200;
response.headers.set("Content-Type", meta.contentType);
response.headers.set("X-Last-Modified", "" + meta.lastModified);
response.headers.set("X-Content-Length", "" + meta.size);
response.headers.set("X-Permission", meta.perm);
} catch {
response.status = 404;
response.body = "Not found";
// console.error("Options failed", err);
}
})
.delete("\/(.+)", async ({ response, params }) => {
const name = params[0];
try {
await spacePrimitives.deleteFile(name);
response.status = 200;
response.body = "OK";
} catch (e: any) {
console.error("Error deleting attachment", e);
response.status = 200;
response.body = e.message;
}
});
return new Router().use("/.fs", fsRouter.routes());
}
stop() {
if (this.abortController) {
this.abortController.abort();
console.log("stopped server");
}
}
}
function utcDateString(mtime: number): string {
return new Date(mtime).toUTCString();
}