From ab2ac1bf4cec6ec60e4e41f5e29c35f0a855e262 Mon Sep 17 00:00:00 2001 From: mleku Date: Thu, 25 Dec 2025 12:04:35 +0100 Subject: [PATCH] Add Blossom admin UI for viewing all users' storage (v0.36.21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/web/src/BlossomView.svelte | 453 ++++++++++++++++++++++++++++----- pkg/blossom/handlers.go | 36 +++ pkg/blossom/server.go | 8 + pkg/blossom/storage.go | 72 ++++++ pkg/version/version | 2 +- 5 files changed, 505 insertions(+), 66 deletions(-) diff --git a/app/web/src/BlossomView.svelte b/app/web/src/BlossomView.svelte index 7e184d0..23c4534 100644 --- a/app/web/src/BlossomView.svelte +++ b/app/web/src/BlossomView.svelte @@ -1,5 +1,7 @@ @@ -306,34 +431,61 @@ {#if canAccess}
-

Blossom Media Storage

- + {#if selectedAdminUser} + +

+ {#if selectedAdminUser.profile?.picture} + + {/if} + {selectedAdminUser.profile?.name || truncateNpub(hexToNpub(selectedAdminUser.pubkey))} +

+ {:else if isAdminView} + +

All Users Storage

+ {:else} +

Blossom Media Storage

+ {/if} + +
+ {#if isAdmin && !isAdminView && !selectedAdminUser} + + {/if} + +
-
- - - {#if selectedFiles.length > 0} - {selectedFiles.length} file(s) selected - - {/if} -
+ {#if selectedFiles.length > 0} + {selectedFiles.length} file(s) selected + + {/if} +
+ {/if} {#if error}
@@ -341,47 +493,91 @@
{/if} - {#if isLoading && blobs.length === 0} -
Loading blobs...
- {:else if blobs.length === 0} -
-

No files found in your Blossom storage.

-
- {:else} -
- {#each blobs as blob} -
openModal(blob)} - on:keypress={(e) => e.key === "Enter" && openModal(blob)} - role="button" - tabindex="0" - > -
- {getMimeIcon(blob.type)} -
-
-
- {truncateHash(blob.sha256)} -
-
- {formatSize(blob.size)} - {blob.type || "unknown"} -
-
-
- {formatDate(blob.uploaded)} -
- -
- {/each} -
+
+ {#if userStat.profile?.picture} + + {:else} +
+ {/if} +
+ +
+ {userStat.blob_count} files + {formatSize(userStat.total_size_bytes)} +
+ + {/each} + + {/if} + {:else} + + {#if isLoading && getDisplayBlobs().length === 0} +
Loading blobs...
+ {:else if getDisplayBlobs().length === 0} +
+

{selectedAdminUser ? "No files found for this user." : "No files found in your Blossom storage."}

+
+ {:else} +
+ {#each getDisplayBlobs() as blob} +
openModal(blob)} + on:keypress={(e) => e.key === "Enter" && openModal(blob)} + role="button" + tabindex="0" + > +
+ {getMimeIcon(blob.type)} +
+
+
+ {truncateHash(blob.sha256)} +
+
+ {formatSize(blob.size)} + {blob.type || "unknown"} +
+
+
+ {formatDate(blob.uploaded)} +
+ +
+ {/each} +
+ {/if} {/if} {:else} @@ -495,6 +691,60 @@ .header-section h3 { margin: 0; color: var(--text-color); + flex: 1; + } + + .header-buttons { + display: flex; + align-items: center; + gap: 0.5em; + } + + .back-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-color); + padding: 0.5em 1em; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + margin-right: 0.5em; + } + + .back-btn:hover { + background-color: var(--sidebar-bg); + } + + .admin-btn { + background-color: var(--primary); + color: var(--text-color); + border: none; + padding: 0.5em 1em; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + } + + .admin-btn:hover:not(:disabled) { + background-color: var(--accent-hover-color); + } + + .admin-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .user-header { + display: flex; + align-items: center; + gap: 0.5em; + } + + .header-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; } .refresh-btn { @@ -663,6 +913,79 @@ color: var(--text-color); } + /* Admin users list styles */ + .admin-users-list { + display: flex; + flex-direction: column; + gap: 0.5em; + } + + .user-stat-item { + display: flex; + align-items: center; + gap: 1em; + padding: 0.75em 1em; + background-color: var(--card-bg); + border-radius: 6px; + cursor: pointer; + transition: background-color 0.2s; + } + + .user-stat-item:hover { + background-color: var(--sidebar-bg); + } + + .user-avatar-container { + flex-shrink: 0; + } + + .user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } + + .user-avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: var(--border-color); + } + + .user-info { + flex: 1; + min-width: 0; + } + + .user-name { + font-weight: 500; + color: var(--text-color); + } + + .user-npub { + font-family: monospace; + font-size: 0.8em; + color: var(--text-color); + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-stats { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25em; + } + + .user-stats .blob-count, + .user-stats .total-size { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + } + .login-prompt { text-align: center; padding: 2em; diff --git a/pkg/blossom/handlers.go b/pkg/blossom/handlers.go index 2e4cca2..d4cc626 100644 --- a/pkg/blossom/handlers.go +++ b/pkg/blossom/handlers.go @@ -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 / requests (BUD-02) func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") diff --git a/pkg/blossom/server.go b/pkg/blossom/server.go index caa4174..b722f7d 100644 --- a/pkg/blossom/server.go +++ b/pkg/blossom/server.go @@ -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) diff --git a/pkg/blossom/storage.go b/pkg/blossom/storage.go index 0cbcbf9..edbf7c2 100644 --- a/pkg/blossom/storage.go +++ b/pkg/blossom/storage.go @@ -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:: + 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 +} diff --git a/pkg/version/version b/pkg/version/version index 2ff60e2..81294a0 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.36.20 +v0.36.21