diff --git a/Doku/architecture.md b/Doku/architecture.md index cb85596..29ce533 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -64,6 +64,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` | | `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen | +| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | diff --git a/README.md b/README.md index bd1696a..9e2c5ce 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | | `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | | `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | +| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. | * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). * **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4012f6..49c0c55 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,7 +51,8 @@ import { WorkspaceView } from './features/workspace/WorkspaceView'; import { SharedListsView } from './features/lists/SharedListsView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { OfflineBanner } from './components/OfflineBanner'; -import { outboxCount } from './lib/offline'; +import { outboxCount, clearOfflineData } from './lib/offline'; +import { DemoCountdown } from './components/DemoCountdown'; import { UserSettingsView } from './features/settings/UserSettingsView'; import { AdminSettingsView } from './features/settings/AdminSettingsView'; import { HelpView } from './features/help/HelpView'; @@ -122,6 +123,12 @@ export const App: React.FC = () => { instanceName: 'MindSift', }); const [user, setUser] = useState(null); + // Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it + const [demoDeadline, setDemoDeadline] = useState(null); + const [dataEpoch, setDataEpoch] = useState(0); + const [demoNotice, setDemoNotice] = useState(null); + const lastDemoResetRef = useRef(0); + const handleDemoResetRef = useRef<() => void>(() => {}); // Link from the password reset mail (?resetToken=…); removed from the address bar right away const [resetToken, setResetToken] = useState(() => { try { @@ -551,11 +558,50 @@ export const App: React.FC = () => { } }, [user?.id]); + useEffect(() => { + setDemoDeadline( + authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number' + ? Date.now() + authStatus.demoResetInSeconds * 1000 + : null + ); + }, [authStatus.demoMode, authStatus.demoResetInSeconds]); + + // The demo database was reset: drop cached answers of the old data, remount the views (they load + // fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs). + const handleDemoReset = useCallback(() => { + if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it + lastDemoResetRef.current = Date.now(); + void clearOfflineData(); + setDataEpoch((n) => n + 1); + refreshAll(); + api.getAuthStatus().then(setAuthStatus).catch(() => {}); + setDemoNotice('Demo-Instanz wurde zurückgesetzt'); + window.setTimeout(() => setDemoNotice(null), 4000); + }, [refreshAll]); + handleDemoResetRef.current = handleDemoReset; + + // Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after + const handleDemoCountdownElapsed = useCallback(() => { + window.setTimeout(() => { + api + .getAuthStatus() + .then((st) => { + if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current(); + else setAuthStatus(st); + }) + .catch(() => {}); + }, 5000); + }, []); + // Refresh stats on task/doc/list real-time events (counters in the navigation) useEffect(() => { const handleWs = (e: any) => { const msg = e.detail; if (!msg) return; + if (msg.event === 'DEMO_RESET') { + handleDemoResetRef.current(); + return; + } if ( typeof msg.event === 'string' && ['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix)) @@ -742,7 +788,16 @@ export const App: React.FC = () => { {authStatus.instanceName || 'MindSift'} - {APP_VERSION} + {demoDeadline ? ( + + ) : ( + {APP_VERSION} + )} { {authStatus.instanceName || 'MindSift'} - {APP_VERSION} + {demoDeadline ? ( + + ) : ( + {APP_VERSION} + )} @@ -1053,11 +1117,19 @@ export const App: React.FC = () => { + {demoNotice && ( +
+ ✨ {demoNotice} +
+ )} {/* Main Container */}
setActiveTab('triage')}> -
+
{activeTab === 'triage' && ( void; +} + +const fmt = (ms: number) => { + const total = Math.max(0, Math.round(ms / 1000)); + return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; +}; + +// "✨ Demo · Reset in MM:SS" in place of the version under the app name while the demo mode resets itself +export const DemoCountdown: React.FC = ({ deadline, minutes, className = '', onElapsed }) => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + const left = deadline - now; + useEffect(() => { + if (left <= 0) onElapsed?.(); + // Runs when the deadline passes, not on every tick + }, [left <= 0, deadline]); + + return ( + + ✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'} + + ); +}; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a372c14..8959c78 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -10,6 +10,10 @@ export interface AuthStatus { sharingMode?: SharingMode; // "Passwort vergessen?" works (SMTP and BASE_URL configured) passwordReset?: boolean; + // Demo mode with periodic reset: next reset (ISO) and seconds until then + demoNextReset?: string; + demoResetInSeconds?: number; + demoResetMinutes?: number; // Impressum / Datenschutz of this instance (LEGAL_* environment variables) legal?: { imprintUrl?: string; diff --git a/internal/api/demo.go b/internal/api/demo.go new file mode 100644 index 0000000..018ddae --- /dev/null +++ b/internal/api/demo.go @@ -0,0 +1,77 @@ +package api + +import ( + "log/slog" + "net/http" + "strings" + "sync" + "time" +) + +// In demo mode the database is reset to the sample data every DEMO_RESET_MINUTES. Requests hold a read +// lock while they run and the reset takes the write lock, so no request sees half-dropped tables. +// Long-lived streams (WebSocket, SSE, long polling) are left out, otherwise the reset would wait for +// them forever; they reconnect or retry on their own. The demo accounts keep their IDs, so sessions +// stay valid; clients get DEMO_RESET and reload their data. + +type demoState struct { + gate sync.RWMutex // read: a running request, write: the reset + + mu sync.Mutex + nextReset time.Time +} + +func isLongLivedRequest(r *http.Request) bool { + p := r.URL.Path + return strings.HasPrefix(p, "/api/v1/ws") || p == "/api/v1/events" || + (strings.HasPrefix(p, "/api/v1/docs/") && strings.HasSuffix(p, "/changes")) +} + +func (s *Server) demoResetGate(next http.Handler) http.Handler { + if !s.cfg.DemoMode { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isLongLivedRequest(r) || !strings.HasPrefix(r.URL.Path, "/api/") { + next.ServeHTTP(w, r) + return + } + s.demo.gate.RLock() + defer s.demo.gate.RUnlock() + next.ServeHTTP(w, r) + }) +} + +// demoNextReset is zero when no periodic reset runs. +func (s *Server) demoNextReset() time.Time { + s.demo.mu.Lock() + defer s.demo.mu.Unlock() + return s.demo.nextReset +} + +func (s *Server) setDemoNextReset(t time.Time) { + s.demo.mu.Lock() + s.demo.nextReset = t + s.demo.mu.Unlock() +} + +func (s *Server) resetDemo() { + s.demo.gate.Lock() + err := s.store.ResetAndSeedDemo() + s.demo.gate.Unlock() + if err != nil { + slog.Error("Demo-Reset fehlgeschlagen", "error", err) + return + } + s.wsHub.BroadcastSystem("DEMO_RESET") +} + +func (s *Server) startDemoResetTicker() { + interval := time.Duration(s.cfg.DemoResetMinutes) * time.Minute + for { + next := time.Now().Add(interval) + s.setDemoNextReset(next) + time.Sleep(time.Until(next)) + s.resetDemo() + } +} diff --git a/internal/api/demo_test.go b/internal/api/demo_test.go new file mode 100644 index 0000000..7dfb165 --- /dev/null +++ b/internal/api/demo_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/sandrodach/mindsift/internal/config" + "github.com/sandrodach/mindsift/internal/storage" + "github.com/sandrodach/mindsift/internal/ws" +) + +func TestDemoResetKeepsSessionsAndRestoresSampleData(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "demo_reset.db") + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.ResetAndSeedDemo(); err != nil { + t.Fatal(err) + } + srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil) + router := srv.Router() + call := func(method, path, token, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + var login struct{ Token string } + _ = json.Unmarshal(call("POST", "/api/v1/auth/login", "", `{"login":"admin","password":"admin"}`).Body.Bytes(), &login) + countDocs := func() int { + var docs []json.RawMessage + _ = json.Unmarshal(call("GET", "/api/v1/documents", login.Token, "").Body.Bytes(), &docs) + return len(docs) + } + seeded := countDocs() + if seeded == 0 { + t.Fatal("expected sample notes") + } + if rec := call("POST", "/api/v1/documents", login.Token, `{"title":"Testnotiz"}`); rec.Code >= 300 { + t.Fatalf("create note: %d %s", rec.Code, rec.Body.String()) + } + + // Requests running during the reset must neither fail half-way nor see missing tables + var wg sync.WaitGroup + failures := make(chan int, 50) + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if rec := call("GET", "/api/v1/documents", login.Token, ""); rec.Code != http.StatusOK { + failures <- rec.Code + } + }() + } + srv.resetDemo() + wg.Wait() + close(failures) + for code := range failures { + t.Errorf("request during reset failed with %d", code) + } + + if rec := call("GET", "/api/v1/auth/me", login.Token, ""); rec.Code != http.StatusOK { + t.Fatalf("session must survive the reset, got %d", rec.Code) + } + if n := countDocs(); n != seeded { + t.Errorf("reset should restore the %d sample notes, got %d", seeded, n) + } + + srv.setDemoNextReset(time.Now().Add(90 * time.Second)) + var st struct { + DemoNextReset string `json:"demoNextReset"` + DemoResetInSeconds int `json:"demoResetInSeconds"` + } + _ = json.Unmarshal(call("GET", "/api/v1/auth/status", "", "").Body.Bytes(), &st) + if st.DemoNextReset == "" || st.DemoResetInSeconds < 85 || st.DemoResetInSeconds > 90 { + t.Errorf("status should announce the next reset: %+v", st) + } +} diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index b48dd3e..4810456 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -37,7 +37,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { instanceName = sys.InstanceName } } - writeJSON(w, http.StatusOK, map[string]interface{}{ + status := map[string]interface{}{ "needsSetup": count == 0, "allowRegistration": allowReg, "instanceName": instanceName, @@ -51,7 +51,14 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "privacyUrl": s.cfg.Legal.PrivacyURL, "hasPrivacyContent": s.cfg.Legal.PrivacyContent != "", }, - }) + } + // Countdown to the next demo reset; seconds as well, so client clocks do not matter + if next := s.demoNextReset(); !next.IsZero() { + status["demoNextReset"] = next.UTC().Format(time.RFC3339) + status["demoResetInSeconds"] = int(time.Until(next).Seconds()) + status["demoResetMinutes"] = s.cfg.DemoResetMinutes + } + writeJSON(w, http.StatusOK, status) } // handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login). diff --git a/internal/api/server.go b/internal/api/server.go index 38cd352..644f875 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -28,6 +28,8 @@ type Server struct { leases *collab.LeaseManager // block leases and open sessions of notes notifier *collab.Notifier // wakes long-poll requests on note changes + + demo demoState // periodic reset in demo mode (demo.go) } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { @@ -46,6 +48,9 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS } go srv.startAlarmTicker() go srv.startCompaction() + if cfg.DemoMode && cfg.DemoResetMinutes > 0 { + go srv.startDemoResetTicker() + } return srv } @@ -70,6 +75,7 @@ func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RealIP) + r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) r.Use(cors.Handler(cors.Options{ diff --git a/internal/config/config.go b/internal/config/config.go index 030f8c1..98853ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "crypto/rand" + "strconv" "encoding/hex" "log/slog" "os" @@ -22,6 +23,8 @@ type Config struct { LogLevel string Debug bool DemoMode bool + // DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never) + DemoResetMinutes int // Optional SMTP environment variables (can also be overridden/configured in DB system_settings) SMTPHost string @@ -161,6 +164,7 @@ func Load() *Config { LogLevel: logLevel, Debug: debug, DemoMode: demoMode, + DemoResetMinutes: demoResetMinutes(), SMTPHost: smtpHost, SMTPPort: smtpPort, SMTPUser: smtpUser, @@ -197,3 +201,11 @@ func getEnv(key, fallback string) string { } return fallback } + +func demoResetMinutes() int { + n, err := strconv.Atoi(strings.TrimSpace(getEnv("DEMO_RESET_MINUTES", "20"))) + if err != nil || n < 0 { + return 20 + } + return n +} diff --git a/internal/storage/users.go b/internal/storage/users.go index ae2bec6..0bf2dc4 100644 --- a/internal/storage/users.go +++ b/internal/storage/users.go @@ -67,6 +67,10 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma } func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) { + return s.createUserWithID(domain.NewID("usr"), email, username, passwordHash, displayName, role) +} + +func (s *Store) createUserWithID(id, email, username, passwordHash, displayName, role string) (*domain.User, error) { if role != "admin" && role != "member" { role = "member" } @@ -76,7 +80,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam cleanUsername = strings.Split(cleanEmail, "@")[0] } u := &domain.User{ - ID: domain.NewID("usr"), + ID: id, Email: cleanEmail, Username: cleanUsername, PasswordHash: passwordHash, @@ -125,6 +129,13 @@ func (s *Store) GetUserByLogin(login string) (*domain.User, error) { } // EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation +// Demo accounts keep the same IDs across resets, so the sessions of people trying the demo stay valid +// when the database is reset underneath them. +const ( + DemoAdminID = "usr_demo_admin" + DemoUserID = "usr_demo_user" +) + func (s *Store) EnsureDemoAccounts() error { s.initMu.Lock() defer s.initMu.Unlock() @@ -142,7 +153,7 @@ func (s *Store) EnsureDemoAccounts() error { adminCreated := false adminUser, err := s.GetUserByLogin("admin") if err != nil || adminUser == nil { - adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin") + adminUser, err = s.createUserWithID(DemoAdminID, "admin@mindsift.local", "admin", adminHash, "Admin", "admin") if err != nil { return fmt.Errorf("failed to create demo admin: %w", err) } @@ -155,7 +166,7 @@ func (s *Store) EnsureDemoAccounts() error { // 2. Regular user (user / user) normalUser, err := s.GetUserByLogin("user") if err != nil || normalUser == nil { - normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member") + normalUser, err = s.createUserWithID(DemoUserID, "user@mindsift.local", "user", userHash, "User", "member") if err != nil { return fmt.Errorf("failed to create demo user: %w", err) } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 02d11a0..fca2e72 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -371,6 +371,26 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) { } } +// BroadcastSystem sends an event to every connection. Only for events without user data (e.g. the +// demo reset notice). +func (h *Hub) BroadcastSystem(event string) { + data, err := json.Marshal(domain.WSMessage{Event: event, Timestamp: domain.NowISO()}) + if err != nil { + return + } + h.mu.RLock() + defer h.mu.RUnlock() + if h.closed { + return + } + for client := range h.allClients { + client.trySend(data) + } + for sseClient := range h.sseClients { + sseClient.trySend(data) + } +} + // BroadcastToUsers sends one event to every connection of the given users (duplicates are ignored). func (h *Hub) BroadcastToUsers(userIDs []string, event string, payload interface{}) { seen := make(map[string]bool, len(userIDs))