From d1df9df899ad89094dda6f519be103e625125340 Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Tue, 30 Sep 2025 14:46:20 +0000 Subject: [PATCH] feat: add demo mode with read-only protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DEMO_MODE environment variable that blocks all write operations while allowing full read/view functionality. Includes banner notification in UI when demo mode is active. Addresses need for safe public demo instances. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend-modern/src/App.tsx | 2 + frontend-modern/src/components/DemoBanner.tsx | 58 +++++++++++++++++++ internal/api/demo_middleware.go | 49 ++++++++++++++++ internal/api/router.go | 6 +- internal/config/config.go | 10 ++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 frontend-modern/src/components/DemoBanner.tsx create mode 100644 internal/api/demo_middleware.go diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index bcf09a8a1..2caf2436c 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -29,6 +29,7 @@ import { SettingsAPI } from './api/settings'; import { eventBus } from './stores/events'; import { updateStore } from './stores/updates'; import { UpdateBanner } from './components/UpdateBanner'; +import { DemoBanner } from './components/DemoBanner'; type TabType = 'main' | 'storage' | 'backups' | 'alerts' | 'settings'; @@ -435,6 +436,7 @@ function App() { +
diff --git a/frontend-modern/src/components/DemoBanner.tsx b/frontend-modern/src/components/DemoBanner.tsx new file mode 100644 index 000000000..124e08cf8 --- /dev/null +++ b/frontend-modern/src/components/DemoBanner.tsx @@ -0,0 +1,58 @@ +import { createSignal, onMount, Show } from 'solid-js'; + +export function DemoBanner() { + const [isDemoMode, setIsDemoMode] = createSignal(false); + const [dismissed, setDismissed] = createSignal(false); + + onMount(async () => { + // Check if we're in demo mode by trying a test request + try { + const response = await fetch('/api/health'); + const demoHeader = response.headers.get('X-Demo-Mode'); + if (demoHeader === 'true') { + setIsDemoMode(true); + } + } catch (e) { + // Ignore errors + } + }); + + const handleDismiss = () => { + setDismissed(true); + // Remember dismissal for this session only + sessionStorage.setItem('demoBannerDismissed', 'true'); + }; + + // Check if already dismissed this session + onMount(() => { + if (sessionStorage.getItem('demoBannerDismissed') === 'true') { + setDismissed(true); + } + }); + + return ( + +
+
+
+ + + + + Demo instance with mock data (read-only) + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/internal/api/demo_middleware.go b/internal/api/demo_middleware.go new file mode 100644 index 000000000..9f7e3fe41 --- /dev/null +++ b/internal/api/demo_middleware.go @@ -0,0 +1,49 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rs/zerolog/log" +) + +// DemoModeMiddleware blocks all modification requests in demo mode +func DemoModeMiddleware(cfg *config.Config, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !cfg.DemoMode { + next.ServeHTTP(w, r) + return + } + + // Add header so frontend knows we're in demo mode + w.Header().Set("X-Demo-Mode", "true") + + // Allow GET and HEAD requests (read-only) + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + + // Allow WebSocket upgrades + if strings.ToLower(r.Header.Get("Upgrade")) == "websocket" { + next.ServeHTTP(w, r) + return + } + + // Block all modification requests (POST, PUT, DELETE, PATCH) + log.Warn(). + Str("method", r.Method). + Str("path", r.URL.Path). + Str("remote", r.RemoteAddr). + Msg("Demo mode: blocked modification request") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Demo mode enabled", + "message": "This is a read-only demo instance. Modifications are disabled.", + }) + }) +} \ No newline at end of file diff --git a/internal/api/router.go b/internal/api/router.go index 65d5b2116..f522e1656 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -70,11 +70,13 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket // Apply middleware chain: // 1. Universal rate limiting (outermost to stop attacks early) - // 2. Error handling - // 3. Security headers with embedding configuration + // 2. Demo mode (read-only protection) + // 3. Error handling + // 4. Security headers with embedding configuration // Note: TimeoutHandler breaks WebSocket upgrades handler := SecurityHeadersWithConfig(r, allowEmbedding, allowedOrigins) handler = ErrorHandler(handler) + handler = DemoModeMiddleware(cfg, handler) handler = UniversalRateLimitMiddleware(handler) return handler } diff --git a/internal/config/config.go b/internal/config/config.go index 0cc3b638b..494f7fdfd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -90,6 +90,7 @@ type Config struct { AuthUser string `envconfig:"PULSE_AUTH_USER"` AuthPass string `envconfig:"PULSE_AUTH_PASS"` DisableAuth bool `envconfig:"DISABLE_AUTH" default:"false"` + DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"` IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"` @@ -355,6 +356,15 @@ func Load() (*Config, error) { log.Debug().Bool("DisableAuth", cfg.DisableAuth).Msg("DISABLE_AUTH not set, DisableAuth remains") } + // Check if demo mode is enabled + demoModeEnv := os.Getenv("DEMO_MODE") + if demoModeEnv != "" { + cfg.DemoMode = demoModeEnv == "true" || demoModeEnv == "1" + if cfg.DemoMode { + log.Warn().Msg("🎭 DEMO MODE - All modifications disabled (read-only)") + } + } + // Load proxy authentication settings if proxyAuthSecret := os.Getenv("PROXY_AUTH_SECRET"); proxyAuthSecret != "" { cfg.ProxyAuthSecret = proxyAuthSecret