mirror of
https://github.com/v0l/route96.git
synced 2025-06-20 07:10:30 +00:00
Add mirror suggestions feature for blossom servers (#35)
Some checks failed
continuous-integration/drone/push Build is failing
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:
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,7 @@
|
||||
target/
|
||||
data/
|
||||
.idea/
|
||||
ui_src/dist/
|
||||
ui_src/node_modules/
|
||||
ui_src/package-lock.json
|
||||
ui_src/*.tsbuildinfo
|
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3323,6 +3323,7 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
@ -36,13 +36,14 @@ sha2 = "0.10.8"
|
||||
sqlx = { version = "0.8.1", features = ["mysql", "runtime-tokio", "chrono", "uuid"] }
|
||||
config = { version = "0.15.7", features = ["yaml"] }
|
||||
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"] }
|
||||
mime2ext = "0.1.53"
|
||||
infer = "0.19.0"
|
||||
tokio-util = { version = "0.7.13", features = ["io", "io-util"] }
|
||||
http-range-header = { version = "0.4.2" }
|
||||
base58 = "0.2.0"
|
||||
url = "2.5.0"
|
||||
|
||||
libc = { version = "0.2.153", optional = true }
|
||||
ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "aa1ce3edcad0fcd286d39b3e0c2fdc610c3988e7", optional = true }
|
||||
|
@ -12,7 +12,7 @@ pub fn admin_routes() -> Vec<Route> {
|
||||
admin_list_files,
|
||||
admin_get_self,
|
||||
admin_list_reports,
|
||||
admin_acknowledge_report
|
||||
admin_acknowledge_report,
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@ use crate::routes::{delete_file, Nip94Event};
|
||||
use crate::settings::Settings;
|
||||
use log::error;
|
||||
use nostr::prelude::hex;
|
||||
use nostr::{Alphabet, JsonUtil, SingleLetterTag, TagKind};
|
||||
use nostr::{Alphabet, SingleLetterTag, TagKind};
|
||||
use rocket::data::ByteUnit;
|
||||
use rocket::futures::StreamExt;
|
||||
use rocket::http::{Header, Status};
|
||||
@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncRead;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct BlobDescriptor {
|
||||
pub url: String,
|
||||
|
235
ui_src/src/components/mirror-suggestions.tsx
Normal file
235
ui_src/src/components/mirror-suggestions.tsx
Normal 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>
|
||||
);
|
||||
}
|
26
ui_src/src/hooks/use-blossom-servers.ts
Normal file
26
ui_src/src/hooks/use-blossom-servers.ts
Normal 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]);
|
||||
}
|
@ -3,6 +3,8 @@ import Button from "../components/button";
|
||||
import FileList from "./files";
|
||||
import PaymentFlow from "../components/payment";
|
||||
import ProgressBar from "../components/progress-bar";
|
||||
import MirrorSuggestions from "../components/mirror-suggestions";
|
||||
import { useBlossomServers } from "../hooks/use-blossom-servers";
|
||||
import { openFiles } from "../upload";
|
||||
import { Blossom, BlobDescriptor } from "../upload/blossom";
|
||||
import useLogin from "../hooks/login";
|
||||
@ -23,6 +25,8 @@ export default function Upload() {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress>();
|
||||
|
||||
const { servers: blossomServers } = useBlossomServers();
|
||||
|
||||
const login = useLogin();
|
||||
const pub = usePublisher();
|
||||
|
||||
@ -337,6 +341,13 @@ export default function Upload() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mirror Suggestions */}
|
||||
{blossomServers.length > 1 && (
|
||||
<MirrorSuggestions
|
||||
servers={blossomServers}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold">Your Files</h2>
|
||||
|
@ -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"}
|
Reference in New Issue
Block a user