From 5ca030e5f93b05b22eb99e3b73fbbfa1e7b88fd4 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 17 Jul 2026 05:58:16 +0800 Subject: [PATCH] feat(web): add bounded admin data views --- internal/server/locale.go | 2 ++ internal/server/routes.go | 1 + internal/server/web/templates/admin.html | 2 +- .../server/web/templates/vault_detail.html | 2 ++ internal/server/web_admin.go | 30 +++++++++++++++-- internal/server/web_locale_test.go | 32 +++++++++++++++++++ internal/server/web_render.go | 10 ++++-- 7 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 internal/server/web/templates/vault_detail.html diff --git a/internal/server/locale.go b/internal/server/locale.go index ece628a..1caa10a 100644 --- a/internal/server/locale.go +++ b/internal/server/locale.go @@ -115,6 +115,7 @@ var _translations = map[string]map[string]string{ "admin.next": "Далее", "admin.runCleanup": "Запустить безопасную очистку", "admin.downloadDiagnostics": "Скачать диагностику", + "admin.vaultPrivacy": "Содержимое файлов и операции не отображаются в диагностике.", "user.account": "Моя учётная запись", "user.devices": "Подключённые устройства", "user.noDevices": "Устройств пока нет", @@ -353,6 +354,7 @@ var _translations = map[string]map[string]string{ "admin.next": "Next", "admin.runCleanup": "Run safe cleanup", "admin.downloadDiagnostics": "Download diagnostics", + "admin.vaultPrivacy": "File contents and operations are not displayed in diagnostics.", "user.account": "My account", "user.devices": "Connected devices", "user.noDevices": "No devices yet", diff --git a/internal/server/routes.go b/internal/server/routes.go index 6c00dee..e8dd06c 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -43,6 +43,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/admin/api/users/create", s.handleAdminAPICreateUser) s.mux.HandleFunc("/admin/devices", s.handleAdminWeb) s.mux.HandleFunc("/admin/vaults", s.handleAdminWeb) + s.mux.HandleFunc("/admin/vault/", s.handleAdminVaultDetail) s.mux.HandleFunc("/admin/storage", s.handleAdminWeb) s.mux.HandleFunc("/admin/audit", s.handleAdminWeb) s.mux.HandleFunc("/admin/settings", s.handleAdminWeb) diff --git a/internal/server/web/templates/admin.html b/internal/server/web/templates/admin.html index bc319e9..7bef216 100644 --- a/internal/server/web/templates/admin.html +++ b/internal/server/web/templates/admin.html @@ -18,7 +18,7 @@ {{if eq .AdminPage "dashboard"}}

{{t .Locale "admin.overview"}}

{{t .Locale "admin.dashboard"}}

{{.Stats.Users}}{{t .Locale "admin.users"}}
{{.Stats.ActiveDevices}}{{t .Locale "admin.activeDevices"}}
{{.Stats.Vaults}}{{t .Locale "admin.vaults"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}}

{{t .Locale "admin.serviceHealth"}}

{{t .Locale "admin.status"}}
{{.Health.Status}}
{{t .Locale "admin.version"}}
{{.Health.Version}} {{.Health.BuildCommit}}
{{t .Locale "admin.lastActivity"}}
{{.Stats.LastSyncAt}}
{{else if eq .AdminPage "users"}}

{{t .Locale "admin.access"}}

{{t .Locale "admin.users"}}

{{t .Locale "admin.createUser"}}
{{range .AdminUsers}}{{else}}{{end}}
{{t .Locale "field.username"}}{{t .Locale "field.email"}}{{t .Locale "admin.devices"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Username}}{{.Email}}{{.Devices}}{{if .Blocked}}{{t $.Locale "admin.blocked"}}{{else if .Confirmed}}{{t $.Locale "device.active"}}{{else}}{{t $.Locale "admin.unconfirmed"}}{{end}}
{{t $.Locale "admin.manage"}}
{{t .Locale "admin.noUsers"}}
{{else if eq .AdminPage "devices"}}

{{t .Locale "admin.access"}}

{{t .Locale "admin.devices"}}

{{range .AdminDevices}}{{else}}{{end}}
{{t .Locale "device.name"}}{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "device.lastSeen"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Name}}{{.User}}{{short .Vault 16}}{{.LastSeen}}{{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}}{{if not .Revoked}}
{{end}}
{{t .Locale "admin.noDevices"}}
- {{else if eq .AdminPage "vaults"}}

{{t .Locale "admin.storage"}}

{{t .Locale "admin.vaults"}}

{{range .Vaults}}{{else}}{{end}}
{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "admin.devices"}}{{t .Locale "admin.operations"}}{{t .Locale "admin.lastActivity"}}
{{.User}}{{short .Vault 24}}{{.Devices}}{{.Operations}}{{.LastActivity}}
{{t .Locale "admin.noVaults"}}
+ {{else if eq .AdminPage "vaults"}}

