From 3d805383bb6cf0410e6c2fab384d107939651d33 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Tue, 17 Jun 2025 13:48:56 +0200 Subject: [PATCH] Cleanup: server --- .clinerules | 1 - DEVELOPMENT.md | 72 ---- Library/Std/Command.md | 2 +- build_bundle.ts | 2 +- build_plugs.ts | 2 +- build_web.ts | 2 +- cmd/compile.ts | 2 +- cmd/server.ts | 165 ++++----- update_version.ts => cmd/update_version.ts | 0 plug-api/syscalls/shell.ts | 34 -- server/db_backend.ts | 26 -- server/http_server.ts | 289 +++++++--------- server/serverside_render.ts | 84 +++++ server/shell.test.ts | 369 +-------------------- server/shell_backend.ts | 303 +++-------------- server/shell_endpoint.ts | 27 ++ server/shell_endpoints.ts | 127 ------- server/space_server.ts | 108 ------ server/util.ts | 7 + silverbullet.ts | 24 +- web/PAGE_TEMPLATES.ts | 6 +- web/client.ts | 1 - web/shell_stream_client.ts | 254 -------------- web/syscalls/shell.ts | 37 --- website/HTTP API.md | 11 +- 25 files changed, 405 insertions(+), 1550 deletions(-) delete mode 100644 .clinerules delete mode 100644 DEVELOPMENT.md rename update_version.ts => cmd/update_version.ts (100%) delete mode 100644 server/db_backend.ts create mode 100644 server/serverside_render.ts create mode 100644 server/shell_endpoint.ts delete mode 100644 server/shell_endpoints.ts delete mode 100644 server/space_server.ts create mode 100644 server/util.ts delete mode 100644 web/shell_stream_client.ts diff --git a/.clinerules b/.clinerules deleted file mode 100644 index ae6689f0..00000000 --- a/.clinerules +++ /dev/null @@ -1 +0,0 @@ -Read DEVELOPMENT.md carefully. \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 4c34d8db..00000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,72 +0,0 @@ -# SilverBullet Development Guide - -## Good practices -* After each significant change, run the typechecker, linter, and tests. -* Write unit tests for new functionality first -* Always keep DEVELOPMENT.md up to date with new best practices, based on guidance. - -## Commands -- Build: `deno task build` -- Test: `deno task test` -- Test single file: `deno task test /path/to/test.ts` -- Run all Lua tests: `deno task test common/space_lua/lua.test.ts` -- Lint: `deno task lint` -- Format: `deno task fmt` -- Type checking: `deno task check` -- Watch server: `deno task watch-server ` -- Watch web: `deno task watch-web` -- Watch plugs: `deno task watch-plugs` - -## Code Style -- TypeScript: Use explicit types for function parameters and return values -- Imports: Group related imports together, sort alphabetically within groups -- Naming: camelCase for variables/functions, PascalCase for classes/interfaces/types -- Error handling: Use explicit error types and handle errors gracefully -- Tests: Write unit tests for new functionality using `@std/assert` for assertions (e.g., `assert`, `assertEquals`) -- Comments: Focus on "why" not "what", especially for complex logic -- Prefer const over let when variable won't be reassigned -- In Lua: use camelCase for variables and functions -- Tests end with .test.ts - -## Architecture Overview - -### Plugin System (Plugs) -- **PlugOS**: Extension framework with System, Plug, Sandbox, and Syscalls components -- **Hooks**: Extension points (commands, events, slash commands, widgets) -- **Manifest**: Configuration for plugins defining functions, permissions, and dependencies - -### Data Models -- **Pages**: Markdown documents with frontmatter, structured as `ObjectValue` -- **Documents**: Any file type (images, PDFs) with metadata -- **Objects**: Structured data extracted from pages, supporting tagging and attributes - -### Key Design Patterns -- Event-driven architecture with EventHook system -- Middleware layers with composable SpacePrimitives implementations -- Syscall pattern for sandboxed plugin functions -- Command pattern for UI and programmatic actions -- Hook system for extension points -- Client-server synchronization with offline support -- Template rendering with query language for data extraction - -## Directory Structure - -### Top-level Directories -- `/Library`: Standard library shipped with SilverBullet -- `/cmd`: Command-line interfaces and entry points -- `/common`: Shared core functionality for both server and client -- `/lib`: Utility libraries and internal modules -- `/plug-api`: API exposed to plugins (syscalls and utility functions) -- `/plugs`: Built-in plugins that provide core functionality -- `/server`: Server-side implementation (HTTP, storage) -- `/web`: Client-side/browser implementation -- `/website`: Documentation site content - -### Key Areas -- `/common/space_lua`: Lua scripting implementation with parsers and runtime -- `/lib/plugos`: Plugin system core functionality -- `/plugs`: Plugs for core functionality (editor, indexing, etc) distributed with the system -- `/web`: Client implementation -- `/web/cm_plugins`: CodeMirror editor extensions -- `/web/hooks`: Client-side hook implementations -- `/web/syscalls`: Client-side specific syscall implementations diff --git a/Library/Std/Command.md b/Library/Std/Command.md index a10eeeba..723236b0 100644 --- a/Library/Std/Command.md +++ b/Library/Std/Command.md @@ -15,7 +15,7 @@ Available keys: * `priority`: Command priority (how high it appears in the list) * `key`: Windows/Linux key binding (and mac, if not separately defined) * `mac`: Mac-specific key binding -* `hide`: Hide this command from the [[Command Palette]] +* `hide`: Hide this command from the Command Palette * `requireMode`: `rw` or `ro` — only enable this command in a particular mode (read-write, or read-only) Example: diff --git a/build_bundle.ts b/build_bundle.ts index 15ae058e..202099c5 100644 --- a/build_bundle.ts +++ b/build_bundle.ts @@ -1,7 +1,7 @@ import { denoPlugins } from "@luca/esbuild-deno-loader"; import * as esbuild from "esbuild"; -import { updateVersionFile } from "./update_version.ts"; +import { updateVersionFile } from "./cmd/update_version.ts"; await updateVersionFile(); await Deno.mkdir("dist", { recursive: true }); diff --git a/build_plugs.ts b/build_plugs.ts index 0e4bf936..5694c905 100644 --- a/build_plugs.ts +++ b/build_plugs.ts @@ -4,7 +4,7 @@ import { compileManifests } from "./cmd/compile.ts"; import { builtinPlugNames } from "./plugs/builtin_plugs.ts"; import { parseArgs } from "@std/cli/parse-args"; import { fileURLToPath } from "node:url"; -import { updateVersionFile } from "./update_version.ts"; +import { updateVersionFile } from "./cmd/update_version.ts"; await updateVersionFile(); diff --git a/build_web.ts b/build_web.ts index 7dca5d3b..f87e7878 100644 --- a/build_web.ts +++ b/build_web.ts @@ -8,7 +8,7 @@ import { parseArgs } from "@std/cli/parse-args"; import { patchDenoLibJS } from "./cmd/compile.ts"; import { denoPlugins } from "@luca/esbuild-deno-loader"; import * as esbuild from "esbuild"; -import { updateVersionFile } from "./update_version.ts"; +import { updateVersionFile } from "./cmd/update_version.ts"; await updateVersionFile(); diff --git a/cmd/compile.ts b/cmd/compile.ts index 7d4881de..5a8b406d 100644 --- a/cmd/compile.ts +++ b/cmd/compile.ts @@ -69,7 +69,7 @@ ${ filePath = path.join(rootPath, filePath); return `import {${jsFunctionName} as ${funcName}} from "file://${ - // Replacaing \ with / for Windows + // Replacing \ with / for Windows path.resolve(filePath).replaceAll( "\\", "\\\\", diff --git a/cmd/server.ts b/cmd/server.ts index 05527891..95bb74db 100644 --- a/cmd/server.ts +++ b/cmd/server.ts @@ -1,14 +1,18 @@ import { HttpServer } from "../server/http_server.ts"; + +// To ship silverbullet as a single binary, we're importing all assets (client and plugs) as JSON blobs import clientAssetBundle from "../dist/client_asset_bundle.json" with { type: "json", }; import plugAssetBundle from "../dist/plug_asset_bundle.json" with { type: "json", }; + import { AssetBundle, type AssetJson } from "../lib/asset_bundle/bundle.ts"; -import { determineDatabaseBackend } from "../server/db_backend.ts"; +import { resolve } from "@std/path"; import { sleep } from "../lib/async.ts"; +import { MemoryKvPrimitives } from "../lib/data/memory_kv_primitives.ts"; export type AuthOptions = { authToken?: string; @@ -24,9 +28,6 @@ export async function serveCommand( port?: number; user?: string; auth?: string; - cert?: string; - key?: string; - reindex?: boolean; }, folder?: string, ) { @@ -39,23 +40,25 @@ export async function serveCommand( const indexPage = Deno.env.get("SB_INDEX_PAGE") || "index"; + folder = folder || Deno.env.get("SB_FOLDER"); if (!folder) { - // Didn't get a folder as an argument, check if we got it as an environment variable - folder = Deno.env.get("SB_FOLDER"); - if (!folder) { - console.error( - "No folder specified. Please pass a folder as an argument or set SB_FOLDER environment variable.", - ); - Deno.exit(1); - } + console.error( + "No folder specified. Please pass a folder as an argument or set SB_FOLDER environment variable.", + ); + Deno.exit(1); } - const baseKvPrimitives = await determineDatabaseBackend(folder); + // This database is only used to store some auth based temporary values + let dbFile = Deno.env.get("SB_KV_DB") || ".silverbullet.db.json"; + dbFile = resolve(folder, dbFile); - console.log( - "Going to start SilverBullet binding to", - `${hostname}:${port}`, + console.info( + `Storing database in ${dbFile}.`, ); + const baseKvPrimitives = new MemoryKvPrimitives(dbFile); + await baseKvPrimitives.init(); + + console.info("Starting SilverBullet binding to", `${hostname}:${port}`); if (hostname === "127.0.0.1") { console.info( `SilverBullet will only be available locally, to allow outside connections, pass -L0.0.0.0 as a flag, and put a TLS terminator on top.`, @@ -64,6 +67,7 @@ export async function serveCommand( let hostUrlPrefix = Deno.env.get("SB_URL_PREFIX"); if (hostUrlPrefix) { + // Normalize the host URL prefix (if set) if (!hostUrlPrefix.startsWith("/")) { hostUrlPrefix = "/" + hostUrlPrefix; } @@ -78,84 +82,58 @@ export async function serveCommand( } } - const userAuth = options.user ?? Deno.env.get("SB_USER"); - + // Built-in authentication setup + const userAuth = options.user || Deno.env.get("SB_USER"); let userCredentials: AuthOptions | undefined; if (userAuth) { const [user, pass] = userAuth.split(":"); userCredentials = { user, pass, - // 10 failed login attempts in 1 minute - lockoutLimit: 10, - lockoutTime: 60, + authToken: Deno.env.get("SB_AUTH_TOKEN"), + // Default lockout: 10 failed login attempts in 1 minute + lockoutLimit: Deno.env.get("SB_LOCKOUT_LIMIT") + ? Number(Deno.env.get("SB_LOCKOUT_LIMIT")) + : 10, + lockoutTime: Deno.env.get("SB_LOCKOUT_TIME") + ? Number(Deno.env.get("SB_LOCKOUT_TIME")) + : 60, }; - // Override lockout settings if they are set in the environment - if (Deno.env.get("SB_LOCKOUT_LIMIT")) { - userCredentials.lockoutLimit = Number(Deno.env.get("SB_LOCKOUT_LIMIT")); - } - if (Deno.env.get("SB_LOCKOUT_TIME")) { - userCredentials.lockoutTime = Number(Deno.env.get("SB_LOCKOUT_TIME")); - } - if (Deno.env.get("SB_AUTH_TOKEN")) { - userCredentials.authToken = Deno.env.get("SB_AUTH_TOKEN"); - } console.log( `User authentication enabled for user "${user}" with lockout limit ${userCredentials.lockoutLimit} and lockout time ${userCredentials.lockoutTime}s`, ); } - const backendConfig = Deno.env.get("SB_SHELL_BACKEND") || "local"; - const enableSpaceScript = Deno.env.get("SB_SPACE_SCRIPT") !== "off"; - + const shellBackend = Deno.env.get("SB_SHELL_BACKEND") || "local"; const spaceIgnore = Deno.env.get("SB_SPACE_IGNORE"); + // All plug code bundled into a JSON blob const plugAssets = new AssetBundle(plugAssetBundle as AssetJson); - + // All client files bundled into a JSON blob const clientAssets = new AssetBundle(clientAssetBundle as AssetJson); - const manifestName = Deno.env.get("SB_NAME"); - const manifestDescription = Deno.env.get("SB_DESCRIPTION"); - if (manifestName || manifestDescription || hostUrlPrefix) { - const manifestData = JSON.parse( - clientAssets.readTextFileSync(".client/manifest.json"), - ); - if (manifestName) { - manifestData.name = manifestData.short_name = manifestName; - } - if (manifestDescription) { - manifestData.description = manifestDescription; - } - if (hostUrlPrefix) { - for (const icon of manifestData.icons) { - if (icon.src) icon.src = hostUrlPrefix + icon.src; - } - manifestData.start_url = hostUrlPrefix + manifestData.start_url; - manifestData.scope = hostUrlPrefix + manifestData.scope; - } - clientAssets.writeTextFileSync( - ".client/manifest.json", - "application/json", - JSON.stringify(manifestData), - ); - } - const httpServer = new HttpServer({ - hostname, - port, - clientAssetBundle: clientAssets, - plugAssetBundle: plugAssets, + // Manifest configuration + buildPWAManifest(hostUrlPrefix, clientAssets); + + // Build the web server + const httpServer = new HttpServer( + { + hostname, + port, + hostUrlPrefix, + indexPage, + spaceIgnore, + auth: userCredentials, + readOnly, + shellBackend, + pagesPath: folder, + }, + clientAssets, + plugAssets, baseKvPrimitives, - keyFile: options.key, - certFile: options.cert, - indexPage, - spaceIgnore, - auth: userCredentials, - readOnly, - shellBackend: backendConfig, - enableSpaceScript, - pagesPath: folder, - hostUrlPrefix, - }); + ); + + // And kick it off await httpServer.start(); // Wait in an infinite loop (to keep the HTTP server running, only cancelable via Ctrl+C or other signal) @@ -163,3 +141,38 @@ export async function serveCommand( await sleep(10000); } } + +/** + * Overrides SilverBullet's manifest.json dynamically based on some (optional) environment variables + */ +function buildPWAManifest( + hostUrlPrefix: string | undefined, + clientAssets: AssetBundle, +) { + const manifestName = Deno.env.get("SB_NAME"); + const manifestDescription = Deno.env.get("SB_DESCRIPTION"); + + // Parse the default manifest.json + const manifestData = JSON.parse( + clientAssets.readTextFileSync(".client/manifest.json"), + ); + if (manifestName) { + manifestData.name = manifestData.short_name = manifestName; + } + if (manifestDescription) { + manifestData.description = manifestDescription; + } + if (hostUrlPrefix) { + for (const icon of manifestData.icons) { + if (icon.src) icon.src = hostUrlPrefix + icon.src; + } + manifestData.start_url = hostUrlPrefix + manifestData.start_url; + manifestData.scope = hostUrlPrefix + manifestData.scope; + } + // Write it back into the asset bundle to be served by the HTTP server + clientAssets.writeTextFileSync( + ".client/manifest.json", + "application/json", + JSON.stringify(manifestData), + ); +} diff --git a/update_version.ts b/cmd/update_version.ts similarity index 100% rename from update_version.ts rename to cmd/update_version.ts diff --git a/plug-api/syscalls/shell.ts b/plug-api/syscalls/shell.ts index c32d07de..708b877e 100644 --- a/plug-api/syscalls/shell.ts +++ b/plug-api/syscalls/shell.ts @@ -1,26 +1,5 @@ -import type { ShellStreamClient } from "../../web/shell_stream_client.ts"; import { syscall } from "../syscall.ts"; -/** - * Interface for the shell stream client - */ -export interface ShellStream { - /** - * Send data to the process stdin - */ - send(data: string): void; - - /** - * Send a signal to the process - */ - kill(signal: string): void; - - /** - * Close the connection - */ - close(): void; -} - /** * Runs a shell command. * @param cmd the command to run @@ -33,16 +12,3 @@ export function run( ): Promise<{ stdout: string; stderr: string; code: number }> { return syscall("shell.run", cmd, args); } - -/** - * Runs a shell command with streaming I/O. - * @param cmd the command to run - * @param args the arguments to pass to the command - * @returns a shell stream client for interacting with the process - */ -export function spawn( - cmd: string, - args: string[], -): Promise { - return syscall("shell.spawn", cmd, args); -} diff --git a/server/db_backend.ts b/server/db_backend.ts deleted file mode 100644 index 712dfed9..00000000 --- a/server/db_backend.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { KvPrimitives } from "../lib/data/kv_primitives.ts"; -import { MemoryKvPrimitives } from "../lib/data/memory_kv_primitives.ts"; -import { resolve } from "@std/path"; - -/** - * Environment variables: - * - SB_KV_DB: path to the database file (default .silverbullet.db.json) - */ - -export async function determineDatabaseBackend( - singleTenantFolder?: string, -): Promise { - let dbFile = Deno.env.get("SB_KV_DB") || ".silverbullet.db.json"; - - if (singleTenantFolder) { - // If we're running in single tenant mode, we may as well use the tenant's space folder to keep the database - dbFile = resolve(singleTenantFolder, dbFile); - } - - console.info( - `Using persistent memory store as a database backend (${dbFile}).`, - ); - const store = new MemoryKvPrimitives(dbFile); - await store.init(); - return store; -} diff --git a/server/http_server.ts b/server/http_server.ts index c05bb053..3ae9f640 100644 --- a/server/http_server.ts +++ b/server/http_server.ts @@ -3,156 +3,108 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { validator } from "hono/validator"; import type { AssetBundle } from "../lib/asset_bundle/bundle.ts"; import type { FileMeta } from "@silverbulletmd/silverbullet/types"; -import { - handleShellEndpoint, - handleShellStreamEndpoint, -} from "./shell_endpoints.ts"; -import { SpaceServer } from "./space_server.ts"; +import { handleShellEndpoint } from "./shell_endpoint.ts"; import type { KvPrimitives } from "../lib/data/kv_primitives.ts"; -import { extendedMarkdownLanguage } from "../web/markdown_parser/parser.ts"; -import { parse } from "../web/markdown_parser/parse_tree.ts"; -import { renderMarkdownToHtml } from "../web/markdown/markdown_render.ts"; -import { - decodePageURI, - looksLikePathWithExtension, -} from "@silverbulletmd/silverbullet/lib/page_ref"; +import { compile as gitIgnoreCompiler } from "gitignore-parser"; +import { decodePageURI } from "@silverbulletmd/silverbullet/lib/page_ref"; import { LockoutTimer } from "./lockout.ts"; import type { AuthOptions } from "../cmd/server.ts"; import type { ClientConfig } from "../web/client.ts"; -import { htmlEscape } from "../web/markdown/html_render.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"; const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week export type ServerOptions = { hostname: string; port: number; - clientAssetBundle: AssetBundle; - plugAssetBundle: AssetBundle; - baseKvPrimitives: KvPrimitives; - certFile?: string; - keyFile?: string; - // Enable username/password/token auth + hostUrlPrefix?: string; auth?: AuthOptions; spaceIgnore?: string; pagesPath: string; shellBackend: string; readOnly: boolean; indexPage: string; - enableSpaceScript: boolean; - hostUrlPrefix: string | undefined; }; export class HttpServer { - abortController?: AbortController; - clientAssetBundle: AssetBundle; - plugAssetBundle: AssetBundle; - hostname: string; - port: number; - app: Hono; - keyFile: string | undefined; - certFile: string | undefined; + private abortController?: AbortController; + private hostname: string; + private port: number; + private app: Hono; + private readonly spacePrimitives: SpacePrimitives; + private jwtIssuer: JWTIssuer; + private readonly shellBackend: ShellBackend; - // Available after start() - spaceServer!: SpaceServer; - baseKvPrimitives: KvPrimitives; - - constructor(private options: ServerOptions) { + constructor( + readonly options: ServerOptions, + private clientAssetBundle: AssetBundle, + private plugAssetBundle: AssetBundle, + public baseKvPrimitives: KvPrimitives, + ) { this.app = new Hono().basePath(options.hostUrlPrefix ?? ""); - this.clientAssetBundle = options.clientAssetBundle; - this.plugAssetBundle = options.plugAssetBundle; this.hostname = options.hostname; this.port = options.port; - this.keyFile = options.keyFile; - this.certFile = options.certFile; - this.baseKvPrimitives = options.baseKvPrimitives; - } - // Server-side renders a markdown file to HTML - async renderHtmlPage( - spaceServer: SpaceServer, - pageName: string, - c: Context, - ): Promise { - let html = ""; - let lastModified = utcDateString(Date.now()); - if (!spaceServer.auth) { - // Only attempt server-side rendering when this site is not protected by auth - if (!looksLikePathWithExtension(pageName)) { - try { - const { data, meta } = await spaceServer.spacePrimitives.readFile( - `${pageName}.md`, - ); - lastModified = utcDateString(meta.lastModified); - - if (c.req.header("If-Modified-Since") === lastModified) { - // Not modified, empty body status 304 - return c.body(null, 304); - } - const text = new TextDecoder().decode(data); - const tree = parse(extendedMarkdownLanguage, text); - html = renderMarkdownToHtml(tree); - } catch (e: any) { - if (e.message !== "Not found") { - console.error("Error server-side rendering page", e); - } - } - } else { - // If it it's a file with an extension and it doesn't exist we can't really create a new one/recover - try { - await spaceServer.spacePrimitives.getFileMeta(`${pageName}`); - } catch (e: any) { - if (e.message !== "Not found") { - return c.notFound(); - } - } - } + let fileFilterFn: (s: string) => boolean = () => true; + if (options.spaceIgnore) { + fileFilterFn = gitIgnoreCompiler(options.spaceIgnore).accepts; } - const templateData = { - TITLE: pageName, - DESCRIPTION: stripHtml(html).substring(0, 255), - CONTENT: html, - HOST_URL_PREFIX: this.options.hostUrlPrefix ?? "", - }; - - html = this.clientAssetBundle.readTextFileSync(".client/index.html"); - - // Replace each template variable with its corresponding value - for (const [key, value] of Object.entries(templateData)) { - const placeholder = `{{${key}}}`; - const stringValue = typeof value === "boolean" - ? (value ? "true" : "false") - : (key === "DESCRIPTION" ? htmlEscape(value) : String(value)); - html = html.replace(placeholder, stringValue); - } - return c.html( - html, - 200, - { - "Last-Modified": lastModified, - }, + this.spacePrimitives = 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.addFsRoutes(); - // Boot space server - this.spaceServer = new SpaceServer( - this.options, - this.plugAssetBundle, - ); - await this.spaceServer.init(); - // 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 this.renderHtmlPage(this.spaceServer, pageName, c); + return renderHtmlPage( + this.spacePrimitives, + pageName, + c, + this.options, + this.clientAssetBundle, + ); }); this.abortController = new AbortController(); @@ -161,12 +113,6 @@ export class HttpServer { port: this.port, signal: this.abortController.signal, }; - if (this.keyFile) { - listenOptions.key = Deno.readTextFileSync(this.keyFile); - } - if (this.certFile) { - listenOptions.cert = Deno.readTextFileSync(this.certFile); - } // Start the actual server Deno.serve(listenOptions, this.app.fetch); @@ -183,13 +129,18 @@ export class HttpServer { this.app.use("*", (c, next): Promise => { const req = c.req; const url = new URL(this.unprefixedUrl(req.url)); - // console.log("URL", url); if ( url.pathname === "/" ) { // Serve the UI (index.html) - const indexPage = this.spaceServer.indexPage ?? "index"; - return this.renderHtmlPage(this.spaceServer, indexPage, c); + const indexPage = this.options.indexPage ?? "index"; + return renderHtmlPage( + this.spacePrimitives, + indexPage, + c, + this.options, + this.clientAssetBundle, + ); } try { const assetName = url.pathname.slice(1); @@ -290,7 +241,7 @@ export class HttpServer { const { user: expectedUser, pass: expectedPassword, - } = this.spaceServer.auth!; + } = this.options.auth!; if (lockoutTimer.isLocked()) { console.error("Authentication locked out, redirecting to auth page."); @@ -300,8 +251,8 @@ export class HttpServer { if (username === expectedUser && password === expectedPassword) { // Generate a JWT and set it as a cookie const jwt = rememberMe - ? await this.spaceServer.jwtIssuer.createJWT({ username }) - : await this.spaceServer.jwtIssuer.createJWT( + ? await this.jwtIssuer.createJWT({ username }) + : await this.jwtIssuer.createJWT( { username }, authenticationExpirySeconds, ); @@ -312,8 +263,6 @@ export class HttpServer { setCookie(c, authCookieName(url.host), jwt, { path: `${this.options.hostUrlPrefix ?? ""}/`, expires: inAWeek, - // sameSite: "Strict", - // httpOnly: true, }); if (rememberMe) { setCookie(c, "refreshLogin", "true", { @@ -336,13 +285,13 @@ export class HttpServer { return c.redirect(this.prefixedUrl("/.auth")); }); - // Check auth + // Check auth on every other request this.app.use("*", async (c, next) => { - const req = c.req; - if (!this.spaceServer.auth) { + 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; @@ -360,12 +309,12 @@ export class HttpServer { if (!excludedPaths.includes(url.pathname)) { const authCookie = getCookie(c, authCookieName(host)); - if (!authCookie && this.spaceServer.auth?.authToken) { + 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.spaceServer.auth.authToken) { + if (authToken === this.options.auth.authToken) { // All good, let's proceed this.refreshLogin(c, host); return next(); @@ -381,10 +330,10 @@ export class HttpServer { console.log("Unauthorized access, redirecting to auth page"); return redirectToAuth(); } - const { user: expectedUser } = this.spaceServer.auth!; + const { user: expectedUser } = this.options.auth!; try { - const verifiedJwt = await this.spaceServer.jwtIssuer + const verifiedJwt = await this.jwtIssuer .verifyAndDecodeJWT( authCookie, ); @@ -406,10 +355,9 @@ export class HttpServer { // Fetch config this.app.get("/.config", (c) => { const clientConfig: ClientConfig = { - readOnly: this.spaceServer.readOnly, - enableSpaceScript: this.spaceServer.enableSpaceScript, - spaceFolderPath: this.spaceServer.pagesPath, - indexPage: this.spaceServer.indexPage, + readOnly: this.options.readOnly, + spaceFolderPath: this.options.pagesPath, + indexPage: this.options.indexPage, }; return c.json(clientConfig, 200, { "Cache-Control": "no-cache", @@ -427,18 +375,8 @@ export class HttpServer { this.app.post("/.shell", (c) => { return handleShellEndpoint( c, - this.spaceServer.shellBackend, - this.spaceServer.readOnly, - ); - }); - - // Shell WebSocket endpoint - this.app.get("/.shell/stream", (c) => { - return handleShellStreamEndpoint( - c, - this.spaceServer.pagesPath, - this.spaceServer.readOnly, - this.options.hostUrlPrefix, + this.shellBackend, + this.options.readOnly, ); }); @@ -448,7 +386,7 @@ export class HttpServer { proxyPathRegex, async (c) => { const req = c.req; - if (this.spaceServer.readOnly) { + if (this.options.readOnly) { return c.text("Read only mode, no proxy allowed", 405); } @@ -528,9 +466,9 @@ export class HttpServer { 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.spaceServer.spacePrimitives.fetchFileList(); + const files = await this.spacePrimitives.fetchFileList(); return c.json(files, 200, { - "X-Space-Path": this.spaceServer.pagesPath, + "X-Space-Path": this.options.pagesPath, }); } else { // Otherwise, redirect to the UI @@ -575,12 +513,12 @@ export class HttpServer { try { if (req.header("X-Get-Meta")) { // Getting meta via GET request - const fileData = await this.spaceServer.spacePrimitives.getFileMeta( + const fileData = await this.spacePrimitives.getFileMeta( name, ); return c.text("", 200, this.fileMetaToHeaders(fileData)); } - const fileData = await this.spaceServer.spacePrimitives.readFile(name); + const fileData = await this.spacePrimitives.readFile(name); const lastModifiedHeader = new Date(fileData.meta.lastModified) .toUTCString(); if ( @@ -600,7 +538,7 @@ export class HttpServer { async (c) => { const req = c.req; const name = req.param("path")!; - if (this.spaceServer.readOnly) { + if (this.options.readOnly) { return c.text("Read only mode, no writes allowed", 405); } console.log("Writing file", name); @@ -612,7 +550,7 @@ export class HttpServer { const body = await req.arrayBuffer(); try { - const meta = await this.spaceServer.spacePrimitives.writeFile( + const meta = await this.spacePrimitives.writeFile( name, new Uint8Array(body), ); @@ -625,7 +563,7 @@ export class HttpServer { ).delete(async (c) => { const req = c.req; const name = req.param("path")!; - if (this.spaceServer.readOnly) { + if (this.options.readOnly) { return c.text("Read only mode, no writes allowed", 405); } console.log("Deleting file", name); @@ -634,7 +572,7 @@ export class HttpServer { return c.text("Forbidden", 403); } try { - await this.spaceServer.spacePrimitives.deleteFile(name); + await this.spacePrimitives.deleteFile(name); return c.text("OK"); } catch (e: any) { console.error("Error deleting document", e); @@ -661,17 +599,36 @@ export class HttpServer { private unprefixedUrl(url: string): string { return removeUrlPrefix(url, this.options.hostUrlPrefix); } -} -function utcDateString(mtime: number): string { - return new Date(mtime).toUTCString(); -} + async ensureBasicPages() { + await this.ensurePageWithContent( + `${this.options.indexPage}.md`, + INDEX_TEMPLATE, + ); -function authCookieName(host: string) { - return `auth_${host.replaceAll(/\W/g, "_")}`; -} + 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); + } + } -function stripHtml(html: string): string { - const regex = /<[^>]*>/g; - return html.replace(regex, ""); + 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; + } + } + } } diff --git a/server/serverside_render.ts b/server/serverside_render.ts new file mode 100644 index 00000000..a1091901 --- /dev/null +++ b/server/serverside_render.ts @@ -0,0 +1,84 @@ +import type { Context } from "hono"; +import { utcDateString } from "./util.ts"; +import { looksLikePathWithExtension } from "@silverbulletmd/silverbullet/lib/page_ref"; +import { parse } from "../web/markdown_parser/parse_tree.ts"; +import { extendedMarkdownLanguage } from "../web/markdown_parser/parser.ts"; +import { renderMarkdownToHtml } from "../web/markdown/markdown_render.ts"; +import type { ServerOptions } from "./http_server.ts"; +import type { AssetBundle } from "../lib/asset_bundle/bundle.ts"; +import { htmlEscape } from "../web/markdown/html_render.ts"; +import type { SpacePrimitives } from "../lib/spaces/space_primitives.ts"; + +// Server-side renders a markdown file to HTML +export async function renderHtmlPage( + spacePrimitives: SpacePrimitives, + pageName: string, + c: Context, + options: ServerOptions, + clientAssetBundle: AssetBundle, +): Promise { + let html = ""; + let lastModified = utcDateString(Date.now()); + if (!options.auth) { + // Only attempt server-side rendering when this site is not protected by auth + if (!looksLikePathWithExtension(pageName)) { + try { + const { data, meta } = await spacePrimitives.readFile( + `${pageName}.md`, + ); + lastModified = utcDateString(meta.lastModified); + + if (c.req.header("If-Modified-Since") === lastModified) { + // Not modified, empty body status 304 + return c.body(null, 304); + } + const text = new TextDecoder().decode(data); + const tree = parse(extendedMarkdownLanguage, text); + html = renderMarkdownToHtml(tree); + } catch (e: any) { + if (e.message !== "Not found") { + console.error("Error server-side rendering page", e); + } + } + } else { + // If it it's a file with an extension and it doesn't exist we can't really create a new one/recover + try { + await spacePrimitives.getFileMeta(`${pageName}`); + } catch (e: any) { + if (e.message !== "Not found") { + return c.notFound(); + } + } + } + } + + const templateData = { + TITLE: pageName, + DESCRIPTION: stripHtml(html).substring(0, 255), + CONTENT: html, + HOST_URL_PREFIX: options.hostUrlPrefix ?? "", + }; + + html = clientAssetBundle.readTextFileSync(".client/index.html"); + + // Replace each template variable with its corresponding value + for (const [key, value] of Object.entries(templateData)) { + const placeholder = `{{${key}}}`; + const stringValue = typeof value === "boolean" + ? (value ? "true" : "false") + : (key === "DESCRIPTION" ? htmlEscape(value) : String(value)); + html = html.replace(placeholder, stringValue); + } + return c.html( + html, + 200, + { + "Last-Modified": lastModified, + }, + ); +} + +function stripHtml(html: string): string { + const regex = /<[^>]*>/g; + return html.replace(regex, ""); +} diff --git a/server/shell.test.ts b/server/shell.test.ts index 0ea4e344..8aeace79 100644 --- a/server/shell.test.ts +++ b/server/shell.test.ts @@ -1,12 +1,7 @@ -import { assert, assertEquals, assertExists } from "@std/assert"; +import { assert, assertEquals } from "@std/assert"; import { HttpServer, type ServerOptions } from "./http_server.ts"; import { AssetBundle } from "../lib/asset_bundle/bundle.ts"; import { MemoryKvPrimitives } from "../lib/data/memory_kv_primitives.ts"; -import { sleep } from "../lib/async.ts"; -import { - ShellStreamClient, - type ShellStreamEvent, -} from "../web/shell_stream_client.ts"; import { HttpSpacePrimitives } from "../lib/spaces/http_space_primitives.ts"; // Create a test script @@ -25,48 +20,6 @@ exit 0 return scriptPath; } -// Create a signal-handling test script -async function createSignalTestScript(dir: string): Promise { - const scriptContent = `#!/bin/sh -echo "Starting signal test script" -# Set up signal handler -trap 'echo "Received SIGTERM"; exit 0' TERM -trap 'echo "Received SIGINT"; exit 0' INT -echo "Signal handlers set up" -# Wait indefinitely -while true; do - sleep 1 -done -`; - - const scriptPath = `${dir}/signal_test.sh`; - await Deno.writeTextFile(scriptPath, scriptContent); - await Deno.chmod(scriptPath, 0o755); // Make executable - return scriptPath; -} - -// Create a long-running test script that writes to a file when it exits -async function createLongRunningScript(dir: string): Promise { - const scriptContent = `#!/bin/sh -echo "Starting long-running script" -# Create a file to track the process ID -echo $$ > "${dir}/process.pid" -# Set up signal handler to detect proper cleanup -trap 'echo "Process terminated properly" > "${dir}/cleanup.log"; exit 0' TERM INT -echo "Ready for long-running operation" -# Wait indefinitely -while true; do - sleep 1 - echo "Still running..." -done -`; - - const scriptPath = `${dir}/long_running.sh`; - await Deno.writeTextFile(scriptPath, scriptContent); - await Deno.chmod(scriptPath, 0o755); // Make executable - return scriptPath; -} - // Setup a minimal HttpServer for testing async function setupTestServer( tempDir: string, @@ -85,19 +38,20 @@ async function setupTestServer( const options: ServerOptions = { hostname: "localhost", port, - clientAssetBundle, - plugAssetBundle, - baseKvPrimitives: kvPrimitives, pagesPath: tempDir, shellBackend: "local", // Use local shell backend readOnly: false, indexPage: "index", - enableSpaceScript: false, hostUrlPrefix: undefined, }; // Create and start the server - const server = new HttpServer(options); + const server = new HttpServer( + options, + clientAssetBundle, + plugAssetBundle, + kvPrimitives, + ); await server.start(); return { server, port }; @@ -118,312 +72,7 @@ exit 1 } // Run all tests in a single test function to avoid resource tracking issues -// TODO: Fix this, make this more stable -Deno.test("Shell endpoint tests", { ignore: true }, async () => { - // Test 1: Basic functionality with POST endpoint - { - // Test: Basic functionality with POST endpoint - // Create a temporary directory for testing - const tempDir = await Deno.makeTempDir(); - - try { - // Set up test server - const { server, port } = await setupTestServer(tempDir); - - try { - // Create a client with HttpSpacePrimitives - const httpSpacePrimitives = new HttpSpacePrimitives( - `http://localhost:${port}`, - ); - const client = new ShellStreamClient({ - httpSpacePrimitives, - cmd: "echo", - args: ["Hello, World!"], - }); - - // Collect events - const events: ShellStreamEvent[] = []; - client.addEventListener("stdout", (event) => events.push(event)); - client.addEventListener("stderr", (event) => events.push(event)); - client.addEventListener("exit", (event) => events.push(event)); - - // Connect to the server - await client.start(); - - // Wait for output and exit - await sleep(1000); - - // Close the connection and wait for cleanup - client.close(); - await sleep(500); - - // Verify events - assert(events.length >= 1, "Expected at least 1 event"); - - // Should have stdout message - const stdoutEvent = events.find((e) => - e.type === "stdout" && - typeof e.data === "string" && - e.data.includes("Hello, World!") - ); - assertExists(stdoutEvent, "Stdout event not found"); - } finally { - // Clean up server - server.stop(); - } - } finally { - // Clean up temporary directory - await Deno.remove(tempDir, { recursive: true }); - } - } - - // Cleanup on premature WebSocket close - { - // Test: Cleanup on premature WebSocket close - // Create a temporary directory for testing - const tempDir = await Deno.makeTempDir(); - - try { - // Create a long-running test script - const scriptPath = await createLongRunningScript(tempDir); - - // Set up test server - const { server, port } = await setupTestServer(tempDir); - - try { - // Create a client with HttpSpacePrimitives - const httpSpacePrimitives = new HttpSpacePrimitives( - `http://localhost:${port}`, - ); - const client = new ShellStreamClient({ - httpSpacePrimitives, - cmd: scriptPath, - args: [], - }); - - // Collect events - const events: ShellStreamEvent[] = []; - client.addEventListener("stdout", (event) => events.push(event)); - client.addEventListener("stderr", (event) => events.push(event)); - client.addEventListener("exit", (event) => events.push(event)); - - // Connect to the server - await client.start(); - - // Wait for the script to start - await sleep(1000); - - // Verify the process is running by checking if the PID file exists - const pidFileExists = await Deno.stat(`${tempDir}/process.pid`) - .then( - () => true, - () => false, - ); - assert(pidFileExists, "Process PID file should exist"); - - // Close the WebSocket connection prematurely - client.close(); - - // Wait for cleanup to happen - await sleep(2000); - - // Check if the cleanup log file was created, indicating proper termination - const cleanupFileExists = await Deno.stat(`${tempDir}/cleanup.log`) - .then( - () => true, - () => false, - ); - assert( - cleanupFileExists, - "Cleanup log file should exist, indicating proper process termination", - ); - - if (cleanupFileExists) { - const cleanupContent = await Deno.readTextFile( - `${tempDir}/cleanup.log`, - ); - assert( - cleanupContent.includes("Process terminated properly"), - "Process should have been terminated properly", - ); - } - - // Verify events - const startupEvent = events.find((e) => - e.type === "stdout" && - typeof e.data === "string" && - e.data.includes("Starting long-running script") - ); - assertExists(startupEvent, "Startup message not found"); - } finally { - // Clean up server - server.stop(); - } - } finally { - // Clean up temporary directory - await Deno.remove(tempDir, { recursive: true }); - } - } - - // Unified send method with POST endpoint - { - // Test: Unified send method with POST endpoint - // Create a temporary directory for testing - const tempDir = await Deno.makeTempDir(); - - try { - // Create a test script - const scriptPath = await createTestScript(tempDir); - - // Set up test server - const { server, port } = await setupTestServer(tempDir); - - try { - // Create a client with HttpSpacePrimitives - const httpSpacePrimitives = new HttpSpacePrimitives( - `http://localhost:${port}`, - ); - const client = new ShellStreamClient({ - httpSpacePrimitives, - cmd: scriptPath, - args: [], - }); - - // Collect events - const events: ShellStreamEvent[] = []; - client.addEventListener("stdout", (event) => events.push(event)); - client.addEventListener("stderr", (event) => events.push(event)); - client.addEventListener("exit", (event) => events.push(event)); - - // Connect to the server - await client.start(); - - // Wait for initial output - await sleep(500); - - // Send input using the unified send() method - client.send("Hello from test"); - - // Wait for response and exit - await sleep(1000); - - // Close the connection and wait for cleanup - client.close(); - await sleep(500); - - // Verify events - assert(events.length >= 3, "Expected at least 3 events"); - - // Should have stdout message - const stdoutEvent = events.find((e) => - e.type === "stdout" && - typeof e.data === "string" && - e.data.includes("Hello from test script") - ); - assertExists(stdoutEvent, "Stdout event not found"); - - // Should have stderr message - const stderrEvent = events.find((e) => - e.type === "stderr" && - typeof e.data === "string" && - e.data.includes("This is stderr output") - ); - assertExists(stderrEvent, "Stderr event not found"); - - // Should have response to input - const responseEvent = events.find((e) => - e.type === "stdout" && - typeof e.data === "string" && - e.data.includes("You said: Hello from test") - ); - assertExists(responseEvent, "Response event not found"); - } finally { - // Clean up server - server.stop(); - } - } finally { - // Clean up temporary directory - await Deno.remove(tempDir, { recursive: true }); - } - } - - // Send signal to process - { - // Test: Send signal to process - // Create a temporary directory for testing - const tempDir = await Deno.makeTempDir(); - - try { - // Create a signal-handling test script - const scriptPath = await createSignalTestScript(tempDir); - - // Set up test server - const { server, port } = await setupTestServer(tempDir); - - try { - // Create a client with HttpSpacePrimitives - const httpSpacePrimitives = new HttpSpacePrimitives( - `http://localhost:${port}`, - ); - const client = new ShellStreamClient({ - httpSpacePrimitives, - cmd: scriptPath, - args: [], - }); - - // Collect events - const events: ShellStreamEvent[] = []; - client.addEventListener("stdout", (event) => events.push(event)); - client.addEventListener("stderr", (event) => events.push(event)); - client.addEventListener("exit", (event) => events.push(event)); - - // Connect to the server - await client.start(); - - // Wait for the script to start and set up signal handlers - await sleep(1000); - - // Send a signal to the process - client.kill("SIGTERM"); - - // Wait for the process to handle the signal and exit - await sleep(1000); - - // Close the connection and wait for cleanup - client.close(); - await sleep(500); - - // Verify events - assert(events.length >= 3, "Expected at least 3 events"); - - // Should have initial stdout messages - const startupEvent = events.find((e) => - e.type === "stdout" && - typeof e.data === "string" && - e.data.includes("Starting signal test script") - ); - assertExists(startupEvent, "Startup message not found"); - - // Should have signal handler message - const signalEvent = events.find((e) => - e.type === "stdout" && - typeof e.data === "string" && - e.data.includes("Received SIGTERM") - ); - assertExists(signalEvent, "Signal handler message not found"); - - // Should have exit event - const exitEvent = events.find((e) => e.type === "exit"); - assertExists(exitEvent, "Exit event not found"); - } finally { - // Clean up server - server.stop(); - } - } finally { - // Clean up temporary directory - await Deno.remove(tempDir, { recursive: true }); - } - } +Deno.test("Shell endpoint tests", async () => { // .shell command with arguments { // Test: Command with arguments @@ -541,7 +190,7 @@ Deno.test("Shell endpoint tests", { ignore: true }, async () => { const { server, port } = await setupTestServer(tempDir); // Set server to read-only mode - server.spaceServer.readOnly = true; + server.options.readOnly = true; try { // Create HTTP space primitives for making requests diff --git a/server/shell_backend.ts b/server/shell_backend.ts index 7aca033b..b518a996 100644 --- a/server/shell_backend.ts +++ b/server/shell_backend.ts @@ -1,277 +1,66 @@ -import type { ShellRequest, ShellResponse } from "../type/rpc.ts"; -import type { ServerOptions } from "./http_server.ts"; -import { timeout } from "../lib/async.ts"; +import type {ShellRequest, ShellResponse} from "../type/rpc.ts"; +import type {ServerOptions} from "./http_server.ts"; /** * Configuration via environment variables: * - SB_SHELL_BACKEND: "local" or "off" */ - export function determineShellBackend( - serverOptions: ServerOptions, + serverOptions: ServerOptions, ): ShellBackend { - const backendConfig = Deno.env.get("SB_SHELL_BACKEND") || "local"; - switch (backendConfig) { - case "local": - return new LocalShell(serverOptions.pagesPath); - default: - console.info( - "Running in shellless mode, meaning shell commands are disabled", - ); - return new NotSupportedShell(); - } + const backendConfig = Deno.env.get("SB_SHELL_BACKEND") || "local"; + switch (backendConfig) { + case "local": + return new LocalShell(serverOptions.pagesPath); + default: + console.info( + "Running in shellless mode, meaning shell commands are disabled", + ); + return new NotSupportedShell(); + } } export interface ShellBackend { - handle(shellRequest: ShellRequest): Promise; + handle(shellRequest: ShellRequest): Promise; } export class NotSupportedShell implements ShellBackend { - handle(): Promise { - return Promise.resolve({ - stdout: "", - stderr: "Not supported", - code: 1, - }); - } -} - -export class StreamingShell { - private process: Deno.ChildProcess | null = null; - private outputHandlers: Array< - (type: string, data: string) => void - > = []; - private textDecoder = new TextDecoder(); - private textEncoder = new TextEncoder(); - - constructor(private cwd: string) { - } - - start(cmd: string, args: string[]): void { - console.log("Starting streaming shell:", cmd, args); - - this.process = new Deno.Command(cmd, { - cwd: this.cwd, - args: args, - stdin: "piped", - stdout: "piped", - stderr: "piped", - }).spawn(); - - // Handle stdout - this.readStream(this.process.stdout, "stdout"); - - // Handle stderr - this.readStream(this.process.stderr, "stderr"); - - // Handle process exit - this.process.status.then((status) => { - this.notifyOutput( - "exit", - "json", - JSON.stringify({ code: status.code || 0 }), - ); - - // Process is already exited, just set to null - this.process = null; - }); - } - - async writeToStdin(data: string): Promise { - if (!this.process || !this.process.stdin) { - throw new Error("Process not started or stdin not available"); + handle(): Promise { + return Promise.resolve({ + stdout: "", + stderr: "Not supported", + code: 1, + }); } - - const writer = this.process.stdin.getWriter(); - try { - // Add newline for text data - await writer.write(this.textEncoder.encode(data + "\n")); - } finally { - writer.releaseLock(); - } - } - - onOutput( - handler: (type: string, data: string) => void, - ): void { - this.outputHandlers.push(handler); - } - - /** - * Send a signal to the process - * @param signal The signal to send (e.g., "SIGTERM", "SIGINT", "SIGKILL") - * @returns True if the signal was sent successfully, false otherwise - */ - sendSignal(signal: string): boolean { - if (!this.process) { - console.error("Cannot send signal: Process not started"); - return false; - } - - try { - // Convert signal string to Deno.Signal enum - let denoSignal: Deno.Signal; - switch (signal.toUpperCase()) { - case "SIGINT": - denoSignal = "SIGINT"; - break; - case "SIGTERM": - denoSignal = "SIGTERM"; - break; - case "SIGKILL": - denoSignal = "SIGKILL"; - break; - case "SIGHUP": - denoSignal = "SIGHUP"; - break; - default: - console.error(`Unsupported signal: ${signal}`); - return false; - } - - this.process.kill(denoSignal); - console.log(`Sent ${signal} to process`); - return true; - } catch (e) { - console.error(`Error sending ${signal} to process:`, e); - return false; - } - } - - async close(): Promise { - if (this.process) { - try { - // First try to send SIGTERM to allow graceful shutdown - try { - console.log("Sending SIGTERM to process"); - this.process.kill("SIGTERM"); - } catch (e) { - console.error("Error sending SIGTERM to process:", e); - } - - // Close stdin if it's available - if (this.process.stdin) { - try { - // Create a writer to check if the stream is writable - const writer = this.process.stdin.getWriter(); - writer.releaseLock(); // Release immediately to avoid locking - - // If we got here, the stream is still open, so close it - this.process.stdin.close(); - } catch (e) { - // If we get an error, the stream might already be closed or errored - // Just log it and continue - console.log("Note: stdin already closed or errored:", e); - } - } - - // Wait for the process to exit with a short timeout - try { - const status = await Promise.race([ - this.process.status, - timeout(500).then(() => { - throw new Error("Process exit timeout after SIGTERM"); - }), - ]); - console.log("Process exited with code:", status.code); - this.process = null; - return; - } catch (e) { - console.log("Process did not exit after SIGTERM:", e); - } - - // If we're here, the process didn't exit after SIGTERM - // Try SIGKILL as a last resort - if (this.process) { // Check if process is still not null - try { - console.log("Sending SIGKILL to process"); - this.process.kill("SIGKILL"); - - // Wait again with a longer timeout - const status = await Promise.race([ - this.process.status, - timeout(500).then(() => { - throw new Error("Process exit timeout after SIGKILL"); - }), - ]); - console.log("Process exited with code after SIGKILL:", status.code); - } catch (e) { - console.error( - "Error waiting for process to exit after SIGKILL:", - e, - ); - } - } - } catch (e) { - console.error("Error closing process:", e); - } - - // Always set process to null to avoid memory leaks - this.process = null; - } - } - - private async readStream( - stream: ReadableStream, - type: string, - ): Promise { - const reader = stream.getReader(); - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - // Handle as text data (UTF-8) - const text = this.textDecoder.decode(value, { stream: true }); - if (text.trim()) { - console.log(`${type.toUpperCase()}: ${text}`); - this.notifyOutput(type, "text", text); - } - } - } catch (e) { - const error = e as Error; - console.error(`Error reading ${type}:`, error); - this.notifyOutput("error", "text", `Stream error: ${error.message}`); - } finally { - reader.releaseLock(); - } - } - - private notifyOutput(type: string, _format: string, data: string): void { - for (const handler of this.outputHandlers) { - handler(type, data); - } - } } export class LocalShell implements ShellBackend { - constructor(private cwd: string) { - } - - async handle(shellRequest: ShellRequest): Promise { - console.log( - "Running shell command:", - shellRequest.cmd, - shellRequest.args, - ); - const p = new Deno.Command(shellRequest.cmd, { - cwd: this.cwd, - args: shellRequest.args, - stdout: "piped", - stderr: "piped", - }); - const output = await p.output(); - const stdout = new TextDecoder().decode(output.stdout); - const stderr = new TextDecoder().decode(output.stderr); - if (output.code !== 0) { - console.error("Error running shell command", stdout, stderr); + constructor(private cwd: string) { } - return { - stderr, - stdout, - code: output.code, - }; - } + async handle(shellRequest: ShellRequest): Promise { + console.log( + "Running shell command:", + shellRequest.cmd, + shellRequest.args, + ); + const p = new Deno.Command(shellRequest.cmd, { + cwd: this.cwd, + args: shellRequest.args, + stdout: "piped", + stderr: "piped", + }); + const output = await p.output(); + const stdout = new TextDecoder().decode(output.stdout); + const stderr = new TextDecoder().decode(output.stderr); + if (output.code !== 0) { + console.error("Error running shell command", stdout, stderr); + } + + return { + stderr, + stdout, + code: output.code, + }; + } } diff --git a/server/shell_endpoint.ts b/server/shell_endpoint.ts new file mode 100644 index 00000000..542641da --- /dev/null +++ b/server/shell_endpoint.ts @@ -0,0 +1,27 @@ +import type { Context } from "hono"; +import type { ShellRequest } from "@silverbulletmd/silverbullet/type/rpc"; +import type { ShellBackend } from "./shell_backend.ts"; + +/** + * Handles the /.shell endpoint for running shell commands + */ +export async function handleShellEndpoint( + c: Context, + shellBackend: ShellBackend, + readOnly: boolean, +): Promise { + const req = c.req; + const body = await req.json(); + try { + if (readOnly) { + return c.text("Read only mode, no shell commands allowed", 405); + } + const shellCommand: ShellRequest = body; + // Note: in read-only this is set to NoShellSupport, so don't worry + const shellResponse = await shellBackend.handle(shellCommand); + return c.json(shellResponse); + } catch (e: any) { + console.error("Shell error", e); + return c.text(e.message, 500); + } +} diff --git a/server/shell_endpoints.ts b/server/shell_endpoints.ts deleted file mode 100644 index eda78acc..00000000 --- a/server/shell_endpoints.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Context } from "hono"; -import type { ShellRequest } from "@silverbulletmd/silverbullet/type/rpc"; -import type { ShellBackend } from "./shell_backend.ts"; -import { StreamingShell } from "./shell_backend.ts"; -import { removeUrlPrefix } from "../lib/url_prefix.ts"; - -/** - * Handles the /.shell endpoint for non-streaming shell commands - */ -export async function handleShellEndpoint( - c: Context, - shellBackend: ShellBackend, - readOnly: boolean, -): Promise { - const req = c.req; - const body = await req.json(); - try { - if (readOnly) { - return c.text("Read only mode, no shell commands allowed", 405); - } - const shellCommand: ShellRequest = body; - // Note: in read-only this is set to NoShellSupport, so don't worry - const shellResponse = await shellBackend.handle(shellCommand); - return c.json(shellResponse); - } catch (e: any) { - console.error("Shell error", e); - return c.text(e.message, 500); - } -} - -/** - * Handles the /.shell/stream endpoint for streaming shell commands - */ -export function handleShellStreamEndpoint( - c: Context, - cwd: string, - readOnly: boolean, - hostUrlPrefix?: string, -): Response { - const req = c.req; - const url = new URL(removeUrlPrefix(req.url, hostUrlPrefix)); - - // Check if read-only mode is enabled - if (readOnly) { - return c.text("Read only mode, no shell commands allowed", 405); - } - - // Get command and arguments from query parameters - const cmd = url.searchParams.get("cmd"); - if (!cmd) { - return c.text("Missing cmd parameter", 400); - } - - let args: string[] = []; - const argsParam = url.searchParams.get("args"); - if (argsParam) { - try { - args = JSON.parse(argsParam); - if (!Array.isArray(args)) { - return c.text("args must be a JSON array", 400); - } - } catch (_e) { - return c.text("Invalid args parameter: must be a JSON array", 400); - } - } - - // Create a streaming shell - const shell = new StreamingShell(cwd); - - // Upgrade the connection to a WebSocket - try { - const { socket, response } = Deno.upgradeWebSocket(req.raw); - - // Set up WebSocket event handlers - socket.onopen = () => { - // Start the shell process when the WebSocket is opened - shell.start(cmd, args); - - // Forward shell output to the WebSocket - shell.onOutput((type, data) => { - socket.send(JSON.stringify({ - type, - data, - })); - }); - }; - - socket.onmessage = async (event) => { - try { - const message = JSON.parse(event.data); - if (message.type === "stdin") { - await shell.writeToStdin(message.data); - } else if (message.type === "signal") { - const success = shell.sendSignal(message.signal); - if (!success) { - socket.send(JSON.stringify({ - type: "error", - data: `Failed to send signal: ${message.signal}`, - })); - } - } - } catch (e) { - console.error("Error handling WebSocket message:", e); - socket.send(JSON.stringify({ - type: "error", - data: `Error: ${e}`, - })); - } - }; - - socket.onclose = async () => { - await shell.close(); - }; - - socket.onerror = (error) => { - console.error("WebSocket error:", error); - shell.close().catch((e) => { - console.error("Error closing shell:", e); - }); - }; - - return response; - } catch (e) { - console.error("WebSocket upgrade failed:", e); - return c.text("WebSocket upgrade failed", 400); - } -} diff --git a/server/space_server.ts b/server/space_server.ts deleted file mode 100644 index 1a849bad..00000000 --- a/server/space_server.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { AssetBundlePlugSpacePrimitives } from "../lib/spaces/asset_bundle_space_primitives.ts"; -import { FilteredSpacePrimitives } from "../lib/spaces/filtered_space_primitives.ts"; -import { ReadOnlySpacePrimitives } from "../lib/spaces/ro_space_primitives.ts"; -import type { SpacePrimitives } from "../lib/spaces/space_primitives.ts"; -import type { AssetBundle } from "../lib/asset_bundle/bundle.ts"; -import { JWTIssuer } from "./crypto.ts"; -import { compile as gitIgnoreCompiler } from "gitignore-parser"; -import type { ShellBackend } from "./shell_backend.ts"; -import { determineShellBackend, NotSupportedShell } from "./shell_backend.ts"; -import { determineStorageBackend } from "./storage_backend.ts"; -import type { ServerOptions } from "./http_server.ts"; -import type { AuthOptions } from "../cmd/server.ts"; -import { CONFIG_TEMPLATE, INDEX_TEMPLATE } from "../web/PAGE_TEMPLATES.ts"; - -// Equivalent of Client on the server -export class SpaceServer { - public pagesPath: string; - auth?: AuthOptions; - hostname: string; - - spacePrimitives!: SpacePrimitives; - - jwtIssuer: JWTIssuer; - - readOnly: boolean; - shellBackend: ShellBackend; - enableSpaceScript: boolean; - indexPage: string; - spaceIgnore?: string; - - constructor( - options: ServerOptions, - private plugAssetBundle: AssetBundle, - ) { - this.pagesPath = options.pagesPath; - this.hostname = options.hostname; - this.auth = options.auth; - this.readOnly = options.readOnly; - this.indexPage = options.indexPage; - this.enableSpaceScript = options.enableSpaceScript; - this.spaceIgnore = options.spaceIgnore; - - this.jwtIssuer = new JWTIssuer(options.baseKvPrimitives); - - this.shellBackend = options.readOnly - ? new NotSupportedShell() // No shell for read only mode - : determineShellBackend(options); - } - - async init() { - let fileFilterFn: (s: string) => boolean = () => true; - if (this.spaceIgnore) { - fileFilterFn = gitIgnoreCompiler(this.spaceIgnore).accepts; - } - - this.spacePrimitives = new FilteredSpacePrimitives( - new AssetBundlePlugSpacePrimitives( - determineStorageBackend(this.pagesPath), - this.plugAssetBundle, - ), - (meta) => fileFilterFn(meta.name), - ); - - if (this.readOnly) { - this.spacePrimitives = new ReadOnlySpacePrimitives(this.spacePrimitives); - } - - if (this.auth) { - // Initialize JWT issuer - await this.jwtIssuer.init( - JSON.stringify({ auth: this.auth }), - ); - } - - await this.ensureBasicPages(); - - console.log("Booted server with hostname", this.hostname); - } - - async ensureBasicPages() { - await this.ensurePageWithContent(`${this.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; - } - } - } -} diff --git a/server/util.ts b/server/util.ts new file mode 100644 index 00000000..3d53b22e --- /dev/null +++ b/server/util.ts @@ -0,0 +1,7 @@ +export function utcDateString(mtime: number): string { + return new Date(mtime).toUTCString(); +} + +export function authCookieName(host: string) { + return `auth_${host.replaceAll(/\W/g, "_")}`; +} \ No newline at end of file diff --git a/silverbullet.ts b/silverbullet.ts index 749585c6..96feacd2 100755 --- a/silverbullet.ts +++ b/silverbullet.ts @@ -17,7 +17,7 @@ globalThis.addEventListener("unhandledrejection", (event) => { await new Command() .name("silverbullet") - .description("Note taking for knowledge hackers") + .description("Workshop for the mind") .version(version) .helpOption(false) .usage(" | (see below)") @@ -32,26 +32,6 @@ await new Command() "--user ", "'username:password' combo for authentication", ) - .option( - "--cert ", - "Path to TLS certificate", - ) - .option( - "--key ", - "Path to TLS key", - ) - .option( - "--sync-only", - "Run the server as a pure space (file) store only without any backend processing (this disables 'online mode' in the client)", - ) - .option( - "--reindex", - "Reindex space on startup", - ) - .option( - "--db ", - "Path to database file", - ) .action(serveCommand) // plug:compile .command("plug:compile") @@ -79,7 +59,7 @@ await new Command() .action(upgradeCommand) // upgrade-edge .command("upgrade-edge") - .description("Upgrade SilverBullet to the latest Edge build") + .description("Upgrade SilverBullet to the latest edge build") .action(upgradeEdgeCommand) // sync .command("sync") diff --git a/web/PAGE_TEMPLATES.ts b/web/PAGE_TEMPLATES.ts index ae88a9e8..b9de561a 100644 --- a/web/PAGE_TEMPLATES.ts +++ b/web/PAGE_TEMPLATES.ts @@ -5,12 +5,14 @@ _One of us!_ You can go ahead and delete this placeholder content in a second, but before you do, let me quickly show you around. -We’ll do this through a list of **silver bullets**. _Ah, so that explains the name!_ Well, [partially](https://en.wikipedia.org/wiki/Silver_bullet). +We’ll do this through a list of **silver bullets**. + +_Ah, so that explains the name!_ * Click on the page picker (book icon) icon at the top right, or hit \`Cmd-k\` (Mac) or \`Ctrl-k\` (Linux and Windows) to open the **page picker**. * Type the name of a non-existent page to create it. * Folders are implicitly created by putting slashes (\`/\`) in the name (even on Windows), e.g. \`My Folder/My Page\`. Don’t worry about folders existing, SilverBullet will automatically create them if they don’t. -* Click on the terminal icon (top right), or hit \`Cmd-/\` (Mac) or \`Ctrl-/\` (Linux and Windows), or tap the screen with 3 fingers at the same time (on mobile) to open the **command palette**. From here you can run various useful and perhaps less useful commands. +* Click on the terminal icon (top right), or hit \`Cmd-/\` (Mac) or \`Ctrl-/\` (Linux and Windows) to open the **command palette**. From here you can run various useful and perhaps less useful commands. * Select some text and hit \`Cmd-b\` (Mac) or \`Ctrl-b\` (Windows/Linux) to make it **bold**, or \`Cmd-i\` (Mac) or \`Ctrl-i\` (Windows/Linux) to make it _italic_. * You can create links to pages using the \`[[other page]]\` syntax, for instance: [[aspiring page]]. When you link to a non-existent page it will initially show up in orange (to indicate it does not yet exist), but once you click it — you will create the page automatically (only for real when you actually enter some text). * Start typing \`:party\` to trigger the emoji picker 🎉 diff --git a/web/client.ts b/web/client.ts index 260dabca..e0895c5e 100644 --- a/web/client.ts +++ b/web/client.ts @@ -79,7 +79,6 @@ export type ClientConfig = { spaceFolderPath: string; indexPage: string; readOnly: boolean; - enableSpaceScript: boolean; }; declare global { diff --git a/web/shell_stream_client.ts b/web/shell_stream_client.ts deleted file mode 100644 index 0501a554..00000000 --- a/web/shell_stream_client.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { HttpSpacePrimitives } from "../lib/spaces/http_space_primitives.ts"; - -/** - * Event types emitted by the ShellStreamClient - */ -export type ShellStreamEventType = - | "stdout" - | "stderr" - | "exit" - | "error" - | "open" - | "close"; - -/** - * Event object emitted by the ShellStreamClient - */ -export interface ShellStreamEvent { - type: ShellStreamEventType; - data: string | { code: number }; -} - -/** - * Options for creating a ShellStreamClient - */ -export interface ShellStreamOptions { - /** - * HTTP space primitives for authenticated requests - */ - httpSpacePrimitives: HttpSpacePrimitives; - - /** - * Command to execute - */ - cmd: string; - - /** - * Command arguments - */ - args?: string[]; -} - -/** - * Client for interacting with the shell stream WebSocket API - */ -export class ShellStreamClient { - private ws: WebSocket | null = null; - private eventHandlers = new Map< - ShellStreamEventType, - Array<(event: ShellStreamEvent) => void> - >(); - - constructor(private options: ShellStreamOptions) { - // Set default options - this.options.args = options.args ?? []; - } - - /** - * Connect to the shell stream WebSocket - */ - public async start(): Promise { - if (this.ws) { - return Promise.resolve(); // Already connected - } - - // Create an authenticated WebSocket connection - this.ws = await this.options.httpSpacePrimitives - .createAuthenticatedWebSocket( - ".shell/stream", - { - cmd: this.options.cmd, - args: JSON.stringify(this.options.args || []), - }, - ); - - // Set up event handlers - this.setupEventHandlers(); - - // Wait for the connection to be established - return new Promise((resolve, reject) => { - if (!this.ws) { - reject(new Error("WebSocket not initialized")); - return; - } - - const onOpen = () => { - this.ws?.removeEventListener("open", onOpen); - this.ws?.removeEventListener("error", onError); - resolve(); - }; - - const onError = (error: Event) => { - this.ws?.removeEventListener("open", onOpen); - this.ws?.removeEventListener("error", onError); - reject(new Error(`Failed to connect: ${error}`)); - }; - - this.ws.addEventListener("open", onOpen); - this.ws.addEventListener("error", onError); - }); - } - - /** - * Close the WebSocket connection - */ - public close(): void { - if (this.ws) { - this.ws.close(); - this.ws = null; - } - } - - /** - * Send data to the process stdin - */ - public send(data: string): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - throw new Error("WebSocket not connected"); - } - - this.ws.send(JSON.stringify({ - type: "stdin", - data, - })); - } - - /** - * Send a signal to the process - * @param signal The signal to send (e.g., "SIGTERM", "SIGINT", "SIGKILL", "SIGHUP") - * @throws Error if the WebSocket is not connected - */ - public kill(signal: string): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - throw new Error("WebSocket not connected"); - } - - this.ws.send(JSON.stringify({ - type: "signal", - signal, - })); - } - - /** - * Register an event handler - */ - public addEventListener( - eventType: ShellStreamEventType, - handler: (event: ShellStreamEvent) => void, - ): void { - if (!this.eventHandlers.has(eventType)) { - this.eventHandlers.set(eventType, []); - } - - this.eventHandlers.get(eventType)!.push(handler); - } - - /** - * Remove an event handler - */ - public removeEventListener( - eventType: ShellStreamEventType, - handler: (event: ShellStreamEvent) => void, - ): void { - if (!this.eventHandlers.has(eventType)) { - return; - } - - const handlers = this.eventHandlers.get(eventType)!; - const index = handlers.indexOf(handler); - - if (index !== -1) { - handlers.splice(index, 1); - } - } - - /** - * Set up WebSocket event handlers - * @private - */ - private setupEventHandlers(): void { - if (!this.ws) return; - - // Set up event handlers - this.ws.onopen = () => { - this.emitEvent({ type: "open", data: "Connected" }); - }; - - this.ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - // Special handling for exit events - if (message.type === "exit") { - // Parse the exit code from the JSON string if needed - let exitData = message.data; - if (typeof exitData === "string" && typeof exitData === "string") { - try { - // Try to parse as JSON in case it's a stringified object - const parsedData = JSON.parse(exitData); - if ( - typeof parsedData === "object" && parsedData !== null && - "code" in parsedData - ) { - exitData = parsedData; - } - } catch (e) { - // If it's not valid JSON, just use it as is - console.error("Failed to parse exit data:", e); - } - } - - this.emitEvent({ - type: message.type, - data: exitData, - }); - } else { - // Regular event - this.emitEvent({ - type: message.type, - data: message.data, - }); - } - } catch (e) { - const error = e as Error; - this.emitEvent({ - type: "error", - data: `Error parsing message: ${error.message}`, - }); - } - }; - - this.ws.onclose = () => { - this.ws = null; - this.emitEvent({ type: "close", data: "Disconnected" }); - }; - - this.ws.onerror = (error) => { - this.emitEvent({ - type: "error", - data: `WebSocket error: ${error}`, - }); - }; - } - - /** - * Emit an event to all registered handlers - */ - private emitEvent(event: ShellStreamEvent): void { - // Call handlers for the specific event type - if (this.eventHandlers.has(event.type)) { - for (const handler of this.eventHandlers.get(event.type)!) { - handler(event); - } - } - } -} diff --git a/web/syscalls/shell.ts b/web/syscalls/shell.ts index edefb333..89a9331f 100644 --- a/web/syscalls/shell.ts +++ b/web/syscalls/shell.ts @@ -1,26 +1,5 @@ import type { SysCallMapping } from "../../lib/plugos/system.ts"; import type { Client } from "../client.ts"; -import { ShellStreamClient } from "../shell_stream_client.ts"; - -/** - * Interface for the shell stream client - */ -export interface ShellStream { - /** - * Send data to the process stdin - */ - send(data: string): void; - - /** - * Send a signal to the process - */ - kill(signal: string): void; - - /** - * Close the connection - */ - close(): void; -} export function shellSyscalls( client: Client, @@ -47,21 +26,5 @@ export function shellSyscalls( const { code, stderr, stdout } = await (await resp).json(); return { code, stderr, stdout }; }, - "shell.spawn": ( - _ctx, - cmd: string, - args: string[], - ): ShellStreamClient => { - if (!client.httpSpacePrimitives) { - throw new Error("Not supported in fully local mode"); - } - - // Create a shell stream client - return new ShellStreamClient({ - httpSpacePrimitives: client.httpSpacePrimitives, - cmd, - args: args || [], - }); - }, }; } diff --git a/website/HTTP API.md b/website/HTTP API.md index 22d2cd96..11bca8e3 100644 --- a/website/HTTP API.md +++ b/website/HTTP API.md @@ -9,8 +9,15 @@ The API: * (optional) `X-Last-Modified` the last modified time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`). This timestamp _has_ to match the `lastModified` listed for this file in `/index.json` otherwise syncing issues may occur. When this header is missing, frequent polling-based sync will be disabled for this file. * (optional) `X-Created` the created time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`). * (optional) `X-Permission`: either `rw` or `ro` which will change whether the editor opens in read-only or edit mode. When missing, `ro` is assumed. - * (optional) `X-Content-Length`: which will be the same as `Content-Length` except if the request was sent with a `X-Get-Meta` header and the body is not returned (then `Content-Length` will be `0` and `X-Content-Length` will be the size of the file) + * (opti[space_server.ts](../server/space_server.ts)onal) `X-Content-Length`: which will be the same as `Content-Length` except if the request was sent with a `X-Get-Meta` header and the body is not returned (then `Content-Length` will be `0` and `X-Content-Length` will be the size of the file) * `PUT /*.*`: The same as `GET` except that it takes the body of the request and _writes_ it to a file. * `DELETE /*.*`: Again the same, except this will _delete_ the given file. -* `GET /.client/*`: Retrieve files implementing the client * `GET /*` and `GET /`: Anything else (any path without a file extension) will serve the SilverBullet UI HTML. +* `GET /.client/*`: Retrieve files implementing the client +* `GET /.config`: Retrieve client configuration, JSON with the following keys: + * `readOnly`: Run the client in read-only mode + * `spaceFolderPath`: Path of the space (used to prefix client database names to support switching space folders) + * `indexPage`: name of the index page +* `GET /.ping`: Returns 200 if the server is available +* `POST /.shell`: Run a shell command on the server side and return the result +* `* /.proxy/`: Proxy a HTTP request (to avoid CORS issues) \ No newline at end of file