mirror of
https://github.com/v0l/route96.git
synced 2025-06-20 15:15:39 +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/
|
target/
|
||||||
data/
|
data/
|
||||||
.idea/
|
.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",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
@ -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 }
|
||||||
|
@ -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,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
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 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>
|
||||||
|
@ -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