silverbullet/server/http_server.ts

402 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 { ensureSettingsAndIndex } from "../common/util.ts";
import { performLocalFetch } from "../common/proxy_fetch.ts";
import { BuiltinSettings } from "../web/types.ts";
import { gitIgnoreCompiler } from "./deps.ts";
import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts";
import { CollabServer } from "./collab.ts";
// @deno-types="npm:@types/express@4.17.15"
import express from "npm:express@4.18.2";
import cookieParser from "npm:cookie-parser";
// @deno-types="npm:@types/express-ws"
import expressWebsockets from "npm:express-ws@5.0.2";
import { Buffer } from "node:buffer";
// @deno-types="npm:body-parser@1.19.2"
import bodyParser from "npm:body-parser@1.19.2";
export type ServerOptions = {
hostname: string;
port: number;
pagesPath: string;
clientAssetBundle: AssetBundle;
user?: string;
pass?: string;
certFile?: string;
keyFile?: string;
maxFileSizeMB?: number;
};
export class HttpServer {
private app: express.Express;
private hostname: string;
private port: number;
user?: string;
abortController?: AbortController;
clientAssetBundle: AssetBundle;
settings?: BuiltinSettings;
spacePrimitives: SpacePrimitives;
collab: CollabServer;
constructor(
spacePrimitives: SpacePrimitives,
private options: ServerOptions,
) {
this.hostname = options.hostname;
this.port = options.port;
this.app = expressWebsockets(express()).app;
this.user = options.user;
this.clientAssetBundle = options.clientAssetBundle;
let fileFilterFn: (s: string) => boolean = () => true;
this.spacePrimitives = new FilteredSpacePrimitives(
spacePrimitives,
(meta) => {
// Don't list file exceeding the maximum file size
if (
options.maxFileSizeMB &&
meta.size / (1024 * 1024) > options.maxFileSizeMB
) {
return false;
}
return fileFilterFn(meta.name);
},
async () => {
await this.reloadSettings();
if (typeof this.settings?.spaceIgnore === "string") {
fileFilterFn = gitIgnoreCompiler(this.settings.spaceIgnore).accepts;
} else {
fileFilterFn = () => true;
}
},
);
this.collab = new CollabServer(this.spacePrimitives);
this.collab.start();
}
// 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 this.reloadSettings();
this.app.use(cookieParser());
this.app.all(/^(\/((?!\.fs).)*)$/, (req, res) => {
// console.log(req.path, req.method);
try {
const assetName = req.path.slice(1);
if (this.clientAssetBundle.has(assetName)) {
console.log("Serving asset", assetName);
if (
req.header("If-Modified-Since") ===
utcDateString(this.clientAssetBundle.getMtime(assetName))
) {
res.status(304);
res.send();
return;
}
res.status(200);
res.header(
"Content-type",
this.clientAssetBundle.getMimeType(assetName),
);
const data = this.clientAssetBundle.readFileSync(
assetName,
);
res.header("Cache-Control", "no-cache");
// res.header("Content-Length", "" + data.length);
res.header(
"Last-Modified",
utcDateString(this.clientAssetBundle.getMtime(assetName)),
);
if (req.method === "GET") {
const buf = Buffer.from(data);
console.log("Buffer length", buf.length, assetName);
res.send(buf);
// res.send
} else {
res.send();
}
return;
}
} catch (e: any) {
console.error("Error", e);
}
res.status(200);
res.header("Content-Type", "text/html");
res.send(this.renderIndexHtml());
});
// Pages API
this.app.use(
"/.fs",
// passwordMiddleware,
this.buildFsRouter(this.spacePrimitives),
);
this.app.listen(this.port, this.hostname, () => {
const visibleHostname = this.hostname === "0.0.0.0"
? "localhost"
: this.hostname;
console.log(
`SilverBullet is now running: http://${visibleHostname}:${this.port}`,
);
});
// return;
// this.collab.route(this.app);
}
async reloadSettings() {
// TODO: Throttle this?
this.settings = await ensureSettingsAndIndex(this.spacePrimitives);
}
private passwordAuth(
req: express.Request,
res: express.Response,
next: express.NextFunction,
) {
const excludedPaths = [
"/manifest.json",
"/favicon.png",
"/logo.png",
"/.auth",
];
if (this.user) {
const b64User = btoa(this.user);
if (!excludedPaths.includes(req.path)) {
const authCookie = req.cookies["auth"];
if (!authCookie || authCookie !== b64User) {
res.status(401);
res.send("Unauthorized, please authenticate");
return;
}
}
if (req.path === "/.auth") {
if (req.method === "GET") {
res.header("Content-type", "text/html");
res.send(this.clientAssetBundle.readTextFileSync(
".client/auth.html",
));
return;
} else if (req.method === "POST") {
bodyParser.urlencoded({ extended: false })(req, res, next);
// const values = req.body;
const username = req.body.username,
password = req.body.password,
refer = req.body.refer;
if (this.user === `${username}:${password}`) {
res.cookie("auth", b64User, {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // in a week
sameSite: "strict",
});
res.redirect(refer || "/");
// console.log("All headers", request.headers);
} else {
res.redirect("/.auth?error=1");
}
return;
} else {
res.status(401);
res.send("Unauthorized");
return;
}
} else {
// Unauthenticated access to excluded paths
next();
}
}
}
private buildFsRouter(spacePrimitives: SpacePrimitives): express.Router {
const fsRouter = express.Router();
// File list
fsRouter.route("/").get(async (_req, res) => {
res.header("X-Space-Path", this.options.pagesPath);
res.json(await spacePrimitives.fetchFileList());
}).post(
bodyParser.json({
type: "*/*",
}),
async (req, res) => {
// console.log("RPC", req.body);
const body = req.body;
try {
switch (body.operation) {
case "fetch": {
const result = await performLocalFetch(body.url, body.options);
console.log("Proxying fetch request to", body.url);
res.header("Content-Type", "application/json");
res.send(JSON.stringify(result));
return;
}
case "shell": {
// TODO: Have a nicer way to do this
if (this.options.pagesPath.startsWith("s3://")) {
res.status(500);
res.header("Content-Type", "application/json");
res.send(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);
res.header("Content-Type", "application/json");
res.send(JSON.stringify({
stdout,
stderr,
code: output.code,
}));
return;
}
case "ping": {
// RPC to check (for collab purposes) which client has what page open
res.header("Content-Type", "application/json");
// console.log("Got ping", body);
res.send(JSON.stringify(
this.collab.ping(body.clientId, body.page),
));
return;
}
default:
res.header("Content-Type", "text/plain");
res.status(400);
res.send("Unknown operation");
}
} catch (e: any) {
console.log("Error", e);
res.status(500);
res.send(e.message);
return;
}
},
);
fsRouter
.route(/\/(.+)/)
.get(async (req, res) => {
const name = req.params[0];
console.log("Loading file", name);
try {
const attachmentData = await spacePrimitives.readFile(
name,
);
const lastModifiedHeader = new Date(attachmentData.meta.lastModified)
.toUTCString();
if (req.header("If-Modified-Since") === lastModifiedHeader) {
res.status(304);
res.end();
return;
}
res.status(200);
res.header(
"X-Last-Modified",
"" + attachmentData.meta.lastModified,
);
res.header("Cache-Control", "no-cache");
res.header("X-Permission", attachmentData.meta.perm);
res.header(
"Last-Modified",
lastModifiedHeader,
);
res.header("Content-Type", attachmentData.meta.contentType);
res.send(Buffer.from(attachmentData.data));
} catch {
// console.error("Error in main router", e);
res.status(404);
res.end();
}
})
.put(
bodyParser.raw({ type: "*/*", limit: "100mb" }),
async (req, res) => {
const name = req.params[0];
console.log("Saving file", name);
try {
const meta = await spacePrimitives.writeFile(
name,
new Uint8Array(req.body as ArrayBuffer),
);
res.status(200);
res.header("Content-Type", meta.contentType);
res.header("X-Last-Modified", "" + meta.lastModified);
res.header("X-Content-Length", "" + meta.size);
res.header("X-Permission", meta.perm);
res.send("OK");
} catch (err) {
res.status(500);
res.send("Write failed");
console.error("Pipeline failed", err);
}
},
)
.options(async (req, res) => {
const name = req.params[0];
try {
const meta = await spacePrimitives.getFileMeta(name);
res.status(200);
res.header("Content-Type", meta.contentType);
res.header("X-Last-Modified", "" + meta.lastModified);
res.header("X-Content-Length", "" + meta.size);
res.header("X-Permission", meta.perm);
} catch {
res.status(404);
res.send("Not found");
// console.error("Options failed", err);
}
})
.delete(async (req, res) => {
const name = req.params[0];
console.log("Deleting file", name);
try {
await spacePrimitives.deleteFile(name);
res.status(200);
res.send("OK");
} catch (e: any) {
console.error("Error deleting attachment", e);
res.status(200);
res.send(e.message);
}
});
return fsRouter;
}
stop() {
if (this.abortController) {
this.abortController.abort();
console.log("stopped server");
}
}
}
function utcDateString(mtime: number): string {
return new Date(mtime).toUTCString();
}