Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4468d305e | |||
| d3f2ea0f08 | |||
| 3f07e47ffb | |||
| aea8fd31e7 | |||
| 0de4137a10 | |||
| 042acd9ed2 | |||
| dddf1ac568 | |||
| d6f2a0f7cf | |||
| 7c60b63df6 | |||
| ab2ac1bf4c | |||
| 96209bd8a5 |
@@ -50,7 +50,7 @@ If no argument provided, default to `patch`.
|
|||||||
|
|
||||||
11. **Deploy to VPS** by running:
|
11. **Deploy to VPS** by running:
|
||||||
```
|
```
|
||||||
ssh 10.0.0.1 'cd ~/src/next.orly.dev && git stash && git pull origin main && export PATH=$PATH:~/go/bin && CGO_ENABLED=0 go build -o orly && sudo systemctl restart orly && ./orly version'
|
ssh 10.0.0.1 'cd ~/src/next.orly.dev && git stash && git pull origin main && export PATH=$PATH:~/go/bin && CGO_ENABLED=0 go build -o ~/.local/bin/next.orly.dev && sudo systemctl restart orly && ~/.local/bin/next.orly.dev version'
|
||||||
```
|
```
|
||||||
|
|
||||||
12. **Report completion** with the new version and commit hash
|
12. **Report completion** with the new version and commit hash
|
||||||
|
|||||||
87
app/web/dist/bundle.css
vendored
Normal file
87
app/web/dist/bundle.css
vendored
Normal file
File diff suppressed because one or more lines are too long
24
app/web/dist/bundle.js
vendored
Normal file
24
app/web/dist/bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/web/dist/bundle.js.map
vendored
Normal file
1
app/web/dist/bundle.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
app/web/dist/favicon.png
vendored
Normal file
BIN
app/web/dist/favicon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 379 KiB |
69
app/web/dist/global.css
vendored
Normal file
69
app/web/dist/global.css
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu,
|
||||||
|
Cantarell, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(0, 100, 200);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: rgb(0, 80, 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
-webkit-padding: 0.4em 0;
|
||||||
|
padding: 0.4em;
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: #333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(:disabled):active {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
BIN
app/web/dist/orly.png
vendored
Normal file
BIN
app/web/dist/orly.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 514 KiB |
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
|
import { npubEncode } from "nostr-tools/nip19";
|
||||||
|
import { fetchUserProfile } from "./nostr.js";
|
||||||
|
|
||||||
export let isLoggedIn = false;
|
export let isLoggedIn = false;
|
||||||
export let userPubkey = "";
|
export let userPubkey = "";
|
||||||
@@ -26,7 +28,15 @@
|
|||||||
const MAX_ZOOM = 4;
|
const MAX_ZOOM = 4;
|
||||||
const ZOOM_STEP = 0.25;
|
const ZOOM_STEP = 0.25;
|
||||||
|
|
||||||
|
// Admin view state
|
||||||
|
let isAdminView = false;
|
||||||
|
let adminUserStats = [];
|
||||||
|
let isLoadingAdmin = false;
|
||||||
|
let selectedAdminUser = null;
|
||||||
|
let selectedUserBlobs = [];
|
||||||
|
|
||||||
$: canAccess = isLoggedIn && userPubkey;
|
$: canAccess = isLoggedIn && userPubkey;
|
||||||
|
$: isAdmin = currentEffectiveRole === "admin" || currentEffectiveRole === "owner";
|
||||||
|
|
||||||
// Track if we've loaded once to prevent repeated loads
|
// Track if we've loaded once to prevent repeated loads
|
||||||
let hasLoadedOnce = false;
|
let hasLoadedOnce = false;
|
||||||
@@ -149,10 +159,10 @@
|
|||||||
function getMimeIcon(mimeType) {
|
function getMimeIcon(mimeType) {
|
||||||
const category = getMimeCategory(mimeType);
|
const category = getMimeCategory(mimeType);
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "image": return "";
|
case "image": return "🖼️";
|
||||||
case "video": return "";
|
case "video": return "🎬";
|
||||||
case "audio": return "";
|
case "audio": return "🎵";
|
||||||
default: return "";
|
default: return "📄";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +309,121 @@
|
|||||||
error = `Failed to upload: ${failed.map(f => f.name).join(", ")}`;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
@@ -306,34 +431,62 @@
|
|||||||
{#if canAccess}
|
{#if canAccess}
|
||||||
<div class="blossom-view">
|
<div class="blossom-view">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h3>Blossom Media Storage</h3>
|
{#if selectedAdminUser}
|
||||||
<button class="refresh-btn" on:click={loadBlobs} disabled={isLoading}>
|
<button class="back-btn" on:click={exitUserView}>
|
||||||
{isLoading ? "Loading..." : "Refresh"}
|
← Back
|
||||||
</button>
|
</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}>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h3>All Users Storage</h3>
|
||||||
|
{:else}
|
||||||
|
<h3>Blossom Media Storage</h3>
|
||||||
|
{/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>
|
</div>
|
||||||
|
|
||||||
<div class="upload-section">
|
{#if !isAdminView && !selectedAdminUser}
|
||||||
<input
|
<div class="upload-section">
|
||||||
type="file"
|
<span class="upload-label">Upload new files</span>
|
||||||
multiple
|
<input
|
||||||
bind:this={fileInput}
|
type="file"
|
||||||
on:change={handleFileSelect}
|
multiple
|
||||||
class="file-input-hidden"
|
bind:this={fileInput}
|
||||||
/>
|
on:change={handleFileSelect}
|
||||||
<button class="select-btn" on:click={triggerFileInput} disabled={isUploading}>
|
class="file-input-hidden"
|
||||||
Select Files
|
/>
|
||||||
</button>
|
{#if selectedFiles.length > 0}
|
||||||
{#if selectedFiles.length > 0}
|
<span class="selected-count">{selectedFiles.length} file(s) selected</span>
|
||||||
<span class="selected-count">{selectedFiles.length} file(s) selected</span>
|
<button
|
||||||
<button
|
class="upload-btn"
|
||||||
class="upload-btn"
|
on:click={uploadFiles}
|
||||||
on:click={uploadFiles}
|
disabled={isUploading}
|
||||||
disabled={isUploading}
|
>
|
||||||
>
|
{isUploading ? uploadProgress : "Upload"}
|
||||||
{isUploading ? uploadProgress : "Upload"}
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="select-btn" on:click={triggerFileInput} disabled={isUploading}>
|
||||||
|
Select Files
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
@@ -341,47 +494,99 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isLoading && blobs.length === 0}
|
{#if isAdminView && !selectedAdminUser}
|
||||||
<div class="loading">Loading blobs...</div>
|
<!-- Admin users list view -->
|
||||||
{:else if blobs.length === 0}
|
{#if isLoadingAdmin}
|
||||||
<div class="empty-state">
|
<div class="loading">Loading user statistics...</div>
|
||||||
<p>No files found in your Blossom storage.</p>
|
{:else if adminUserStats.length === 0}
|
||||||
</div>
|
<div class="empty-state">
|
||||||
{:else}
|
<p>No users have uploaded files yet.</p>
|
||||||
<div class="blob-list">
|
</div>
|
||||||
{#each blobs as blob}
|
{:else}
|
||||||
<div
|
<div class="admin-users-list">
|
||||||
class="blob-item"
|
{#each adminUserStats as userStat}
|
||||||
on:click={() => openModal(blob)}
|
<div
|
||||||
on:keypress={(e) => e.key === "Enter" && openModal(blob)}
|
class="user-stat-item"
|
||||||
role="button"
|
on:click={() => selectUser(userStat)}
|
||||||
tabindex="0"
|
on:keypress={(e) => e.key === "Enter" && selectUser(userStat)}
|
||||||
>
|
role="button"
|
||||||
<div class="blob-icon">
|
tabindex="0"
|
||||||
{getMimeIcon(blob.type)}
|
|
||||||
</div>
|
|
||||||
<div class="blob-info">
|
|
||||||
<div class="blob-hash" title={blob.sha256}>
|
|
||||||
{truncateHash(blob.sha256)}
|
|
||||||
</div>
|
|
||||||
<div class="blob-meta">
|
|
||||||
<span class="blob-size">{formatSize(blob.size)}</span>
|
|
||||||
<span class="blob-type">{blob.type || "unknown"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="blob-date">
|
|
||||||
{formatDate(blob.uploaded)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="delete-btn"
|
|
||||||
on:click|stopPropagation={() => deleteBlob(blob)}
|
|
||||||
title="Delete"
|
|
||||||
>
|
>
|
||||||
X
|
<div class="user-avatar-container">
|
||||||
</button>
|
{#if userStat.profile?.picture}
|
||||||
</div>
|
<img src={userStat.profile.picture} alt="" class="user-avatar" />
|
||||||
{/each}
|
{:else}
|
||||||
</div>
|
<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}>
|
||||||
|
<span class="npub-full">{hexToNpub(userStat.pubkey)}</span>
|
||||||
|
<span class="npub-truncated">{truncateNpub(hexToNpub(userStat.pubkey))}</span>
|
||||||
|
</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 getDisplayBlobs() as blob}
|
||||||
|
<div
|
||||||
|
class="blob-item"
|
||||||
|
on:click={() => openModal(blob)}
|
||||||
|
on:keypress={(e) => e.key === "Enter" && openModal(blob)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="blob-thumbnail">
|
||||||
|
{#if getMimeCategory(blob.type) === "image"}
|
||||||
|
<img src={getBlobUrl(blob)} alt="" class="thumbnail-img" />
|
||||||
|
{:else if getMimeCategory(blob.type) === "video"}
|
||||||
|
<video src={getBlobUrl(blob)} class="thumbnail-video" muted preload="metadata"></video>
|
||||||
|
{:else}
|
||||||
|
<span class="thumbnail-icon">{getMimeIcon(blob.type)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="blob-info">
|
||||||
|
<div class="blob-hash" title={blob.sha256}>
|
||||||
|
<span class="hash-full">{blob.sha256}</span>
|
||||||
|
<span class="hash-truncated">{truncateHash(blob.sha256)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="blob-meta">
|
||||||
|
<span class="blob-size">{formatSize(blob.size)}</span>
|
||||||
|
<span class="blob-type">{blob.type || "unknown"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="blob-date">
|
||||||
|
{formatDate(blob.uploaded)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="delete-btn"
|
||||||
|
on:click|stopPropagation={() => deleteBlob(blob)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -482,7 +687,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.blossom-view {
|
.blossom-view {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
max-width: 900px;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
@@ -495,6 +700,60 @@
|
|||||||
.header-section h3 {
|
.header-section h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-color);
|
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 {
|
.refresh-btn {
|
||||||
@@ -527,6 +786,12 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.95em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.file-input-hidden {
|
.file-input-hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -594,6 +859,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blob-item {
|
.blob-item {
|
||||||
@@ -611,10 +877,27 @@
|
|||||||
background-color: var(--sidebar-bg);
|
background-color: var(--sidebar-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blob-icon {
|
.blob-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-img,
|
||||||
|
.thumbnail-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-icon {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
width: 2em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blob-info {
|
.blob-info {
|
||||||
@@ -628,6 +911,14 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hash-full {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-truncated {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.blob-meta {
|
.blob-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
@@ -663,6 +954,86 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admin users list styles */
|
||||||
|
.admin-users-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5em;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npub-full {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npub-truncated {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.login-prompt {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
@@ -940,6 +1311,24 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.hash-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-truncated {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npub-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npub-truncated {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.blob-item {
|
.blob-item {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -948,7 +1337,7 @@
|
|||||||
.blob-date {
|
.blob-date {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
padding-left: 3em;
|
padding-left: 3.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
|
|||||||
@@ -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)
|
// handleDeleteBlob handles DELETE /<sha256> requests (BUD-02)
|
||||||
func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
s.handleReport(w, r)
|
s.handleReport(w, r)
|
||||||
return
|
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/"):
|
case strings.HasPrefix(path, "list/"):
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
s.handleListBlobs(w, r)
|
s.handleListBlobs(w, r)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/dgraph-io/badger/v4"
|
"github.com/dgraph-io/badger/v4"
|
||||||
"lol.mleku.dev/chk"
|
"lol.mleku.dev/chk"
|
||||||
@@ -453,3 +455,73 @@ func (s *Storage) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, er
|
|||||||
|
|
||||||
return
|
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.19
|
v0.37.2
|
||||||
|
|||||||
Reference in New Issue
Block a user