feat(web): add bounded admin data views
This commit is contained in:
parent
f82d3b0224
commit
5ca030e5f9
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
{{if eq .AdminPage "dashboard"}}<p class="eyebrow">{{t .Locale "admin.overview"}}</p><h1>{{t .Locale "admin.dashboard"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.Users}}</strong><span>{{t .Locale "admin.users"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}}</span></article><article class="card stat"><strong>{{.Stats.Vaults}}</strong><span>{{t .Locale "admin.vaults"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.Stats.LastSyncAt}}</dd></dl></section>
|
||||
{{else if eq .AdminPage "users"}}<div class="section-heading"><div><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.users"}}</h1></div><a class="button primary" href="/admin/create-user">{{t .Locale "admin.createUser"}}</a></div><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "field.username"}}</th><th>{{t .Locale "field.email"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminUsers}}<tr><td>{{.Username}}</td><td>{{.Email}}</td><td>{{.Devices}}</td><td>{{if .Blocked}}<span class="badge danger">{{t $.Locale "admin.blocked"}}</span>{{else if .Confirmed}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{else}}<span class="badge">{{t $.Locale "admin.unconfirmed"}}</span>{{end}}</td><td><form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="toggle-user"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button secondary" type="submit">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</button></form><details><summary>{{t $.Locale "admin.manage"}}</summary><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="edit-user"><label>{{t $.Locale "field.username"}}<input name="username" value="{{.Username}}" required></label><label>{{t $.Locale "field.email"}}<input name="email" type="email" value="{{.Email}}" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.saveUser"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="reset-user-password"><label>{{t $.Locale "field.newPassword"}}<input name="new_password" type="password" required></label><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.resetPassword"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="delete-user"><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button danger" type="submit">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "devices"}}<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminDevices}}<tr><td>{{.Name}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="revoke-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="6" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "vaults"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono">{{short .Vault 24}}</td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "vaults"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono"><a href="/admin/vault/?user={{.UserID}}&vault={{.Vault}}">{{short .Vault 24}}</a></td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "storage"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{.Stats.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><p class="muted">{{t .Locale "admin.retentionNote"}}</p><form method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><input name="password" type="password" required placeholder="{{t .Locale "field.password"}}"><button class="button secondary" type="submit">{{t .Locale "admin.runCleanup"}}</button></form>
|
||||
{{else if eq .AdminPage "audit"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{.Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.At}}</td></tr>{{else}}<tr><td colspan="4" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "settings"}}<p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.smtpTitle"}}</h1><section class="card panel"><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp"><label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}"></label><label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" value="{{.SMTP.Port}}"></label><label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}"></label><label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label><label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>STARTTLS</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>TLS</option></select></label><label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" value="{{.SMTP.From}}"></label><label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" value="{{.SMTP.ServerURL}}"></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button></form></section>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
{{define "vault_detail"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p><div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article><article class="card stat"><strong>{{.VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p><a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue