diff --git a/client/boot.ts b/client/boot.ts index a5f213a7..b29b56c9 100644 --- a/client/boot.ts +++ b/client/boot.ts @@ -11,7 +11,7 @@ import "./polyfills.ts"; import type { BootConfig } from "./ui_types.ts"; import { BoxProxy } from "../lib/box_proxy.ts"; -initLogger("[Client]"); +const logger = initLogger("[Client]"); safeRun(async () => { // First we attempt to fetch the config from the server @@ -146,6 +146,11 @@ safeRun(async () => { bootConfig!, config!, ); + if (bootConfig!.logPush) { + setInterval(() => { + logger.postToServer(".logs", "client"); + }, 1000); + } // @ts-ignore: on purpose globalThis.client = client; clientProxy.setTarget(client); diff --git a/client/service_worker.ts b/client/service_worker.ts index 65cfa6ca..4b9ee2e2 100644 --- a/client/service_worker.ts +++ b/client/service_worker.ts @@ -13,7 +13,7 @@ import { DataStoreSpacePrimitives } from "../lib/spaces/datastore_space_primitiv import { HttpSpacePrimitives } from "../lib/spaces/http_space_primitives.ts"; import { throttleImmediately } from "../lib/async.ts"; -initLogger("[Service Worker]"); +const logger = initLogger("[Service Worker]"); // Note: the only thing cached here is SilverBullet client assets, files are kept in IndexedDB const CACHE_NAME = "{{CACHE_NAME}}"; @@ -158,6 +158,12 @@ self.addEventListener("message", async (event: any) => { // And we'll use a _files postfix to signify where synced files are kept const dbName = `${spaceHash}_files`; + if (config.logPush) { + setInterval(() => { + logger.postToServer(".logs", "service_worker"); + }, 1000); + } + // Setup KV (database) for store synced files const kv = new IndexedDBKvPrimitives(dbName); await kv.init(); diff --git a/client/service_worker/proxy_router.ts b/client/service_worker/proxy_router.ts index 4f34e2e7..f084ef9d 100644 --- a/client/service_worker/proxy_router.ts +++ b/client/service_worker/proxy_router.ts @@ -12,6 +12,7 @@ const alwaysProxy = [ "/.shell", "/.logout", "/.config", + "/.logs", ]; const pingTimeout = 2000; diff --git a/client/ui_types.ts b/client/ui_types.ts index 7ad4d442..79801537 100644 --- a/client/ui_types.ts +++ b/client/ui_types.ts @@ -173,6 +173,7 @@ export type BootConfig = { spaceFolderPath: string; indexPage: string; readOnly: boolean; + logPush?: boolean; // Sync configuration syncDocuments?: boolean; syncIgnore?: string; diff --git a/lib/logger.test.ts b/lib/logger.test.ts index 1294ca9d..670d9256 100644 --- a/lib/logger.test.ts +++ b/lib/logger.test.ts @@ -72,25 +72,21 @@ Deno.test("Logger log capture", () => { console.warn("Third message", 123); console.error("Fourth message"); - const capturedLogs = logger.getCapturedLogs(); + const capturedLogs = logger.logBuffer; assertEquals(capturedLogs.length, 4); assertEquals(capturedLogs[0].level, "log"); assertEquals(capturedLogs[0].message, "First message"); - assertEquals(capturedLogs[0].args, ["First message"]); assertEquals(capturedLogs[1].level, "info"); assertEquals(capturedLogs[1].message, 'Second message {"key":"value"}'); - assertEquals(capturedLogs[1].args, ["Second message", { key: "value" }]); assertEquals(capturedLogs[2].level, "warn"); assertEquals(capturedLogs[2].message, "Third message 123"); - assertEquals(capturedLogs[2].args, ["Third message", 123]); assertEquals(capturedLogs[3].level, "error"); assertEquals(capturedLogs[3].message, "Fourth message"); - assertEquals(capturedLogs[3].args, ["Fourth message"]); // Check that all entries have timestamps capturedLogs.forEach((entry) => { @@ -109,7 +105,7 @@ Deno.test("Logger max capture size", () => { console.log("Message 4"); console.log("Message 5"); - const capturedLogs = logger.getCapturedLogs(); + const capturedLogs = logger.logBuffer; // Should only keep the last 3 messages assertEquals(capturedLogs.length, 3); @@ -153,15 +149,16 @@ Deno.test("Logger handles complex objects", () => { console.log("Complex object:", complexObject); console.log("Circular object:", circularObject); - const capturedLogs = logger.getCapturedLogs(); + const capturedLogs = logger.logBuffer; assertEquals(capturedLogs.length, 2); // First log should handle complex object properly - assertEquals(capturedLogs[0].args[0], "Complex object:"); - assertEquals(capturedLogs[0].args[1], complexObject); + assertEquals( + capturedLogs[0].message, + `Complex object: {"name":"test","nested":{"value":42},"array":[1,2,3]}`, + ); // Second log should handle circular reference gracefully - assertEquals(capturedLogs[1].args[0], "Circular object:"); - assertEquals(typeof capturedLogs[1].args[1], "string"); // Should be converted to string + assertEquals(capturedLogs[1].message, `Circular object: [object Object]`); }); diff --git a/lib/logger.ts b/lib/logger.ts index b7aaad56..187178fc 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -4,7 +4,6 @@ export interface LogEntry { level: "log" | "info" | "warn" | "error" | "debug"; timestamp: number; message: string; - args: any[]; } export class Logger { @@ -15,7 +14,7 @@ export class Logger { error: typeof console.error; debug: typeof console.debug; }; - private logCapture: LogEntry[] = []; + public logBuffer: LogEntry[] = []; constructor( private prefix: string = "", @@ -70,29 +69,42 @@ export class Logger { return String(arg); } }).join(" "), - args: args.map((arg) => { - // Serialize complex objects for safe transmission - try { - return typeof arg === "object" - ? JSON.parse(JSON.stringify(arg)) - : arg; - } catch { - // This may fail due to recursive structures, in that case just fall back to a best-effort string - return String(arg); - } - }), }; - this.logCapture.push(entry); + this.logBuffer.push(entry); // Maintain max capture size by removing oldest entries - if (this.logCapture.length > this.maxCaptureSize) { - this.logCapture.shift(); + if (this.logBuffer.length > this.maxCaptureSize) { + this.logBuffer.shift(); } } - getCapturedLogs(): LogEntry[] { - return [...this.logCapture]; + /** + * Posts all buffered logs to a server endpoint + */ + async postToServer(logEndpoint: string, source: string) { + const logs = this.logBuffer; + if (logs.length > 0) { + // Flush the buffer + const logCopy = [...this.logBuffer]; + this.logBuffer = []; + try { + const resp = await fetch(logEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(logCopy.map((entry) => ({ ...entry, source }))), + }); + if (!resp.ok) { + throw new Error("Failed to post logs to server"); + } + } catch (e: any) { + console.warn("Could not post logs to server", e.message); + // Put back the logs into the buffer + this.logBuffer.unshift(...logCopy); + } + } } } diff --git a/server/auth.go b/server/auth.go index f053dc3b..d3cf60d7 100644 --- a/server/auth.go +++ b/server/auth.go @@ -4,9 +4,11 @@ import ( "fmt" "html/template" "log" + "math" "net/http" "path" "strings" + "sync" "time" "github.com/go-chi/chi/v5" @@ -27,15 +29,14 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) { // Logout r.Get("/.logout", func(w http.ResponseWriter, r *http.Request) { host := extractHost(r) - spaceConfig := spaceConfigFromContext(r.Context()) cookieOptions := CookieOptions{ - Path: fmt.Sprintf("%s/", spaceConfig.HostURLPrefix), + Path: fmt.Sprintf("%s/", config.HostURLPrefix), } deleteCookie(w, authCookieName(host), cookieOptions) deleteCookie(w, "refreshLogin", cookieOptions) - http.Redirect(w, r, applyURLPrefix("/.auth", spaceConfig.HostURLPrefix), http.StatusFound) + http.Redirect(w, r, applyURLPrefix("/.auth", config.HostURLPrefix), http.StatusFound) }) // Auth page @@ -58,7 +59,7 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) { HostPrefix string SpaceName string }{ - HostPrefix: spaceConfig.HostURLPrefix, + HostPrefix: config.HostURLPrefix, SpaceName: spaceConfig.SpaceName, } @@ -88,13 +89,13 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) { from := r.FormValue("from") if username == "" || password == "" { - http.Redirect(w, r, applyURLPrefix("/.auth?error=0", spaceConfig.HostURLPrefix), http.StatusFound) + http.Redirect(w, r, applyURLPrefix("/.auth?error=0", config.HostURLPrefix), http.StatusFound) return } if spaceConfig.LockoutTimer.IsLocked() { log.Println("Authentication locked out, redirecting to auth page.") - http.Redirect(w, r, applyURLPrefix("/.auth?error=2", spaceConfig.HostURLPrefix), http.StatusFound) + http.Redirect(w, r, applyURLPrefix("/.auth?error=2", config.HostURLPrefix), http.StatusFound) return } @@ -122,7 +123,7 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) { inAWeek := time.Now().Add(time.Duration(authenticationExpirySeconds) * time.Second) cookieOptions := CookieOptions{ - Path: fmt.Sprintf("%s/", spaceConfig.HostURLPrefix), + Path: fmt.Sprintf("%s/", config.HostURLPrefix), Expires: inAWeek, } @@ -137,12 +138,12 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) { redirectPath = from } - http.Redirect(w, r, applyURLPrefix(redirectPath, spaceConfig.HostURLPrefix), http.StatusFound) + http.Redirect(w, r, applyURLPrefix(redirectPath, config.HostURLPrefix), http.StatusFound) } else { log.Println("Authentication failed, redirecting to auth page.") spaceConfig.LockoutTimer.AddCount() - http.Redirect(w, r, applyURLPrefix("/.auth?error=1", spaceConfig.HostURLPrefix), http.StatusFound) + http.Redirect(w, r, applyURLPrefix("/.auth?error=1", config.HostURLPrefix), http.StatusFound) } }) } @@ -178,7 +179,7 @@ func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler { return } - path := removeURLPrefix(r.URL.Path, spaceConfig.HostURLPrefix) + path := removeURLPrefix(r.URL.Path, config.HostURLPrefix) host := extractHost(r) if isExcludedPath(path) { @@ -211,7 +212,7 @@ func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler { if authCookie == "" { log.Printf("Unauthorized access to %s, redirecting to auth page", path) - redirectToAuth(w, "/.auth", path, spaceConfig.HostURLPrefix) + redirectToAuth(w, "/.auth", path, config.HostURLPrefix) return } @@ -219,14 +220,14 @@ func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler { claims, err := spaceConfig.JwtIssuer.VerifyAndDecodeJWT(authCookie) if err != nil { log.Printf("Error verifying JWT on %s, redirecting to auth page: %v\n", path, err) - redirectToAuth(w, "/.auth", path, spaceConfig.HostURLPrefix) + redirectToAuth(w, "/.auth", path, config.HostURLPrefix) return } username, ok := claims["username"].(string) if !ok || username != spaceConfig.Auth.User { log.Printf("Username mismatch in JWT on %s", path) - redirectToAuth(w, "/.auth", path, spaceConfig.HostURLPrefix) + redirectToAuth(w, "/.auth", path, config.HostURLPrefix) return } @@ -239,13 +240,12 @@ func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler { // refreshLogin refreshes the login cookie if needed func refreshLogin(w http.ResponseWriter, r *http.Request, config *ServerConfig, host string) { if getCookie(r, "refreshLogin") != "" { - spaceConfig := spaceConfigFromContext(r.Context()) inAWeek := time.Now().Add(time.Duration(authenticationExpirySeconds) * time.Second) jwt := getCookie(r, authCookieName(host)) if jwt != "" { cookieOptions := CookieOptions{ - Path: fmt.Sprintf("%s/", spaceConfig.HostURLPrefix), + Path: fmt.Sprintf("%s/", config.HostURLPrefix), Expires: inAWeek, } @@ -254,3 +254,65 @@ func refreshLogin(w http.ResponseWriter, r *http.Request, config *ServerConfig, } } } + +// LockoutTimer implements a simple rate limiter to prevent brute force attacks +type LockoutTimer struct { + mutex sync.Mutex + bucketTime int64 + bucketCount int + bucketSize int64 // duration in milliseconds + limit int + disabled bool +} + +// NewLockoutTimer creates a new lockout timer +// countPeriodMs: time window in milliseconds +// limit: maximum attempts allowed in the time window +func NewLockoutTimer(countPeriodMs int, limit int) *LockoutTimer { + disabled := math.IsNaN(float64(countPeriodMs)) || math.IsNaN(float64(limit)) || + countPeriodMs < 1 || limit < 1 + + return &LockoutTimer{ + bucketSize: int64(countPeriodMs), + limit: limit, + disabled: disabled, + } +} + +// updateBucketTime updates the current bucket time and resets count if needed +func (lt *LockoutTimer) updateBucketTime() { + currentBucketTime := time.Now().UnixMilli() / lt.bucketSize + if lt.bucketTime == currentBucketTime { + return + } + // the bucket is too old - empty it + lt.bucketTime = currentBucketTime + lt.bucketCount = 0 +} + +// IsLocked checks if the timer is currently locked due to too many attempts +func (lt *LockoutTimer) IsLocked() bool { + if lt.disabled { + return false + } + + lt.mutex.Lock() + defer lt.mutex.Unlock() + + lt.updateBucketTime() + return lt.bucketCount >= lt.limit +} + +// AddCount increments the attempt counter +// IsLocked() should be called first to keep bucketTime current +func (lt *LockoutTimer) AddCount() { + if lt.disabled { + return + } + + lt.mutex.Lock() + defer lt.mutex.Unlock() + + // updateBucketTime should have been called by IsLocked first + lt.bucketCount++ +} diff --git a/server/cmd/server.go b/server/cmd/server.go index e624dc5a..079b5467 100644 --- a/server/cmd/server.go +++ b/server/cmd/server.go @@ -53,6 +53,11 @@ func buildConfig(bundledFiles fs.FS, args []string) *server.ServerConfig { rootSpaceConfig.SpaceFolderPath = os.Getenv("SB_FOLDER") } + if os.Getenv("SB_LOG_PUSH") != "" { + rootSpaceConfig.LogPush = true + log.Println("Client log push enabled") + } + if rootSpaceConfig.SpaceFolderPath == "" { log.Fatal("No folder specified. Please pass a folder as an argument or set SB_FOLDER environment variable.") } @@ -126,7 +131,7 @@ func buildConfig(bundledFiles fs.FS, args []string) *server.ServerConfig { if hostUrlPrefix != "" { log.Printf("Host URL Prefix: %s", hostUrlPrefix) - rootSpaceConfig.HostURLPrefix = hostUrlPrefix + serverConfig.HostURLPrefix = hostUrlPrefix } } @@ -141,7 +146,6 @@ func buildConfig(bundledFiles fs.FS, args []string) *server.ServerConfig { serverConfig.ClientBundle = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "dist_client_bundle", bundlePathDate, nil) rootSpaceConfig.SpacePrimitives = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "dist_base_fs_bundle", bundlePathDate, spacePrimitives) - log.Printf("Starting SilverBullet binding to %s:%d", serverConfig.BindHost, serverConfig.Port) if serverConfig.BindHost == "127.0.0.1" { log.Println("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.") } @@ -173,7 +177,7 @@ func ServerCommand(bundledFiles fs.FS) *cobra.Command { Args: cobra.MinimumNArgs(0), Run: func(cmd *cobra.Command, args []string) { serverConfig := buildConfig(bundledFiles, args) - if port != 0 { + if port != 3000 { serverConfig.Port = port } if hostname != "" { diff --git a/server/lockout.go b/server/lockout.go deleted file mode 100644 index 6b26b42b..00000000 --- a/server/lockout.go +++ /dev/null @@ -1,69 +0,0 @@ -package server - -import ( - "math" - "sync" - "time" -) - -// LockoutTimer implements a simple rate limiter to prevent brute force attacks -type LockoutTimer struct { - mutex sync.Mutex - bucketTime int64 - bucketCount int - bucketSize int64 // duration in milliseconds - limit int - disabled bool -} - -// NewLockoutTimer creates a new lockout timer -// countPeriodMs: time window in milliseconds -// limit: maximum attempts allowed in the time window -func NewLockoutTimer(countPeriodMs int, limit int) *LockoutTimer { - disabled := math.IsNaN(float64(countPeriodMs)) || math.IsNaN(float64(limit)) || - countPeriodMs < 1 || limit < 1 - - return &LockoutTimer{ - bucketSize: int64(countPeriodMs), - limit: limit, - disabled: disabled, - } -} - -// updateBucketTime updates the current bucket time and resets count if needed -func (lt *LockoutTimer) updateBucketTime() { - currentBucketTime := time.Now().UnixMilli() / lt.bucketSize - if lt.bucketTime == currentBucketTime { - return - } - // the bucket is too old - empty it - lt.bucketTime = currentBucketTime - lt.bucketCount = 0 -} - -// IsLocked checks if the timer is currently locked due to too many attempts -func (lt *LockoutTimer) IsLocked() bool { - if lt.disabled { - return false - } - - lt.mutex.Lock() - defer lt.mutex.Unlock() - - lt.updateBucketTime() - return lt.bucketCount >= lt.limit -} - -// AddCount increments the attempt counter -// IsLocked() should be called first to keep bucketTime current -func (lt *LockoutTimer) AddCount() { - if lt.disabled { - return - } - - lt.mutex.Lock() - defer lt.mutex.Unlock() - - // updateBucketTime should have been called by IsLocked first - lt.bucketCount++ -} diff --git a/server/manifest.go b/server/manifest.go deleted file mode 100644 index 8d7bb574..00000000 --- a/server/manifest.go +++ /dev/null @@ -1,61 +0,0 @@ -package server - -import ( - "net/http" - - "github.com/go-chi/render" -) - -// ManifestIcon represents an icon in the PWA manifest -type ManifestIcon struct { - Src string `json:"src"` - Type string `json:"type"` - Sizes string `json:"sizes"` -} - -// Manifest represents the structure of a PWA manifest.json file -type Manifest struct { - ShortName string `json:"short_name"` - Name string `json:"name"` - Icons []ManifestIcon `json:"icons"` - CaptureLinks string `json:"capture_links"` - StartURL string `json:"start_url"` - Display string `json:"display"` - DisplayOverride []string `json:"display_override"` - Scope string `json:"scope"` - ThemeColor string `json:"theme_color"` - Description string `json:"description"` -} - -// Generate PWA manifest.json with dynamic values -func manifestHandler(config *ServerConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - spaceConfig := spaceConfigFromContext(r.Context()) - manifest := Manifest{ - ShortName: spaceConfig.SpaceName, - Name: spaceConfig.SpaceName, - Icons: []ManifestIcon{ - { - Src: "/.client/logo-dock.png", - Type: "image/png", - Sizes: "512x512", - }, - }, - CaptureLinks: "new-client", - StartURL: "/#boot", - Display: "standalone", - DisplayOverride: []string{"window-controls-overlay"}, - Scope: "/", - ThemeColor: "#e1e1e1", - Description: spaceConfig.SpaceDescription, - } - - if spaceConfig.HostURLPrefix != "" { - manifest.Icons[0].Src = spaceConfig.HostURLPrefix + manifest.Icons[0].Src - manifest.StartURL = spaceConfig.HostURLPrefix + manifest.StartURL - manifest.Scope = spaceConfig.HostURLPrefix + manifest.Scope - } - - render.JSON(w, r, manifest) - } -} diff --git a/server/proxy.go b/server/proxy.go index 3929323c..710d2f72 100644 --- a/server/proxy.go +++ b/server/proxy.go @@ -16,81 +16,79 @@ func init() { localhostRegex = regexp.MustCompile(`^(localhost|127\.0\.0\.1|\d+\.\d+\.\d+\.\d+)`) } -func proxyHandler(config *ServerConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - spaceConfig := spaceConfigFromContext(r.Context()) - if spaceConfig.ReadOnlyMode { - http.Error(w, "Read only mode, no proxy allowed", http.StatusMethodNotAllowed) - return - } - - // Extract the URI from the path - path := chi.URLParam(r, "*") - if path == "" { - http.Error(w, "No URI provided", http.StatusBadRequest) - return - } - - // Add query parameters if they exist - if r.URL.RawQuery != "" { - path += "?" + r.URL.RawQuery - } - - // Determine protocol based on hostname - var targetURL string - if localhostRegex.MatchString(path) { - targetURL = "http://" + path - } else { - targetURL = "https://" + path - } - - log.Printf("Proxying to %s\n", targetURL) - - // Create the proxy request - req, err := http.NewRequest(r.Method, targetURL, r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // Copy safe headers - safeHeaders := []string{"Authorization", "Accept", "Content-Type"} - for _, header := range safeHeaders { - if value := r.Header.Get(header); value != "" { - req.Header.Set(header, value) - } - } - - // Copy headers starting with X-Proxy-Header-, removing the prefix - for key, values := range r.Header { - if strings.HasPrefix(strings.ToLower(key), "x-proxy-header-") { - headerName := key[len("x-proxy-header-"):] - for _, value := range values { - req.Header.Set(headerName, value) - } - } - } - - // Make the request - client := http.DefaultClient - - resp, err := client.Do(req) - if err != nil { - log.Printf("Error fetching proxied URL: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer resp.Body.Close() - - // Copy response headers - for key, values := range resp.Header { - for _, value := range values { - w.Header().Set(key, value) - } - } - - // Set status code and copy body - w.WriteHeader(resp.StatusCode) - io.Copy(w, resp.Body) +func proxyHandler(w http.ResponseWriter, r *http.Request) { + spaceConfig := spaceConfigFromContext(r.Context()) + if spaceConfig.ReadOnlyMode { + http.Error(w, "Read only mode, no proxy allowed", http.StatusMethodNotAllowed) + return } + + // Extract the URI from the path + path := chi.URLParam(r, "*") + if path == "" { + http.Error(w, "No URI provided", http.StatusBadRequest) + return + } + + // Add query parameters if they exist + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + + // Determine protocol based on hostname + var targetURL string + if localhostRegex.MatchString(path) { + targetURL = "http://" + path + } else { + targetURL = "https://" + path + } + + log.Printf("Proxying to %s\n", targetURL) + + // Create the proxy request + req, err := http.NewRequest(r.Method, targetURL, r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Copy safe headers + safeHeaders := []string{"Authorization", "Accept", "Content-Type"} + for _, header := range safeHeaders { + if value := r.Header.Get(header); value != "" { + req.Header.Set(header, value) + } + } + + // Copy headers starting with X-Proxy-Header-, removing the prefix + for key, values := range r.Header { + if strings.HasPrefix(strings.ToLower(key), "x-proxy-header-") { + headerName := key[len("x-proxy-header-"):] + for _, value := range values { + req.Header.Set(headerName, value) + } + } + } + + // Make the request + client := http.DefaultClient + + resp, err := client.Do(req) + if err != nil { + log.Printf("Error fetching proxied URL: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + w.Header().Set(key, value) + } + } + + // Set status code and copy body + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) } diff --git a/server/server.go b/server/server.go index a4bfc7c4..504c7360 100644 --- a/server/server.go +++ b/server/server.go @@ -17,6 +17,9 @@ type BootConfig struct { SpaceFolderPath string `json:"spaceFolderPath"` IndexPage string `json:"indexPage"` ReadOnly bool `json:"readOnly"` + + // Whether or not the client should push logs to the server + LogPush bool `json:"logPush"` } func RunServer(config *ServerConfig) error { @@ -26,6 +29,8 @@ func RunServer(config *ServerConfig) error { r.Use(middleware.Logger) } + r.Use(middleware.RealIP) + // Expose space primitives and path to the request r.Use(spaceMiddleware(config)) @@ -35,22 +40,25 @@ func RunServer(config *ServerConfig) error { // Authentication endpoints (must come before auth middleware) addAuthEndpoints(r, config) - r.Get("/.ping", func(w http.ResponseWriter, r *http.Request) { + routes := chi.NewRouter() + + routes.Get("/.ping", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) }) // Mount filesystem routes under /.fs - r.Mount("/.fs", buildFsRoutes()) + routes.Mount("/.fs", buildFsRoutes()) // Config endpoint - r.Get("/.config", func(w http.ResponseWriter, r *http.Request) { + routes.Get("/.config", func(w http.ResponseWriter, r *http.Request) { spaceConfig := spaceConfigFromContext(r.Context()) clientConfig := &BootConfig{ SpaceFolderPath: spaceConfig.SpaceFolderPath, IndexPage: spaceConfig.IndexPage, ReadOnly: spaceConfig.ReadOnlyMode, + LogPush: spaceConfig.LogPush, } w.Header().Set("Cache-Control", "no-cache") @@ -58,15 +66,18 @@ func RunServer(config *ServerConfig) error { }) // Shell endpoint - r.Post("/.shell", handleShellEndpoint(config)) + routes.Post("/.shell", handleShellEndpoint) + + // Log collection endpoint + routes.Post("/.logs", handleLogsEndpoint) // Proxy endpoint - r.HandleFunc("/.proxy/*", proxyHandler(config)) + routes.HandleFunc("/.proxy/*", proxyHandler) // Manifest endpoint - r.HandleFunc("/.client/manifest.json", manifestHandler(config)) + routes.HandleFunc("/.client/manifest.json", manifestHandler) - r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + routes.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { path := chi.URLParam(r, "*") spaceConfig := spaceConfigFromContext(r.Context()) @@ -90,6 +101,12 @@ func RunServer(config *ServerConfig) error { ServerSideRender(config, spaceConfig, path, w, r) }) + if config.HostURLPrefix == "" { + r.Mount("/", routes) + } else { + r.Mount(config.HostURLPrefix, routes) + } + // Display the final server running message visibleHostname := config.BindHost if config.BindHost == "127.0.0.1" { diff --git a/server/shell_endpoint.go b/server/shell_endpoint.go index 2e011e05..8590bec6 100644 --- a/server/shell_endpoint.go +++ b/server/shell_endpoint.go @@ -7,30 +7,28 @@ import ( ) // handleShellEndpoint handles POST requests to /.shell for executing shell commands -func handleShellEndpoint(config *ServerConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - spaceConfig := spaceConfigFromContext(r.Context()) - // Parse the request body - var shellRequest ShellRequest - if err := json.NewDecoder(r.Body).Decode(&shellRequest); err != nil { - http.Error(w, "Invalid JSON request", http.StatusBadRequest) - return - } +func handleShellEndpoint(w http.ResponseWriter, r *http.Request) { + spaceConfig := spaceConfigFromContext(r.Context()) + // Parse the request body + var shellRequest ShellRequest + if err := json.NewDecoder(r.Body).Decode(&shellRequest); err != nil { + http.Error(w, "Invalid JSON request", http.StatusBadRequest) + return + } - // Execute the shell command - shellResponse, err := spaceConfig.ShellBackend.Handle(shellRequest) - if err != nil { - log.Printf("Shell error: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + // Execute the shell command + shellResponse, err := spaceConfig.ShellBackend.Handle(shellRequest) + if err != nil { + log.Printf("Shell error: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - // Return the response as JSON - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(shellResponse); err != nil { - log.Printf("Error encoding shell response: %v\n", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + // Return the response as JSON + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(shellResponse); err != nil { + log.Printf("Error encoding shell response: %v\n", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return } } diff --git a/server/spaces.go b/server/spaces.go index 1aede02b..a5064e98 100644 --- a/server/spaces.go +++ b/server/spaces.go @@ -6,12 +6,15 @@ import ( ) const spaceConfigKey = "spaceConfig" +const serverConfigKey = "serverConfig" func spaceMiddleware(config *ServerConfig) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { spaceConfig := config.SpaceConfigResolver(r) - augmentedRequest := r.WithContext(context.WithValue(r.Context(), spaceConfigKey, spaceConfig)) + ctx := context.WithValue(r.Context(), spaceConfigKey, spaceConfig) + ctx = context.WithValue(ctx, serverConfigKey, config) + augmentedRequest := r.WithContext(ctx) next.ServeHTTP(w, augmentedRequest) }) } @@ -20,3 +23,7 @@ func spaceMiddleware(config *ServerConfig) func(http.Handler) http.Handler { func spaceConfigFromContext(ctx context.Context) *SpaceConfig { return ctx.Value(spaceConfigKey).(*SpaceConfig) } + +func serverConfigFromContext(ctx context.Context) *ServerConfig { + return ctx.Value(serverConfigKey).(*ServerConfig) +} diff --git a/server/ssr.go b/server/ssr.go index 760800a8..8d386a8a 100644 --- a/server/ssr.go +++ b/server/ssr.go @@ -69,7 +69,7 @@ func ServerSideRender(config *ServerConfig, spaceConfig *SpaceConfig, pageName s Content string }{ Title: "SilverBullet", - HostPrefix: spaceConfig.HostURLPrefix, + HostPrefix: config.HostURLPrefix, } if spaceConfig.ReadOnlyMode && spaceConfig.Auth == nil { diff --git a/server/types.go b/server/types.go index d2dda568..8f449dcb 100644 --- a/server/types.go +++ b/server/types.go @@ -11,14 +11,15 @@ type ServerConfig struct { BindHost string Port int EnableHTTPLogging bool + // TODO: Ideally this is configurable per space, but kinda hard + HostURLPrefix string ClientBundle SpacePrimitives } type SpaceConfig struct { - Hostname string - HostURLPrefix string - Auth *AuthOptions + Hostname string + Auth *AuthOptions SpacePrimitives SpacePrimitives @@ -26,6 +27,7 @@ type SpaceConfig struct { IndexPage string GitIgnore string ReadOnlyMode bool + LogPush bool // Used for the PWA manifest and login page SpaceName string diff --git a/website/Install/Configuration.md b/website/Install/Configuration.md index bfede70d..2fd9d952 100644 --- a/website/Install/Configuration.md +++ b/website/Install/Configuration.md @@ -5,6 +5,7 @@ SilverBullet is primarily configured via environment variables. This page gives * `SB_INDEX_PAGE`: Sets the default page to load, defaults to `index`. * `SB_SPACE_IGNORE`: Ignore certain path patterns based on a .gitignore style format, e.g. `SB_SPACE_IGNORE="IgnoreMe/*"`. * `SB_HTTP_LOGGING`: Set to any value to enable HTTP logging +* `SB_LOG_PUSH`: Set to any value to ask clients to push their logs to the server (for debugging purposes) # Network * `SB_HOSTNAME`: Set to the hostname to bind to (defaults to `127.0.0.0`, set to `0.0.0.0` to accept outside connections for the local deno setup, defaults to `0.0.0.0` for docker)