mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user