openModal(blob)}
- on:keypress={(e) => e.key === "Enter" && openModal(blob)}
- role="button"
- tabindex="0"
- >
-
- {getMimeIcon(blob.type)}
-
-
-
- {truncateHash(blob.sha256)}
+ {#if isAdminView && !selectedAdminUser}
+
+ {#if isLoadingAdmin}
+
Loading user statistics...
+ {:else if adminUserStats.length === 0}
+
+
No users have uploaded files yet.
+
+ {:else}
+
+ {#each adminUserStats as userStat}
+
selectUser(userStat)}
+ on:keypress={(e) => e.key === "Enter" && selectUser(userStat)}
+ role="button"
+ tabindex="0"
+ >
+
+ {#if userStat.profile?.picture}
+

+ {:else}
+
+ {/if}
-
-
- {formatDate(blob.uploaded)}
-
-
+ {/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"
>
- X
-
-
- {/each}
-
+
+ {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