Files
plainleaf/server/http_server.ts
T

613 lines
19 KiB
TypeScript

import { type Context, Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { validator } from "hono/validator";
import type { AssetBundle } from "../lib/asset_bundle/bundle.ts";
import { handleShellEndpoint } from "./shell_endpoint.ts";
import type { KvPrimitives } from "../lib/data/kv_primitives.ts";
import { compile as gitIgnoreCompiler } from "gitignore-parser";
import { decodePageURI } from "@silverbulletmd/silverbullet/lib/ref";
import { LockoutTimer } from "./lockout.ts";
import type { AuthOptions } from "../cmd/server.ts";
import type { ClientConfig } from "../web/client.ts";
import { applyUrlPrefix, removeUrlPrefix } from "../lib/url_prefix.ts";
import { authCookieName, utcDateString } from "./util.ts";
import { renderHtmlPage } from "./serverside_render.ts";
import { FilteredSpacePrimitives } from "../lib/spaces/filtered_space_primitives.ts";
import { AssetBundlePlugSpacePrimitives } from "../lib/spaces/asset_bundle_space_primitives.ts";
import { determineStorageBackend } from "./storage_backend.ts";
import { ReadOnlySpacePrimitives } from "../lib/spaces/ro_space_primitives.ts";
import type { SpacePrimitives } from "../lib/spaces/space_primitives.ts";
import { JWTIssuer } from "./crypto.ts";
import {
determineShellBackend,
NotSupportedShell,
type ShellBackend,
} from "./shell_backend.ts";
import { CONFIG_TEMPLATE, INDEX_TEMPLATE } from "../web/PAGE_TEMPLATES.ts";
import type { FileMeta } from "../type/index.ts";
import {
CheckPathSpacePrimitives,
} from "../lib/spaces/checked_space_primitives.ts";
const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week
export type ServerOptions = {
hostname: string;
port: number;
hostUrlPrefix?: string;
auth?: AuthOptions;
spaceIgnore?: string;
pagesPath: string;
shellBackend: string;
shellCommandWhiteList?: string[];
readOnly: boolean;
indexPage: string;
};
export class HttpServer {
private abortController?: AbortController;
private readonly hostname: string;
private readonly port: number;
private app: Hono;
private readonly spacePrimitives: SpacePrimitives;
private jwtIssuer: JWTIssuer;
private readonly shellBackend: ShellBackend;
constructor(
readonly options: ServerOptions,
private clientAssetBundle: AssetBundle,
private plugAssetBundle: AssetBundle,
public baseKvPrimitives: KvPrimitives,
) {
this.app = new Hono().basePath(options.hostUrlPrefix ?? "");
this.hostname = options.hostname;
this.port = options.port;
let fileFilterFn: (s: string) => boolean = () => true;
if (options.spaceIgnore) {
fileFilterFn = gitIgnoreCompiler(options.spaceIgnore).accepts;
}
this.spacePrimitives = new CheckPathSpacePrimitives(
new FilteredSpacePrimitives(
new AssetBundlePlugSpacePrimitives(
determineStorageBackend(options.pagesPath),
this.plugAssetBundle,
),
(meta) => fileFilterFn(meta.name),
),
);
if (options.readOnly) {
this.spacePrimitives = new ReadOnlySpacePrimitives(this.spacePrimitives);
}
this.shellBackend = options.readOnly
? new NotSupportedShell() // No shell for read only mode
: determineShellBackend(options);
this.jwtIssuer = new JWTIssuer(baseKvPrimitives);
}
async start() {
if (this.options.auth) {
// Initialize JWT issuer
await this.jwtIssuer.init(
JSON.stringify({ auth: this.options.auth }),
);
}
await this.ensureBasicPages();
// Serve static files (javascript, css, html)
this.serveStatic();
this.addAuth();
this.app.route("/.fs", this.buildFsRoutes());
// Fallback, serve the UI index.html
this.app.use("*", (c) => {
const url = new URL(this.unprefixedUrl(c.req.url));
const pageName = decodePageURI(url.pathname.slice(1));
return renderHtmlPage(
this.spacePrimitives,
pageName,
c,
this.options,
this.clientAssetBundle,
);
});
this.abortController = new AbortController();
const listenOptions: any = {
hostname: this.hostname,
port: this.port,
signal: this.abortController.signal,
};
// Start the actual server
Deno.serve(listenOptions, this.app.fetch);
const visibleHostname = this.hostname === "0.0.0.0"
? "localhost"
: this.hostname;
console.log(
`SilverBullet is now running: http://${visibleHostname}:${this.port}`,
);
}
serveStatic() {
this.app.use("*", (c, next): Promise<void | Response> => {
const req = c.req;
const url = new URL(this.unprefixedUrl(req.url));
if (
url.pathname === "/"
) {
// Serve the UI (index.html)
const indexPage = this.options.indexPage ?? "index";
return renderHtmlPage(
this.spacePrimitives,
indexPage,
c,
this.options,
this.clientAssetBundle,
);
}
try {
const assetName = url.pathname.slice(1);
if (!this.clientAssetBundle.has(assetName)) {
return next();
}
if (
this.clientAssetBundle.has(assetName) &&
req.header("If-Modified-Since") ===
utcDateString(this.clientAssetBundle.getMtime(assetName))
) {
return Promise.resolve(c.body(null, 304));
}
c.status(200);
c.header("Content-type", this.clientAssetBundle.getMimeType(assetName));
const data = this.clientAssetBundle.readFileSync(
assetName,
);
c.header("Content-length", "" + data.length);
c.header(
"Last-Modified",
utcDateString(this.clientAssetBundle.getMtime(assetName)),
);
if (req.method === "GET") {
return Promise.resolve(c.body(new Uint8Array(data).buffer));
} // else e.g. HEAD, OPTIONS, don't send body
} catch {
return next();
}
return Promise.resolve();
});
}
stop() {
if (this.abortController) {
this.abortController.abort();
console.log("stopped server");
}
}
private addAuth() {
const excludedPaths = [
"/manifest.json",
"/favicon.png",
"/logo.png",
"/.auth",
];
// Since we're a single user app, we can use a single lockout timer to prevent brute force attacks
const lockoutTimer = this.options.auth?.lockoutLimit
? new LockoutTimer(
// Turn into ms
this.options.auth.lockoutTime * 1000,
this.options.auth.lockoutLimit!,
)
: new LockoutTimer(0, 0); // disabled
this.app.get("/.logout", (c) => {
const url = new URL(this.unprefixedUrl(c.req.url));
deleteCookie(c, authCookieName(url.host), {
path: `${this.options.hostUrlPrefix ?? ""}/`,
});
deleteCookie(c, "refreshLogin", {
path: `${this.options.hostUrlPrefix ?? ""}/`,
});
return c.redirect(this.prefixedUrl("/.auth"));
});
// Authentication endpoints
this.app.get("/.auth", (c) => {
const html = this.clientAssetBundle.readTextFileSync(".client/auth.html")
.replaceAll("{{HOST_URL_PREFIX}}", this.options.hostUrlPrefix ?? "");
return c.html(html);
}).post(
validator("form", (value: any, c: Context) => {
const username = value["username"];
const password = value["password"];
const rememberMe = value["rememberMe"];
if (
!username || typeof username !== "string" ||
!password || typeof password !== "string" ||
(rememberMe && typeof rememberMe !== "string")
) {
return c.redirect(this.prefixedUrl("/.auth?error=0"));
}
return { username, password, rememberMe };
}),
async (c) => {
const req = c.req;
const url = new URL(this.unprefixedUrl(req.url));
const { username, password, rememberMe } = req.valid("form");
const {
user: expectedUser,
pass: expectedPassword,
} = this.options.auth!;
if (lockoutTimer.isLocked()) {
console.error("Authentication locked out, redirecting to auth page.");
return c.redirect(this.prefixedUrl("/.auth?error=2"));
}
if (username === expectedUser && password === expectedPassword) {
// Generate a JWT and set it as a cookie
const jwt = rememberMe
? await this.jwtIssuer.createJWT({ username })
: await this.jwtIssuer.createJWT(
{ username },
authenticationExpirySeconds,
);
console.log("Successful auth");
const inAWeek = new Date(
Date.now() + authenticationExpirySeconds * 1000,
);
setCookie(c, authCookieName(url.host), jwt, {
path: `${this.options.hostUrlPrefix ?? ""}/`,
expires: inAWeek,
});
if (rememberMe) {
setCookie(c, "refreshLogin", "true", {
path: `${this.options.hostUrlPrefix ?? ""}/`,
expires: inAWeek,
});
}
const values = await req.parseBody();
const from = values["from"];
return c.redirect(
this.prefixedUrl(typeof from === "string" ? from : "/"),
);
} else {
console.error("Authentication failed, redirecting to auth page.");
lockoutTimer.addCount();
return c.redirect(this.prefixedUrl("/.auth?error=1"));
}
},
).all((c) => {
return c.redirect(this.prefixedUrl("/.auth"));
});
// Simple ping health endpoint
this.app.get("/.ping", (c) => {
return c.text("OK", 200, {
"Cache-Control": "no-cache",
});
});
// Check auth on every other request
this.app.use("*", async (c, next) => {
if (!this.options.auth) {
// Auth disabled in this config, skip
return next();
}
const req = c.req;
const url = new URL(this.unprefixedUrl(req.url));
const path = this.unprefixedUrl(req.path);
const host = url.host;
const redirectToAuth = () => {
// Try filtering api paths
if (path.startsWith("/.") || path.endsWith(".md")) {
return c.redirect(this.prefixedUrl("/.auth"), 401 as any);
} else {
return c.redirect(
this.prefixedUrl(`/.auth?from=${path}`),
302 as any,
);
}
};
if (!excludedPaths.includes(url.pathname)) {
const authCookie = getCookie(c, authCookieName(host));
if (!authCookie && this.options.auth?.authToken) {
// Attempt Bearer Authorization based authentication
const authHeader = req.header("Authorization");
if (authHeader && authHeader.startsWith("Bearer ")) {
const authToken = authHeader.slice("Bearer ".length);
if (authToken === this.options.auth.authToken) {
// All good, let's proceed
this.refreshLogin(c, host);
return next();
} else {
console.log(
"Unauthorized token access, redirecting to auth page",
);
return c.text("Unauthorized", 401);
}
}
}
if (!authCookie) {
console.log("Unauthorized access, redirecting to auth page");
return redirectToAuth();
}
const { user: expectedUser } = this.options.auth!;
try {
const verifiedJwt = await this.jwtIssuer
.verifyAndDecodeJWT(
authCookie,
);
if (verifiedJwt.username !== expectedUser) {
throw new Error("Username mismatch");
}
} catch (e: any) {
console.error(
"Error verifying JWT, redirecting to auth page",
e.message,
);
return redirectToAuth();
}
}
this.refreshLogin(c, host);
return next();
});
// Fetch config
this.app.get("/.config", (c) => {
const clientConfig: ClientConfig = {
readOnly: this.options.readOnly,
spaceFolderPath: this.options.pagesPath,
indexPage: this.options.indexPage,
};
return c.json(clientConfig, 200, {
"Cache-Control": "no-cache",
});
});
// Shell command endpoint
this.app.post("/.shell", (c) => {
return handleShellEndpoint(
c,
this.shellBackend,
this.options.readOnly,
);
});
// HTTP Proxy endpoint
const proxyPathRegex = "/.proxy/:uri{.+}";
this.app.all(
proxyPathRegex,
async (c) => {
const req = c.req;
if (this.options.readOnly) {
return c.text("Read only mode, no proxy allowed", 405);
}
// Get the full URL including query parameters
const originalUrl = new URL(req.url);
let url = req.param("uri")! + originalUrl.search;
// Assume https unless this is localhost or an IP address
if (
url.startsWith("localhost") || url.match(/^\d+\./)
) {
url = `http://${url}`;
} else {
url = `https://${url}`;
}
console.log("Proxying to", url);
try {
const safeRequestHeaders = new Headers();
// List all headers
for (
const headerName of ["Authorization", "Accept", "Content-Type"]
) {
if (req.header(headerName)) {
safeRequestHeaders.set(
headerName,
req.header(headerName)!,
);
}
}
// List all headers starting with X-Proxy-Header-, remove the prefix and add to the safe headers
for (const [key, value] of Object.entries(req.header())) {
if (key.startsWith("x-proxy-header-")) {
safeRequestHeaders.set(
key.slice("x-proxy-header-".length), // corrected casing of header prefix
value,
);
}
}
const body = await req.arrayBuffer();
return fetch(url, {
method: req.method,
headers: safeRequestHeaders,
body: body.byteLength > 0 ? body : undefined,
});
} catch (e: any) {
console.error("Error fetching federated link", e);
return c.text(e.message, 500);
}
},
);
}
private refreshLogin(c: Context, host: string) {
if (getCookie(c, "refreshLogin")) {
const inAWeek = new Date(
Date.now() + authenticationExpirySeconds * 1000,
);
const jwt = getCookie(c, authCookieName(host));
if (jwt) {
setCookie(c, authCookieName(host), jwt, {
path: `${this.options.hostUrlPrefix ?? ""}/`,
expires: inAWeek,
// sameSite: "Strict",
// httpOnly: true,
});
setCookie(c, "refreshLogin", "true", {
path: `${this.options.hostUrlPrefix ?? ""}/`,
expires: inAWeek,
});
}
}
}
private buildFsRoutes(): Hono {
const fsRoutes = new Hono();
// File list
fsRoutes.get("/", async (c) => {
const req = c.req;
if (req.header("X-Sync-Mode")) {
// Only handle direct requests for a JSON representation of the file list
const files = await this.spacePrimitives.fetchFileList();
return c.json(files, 200, {
"X-Space-Path": this.options.pagesPath,
});
} else {
// Otherwise, redirect to the UI
// The reason to do this is to handle authentication systems like Authelia nicely
return c.redirect(this.prefixedUrl("/"));
}
});
fsRoutes.get("/:path{.*}", this.handleGet.bind(this)).put(
this.handlePut.bind(this),
).delete(this.handleDelete.bind(this)).options();
return fsRoutes;
}
private async handleDelete(c: Context<any>) {
const req = c.req;
const name = req.param("path")!;
if (this.options.readOnly) {
return c.text("Read only mode, no writes allowed", 405);
}
console.log("Deleting file", name);
try {
await this.spacePrimitives.deleteFile(name);
return c.text("OK");
} catch (e: any) {
console.error("Error deleting document", e);
return c.text(e.message, 500);
}
}
private async handlePut(c: Context<any>) {
const req = c.req;
const path = req.param("path")!;
if (this.options.readOnly) {
return c.text("Read only mode, no writes allowed", 405);
}
console.log("Writing file", path);
const body = await req.arrayBuffer();
try {
const meta = await this.spacePrimitives.writeFile(
path,
new Uint8Array(body),
);
return c.text("OK", 200, this.fileMetaToHeaders(meta));
} catch (err) {
console.error("Write failed", err);
return c.text("Write failed", 500);
}
}
async handleGet(c: Context<any>) {
const req = c.req;
const name = req.param("path")!;
try {
if (req.header("X-Get-Meta")) {
// Getting meta via GET request
const fileData = await this.spacePrimitives.getFileMeta(
name,
);
return c.text("", 200, this.fileMetaToHeaders(fileData));
}
const fileData = await this.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(new Uint8Array(fileData.data).buffer, 200, {
...this.fileMetaToHeaders(fileData.meta),
"Last-Modified": lastModifiedHeader,
});
} catch (e: any) {
console.error("Error GETting file", name, e.message);
return c.notFound();
}
}
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,
};
}
private prefixedUrl(url: string): string {
return applyUrlPrefix(url, this.options.hostUrlPrefix);
}
private unprefixedUrl(url: string): string {
return removeUrlPrefix(url, this.options.hostUrlPrefix);
}
async ensureBasicPages() {
await this.ensurePageWithContent(
`${this.options.indexPage}.md`,
INDEX_TEMPLATE,
);
const files = await this.spacePrimitives.fetchFileList();
const hasConfig = files.some(
(f) => f.name === "CONFIG.md" || f.name.endsWith("/CONFIG.md"),
);
if (!hasConfig) {
await this.ensurePageWithContent("CONFIG.md", CONFIG_TEMPLATE);
}
}
private async ensurePageWithContent(path: string, content: string) {
try {
// This will blow up if the page doesn't exist
await this.spacePrimitives.getFileMeta(path);
} catch (e: any) {
if (e.message === "Not found") {
console.info(path, "page not found, creating...");
await this.spacePrimitives.writeFile(
path,
new TextEncoder().encode(content),
);
} else {
throw e;
}
}
}
}