613 lines
19 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|
|
}
|