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 { BootConfig } from "../web/ui_types.ts"; import { applyUrlPrefix, removeUrlPrefix } from "../lib/url_prefix.ts"; import { authCookieName, fileMetaToHeaders, 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 { CheckPathSpacePrimitives, } from "../lib/spaces/checked_space_primitives.ts"; import { notFoundError } from "../lib/constants.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 => { 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: BootConfig = { 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) { 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) { 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, fileMetaToHeaders(meta)); } catch (err) { console.error("Write failed", err); return c.text("Write failed", 500); } } async handleGet(c: Context) { 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, 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, { ...fileMetaToHeaders(fileData.meta), "Last-Modified": lastModifiedHeader, }); } catch (e: any) { console.error("Error GETting file", name, e.message); return c.notFound(); } } 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 === notFoundError.message) { console.info(path, "page not found, creating..."); await this.spacePrimitives.writeFile( path, new TextEncoder().encode(content), ); } else { throw e; } } } }