Skip to content

Commit 06aadb4

Browse files
Copilotv0l
andcommitted
Add mirror suggestions feature with backend API and frontend UI
Co-authored-by: v0l <[email protected]>
1 parent a201cec commit 06aadb4

File tree

8 files changed

+490
-4
lines changed

8 files changed

+490
-4
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,14 @@ sha2 = "0.10.8"
3636
sqlx = { version = "0.8.1", features = ["mysql", "runtime-tokio", "chrono", "uuid"] }
3737
config = { version = "0.15.7", features = ["yaml"] }
3838
chrono = { version = "0.4.38", features = ["serde"] }
39-
reqwest = { version = "0.12.8", features = ["stream", "http2"] }
39+
reqwest = { version = "0.12.8", features = ["stream", "http2", "json"] }
4040
clap = { version = "4.5.18", features = ["derive"] }
4141
mime2ext = "0.1.53"
4242
infer = "0.19.0"
4343
tokio-util = { version = "0.7.13", features = ["io", "io-util"] }
4444
http-range-header = { version = "0.4.2" }
4545
base58 = "0.2.0"
46+
url = "2.5.0"
4647

4748
libc = { version = "0.2.153", optional = true }
4849
ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "aa1ce3edcad0fcd286d39b3e0c2fdc610c3988e7", optional = true }

src/routes/blossom.rs

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,19 @@ use crate::routes::{delete_file, Nip94Event};
55
use crate::settings::Settings;
66
use log::error;
77
use nostr::prelude::hex;
8-
use nostr::{Alphabet, JsonUtil, SingleLetterTag, TagKind};
8+
use nostr::{Alphabet, SingleLetterTag, TagKind};
99
use rocket::data::ByteUnit;
1010
use rocket::futures::StreamExt;
1111
use rocket::http::{Header, Status};
1212
use rocket::response::Responder;
1313
use rocket::serde::json::Json;
1414
use rocket::{routes, Data, Request, Response, Route, State};
1515
use serde::{Deserialize, Serialize};
16+
use std::collections::HashMap;
1617
use tokio::io::AsyncRead;
1718
use tokio_util::io::StreamReader;
1819

