MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
- Table media (migration 5), files in $DATA_DIR/media/<docId>/.
- POST /documents/{id}/media (write access), POST .../media/fetch copies an
external https image (SSRF-guarded, not in the demo), GET /media/{id}
with range requests for readers of a note that shows the file.
- Format detected from the content (no SVG); own sandbox CSP on answers.
- Blocks point to files through their content (media:<id>), so the note
sync needs no change; compaction removes files no block mentions any
more after a day. Demo reset and demo start clear the folder.
- Limits MEDIA_MAX_IMAGE_MB, MEDIA_MAX_AUDIO_MB, MEDIA_QUOTA_MB,
MEDIA_DEMO_MAX_MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
97 lines
2.8 KiB
Go
97 lines
2.8 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"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()
|
|
// Uploaded images and recordings belong to the old state; their rows are gone with the tables
|
|
_ = os.RemoveAll(s.mediaRoot())
|
|
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()
|
|
}
|
|
}
|
|
|
|
// A public demo must not send anything to the outside (FLAW-33): no alarm mails, Discord or webhook
|
|
// calls, no test messages. The senders refuse, and the routes that only exist to send or to change
|
|
// server settings answer with 403 right away. All visitors share the demo accounts, so whatever
|
|
// would lock the others out (passwords, ending sessions, deleting accounts) is refused as well.
|
|
var errDemoOutgoing = errors.New("In der Demo deaktiviert")
|
|
|
|
func (s *Server) notInDemo(h http.HandlerFunc) http.HandlerFunc {
|
|
if !s.cfg.DemoMode {
|
|
return h
|
|
}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
|
|
}
|
|
}
|