feat(demo): reset the demo instance every 20 minutes with a countdown
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s

DEMO_RESET_MINUTES (default 20, 0 = off) resets the database to the
sample data. Demo accounts get fixed IDs so sessions survive; requests
hold a read lock so the reset never runs under a half-finished request
(streams excluded). DEMO_RESET goes to every connection, /auth/status
announces the next reset. The app shows "✨ Demo · Reset in MM:SS" in
place of the version, then clears cached answers, remounts the views,
reloads in the background and shows a short notice; if no event
arrives (long polling) it asks the server after the countdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:22:06 +01:00
1 parent afd3b0347c
commit 6b9eb62caf
12 files changed
+346 -7

No files matched your search

+1
View File
@@ -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)`) |
+1
View File
@@ -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.
+74 -2
View File
@@ -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<User | null>(null);
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState<string | null>(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<string | null>(() => {
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 = () => {
<span className="font-bold text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[9px] mt-0.5"
/>
) : (
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
<ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-150 ${
@@ -817,7 +872,16 @@ export const App: React.FC = () => {
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[10px] mt-0.5"
/>
) : (
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
</div>
@@ -1053,11 +1117,19 @@ export const App: React.FC = () => {
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner />
{demoNotice && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] px-3 py-1.5 rounded-full border border-accent/40 bg-panel-elevated text-main text-[11px] font-medium shadow-panel"
>
✨ {demoNotice}
</div>
)}
{/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section>
<section key={dataEpoch}>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
+38
View File
@@ -0,0 +1,38 @@
import React, { useEffect, useState } from 'react';
interface DemoCountdownProps {
deadline: number; // ms timestamp of the next reset, in this browser's clock
minutes?: number;
className?: string;
onElapsed?: () => 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<DemoCountdownProps> = ({ 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 (
<span
className={`font-mono leading-tight text-accent ${className}`}
title={`Diese Test-Instanz wird alle ${minutes || 20} Minuten automatisch auf den Ausgangszustand zurückgesetzt.`}
>
✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'}
</span>
);
};
+4
View File
@@ -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;
+77
View File
@@ -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()
}
}
+90
View File
@@ -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)
}
}
+9 -2
View File
@@ -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).
+6
View File
@@ -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{
+12
View File
@@ -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
}
+14 -3
View File
@@ -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)
}
+20
View File
@@ -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))