19-
#[derive(Debug, Clone, Serialize)]
20+
#[derive(Debug, Clone, Serialize, Deserialize)]
2021
#[serde(crate = "rocket::serde")]
2122
pub struct BlobDescriptor {
2223
pub url: String,
@@ -55,6 +56,29 @@ struct MirrorRequest {
5556
pub url: String,
5657
}
5758

59+
#[derive(Debug, Clone, Serialize, Deserialize)]
60+
#[serde(crate = "rocket::serde")]
61+
pub struct MirrorSuggestionsRequest {
62+
pub servers: Vec<String>,
63+
}
64+
65+
#[derive(Debug, Clone, Serialize, Deserialize)]
66+
#[serde(crate = "rocket::serde")]
67+
pub struct FileMirrorSuggestion {
68+
pub sha256: String,
69+
pub url: String,
70+
pub size: u64,
71+
pub mime_type: Option<String>,
72+
pub available_on: Vec<String>,
73+
pub missing_from: Vec<String>,
74+
}
75+
76+
#[derive(Debug, Clone, Serialize, Deserialize)]
77+
#[serde(crate = "rocket::serde")]
78+
pub struct MirrorSuggestionsResponse {
79+
pub suggestions: Vec<FileMirrorSuggestion>,
80+
}
81+
5882
#[cfg(feature = "media-compression")]
5983
pub fn blossom_routes() -> Vec<Route> {
6084
let mut routes = routes![
@@ -65,6 +89,7 @@ pub fn blossom_routes() -> Vec<Route> {
6589
upload_media,
6690
head_media,
6791
mirror,
92+
mirror_suggestions,
6893
];
6994

7095
#[cfg(feature = "payments")]
@@ -83,6 +108,7 @@ pub fn blossom_routes() -> Vec<Route> {
83108
list_files,
84109
upload_head,
85110
mirror,
111+
mirror_suggestions,
86112
];
87113

88114
#[cfg(feature = "payments")]
@@ -118,6 +144,9 @@ enum BlossomResponse {
118144

119145
#[response(status = 200)]
120146
BlobDescriptorList(Json<Vec<BlobDescriptor>>),
147+
148+
#[response(status = 200)]
149+
MirrorSuggestions(Json<MirrorSuggestionsResponse>),
121150
}
122151

123152
impl BlossomResponse {
@@ -277,6 +306,77 @@ async fn mirror(
277306
.await
278307
}
279308

309+
#[rocket::post("/mirror-suggestions", data = "<req>", format = "json")]
310+
async fn mirror_suggestions(
311+
auth: BlossomAuth,
312+
req: Json<MirrorSuggestionsRequest>,
313+
) -> BlossomResponse {
314+
if !check_method(&auth.event, "mirror-suggestions") {
315+
return BlossomResponse::error("Invalid request method tag");
316+
}
317+
318+
let pubkey_hex = auth.event.pubkey.to_hex();
319+
let mut file_map: HashMap<String, FileMirrorSuggestion> = HashMap::new();
320+
321+
// Fetch files from each server
322+
for server_url in &req.servers {
323+
if let Ok(_server_url_parsed) = url::Url::parse(server_url) {
324+
let list_url = format!("{}/list/{}", server_url, pubkey_hex);
325+
326+
match reqwest::get(&list_url).await {
327+
Ok(response) => {
328+
if response.status().is_success() {
329+
match response.json::<Vec<BlobDescriptor>>().await {
330+
Ok(files) => {
331+
for file in files {
332+
file_map
333+
.entry(file.sha256.clone())
334+
.and_modify(|suggestion| {
335+
suggestion.available_on.push(server_url.clone());
336+
})
337+
.or_insert_with(|| FileMirrorSuggestion {
338+
sha256: file.sha256.clone(),
339+
url: file.url.clone(),
340+
size: file.size,
341+
mime_type: file.mime_type.clone(),
342+
available_on: vec![server_url.clone()],
343+
missing_from: Vec::new(),
344+
});
345+
}
346+
}
347+
Err(e) => {
348+
error!("Failed to parse file list from {}: {}", server_url, e);
349+
}
350+
}
351+
}
352+
}
353+
Err(e) => {
354+
error!("Failed to fetch file list from {}: {}", server_url, e);
355+
}
356+
}
357+
}
358+
}
359+
360+
// Determine missing servers for each file
361+
for suggestion in file_map.values_mut() {
362+
for server_url in &req.servers {
363+
if !suggestion.available_on.contains(server_url) {
364+
suggestion.missing_from.push(server_url.clone());
365+
}
366+
}
367+
}
368+
369+
// Filter to only files that are missing from at least one server and available on at least one
370+
let filtered_suggestions: Vec<FileMirrorSuggestion> = file_map
371+
.into_values()
372+
.filter(|s| !s.missing_from.is_empty() && !s.available_on.is_empty())
373+
.collect();
374+
375+
BlossomResponse::MirrorSuggestions(Json(MirrorSuggestionsResponse {
376+
suggestions: filtered_suggestions,
377+
}))
378+
}
379+
280380
#[cfg(feature = "media-compression")]
281381
#[rocket::head("/media")]
282382
fn head_media(auth: BlossomAuth, settings: &State<Settings>) -> BlossomHead {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { useState, useEffect } from "react";
2+
import { FileMirrorSuggestion, Blossom } from "../upload/blossom";
3+
import { FormatBytes } from "../const";
4+
import Button from "./button";
5+
import usePublisher from "../hooks/publisher";
6+
7+
interface MirrorSuggestionsProps {
8+
servers: string[];
9+
currentServer: string;
10+
}
11+
12+
export default function MirrorSuggestions({ servers, currentServer }: MirrorSuggestionsProps) {
13+
const [suggestions, setSuggestions] = useState<FileMirrorSuggestion[]>([]);
14+
const [loading, setLoading] = useState(false);
15+
const [error, setError] = useState<string>();
16+
const [mirroring, setMirroring] = useState<Set<string>>(new Set());
17+
18+
const pub = usePublisher();
19+
20+
useEffect(() => {
21+
if (servers.length > 1 && pub) {
22+
fetchSuggestions();
23+
}
24+
}, [servers, pub]);
25+
26+
async function fetchSuggestions() {
27+
if (!pub) return;
28+
29+
try {
30+
setLoading(true);
31+
setError(undefined);
32+
33+
const blossom = new Blossom(currentServer, pub);
34+
const result = await blossom.getMirrorSuggestions(servers);
35+
setSuggestions(result.suggestions);
36+
} catch (e) {
37+
if (e instanceof Error) {
38+
setError(e.message);
39+
} else {
40+
setError("Failed to fetch mirror suggestions");
41+
}
42+
} finally {
43+
setLoading(false);
44+
}
45+
}
46+
47+
async function mirrorFile(suggestion: FileMirrorSuggestion, targetServer: string) {
48+
if (!pub) return;
49+
50+
const mirrorKey = `${suggestion.sha256}-${targetServer}`;
51+
setMirroring(prev => new Set(prev.add(mirrorKey)));
52+
53+
try {
54+
const blossom = new Blossom(targetServer, pub);
55+
await blossom.mirror(suggestion.url);
56+
57+
// Update suggestions by removing this server from missing_from
58+
setSuggestions(prev =>
59+
prev.map(s =>
60+
s.sha256 === suggestion.sha256
61+
? {
62+
...s,
63+
available_on: [...s.available_on, targetServer],
64+
missing_from: s.missing_from.filter(server => server !== targetServer)
65+
}
66+
: s
67+
).filter(s => s.missing_from.length > 0) // Remove suggestions with no missing servers
68+
);
69+
} catch (e) {
70+
if (e instanceof Error) {
71+
setError(`Failed to mirror file: ${e.message}`);
72+
} else {
73+
setError("Failed to mirror file");
74+
}
75+
} finally {
76+
setMirroring(prev => {
77+
const newSet = new Set(prev);
78+
newSet.delete(mirrorKey);
79+
return newSet;
80+
});
81+
}
82+
}
83+
84+
if (servers.length <= 1) {
85+
return null; // No suggestions needed for single server
86+
}
87+
88+
if (loading) {
89+
return (
90+
<div className="card">
91+
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
92+
<p className="text-gray-400">Loading mirror suggestions...</p>
93+
</div>
94+
);
95+
}
96+
97+
if (error) {
98+
return (
99+
<div className="card">
100+
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
101+
<div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg mb-4">
102+
{error}
103+
</div>
104+
<Button onClick={fetchSuggestions} className="btn-secondary">
105+
Retry
106+
</Button>
107+
</div>
108+
);
109+
}
110+
111+
if (suggestions.length === 0) {
112+
return (
113+
<div className="card">
114+
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
115+
<p className="text-gray-400">All your files are synchronized across all servers.</p>
116+
</div>
117+
);
118+
}
119+
120+
return (
121+
<div className="card">
122+
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
123+
<p className="text-gray-400 mb-6">
124+
The following files are missing from some of your servers and can be mirrored:
125+
</p>
126+
127+
<div className="space-y-4">
128+
{suggestions.map((suggestion) => (
129+
<div key={suggestion.sha256} className="bg-gray-800 border border-gray-700 rounded-lg p-4">
130+
<div className="flex items-start justify-between mb-3">
131+
<div className="flex-1">
132+
<p className="text-sm font-medium text-gray-300 mb-1">
133+
File: {suggestion.sha256.substring(0, 16)}...
134+
</p>
135+
<p className="text-xs text-gray-400">
136+
Size: {FormatBytes(suggestion.size)}
137+
{suggestion.mime_type && ` • Type: ${suggestion.mime_type}`}
138+
</p>
139+
</div>
140+
</div>
141+
142+
<div className="space-y-2">
143+
<div>
144+
<p className="text-xs text-green-400 mb-1">Available on:</p>
145+
<div className="flex flex-wrap gap-1">
146+
{suggestion.available_on.map((server) => (
147+
<span key={server} className="text-xs bg-green-900/30 text-green-300 px-2 py-1 rounded">
148+
{new URL(server).hostname}
149+
</span>
150+
))}
151+
</div>
152+
</div>
153+
154+
<div>
155+
<p className="text-xs text-red-400 mb-1">Missing from:</p>
156+
<div className="flex flex-wrap gap-2">
157+
{suggestion.missing_from.map((server) => {
158+
const mirrorKey = `${suggestion.sha256}-${server}`;
159+
const isMirroring = mirroring.has(mirrorKey);
160+
161+
return (
162+
<div key={server} className="flex items-center gap-2">
163+
<span className="text-xs bg-red-900/30 text-red-300 px-2 py-1 rounded">
164+
{new URL(server).hostname}
165+
</span>
166+
<Button
167+
onClick={() => mirrorFile(suggestion, server)}
168+
disabled={isMirroring}
169+
className="btn-primary text-xs py-1 px-2"
170+
>
171+
{isMirroring ? "Mirroring..." : "Mirror"}
172+
</Button>
173+
</div>
174+
);
175+
})}
176+
</div>
177+
</div>
178+
</div>
179+
</div>
180+
))}
181+
</div>
182+
</div>
183+
);
184+
}

0 commit comments

Comments
 (0)