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

@@ -1,5 +1,7 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import { npubEncode } from "nostr-tools/nip19";
import { fetchUserProfile } from "./nostr.js";
export let isLoggedIn = false;
export let userPubkey = "";
@@ -26,7 +28,15 @@
const MAX_ZOOM = 4;
const ZOOM_STEP = 0.25;
// Admin view state
let isAdminView = false;
let adminUserStats = [];
let isLoadingAdmin = false;
let selectedAdminUser = null;
let selectedUserBlobs = [];
$: canAccess = isLoggedIn && userPubkey;
$: isAdmin = currentEffectiveRole === "admin" || currentEffectiveRole === "owner";
// Track if we've loaded once to prevent repeated loads
let hasLoadedOnce = false;
@@ -299,6 +309,121 @@
error = `Failed to upload: ${failed.map(f => f.name).join(", ")}`;
}
}
// Admin functions
function hexToNpub(pubkeyHex) {
try {
return npubEncode(pubkeyHex);
} catch (e) {
return truncateHash(pubkeyHex);
}
}
function truncateNpub(npub) {
if (!npub) return "";
return `${npub.slice(0, 12)}...${npub.slice(-8)}`;
}
async function fetchAdminUserStats() {
isLoadingAdmin = true;
error = "";
try {
const url = `${window.location.origin}/blossom/admin/users`;
const authHeader = await createBlossomAuth(userSigner, "admin");
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) {
throw new Error(`Failed to load user stats: ${response.statusText}`);
}
adminUserStats = await response.json();
// Fetch profiles for each user (non-blocking)
for (const stat of adminUserStats) {
fetchUserProfile(stat.pubkey).then(profile => {
stat.profile = profile || { name: "", picture: "" };
adminUserStats = adminUserStats; // trigger reactivity
}).catch(() => {
stat.profile = { name: "", picture: "" };
});
}
} catch (err) {
console.error("Error fetching admin user stats:", err);
error = err.message || "Failed to load user stats";
} finally {
isLoadingAdmin = false;
}
}
async function loadUserBlobs(pubkeyHex) {
isLoading = true;
error = "";
try {
const url = `${window.location.origin}/blossom/list/${pubkeyHex}`;
const authHeader = await createBlossomAuth(userSigner, "list");
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) {
throw new Error(`Failed to load user blobs: ${response.statusText}`);
}
selectedUserBlobs = await response.json();
selectedUserBlobs.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
} catch (err) {
console.error("Error loading user blobs:", err);
error = err.message || "Failed to load user blobs";
} finally {
isLoading = false;
}
}
function enterAdminView() {
isAdminView = true;
fetchAdminUserStats();
}
function exitAdminView() {
isAdminView = false;
adminUserStats = [];
selectedAdminUser = null;
selectedUserBlobs = [];
}
async function selectUser(userStat) {
selectedAdminUser = {
pubkey: userStat.pubkey,
profile: userStat.profile
};
await loadUserBlobs(userStat.pubkey);
}
function exitUserView() {
selectedAdminUser = null;
selectedUserBlobs = [];
}
function handleRefresh() {
if (selectedAdminUser) {
loadUserBlobs(selectedAdminUser.pubkey);
} else if (isAdminView) {
fetchAdminUserStats();
} else {
loadBlobs();
}
}
function getDisplayBlobs() {
if (selectedAdminUser) {
return selectedUserBlobs;
}
return blobs;
}
</script>
<svelte:window on:keydown={handleKeydown} />
@@ -306,12 +431,38 @@
{#if canAccess}
<div class="blossom-view">
<div class="header-section">
{#if selectedAdminUser}
<button class="back-btn" on:click={exitUserView}>
&larr; Back
</button>
<h3 class="user-header">
{#if selectedAdminUser.profile?.picture}
<img src={selectedAdminUser.profile.picture} alt="" class="header-avatar" />
{/if}
{selectedAdminUser.profile?.name || truncateNpub(hexToNpub(selectedAdminUser.pubkey))}
</h3>
{:else if isAdminView}
<button class="back-btn" on:click={exitAdminView}>
&larr; Back
</button>
<h3>All Users Storage</h3>
{:else}
<h3>Blossom Media Storage</h3>
<button class="refresh-btn" on:click={loadBlobs} disabled={isLoading}>
{isLoading ? "Loading..." : "Refresh"}
{/if}
<div class="header-buttons">
{#if isAdmin && !isAdminView && !selectedAdminUser}
<button class="admin-btn" on:click={enterAdminView} disabled={isLoading}>
Admin
</button>
{/if}
<button class="refresh-btn" on:click={handleRefresh} disabled={isLoading || isLoadingAdmin}>
{isLoading || isLoadingAdmin ? "Loading..." : "Refresh"}
</button>
</div>
</div>
{#if !isAdminView && !selectedAdminUser}
<div class="upload-section">
<input
type="file"
@@ -334,6 +485,7 @@
</button>
{/if}
</div>
{/if}
{#if error}
<div class="error-message">
@@ -341,15 +493,58 @@
</div>
{/if}
{#if isLoading && blobs.length === 0}
<div class="loading">Loading blobs...</div>
{:else if blobs.length === 0}
{#if isAdminView && !selectedAdminUser}
<!-- Admin users list view -->
{#if isLoadingAdmin}
<div class="loading">Loading user statistics...</div>
{:else if adminUserStats.length === 0}
<div class="empty-state">
<p>No files found in your Blossom storage.</p>
<p>No users have uploaded files yet.</p>
</div>
{:else}
<div class="admin-users-list">
{#each adminUserStats as userStat}
<div
class="user-stat-item"
on:click={() => selectUser(userStat)}
on:keypress={(e) => e.key === "Enter" && selectUser(userStat)}
role="button"
tabindex="0"
>
<div class="user-avatar-container">
{#if userStat.profile?.picture}
<img src={userStat.profile.picture} alt="" class="user-avatar" />
{:else}
<div class="user-avatar-placeholder"></div>
{/if}
</div>
<div class="user-info">
<div class="user-name">
{userStat.profile?.name || truncateNpub(hexToNpub(userStat.pubkey))}
</div>
<div class="user-npub" title={userStat.pubkey}>
{truncateNpub(hexToNpub(userStat.pubkey))}
</div>
</div>
<div class="user-stats">
<span class="blob-count">{userStat.blob_count} files</span>
<span class="total-size">{formatSize(userStat.total_size_bytes)}</span>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<!-- Normal blob list view (own files or selected user's files) -->
{#if isLoading && getDisplayBlobs().length === 0}
<div class="loading">Loading blobs...</div>
{:else if getDisplayBlobs().length === 0}
<div class="empty-state">
<p>{selectedAdminUser ? "No files found for this user." : "No files found in your Blossom storage."}</p>
</div>
{:else}
<div class="blob-list">
{#each blobs as blob}
{#each getDisplayBlobs() as blob}
<div
class="blob-item"
on:click={() => openModal(blob)}
@@ -383,6 +578,7 @@
{/each}
</div>
{/if}
{/if}
</div>
{:else}
<div class="login-prompt">
@@ -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;

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
}

View File

@@ -1 +1 @@
v0.36.20
v0.36.21