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
+346
-7
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 |
|
| `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` |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
| `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_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) |
|
| `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)`) |
|
| `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_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. |
|
| `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!** |
|
| `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).
|
* **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.
|
* **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
@@ -51,7 +51,8 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
|
|||||||
import { SharedListsView } from './features/lists/SharedListsView';
|
import { SharedListsView } from './features/lists/SharedListsView';
|
||||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||||
import { OfflineBanner } from './components/OfflineBanner';
|
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 { UserSettingsView } from './features/settings/UserSettingsView';
|
||||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||||
import { HelpView } from './features/help/HelpView';
|
import { HelpView } from './features/help/HelpView';
|
||||||
@@ -122,6 +123,12 @@ export const App: React.FC = () => {
|
|||||||
instanceName: 'MindSift',
|
instanceName: 'MindSift',
|
||||||
});
|
});
|
||||||
const [user, setUser] = useState<User | null>(null);
|
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
|
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
|
||||||
const [resetToken, setResetToken] = useState<string | null>(() => {
|
const [resetToken, setResetToken] = useState<string | null>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -551,11 +558,50 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [user?.id]);
|
}, [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)
|
// Refresh stats on task/doc/list real-time events (counters in the navigation)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWs = (e: any) => {
|
const handleWs = (e: any) => {
|
||||||
const msg = e.detail;
|
const msg = e.detail;
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
|
if (msg.event === 'DEMO_RESET') {
|
||||||
|
handleDemoResetRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
typeof msg.event === 'string' &&
|
typeof msg.event === 'string' &&
|
||||||
['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix))
|
['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">
|
<span className="font-bold text-base text-main tracking-tight leading-none">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
{authStatus.instanceName || 'MindSift'}
|
||||||
</span>
|
</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>
|
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
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">
|
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
{authStatus.instanceName || 'MindSift'}
|
||||||
</span>
|
</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>
|
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1053,11 +1117,19 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
||||||
<OfflineBanner />
|
<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 Container */}
|
||||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
<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')}>
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||||
<section>
|
<section key={dataEpoch}>
|
||||||
{activeTab === 'triage' && (
|
{activeTab === 'triage' && (
|
||||||
<TriageDeck
|
<TriageDeck
|
||||||
deck={deck}
|
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;
|
sharingMode?: SharingMode;
|
||||||
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
|
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
|
||||||
passwordReset?: boolean;
|
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)
|
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
|
||||||
legal?: {
|
legal?: {
|
||||||
imprintUrl?: string;
|
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
|
instanceName = sys.InstanceName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
status := map[string]interface{}{
|
||||||
"needsSetup": count == 0,
|
"needsSetup": count == 0,
|
||||||
"allowRegistration": allowReg,
|
"allowRegistration": allowReg,
|
||||||
"instanceName": instanceName,
|
"instanceName": instanceName,
|
||||||
@@ -51,7 +51,14 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"privacyUrl": s.cfg.Legal.PrivacyURL,
|
"privacyUrl": s.cfg.Legal.PrivacyURL,
|
||||||
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
|
"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).
|
// 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
|
leases *collab.LeaseManager // block leases and open sessions of notes
|
||||||
notifier *collab.Notifier // wakes long-poll requests on note changes
|
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 {
|
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.startAlarmTicker()
|
||||||
go srv.startCompaction()
|
go srv.startCompaction()
|
||||||
|
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
|
||||||
|
go srv.startDemoResetTicker()
|
||||||
|
}
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +75,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(s.demoResetGate)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(s.apiLoggingMiddleware)
|
r.Use(s.apiLoggingMiddleware)
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"strconv"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -22,6 +23,8 @@ type Config struct {
|
|||||||
LogLevel string
|
LogLevel string
|
||||||
Debug bool
|
Debug bool
|
||||||
DemoMode 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)
|
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
@@ -161,6 +164,7 @@ func Load() *Config {
|
|||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
DemoMode: demoMode,
|
DemoMode: demoMode,
|
||||||
|
DemoResetMinutes: demoResetMinutes(),
|
||||||
SMTPHost: smtpHost,
|
SMTPHost: smtpHost,
|
||||||
SMTPPort: smtpPort,
|
SMTPPort: smtpPort,
|
||||||
SMTPUser: smtpUser,
|
SMTPUser: smtpUser,
|
||||||
@@ -197,3 +201,11 @@ func getEnv(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
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) {
|
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" {
|
if role != "admin" && role != "member" {
|
||||||
role = "member"
|
role = "member"
|
||||||
}
|
}
|
||||||
@@ -76,7 +80,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
|
|||||||
cleanUsername = strings.Split(cleanEmail, "@")[0]
|
cleanUsername = strings.Split(cleanEmail, "@")[0]
|
||||||
}
|
}
|
||||||
u := &domain.User{
|
u := &domain.User{
|
||||||
ID: domain.NewID("usr"),
|
ID: id,
|
||||||
Email: cleanEmail,
|
Email: cleanEmail,
|
||||||
Username: cleanUsername,
|
Username: cleanUsername,
|
||||||
PasswordHash: passwordHash,
|
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
|
// 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 {
|
func (s *Store) EnsureDemoAccounts() error {
|
||||||
s.initMu.Lock()
|
s.initMu.Lock()
|
||||||
defer s.initMu.Unlock()
|
defer s.initMu.Unlock()
|
||||||
@@ -142,7 +153,7 @@ func (s *Store) EnsureDemoAccounts() error {
|
|||||||
adminCreated := false
|
adminCreated := false
|
||||||
adminUser, err := s.GetUserByLogin("admin")
|
adminUser, err := s.GetUserByLogin("admin")
|
||||||
if err != nil || adminUser == nil {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create demo admin: %w", err)
|
return fmt.Errorf("failed to create demo admin: %w", err)
|
||||||
}
|
}
|
||||||
@@ -155,7 +166,7 @@ func (s *Store) EnsureDemoAccounts() error {
|
|||||||
// 2. Regular user (user / user)
|
// 2. Regular user (user / user)
|
||||||
normalUser, err := s.GetUserByLogin("user")
|
normalUser, err := s.GetUserByLogin("user")
|
||||||
if err != nil || normalUser == nil {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create demo user: %w", err)
|
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).
|
// BroadcastToUsers sends one event to every connection of the given users (duplicates are ignored).
|
||||||
func (h *Hub) BroadcastToUsers(userIDs []string, event string, payload interface{}) {
|
func (h *Hub) BroadcastToUsers(userIDs []string, event string, payload interface{}) {
|
||||||
seen := make(map[string]bool, len(userIDs))
|
seen := make(map[string]bool, len(userIDs))
|
||||||
|
|||||||
Reference in new issue
Block a user