Add Blossom admin UI for viewing all users' storage (v0.36.21)
Some checks failed
Go / build-and-release (push) Has been cancelled
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:
@@ -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, "/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.36.20
|
||||
v0.36.21
|
||||
|
||||
Reference in New Issue
Block a user