fix(security): demo sends nothing, hidden SMTP password, body limits (FLAW-33..38)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker; test alarm, SMTP test, server settings, backups, account and session changes of the shared demo accounts answer 403; the session list only shows the own device. The UI shows the notices and disables the controls. - SMTP password is never sent back (smtpPassSet), empty keeps it. - ?token= on webhook ingest accepts only API keys. - Request bodies capped at 2 MB (sync 8 MB, imports 101 MB). - Deleting an account removes its server backups. - Backup downloads, exports and import uploads get 30 minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3709282a4f
commit
9d9698186c
21 files changed
+631
-304
No files matched your search
@@ -13,6 +13,9 @@ import (
|
||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||
|
||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
cfg, err := s.smtpSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -29,6 +32,9 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error
|
||||
}
|
||||
|
||||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
|
||||
return err
|
||||
@@ -63,6 +69,9 @@ func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate
|
||||
}
|
||||
|
||||
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
|
||||
return err
|
||||
|
||||
@@ -162,7 +162,21 @@ func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentT
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
allowLongTransfer(w)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
// removeUserBackups deletes the server backups of a deleted account (FLAW-37). The system snapshots
|
||||
// still contain it until they expire (BACKUP retention).
|
||||
func (s *Server) removeUserBackups(userID string) {
|
||||
dir := storage.UserBackupDir(s.backupRoot(), userID)
|
||||
// Only ever one user's own folder, never backups/users itself (IDs like "." or "/")
|
||||
if base := filepath.Base(dir); userID == "" || base == "." || base == "users" || filepath.Dir(dir) != filepath.Join(s.backupRoot(), "users") {
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
slog.Warn("Backups des gelöschten Kontos konnten nicht entfernt werden", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Request bodies are capped for every endpoint (FLAW-36), so a session or API key cannot tie up memory
|
||||
// with huge uploads. Note sync and imports need more; their handlers apply the same limits again.
|
||||
const defaultMaxBody = 2 << 20
|
||||
|
||||
func bodyLimitFor(path string) int64 {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v1/import/"):
|
||||
return storage.MaxImportUploadBytes + 1<<20
|
||||
case strings.HasPrefix(path, "/api/v1/docs/") && strings.HasSuffix(path, "/sync"):
|
||||
return syncMaxBody
|
||||
}
|
||||
return defaultMaxBody
|
||||
}
|
||||
|
||||
func bodyLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, bodyLimitFor(r.URL.Path))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// The server gives every request 15 s to send its body and 30 s for the answer. Whole archives on a slow
|
||||
// line need longer: downloads of backups and exports, uploads of imports (FLAW-38).
|
||||
const longTransferTime = 30 * time.Minute
|
||||
|
||||
func allowLongTransfer(w http.ResponseWriter) {
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(longTransferTime))
|
||||
_ = rc.SetReadDeadline(time.Now().Add(longTransferTime))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestBodyLimited(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
huge := `{"content":"` + strings.Repeat("x", defaultMaxBody) + `"}`
|
||||
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, huge, ""); rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("body over the limit: want 400/413, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, `{"content":"klein"}`, ""); rec.Code >= 300 {
|
||||
t.Fatalf("normal body: got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
|
||||
t.Fatal("sync and import keep their larger limits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTokenInURLOnlyAPIKey(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
rec := sessionCall(srv.Router(), "POST", "/api/v1/webhooks/ingest?token="+token, "", `{"text":"x"}`, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session token in URL: want 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -75,3 +76,18 @@ func (s *Server) startDemoResetTicker() {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
|
||||
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
|
||||
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
allowLongTransfer(w)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
|
||||
@@ -145,6 +146,7 @@ func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
allowLongTransfer(w)
|
||||
exportData, err := s.store.ExportUserData(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -246,6 +246,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeUserBackups(targetUserID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -302,8 +303,16 @@ func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
writeJSON(w, http.StatusOK, s.settingsForBrowser(settings))
|
||||
}
|
||||
|
||||
// settingsForBrowser hides the SMTP password; the admin only sees whether one is stored.
|
||||
func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.SystemSettings {
|
||||
out := *settings
|
||||
out.SMTPPassSet = out.SMTPPass != ""
|
||||
out.SMTPPass = ""
|
||||
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
return &out
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -312,6 +321,12 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
// Empty means unchanged (the browser never has it); without SMTP user the password goes too
|
||||
if settings.SMTPPass == "" && strings.TrimSpace(settings.SMTPUser) != "" {
|
||||
if cur, err := s.store.GetSystemSettings(); err == nil {
|
||||
settings.SMTPPass = cur.SMTPPass
|
||||
}
|
||||
}
|
||||
previousMode := s.store.SharingMode()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -324,8 +339,7 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -385,12 +385,14 @@ func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeUserBackups(user.ID)
|
||||
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
|
||||
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
|
||||
allowLongTransfer(w)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -456,6 +458,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
|
||||
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
|
||||
if (changesPassword || changesEmail) && s.cfg.DemoMode {
|
||||
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
|
||||
return
|
||||
}
|
||||
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -54,7 +54,13 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
|
||||
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
// In the URL only API keys are taken (devices that cannot set headers); a session token there would
|
||||
// end up in proxy logs as a full login (FLAW-35)
|
||||
token := r.URL.Query().Get("token")
|
||||
if token != "" && !strings.HasPrefix(token, "mindsift_sec_") {
|
||||
writeError(w, http.StatusUnauthorized, "In der URL sind nur API-Keys erlaubt; Sitzungs-Tokens gehören in den Authorization-Header")
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
token = r.Header.Get("X-API-Key")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
"github.com/sandrodach/mindsift/internal/ws"
|
||||
)
|
||||
|
||||
func TestDemoSendsNothingAndKeepsAccounts(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "demo.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()
|
||||
token := sessionLogin(t, router, "admin", "admin", "visitor-1")
|
||||
_ = sessionLogin(t, router, "admin", "admin", "visitor-2")
|
||||
|
||||
if err := srv.sendCustomWebhookNotification("https://example.com/hook", "x", "x", "", "", ""); err != errDemoOutgoing {
|
||||
t.Fatalf("webhook sender must refuse in demo, got %v", err)
|
||||
}
|
||||
if err := srv.sendDiscordNotification("https://discord.com/api/webhooks/1/x", "x", "", "", ""); err != errDemoOutgoing {
|
||||
t.Fatalf("discord sender must refuse in demo, got %v", err)
|
||||
}
|
||||
if err := srv.sendEmailNotification("a@example.com", "x", "x"); err != errDemoOutgoing {
|
||||
t.Fatalf("mail sender must refuse in demo, got %v", err)
|
||||
}
|
||||
blocked := []struct{ method, path, body string }{
|
||||
{"POST", "/api/v1/auth/alarms/test", `{"channel":"webhook","target":"https://example.com/hook"}`},
|
||||
{"PUT", "/api/v1/admin/settings", `{"allowPrivateWebhooks":true}`},
|
||||
{"POST", "/api/v1/admin/smtp/test", `{"toEmail":"a@example.com"}`},
|
||||
{"POST", "/api/v1/admin/backups", ``},
|
||||
{"POST", "/api/v1/auth/revoke-all", ``},
|
||||
{"DELETE", "/api/v1/user/account", `{"password":"admin"}`},
|
||||
{"DELETE", "/api/v1/admin/users/" + storage.DemoUserID, ``},
|
||||
{"PATCH", "/api/v1/auth/settings", `{"password":"neuesPasswort1","currentPassword":"admin"}`},
|
||||
}
|
||||
for _, b := range blocked {
|
||||
rec := sessionCall(router, b.method, b.path, token, b.body, "")
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Demo") {
|
||||
t.Fatalf("%s %s: want 403 demo notice, got %d %s", b.method, b.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Harmless profile changes still work
|
||||
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", token, `{"displayName":"Besucher"}`, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("display name in demo: %d", rec.Code)
|
||||
}
|
||||
// Visitors only see their own device, not the others' addresses
|
||||
var list []storage.Session
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", token, "", "").Body.Bytes(), &list)
|
||||
if len(list) != 1 || !list[0].Current {
|
||||
t.Fatalf("demo session list must only show the own device, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPPasswordNeverReturned(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", token,
|
||||
`{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":"geheim123"}`, "")
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "geheim123") || !strings.Contains(rec.Body.String(), `"smtpPassSet":true`) {
|
||||
t.Fatalf("save: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := sessionCall(router, "GET", "/api/v1/admin/settings", token, "", "").Body.String(); strings.Contains(body, "geheim123") {
|
||||
t.Fatalf("GET leaks the password: %s", body)
|
||||
}
|
||||
// Saving again with the empty field keeps it
|
||||
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":""}`, "")
|
||||
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "geheim123" {
|
||||
t.Fatalf("empty field must keep the password, got %q", sys.SMTPPass)
|
||||
}
|
||||
// Without SMTP user the password is dropped
|
||||
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"","smtpPass":""}`, "")
|
||||
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "" {
|
||||
t.Fatalf("password without user must be cleared, got %q", sys.SMTPPass)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedAccountLosesServerBackups(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
root := srv.backupRoot()
|
||||
gone, _ := newTestUser(t, store, "gone@example.com")
|
||||
stays, _ := newTestUser(t, store, "stays@example.com")
|
||||
for _, id := range []string{gone.ID, stays.ID} {
|
||||
dir := storage.UserBackupDir(root, id)
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "backup.zip"), []byte("x"), 0o600)
|
||||
}
|
||||
if rec := sessionCall(srv.Router(), "DELETE", "/api/v1/admin/users/"+gone.ID, token, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete user: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(storage.UserBackupDir(root, gone.ID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("backups of the deleted account must be gone: %v", err)
|
||||
}
|
||||
for _, odd := range []string{"", ".", "/", "..", "../users"} {
|
||||
srv.removeUserBackups(odd)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(storage.UserBackupDir(root, stays.ID), "backup.zip")); err != nil {
|
||||
t.Fatalf("other accounts' backups must stay: %v", err)
|
||||
}
|
||||
}
|
||||
+13
-12
@@ -51,12 +51,12 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
|
||||
store.OnTaskCreated = func(th *domain.Thought) {
|
||||
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
|
||||
}
|
||||
go srv.startAlarmTicker()
|
||||
go srv.startCompaction()
|
||||
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
|
||||
go srv.startDemoResetTicker()
|
||||
}
|
||||
if !cfg.DemoMode {
|
||||
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
|
||||
go srv.startBackupScheduler()
|
||||
}
|
||||
return srv
|
||||
@@ -84,6 +84,7 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
r.Use(s.clientIPMiddleware)
|
||||
r.Use(s.securityHeaders)
|
||||
r.Use(bodyLimit)
|
||||
r.Use(s.demoResetGate)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(s.apiLoggingMiddleware)
|
||||
@@ -126,11 +127,11 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
|
||||
r.Get("/api/v1/auth/sessions", s.handleListSessions)
|
||||
r.Delete("/api/v1/auth/sessions/{id}", s.handleEndSession)
|
||||
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
|
||||
r.Post("/api/v1/auth/logout", s.handleLogout)
|
||||
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
|
||||
r.Post("/api/v1/auth/revoke-all", s.notInDemo(s.handleRevokeAllSessions))
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||||
r.Post("/api/v1/auth/alarms/test", s.notInDemo(s.handleTestUserAlarm))
|
||||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||||
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
|
||||
r.Get("/api/v1/stats", s.handleStats)
|
||||
@@ -142,7 +143,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
||||
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
|
||||
r.Delete("/api/v1/user/data", s.handlePurgeOwnData)
|
||||
r.Delete("/api/v1/user/account", s.handleDeleteOwnAccount)
|
||||
r.Delete("/api/v1/user/account", s.notInDemo(s.handleDeleteOwnAccount))
|
||||
r.Post("/api/v1/import/obsidian", s.handleImportObsidian)
|
||||
r.Get("/api/v1/themes", s.handleGetThemes)
|
||||
r.Post("/api/v1/themes", s.handleSaveTheme)
|
||||
@@ -251,10 +252,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.staffMiddleware)
|
||||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||||
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
|
||||
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
|
||||
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
|
||||
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
|
||||
r.Post("/api/v1/admin/users", s.notInDemo(s.handleAdminCreateUser))
|
||||
r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser))
|
||||
r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword))
|
||||
r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser))
|
||||
|
||||
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||||
@@ -266,9 +267,9 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||||
r.Get("/api/v1/admin/backups", s.handleAdminListBackups)
|
||||
r.Post("/api/v1/admin/backups", s.handleAdminRunBackup)
|
||||
r.Post("/api/v1/admin/backups", s.notInDemo(s.handleAdminRunBackup))
|
||||
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
|
||||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||||
r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings))
|
||||
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
|
||||
|
||||
// Organizations decide who can see whom; only server admins manage them
|
||||
@@ -277,7 +278,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
|
||||
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
|
||||
r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization)
|
||||
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
|
||||
r.Post("/api/v1/admin/smtp/test", s.notInDemo(s.handleAdminTestSMTP))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -58,6 +58,16 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if s.cfg.DemoMode {
|
||||
// Demo visitors share the accounts; the others' devices and addresses are none of their business
|
||||
own := list[:0]
|
||||
for _, sess := range list {
|
||||
if sess.Current {
|
||||
own = append(own, sess)
|
||||
}
|
||||
}
|
||||
list = own
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
|
||||
@@ -285,10 +285,13 @@ type SystemSettings struct {
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
SMTPFrom string `json:"smtpFrom"`
|
||||
SMTPTLS bool `json:"smtpTls"`
|
||||
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
|
||||
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
|
||||
// writing it empty keeps the stored password
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
SMTPPassSet bool `json:"smtpPassSet,omitempty"`
|
||||
SMTPFrom string `json:"smtpFrom"`
|
||||
SMTPTLS bool `json:"smtpTls"`
|
||||
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
|
||||
// AllowPrivateWebhooks lets alarm webhooks reach LAN addresses (and plain http); localhost and cloud
|
||||
// metadata stay blocked
|
||||
AllowPrivateWebhooks bool `json:"allowPrivateWebhooks"`
|
||||
|
||||
Reference in new issue
Block a user