Add Blossom admin UI for viewing all users' storage (v0.36.21)
Some checks failed
Go / build-and-release (push) Has been cancelled

- Add ListAllUserStats() storage method to aggregate user blob stats
- Add handleAdminListUsers() handler for admin endpoint
- Add /blossom/admin/users route requiring admin ACL
- Add Admin button to Blossom UI for admin/owner roles
- Add admin view showing all users with file counts and sizes
- Add user detail view to browse individual user's files
- Fetch user profiles (avatar, name) for admin list display

Files modified:
- pkg/blossom/storage.go: Add UserBlobStats struct and ListAllUserStats()
- pkg/blossom/handlers.go: Add handleAdminListUsers() handler
- pkg/blossom/server.go: Add admin/users route
- app/web/src/BlossomView.svelte: Add admin view state, UI, and styles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-25 12:04:35 +01:00
parent 96209bd8a5
commit ab2ac1bf4c
5 changed files with 505 additions and 66 deletions

View File

@@ -474,6 +474,42 @@ func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) {
}
}
// handleAdminListUsers handles GET /admin/users requests (admin only)
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
// Authorization required
authEv, err := ValidateAuthEvent(r, "admin", nil)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
if authEv == nil {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Check admin ACL
remoteAddr := s.getRemoteAddr(r)
if !s.checkACL(authEv.Pubkey, remoteAddr, "admin") {
s.setErrorResponse(w, http.StatusForbidden, "admin access required")
return
}
// Get all user stats
stats, err := s.storage.ListAllUserStats()
if err != nil {
log.E.F("error listing user stats: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
// Return JSON
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err = json.NewEncoder(w).Encode(stats); err != nil {
log.E.F("error encoding response: %v", err)
}
}
// handleDeleteBlob handles DELETE /<sha256> requests (BUD-02)
func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")

View File

@@ -108,6 +108,14 @@ func (s *Server) Handler() http.Handler {
s.handleReport(w, r)
return
case path == "admin/users":
if r.Method == http.MethodGet {
s.handleAdminListUsers(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
case strings.HasPrefix(path, "list/"):
if r.Method == http.MethodGet {
s.handleListBlobs(w, r)

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
@@ -453,3 +455,73 @@ func (s *Storage) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, er
return
}
// UserBlobStats represents storage statistics for a single user
type UserBlobStats struct {
PubkeyHex string `json:"pubkey"`
BlobCount int64 `json:"blob_count"`
TotalSizeBytes int64 `json:"total_size_bytes"`
}
// ListAllUserStats returns storage statistics for all users who have uploaded blobs
func (s *Storage) ListAllUserStats() (stats []*UserBlobStats, err error) {
statsMap := make(map[string]*UserBlobStats)
if err = s.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefixBlobIndex)
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key())
// Key format: blob:index:<pubkey-hex>:<sha256-hex>
remainder := key[len(prefixBlobIndex):]
parts := strings.SplitN(remainder, ":", 2)
if len(parts) != 2 {
continue
}
pubkeyHex := parts[0]
sha256Hex := parts[1]
// Get or create stats entry
stat, ok := statsMap[pubkeyHex]
if !ok {
stat = &UserBlobStats{PubkeyHex: pubkeyHex}
statsMap[pubkeyHex] = stat
}
stat.BlobCount++
// Get blob size from metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, errGet := txn.Get([]byte(metaKey))
if errGet != nil {
continue
}
metaItem.Value(func(val []byte) error {
metadata, errDeser := DeserializeBlobMetadata(val)
if errDeser == nil {
stat.TotalSizeBytes += metadata.Size
}
return nil
})
}
return nil
}); chk.E(err) {
return
}
// Convert map to slice
stats = make([]*UserBlobStats, 0, len(statsMap))
for _, stat := range statsMap {
stats = append(stats, stat)
}
// Sort by total size descending
sort.Slice(stats, func(i, j int) bool {
return stats[i].TotalSizeBytes > stats[j].TotalSizeBytes
})
return
}