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
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:
1 parent
afd3b0347c
commit
6b9eb62caf
12 files changed
+348
-9
No files matched your search
@@ -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)`) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+76
-4
@@ -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>
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</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>
|
||||
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user