{{t .Locale "admin.storage"}}

{{t .Locale "admin.vaults"}}

{{range .Vaults}}{{else}}{{end}}
{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "admin.devices"}}{{t .Locale "admin.operations"}}{{t .Locale "admin.lastActivity"}}
{{.User}}{{short .Vault 24}}{{.Devices}}{{.Operations}}{{.LastActivity}}
{{t .Locale "admin.noVaults"}}
{{else if eq .AdminPage "storage"}}

{{t .Locale "admin.storage"}}

{{t .Locale "admin.storage"}}

{{.Stats.DatabaseBytes}}{{t .Locale "admin.databaseBytes"}}
{{.Stats.BlobBytes}}{{t .Locale "admin.blobBytes"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}}

{{t .Locale "admin.retentionNote"}}

{{else if eq .AdminPage "audit"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.audit"}}

{{range .Audit}}{{else}}{{end}}
{{t .Locale "admin.event"}}{{t .Locale "admin.user"}}{{t .Locale "device.name"}}{{t .Locale "admin.time"}}
{{.Event}}{{.User}}{{.Device}}{{.At}}
{{t .Locale "admin.noAudit"}}
{{else if eq .AdminPage "settings"}}

{{t .Locale "admin.settings"}}

{{t .Locale "admin.smtpTitle"}}

diff --git a/internal/server/web/templates/vault_detail.html b/internal/server/web/templates/vault_detail.html new file mode 100644 index 0000000..4a6a4ff --- /dev/null +++ b/internal/server/web/templates/vault_detail.html @@ -0,0 +1,2 @@ +{{define "vault_detail"}}{{template "layout" .}}{{end}} +{{define "content"}}

{{t .Locale "admin.vaults"}}

{{short .VaultDetail.Vault 32}}

{{.VaultDetail.User}}

{{.VaultDetail.Devices}}{{t .Locale "admin.devices"}}
{{.VaultDetail.Operations}}{{t .Locale "admin.operations"}}
{{.VaultDetail.BlobBytes}}{{t .Locale "admin.blobBytes"}}
{{t .Locale "admin.lastActivity"}}
{{.VaultDetail.LastActivity}}

{{t .Locale "admin.vaultPrivacy"}}

{{t .Locale "common.back"}}
{{end}} diff --git a/internal/server/web_admin.go b/internal/server/web_admin.go index 983cc17..a4c1f7a 100644 --- a/internal/server/web_admin.go +++ b/internal/server/web_admin.go @@ -21,6 +21,32 @@ func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/dashboard", http.StatusFound) } +func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } + if !s.requireAdminCookie(w, r) { + return + } + userID, vaultID := r.URL.Query().Get("user"), r.URL.Query().Get("vault") + if userID == "" || vaultID == "" { + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/vaults") + return + } + var d webVaultDetail + if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT o.op_id), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Operations, &d.LastActivity); err != nil { + jsonInternalError(w, err) + return + } + d.Vault = vaultID + if err := s.db.QueryRow(`SELECT COALESCE(SUM(size),0) FROM server_blob_refs WHERE user_id=? AND vault_id=?`, userID, vaultID).Scan(&d.BlobBytes); err != nil { + jsonInternalError(w, err) + return + } + s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, VaultDetail: d}) +} + func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(w, r) { return @@ -234,7 +260,7 @@ func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error } func (s *Server) webVaults() ([]webVault, error) { - rows, err := s.db.Query(`SELECT COALESCE(u.username,''),d.vault_id,COUNT(DISTINCT d.id),COUNT(o.op_id),COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE COALESCE(d.user_id,'')!='' AND COALESCE(d.vault_id,'')!='' GROUP BY d.user_id,d.vault_id ORDER BY MAX(d.last_seen) DESC`) + rows, err := s.db.Query(`SELECT COALESCE(u.username,''),d.user_id,d.vault_id,COUNT(DISTINCT d.id),COUNT(o.op_id),COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE COALESCE(d.user_id,'')!='' AND COALESCE(d.vault_id,'')!='' GROUP BY d.user_id,d.vault_id ORDER BY MAX(d.last_seen) DESC`) if err != nil { return nil, err } @@ -242,7 +268,7 @@ func (s *Server) webVaults() ([]webVault, error) { var out []webVault for rows.Next() { var v webVault - if err := rows.Scan(&v.User, &v.Vault, &v.Devices, &v.Operations, &v.LastActivity); err != nil { + if err := rows.Scan(&v.User, &v.UserID, &v.Vault, &v.Devices, &v.Operations, &v.LastActivity); err != nil { return nil, err } out = append(out, v) diff --git a/internal/server/web_locale_test.go b/internal/server/web_locale_test.go index bffc93a..1762a60 100644 --- a/internal/server/web_locale_test.go +++ b/internal/server/web_locale_test.go @@ -155,6 +155,38 @@ func TestWebSessionScopesDoNotCrossAuthorizePages(t *testing.T) { } } +func TestAdminVaultDetailIsScopedAndDoesNotExposePayload(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.SetupRoutes() + if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','a@example.test','hash',1,'2026-01-01T00:00:00Z')"); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES ('d1','Laptop','legacy','u1','vault-a','2026-01-01T00:00:00Z')"); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec("INSERT INTO server_ops (op_id,server_sequence,user_id,vault_id,device_id,entity_type,entity_id,op_type,payload_json,created_at,pushed_at) VALUES ('op1',1,'u1','vault-a','d1','file','x','create','{\"secret\":\"payload\"}','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')"); err != nil { + t.Fatal(err) + } + token, _, err := s.createSession(sessionScopeAdmin, "admin") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/admin/vault/?user=u1&vault=vault-a", nil) + req.AddCookie(&http.Cookie{Name: "admin_session", Value: token}) + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("vault detail=%d: %s", res.Code, res.Body.String()) + } + if strings.Contains(res.Body.String(), "payload") || strings.Contains(res.Body.String(), "secret") { + t.Fatalf("vault detail leaked operation payload: %s", res.Body.String()) + } +} + func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) { s, err := newTestServer(t) if err != nil { diff --git a/internal/server/web_render.go b/internal/server/web_render.go index dc4aab0..924bf70 100644 --- a/internal/server/web_render.go +++ b/internal/server/web_render.go @@ -49,6 +49,7 @@ type webPage struct { Audit []webAudit SMTP webSMTP List webList + VaultDetail webVaultDetail } type webAdminUser struct { @@ -61,7 +62,7 @@ type webAdminDevice struct { Revoked bool } type webVault struct { - User, Vault string + User, UserID, Vault string Devices, Operations int LastActivity string } @@ -78,6 +79,11 @@ type webList struct { Previous int Next int } +type webVaultDetail struct { + User, Vault, LastActivity string + Devices, Operations int + BlobBytes int64 +} type webDevice struct { ID string @@ -105,7 +111,7 @@ func newWebRenderer() (*webRenderer, error) { return nil, err } renderer := &webRenderer{templates: make(map[string]*template.Template)} - for _, page := range []string{"home", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user"} { + for _, page := range []string{"home", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user", "vault_detail"} { clone, err := layout.Clone() if err != nil { return nil, err