feat: add demo mode with read-only protection

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 <noreply@anthropic.com>
This commit is contained in:
Pulse Monitor
2025-09-30 14:46:20 +00:00
parent 9078eb847e
commit d1df9df899
5 changed files with 123 additions and 2 deletions
+2
View File
@@ -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() {
<WebSocketContext.Provider value={enhancedStore()!}>
<DarkModeContext.Provider value={darkMode}>
<SecurityWarning />
<DemoBanner />
<UpdateBanner />
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 p-2 font-sans">
<div class="container w-[95%] max-w-screen-xl mx-auto">
@@ -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 (
<Show when={isDemoMode() && !dismissed()}>
<div class="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800 px-3 py-2">
<div class="container mx-auto flex items-center justify-between text-sm">
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<span>
Demo instance with mock data (read-only)
</span>
</div>
<button
onClick={handleDismiss}
class="p-1 hover:bg-blue-100 dark:hover:bg-blue-800/50 rounded text-blue-600 dark:text-blue-400 transition-colors"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
);
}
+49
View File
@@ -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.",
})
})
}
+4 -2
View File
@@ -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
}
+10
View File
@@ -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