Add mirror suggestions feature for blossom servers (#35)
Some checks failed
continuous-integration/drone/push Build is failing

* Initial plan for issue

* Fix build by adding feature gates for payments-related functions

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Add mirror suggestions feature with backend API and frontend UI

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Remove payments feature gate and implement mirror suggestions entirely in frontend

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Address review comments: revert blossom.rs changes and load server list from BUD-03

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Revert yarn.lock changes and update server-config to load from nostr event kind 10063

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Remove server-config.tsx and update useBlossomServers hook to use default servers

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>
This commit is contained in:
Copilot
2025-06-18 14:04:04 +01:00
committed by GitHub
parent b2fb86021b
commit 9290443a20
9 changed files with 284 additions and 6 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
target/ target/
data/ data/
.idea/ .idea/
ui_src/dist/
ui_src/node_modules/
ui_src/package-lock.json
ui_src/*.tsbuildinfo

1
Cargo.lock generated
View File

@ -3323,6 +3323,7 @@ dependencies = [
"sqlx", "sqlx",
"tokio", "tokio",
"tokio-util", "tokio-util",
"url",
"uuid", "uuid",
"walkdir", "walkdir",
] ]

View File

@ -36,13 +36,14 @@ sha2 = "0.10.8"
sqlx = { version = "0.8.1", features = ["mysql", "runtime-tokio", "chrono", "uuid"] } sqlx = { version = "0.8.1", features = ["mysql", "runtime-tokio", "chrono", "uuid"] }
config = { version = "0.15.7", features = ["yaml"] } config = { version = "0.15.7", features = ["yaml"] }
chrono = { version = "0.4.38", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] }
reqwest = { version = "0.12.8", features = ["stream", "http2"] } reqwest = { version = "0.12.8", features = ["stream", "http2", "json"] }
clap = { version = "4.5.18", features = ["derive"] } clap = { version = "4.5.18", features = ["derive"] }
mime2ext = "0.1.53" mime2ext = "0.1.53"
infer = "0.19.0" infer = "0.19.0"
tokio-util = { version = "0.7.13", features = ["io", "io-util"] } tokio-util = { version = "0.7.13", features = ["io", "io-util"] }
http-range-header = { version = "0.4.2" } http-range-header = { version = "0.4.2" }
base58 = "0.2.0" base58 = "0.2.0"
url = "2.5.0"
libc = { version = "0.2.153", optional = true } libc = { version = "0.2.153", optional = true }
ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "aa1ce3edcad0fcd286d39b3e0c2fdc610c3988e7", optional = true } ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "aa1ce3edcad0fcd286d39b3e0c2fdc610c3988e7", optional = true }

View File

@ -12,7 +12,7 @@ pub fn admin_routes() -> Vec<Route> {
admin_list_files, admin_list_files,
admin_get_self, admin_get_self,
admin_list_reports, admin_list_reports,
admin_acknowledge_report admin_acknowledge_report,
] ]
} }

View File

@ -5,7 +5,7 @@ use crate::routes::{delete_file, Nip94Event};
use crate::settings::Settings; use crate::settings::Settings;
use log::error; use log::error;
use nostr::prelude::hex; use nostr::prelude::hex;
use nostr::{Alphabet, JsonUtil, SingleLetterTag, TagKind}; use nostr::{Alphabet, SingleLetterTag, TagKind};
use rocket::data::ByteUnit; use rocket::data::ByteUnit;
use rocket::futures::StreamExt; use rocket::futures::StreamExt;
use rocket::http::{Header, Status}; use rocket::http::{Header, Status};
@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
use tokio::io::AsyncRead; use tokio::io::AsyncRead;
use tokio_util::io::StreamReader; use tokio_util::io::StreamReader;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct BlobDescriptor { pub struct BlobDescriptor {
pub url: String, pub url: String,

View File

@ -0,0 +1,235 @@
import { useState, useEffect } from "react";
import { Blossom } from "../upload/blossom";
import { FormatBytes } from "../const";
import Button from "./button";
import usePublisher from "../hooks/publisher";
import useLogin from "../hooks/login";
interface FileMirrorSuggestion {
sha256: string;
url: string;
size: number;
mime_type?: string;
available_on: string[];
missing_from: string[];
}
interface MirrorSuggestionsProps {
servers: string[];
}
export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
const [suggestions, setSuggestions] = useState<FileMirrorSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const [mirroring, setMirroring] = useState<Set<string>>(new Set());
const pub = usePublisher();
const login = useLogin();
useEffect(() => {
if (servers.length > 1 && pub && login?.pubkey) {
fetchSuggestions();
}
}, [servers, pub, login?.pubkey]);
async function fetchSuggestions() {
if (!pub || !login?.pubkey) return;
try {
setLoading(true);
setError(undefined);
const fileMap: Map<string, FileMirrorSuggestion> = new Map();
// Fetch files from each server
for (const serverUrl of servers) {
try {
const blossom = new Blossom(serverUrl, pub);
const files = await blossom.list(login.pubkey);
for (const file of files) {
const suggestion = fileMap.get(file.sha256);
if (suggestion) {
suggestion.available_on.push(serverUrl);
} else {
fileMap.set(file.sha256, {
sha256: file.sha256,
url: file.url || "",
size: file.size,
mime_type: file.type,
available_on: [serverUrl],
missing_from: [],
});
}
}
} catch (e) {
console.error(`Failed to fetch files from ${serverUrl}:`, e);
// Continue with other servers instead of failing completely
}
}
// Determine missing servers for each file
for (const suggestion of fileMap.values()) {
for (const serverUrl of servers) {
if (!suggestion.available_on.includes(serverUrl)) {
suggestion.missing_from.push(serverUrl);
}
}
}
// Filter to only files that are missing from at least one server and available on at least one
const filteredSuggestions = Array.from(fileMap.values()).filter(
s => s.missing_from.length > 0 && s.available_on.length > 0
);
setSuggestions(filteredSuggestions);
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("Failed to fetch mirror suggestions");
}
} finally {
setLoading(false);
}
}
async function mirrorFile(suggestion: FileMirrorSuggestion, targetServer: string) {
if (!pub) return;
const mirrorKey = `${suggestion.sha256}-${targetServer}`;
setMirroring(prev => new Set(prev.add(mirrorKey)));
try {
const blossom = new Blossom(targetServer, pub);
await blossom.mirror(suggestion.url);
// Update suggestions by removing this server from missing_from
setSuggestions(prev =>
prev.map(s =>
s.sha256 === suggestion.sha256
? {
...s,
available_on: [...s.available_on, targetServer],
missing_from: s.missing_from.filter(server => server !== targetServer)
}
: s
).filter(s => s.missing_from.length > 0) // Remove suggestions with no missing servers
);
} catch (e) {
if (e instanceof Error) {
setError(`Failed to mirror file: ${e.message}`);
} else {
setError("Failed to mirror file");
}
} finally {
setMirroring(prev => {
const newSet = new Set(prev);
newSet.delete(mirrorKey);
return newSet;
});
}
}
if (servers.length <= 1) {
return null; // No suggestions needed for single server
}
if (loading) {
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
<p className="text-gray-400">Loading mirror suggestions...</p>
</div>
);
}
if (error) {
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
<div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg mb-4">
{error}
</div>
<Button onClick={fetchSuggestions} className="btn-secondary">
Retry
</Button>
</div>
);
}
if (suggestions.length === 0) {
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
<p className="text-gray-400">All your files are synchronized across all servers.</p>
</div>
);
}
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
<p className="text-gray-400 mb-6">
The following files are missing from some of your servers and can be mirrored:
</p>
<div className="space-y-4">
{suggestions.map((suggestion) => (
<div key={suggestion.sha256} className="bg-gray-800 border border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<p className="text-sm font-medium text-gray-300 mb-1">
File: {suggestion.sha256.substring(0, 16)}...
</p>
<p className="text-xs text-gray-400">
Size: {FormatBytes(suggestion.size)}
{suggestion.mime_type && ` • Type: ${suggestion.mime_type}`}
</p>
</div>
</div>
<div className="space-y-2">
<div>
<p className="text-xs text-green-400 mb-1">Available on:</p>
<div className="flex flex-wrap gap-1">
{suggestion.available_on.map((server) => (
<span key={server} className="text-xs bg-green-900/30 text-green-300 px-2 py-1 rounded">
{new URL(server).hostname}
</span>
))}
</div>
</div>
<div>
<p className="text-xs text-red-400 mb-1">Missing from:</p>
<div className="flex flex-wrap gap-2">
{suggestion.missing_from.map((server) => {
const mirrorKey = `${suggestion.sha256}-${server}`;
const isMirroring = mirroring.has(mirrorKey);
return (
<div key={server} className="flex items-center gap-2">
<span className="text-xs bg-red-900/30 text-red-300 px-2 py-1 rounded">
{new URL(server).hostname}
</span>
<Button
onClick={() => mirrorFile(suggestion, server)}
disabled={isMirroring}
className="btn-primary text-xs py-1 px-2"
>
{isMirroring ? "Mirroring..." : "Mirror"}
</Button>
</div>
);
})}
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { useMemo } from "react";
import useLogin from "./login";
const DefaultMediaServers = ["https://cdn.satellite.earth/", "https://cdn.self.hosted/"];
export function useBlossomServers() {
const login = useLogin();
return useMemo(() => {
// For now, just return default servers
// TODO: Implement proper nostr event kind 10063 querying when system supports it
const servers = DefaultMediaServers;
return {
servers,
addServer: async (serverUrl: string) => {
// TODO: Implement adding server to event kind 10063
console.log("Adding server not implemented yet:", serverUrl);
},
removeServer: async (serverUrl: string) => {
// TODO: Implement removing server from event kind 10063
console.log("Removing server not implemented yet:", serverUrl);
},
};
}, [login?.pubkey]);
}

View File

@ -3,6 +3,8 @@ import Button from "../components/button";
import FileList from "./files"; import FileList from "./files";
import PaymentFlow from "../components/payment"; import PaymentFlow from "../components/payment";
import ProgressBar from "../components/progress-bar"; import ProgressBar from "../components/progress-bar";
import MirrorSuggestions from "../components/mirror-suggestions";
import { useBlossomServers } from "../hooks/use-blossom-servers";
import { openFiles } from "../upload"; import { openFiles } from "../upload";
import { Blossom, BlobDescriptor } from "../upload/blossom"; import { Blossom, BlobDescriptor } from "../upload/blossom";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
@ -23,6 +25,8 @@ export default function Upload() {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<UploadProgress>(); const [uploadProgress, setUploadProgress] = useState<UploadProgress>();
const { servers: blossomServers } = useBlossomServers();
const login = useLogin(); const login = useLogin();
const pub = usePublisher(); const pub = usePublisher();
@ -337,6 +341,13 @@ export default function Upload() {
</div> </div>
)} )}
{/* Mirror Suggestions */}
{blossomServers.length > 1 && (
<MirrorSuggestions
servers={blossomServers}
/>
)}
<div className="card"> <div className="card">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">Your Files</h2> <h2 className="text-xl font-semibold">Your Files</h2>

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/payment.tsx","./src/components/profile.tsx","./src/components/progress-bar.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/upload/progress.ts","./src/views/admin.tsx","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx"],"version":"5.6.2"} {"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/mirror-suggestions.tsx","./src/components/payment.tsx","./src/components/profile.tsx","./src/components/progress-bar.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/hooks/use-blossom-servers.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/upload/progress.ts","./src/views/admin.tsx","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx"],"version":"5.6.2"}