fix: AI slop

This commit is contained in:
2025-06-18 14:48:40 +01:00
parent 9290443a20
commit 84b475d11a
6 changed files with 96 additions and 82 deletions

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, SingleLetterTag, TagKind}; use nostr::{Alphabet, JsonUtil, 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};
@ -15,6 +15,7 @@ use rocket::{routes, Data, Request, Response, Route, State};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::AsyncRead; use tokio::io::AsyncRead;
use tokio_util::io::StreamReader; use tokio_util::io::StreamReader;
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
@ -225,15 +226,25 @@ async fn mirror(
settings: &State<Settings>, settings: &State<Settings>,
req: Json<MirrorRequest>, req: Json<MirrorRequest>,
) -> BlossomResponse { ) -> BlossomResponse {
if !check_method(&auth.event, "mirror") { if !check_method(&auth.event, "upload") {
return BlossomResponse::error("Invalid request method tag"); return BlossomResponse::error("Invalid request method tag");
} }
if let Some(e) = check_whitelist(&auth, settings) { if let Some(e) = check_whitelist(&auth, settings) {
return e; return e;
} }
let url = match Url::parse(&req.url) {
Ok(u) => u,
Err(e) => return BlossomResponse::error(format!("Invalid URL: {}", e)),
};
let hash = url
.path_segments()
.and_then(|mut c| c.next_back())
.and_then(|s| s.split(".").next());
// download file // download file
let rsp = match reqwest::get(&req.url).await { let rsp = match reqwest::get(url.clone()).await {
Err(e) => { Err(e) => {
error!("Error downloading file: {}", e); error!("Error downloading file: {}", e);
return BlossomResponse::error("Failed to mirror file"); return BlossomResponse::error("Failed to mirror file");
@ -250,9 +261,10 @@ async fn mirror(
let pubkey = auth.event.pubkey.to_bytes().to_vec(); let pubkey = auth.event.pubkey.to_bytes().to_vec();
process_stream( process_stream(
StreamReader::new(rsp.bytes_stream().map(|result| { StreamReader::new(
result.map_err(std::io::Error::other) rsp.bytes_stream()
})), .map(|result| result.map_err(std::io::Error::other)),
),
&mime_type, &mime_type,
&None, &None,
&pubkey, &pubkey,
@ -261,6 +273,7 @@ async fn mirror(
fs, fs,
db, db,
settings, settings,
hash.and_then(|h| hex::decode(h).ok()),
) )
.await .await
} }
@ -353,7 +366,7 @@ async fn process_upload(
None None
} }
}); });
let size = size_tag.or(auth.x_content_length).unwrap_or(0); let size = size_tag.or(auth.x_content_length).unwrap_or(0);
if size > 0 && size > settings.max_upload_bytes { if size > 0 && size > settings.max_upload_bytes {
return BlossomResponse::error("File too large"); return BlossomResponse::error("File too large");
@ -367,15 +380,11 @@ async fn process_upload(
// check quota (only if payments are configured) // check quota (only if payments are configured)
#[cfg(feature = "payments")] #[cfg(feature = "payments")]
if let Some(payment_config) = &settings.payments { if let Some(payment_config) = &settings.payments {
let free_quota = payment_config.free_quota_bytes let free_quota = payment_config.free_quota_bytes.unwrap_or(104857600); // Default to 100MB
.unwrap_or(104857600); // Default to 100MB
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
if size > 0 { if size > 0 {
match db match db.check_user_quota(&pubkey_vec, size, free_quota).await {
.check_user_quota(&pubkey_vec, size, free_quota)
.await
{
Ok(false) => return BlossomResponse::error("Upload would exceed quota"), Ok(false) => return BlossomResponse::error("Upload would exceed quota"),
Err(_) => return BlossomResponse::error("Failed to check quota"), Err(_) => return BlossomResponse::error("Failed to check quota"),
Ok(true) => {} // Quota check passed Ok(true) => {} // Quota check passed
@ -395,6 +404,7 @@ async fn process_upload(
fs, fs,
db, db,
settings, settings,
None,
) )
.await .await
} }
@ -409,6 +419,7 @@ async fn process_stream<'p, S>(
fs: &State<FileStore>, fs: &State<FileStore>,
db: &State<Database>, db: &State<Database>,
settings: &State<Settings>, settings: &State<Settings>,
expect_hash: Option<Vec<u8>>,
) -> BlossomResponse ) -> BlossomResponse
where where
S: AsyncRead + Unpin + 'p, S: AsyncRead + Unpin + 'p,
@ -417,6 +428,15 @@ where
Ok(FileSystemResult::NewFile(blob)) => { Ok(FileSystemResult::NewFile(blob)) => {
let mut ret: FileUpload = (&blob).into(); let mut ret: FileUpload = (&blob).into();
// check expected hash (mirroring)
if let Some(h) = expect_hash {
if h != ret.id {
if let Err(e) = tokio::fs::remove_file(fs.get(&ret.id)).await {
log::warn!("Failed to cleanup file: {}", e);
}
return BlossomResponse::error("Mirror request failed, server responses with invalid file content (hash mismatch)");
}
}
// update file data before inserting // update file data before inserting
ret.name = name.map(|s| s.to_string()); ret.name = name.map(|s| s.to_string());
@ -443,9 +463,8 @@ where
#[cfg(feature = "payments")] #[cfg(feature = "payments")]
if size == 0 { if size == 0 {
if let Some(payment_config) = &settings.payments { if let Some(payment_config) = &settings.payments {
let free_quota = payment_config.free_quota_bytes let free_quota = payment_config.free_quota_bytes.unwrap_or(104857600); // Default to 100MB
.unwrap_or(104857600); // Default to 100MB
match db.check_user_quota(pubkey, upload.size, free_quota).await { match db.check_user_quota(pubkey, upload.size, free_quota).await {
Ok(false) => { Ok(false) => {
// Clean up the uploaded file if quota exceeded // Clean up the uploaded file if quota exceeded

View File

@ -35,19 +35,20 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
async function fetchSuggestions() { async function fetchSuggestions() {
if (!pub || !login?.pubkey) return; if (!pub || !login?.pubkey) return;
if (loading) return;
try { try {
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
const fileMap: Map<string, FileMirrorSuggestion> = new Map(); const fileMap: Map<string, FileMirrorSuggestion> = new Map();
// Fetch files from each server // Fetch files from each server
for (const serverUrl of servers) { for (const serverUrl of servers) {
try { try {
const blossom = new Blossom(serverUrl, pub); const blossom = new Blossom(serverUrl, pub);
const files = await blossom.list(login.pubkey); const files = await blossom.list(login.pubkey);
for (const file of files) { for (const file of files) {
const suggestion = fileMap.get(file.sha256); const suggestion = fileMap.get(file.sha256);
if (suggestion) { if (suggestion) {
@ -68,7 +69,7 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
// Continue with other servers instead of failing completely // Continue with other servers instead of failing completely
} }
} }
// Determine missing servers for each file // Determine missing servers for each file
for (const suggestion of fileMap.values()) { for (const suggestion of fileMap.values()) {
for (const serverUrl of servers) { for (const serverUrl of servers) {
@ -77,12 +78,12 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
} }
} }
} }
// Filter to only files that are missing from at least one server and available on at least one // 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( const filteredSuggestions = Array.from(fileMap.values()).filter(
s => s.missing_from.length > 0 && s.available_on.length > 0 s => s.missing_from.length > 0 && s.available_on.length > 0
); );
setSuggestions(filteredSuggestions); setSuggestions(filteredSuggestions);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
@ -97,23 +98,23 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
async function mirrorFile(suggestion: FileMirrorSuggestion, targetServer: string) { async function mirrorFile(suggestion: FileMirrorSuggestion, targetServer: string) {
if (!pub) return; if (!pub) return;
const mirrorKey = `${suggestion.sha256}-${targetServer}`; const mirrorKey = `${suggestion.sha256}-${targetServer}`;
setMirroring(prev => new Set(prev.add(mirrorKey))); setMirroring(prev => new Set(prev.add(mirrorKey)));
try { try {
const blossom = new Blossom(targetServer, pub); const blossom = new Blossom(targetServer, pub);
await blossom.mirror(suggestion.url); await blossom.mirror(suggestion.url);
// Update suggestions by removing this server from missing_from // Update suggestions by removing this server from missing_from
setSuggestions(prev => setSuggestions(prev =>
prev.map(s => prev.map(s =>
s.sha256 === suggestion.sha256 s.sha256 === suggestion.sha256
? { ? {
...s, ...s,
available_on: [...s.available_on, targetServer], available_on: [...s.available_on, targetServer],
missing_from: s.missing_from.filter(server => server !== targetServer) missing_from: s.missing_from.filter(server => server !== targetServer)
} }
: s : s
).filter(s => s.missing_from.length > 0) // Remove suggestions with no missing servers ).filter(s => s.missing_from.length > 0) // Remove suggestions with no missing servers
); );
@ -174,14 +175,14 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
<p className="text-gray-400 mb-6"> <p className="text-gray-400 mb-6">
The following files are missing from some of your servers and can be mirrored: The following files are missing from some of your servers and can be mirrored:
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{suggestions.map((suggestion) => ( {suggestions.map((suggestion) => (
<div key={suggestion.sha256} className="bg-gray-800 border border-gray-700 rounded-lg p-4"> <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 items-start justify-between mb-3">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-gray-300 mb-1"> <p className="text-sm font-medium text-gray-300 mb-1">
File: {suggestion.sha256.substring(0, 16)}... File: {suggestion.sha256}
</p> </p>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Size: {FormatBytes(suggestion.size)} Size: {FormatBytes(suggestion.size)}
@ -189,7 +190,7 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<p className="text-xs text-green-400 mb-1">Available on:</p> <p className="text-xs text-green-400 mb-1">Available on:</p>
@ -201,14 +202,14 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
))} ))}
</div> </div>
</div> </div>
<div> <div>
<p className="text-xs text-red-400 mb-1">Missing from:</p> <p className="text-xs text-red-400 mb-1">Missing from:</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{suggestion.missing_from.map((server) => { {suggestion.missing_from.map((server) => {
const mirrorKey = `${suggestion.sha256}-${server}`; const mirrorKey = `${suggestion.sha256}-${server}`;
const isMirroring = mirroring.has(mirrorKey); const isMirroring = mirroring.has(mirrorKey);
return ( return (
<div key={server} className="flex items-center gap-2"> <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"> <span className="text-xs bg-red-900/30 text-red-300 px-2 py-1 rounded">

View File

@ -43,3 +43,6 @@ export function FormatBytes(b: number, f?: number) {
if (b >= kiB) return (b / kiB).toFixed(f) + " KiB"; if (b >= kiB) return (b / kiB).toFixed(f) + " KiB";
return b.toFixed(f) + " B"; return b.toFixed(f) + " B";
} }
export const ServerUrl =
import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`;

View File

@ -1,26 +1,22 @@
import { useMemo } from "react";
import useLogin from "./login"; import useLogin from "./login";
import { useRequestBuilder } from "@snort/system-react";
import { EventKind, RequestBuilder } from "@snort/system";
import { appendDedupe, dedupe, removeUndefined, sanitizeRelayUrl } from "@snort/shared";
import { ServerUrl } from "../const";
const DefaultMediaServers = ["https://cdn.satellite.earth/", "https://cdn.self.hosted/"]; const DefaultMediaServers = ["https://blossom.band/", "https://blossom.primal.net", ServerUrl];
export function useBlossomServers() { export function useBlossomServers() {
const login = useLogin(); const login = useLogin();
return useMemo(() => { const rb = new RequestBuilder("media-servers");
// For now, just return default servers if (login?.pubkey) {
// TODO: Implement proper nostr event kind 10063 querying when system supports it rb.withFilter()
const servers = DefaultMediaServers; .kinds([10_063 as EventKind])
.authors([login.pubkey]);
}
const req = useRequestBuilder(rb);
return { const servers = req === undefined ? undefined : dedupe(removeUndefined(req.flatMap((e) => e.tags.filter(t => t[0] === "server").map((t) => sanitizeRelayUrl(t[1])))));
servers, return appendDedupe(DefaultMediaServers, 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

@ -65,7 +65,7 @@ export class Blossom {
const rsp = await this.#req( const rsp = await this.#req(
"mirror", "mirror",
"PUT", "PUT",
"mirror", "upload",
JSON.stringify({ url }), JSON.stringify({ url }),
undefined, undefined,
{ {

View File

@ -11,7 +11,7 @@ import useLogin from "../hooks/login";
import usePublisher from "../hooks/publisher"; import usePublisher from "../hooks/publisher";
import { Nip96, Nip96FileList } from "../upload/nip96"; import { Nip96, Nip96FileList } from "../upload/nip96";
import { AdminSelf, Route96 } from "../upload/admin"; import { AdminSelf, Route96 } from "../upload/admin";
import { FormatBytes } from "../const"; import { FormatBytes, ServerUrl } from "../const";
import { UploadProgress } from "../upload/progress"; import { UploadProgress } from "../upload/progress";
export default function Upload() { export default function Upload() {
@ -25,14 +25,11 @@ 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 blossomServers = useBlossomServers();
const login = useLogin(); const login = useLogin();
const pub = usePublisher(); const pub = usePublisher();
const url =
import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`;
// Check if file should have compression enabled by default // Check if file should have compression enabled by default
const shouldCompress = (file: File) => { const shouldCompress = (file: File) => {
return file.type.startsWith('video/') || file.type.startsWith('image/'); return file.type.startsWith('video/') || file.type.startsWith('image/');
@ -52,7 +49,7 @@ export default function Upload() {
setUploadProgress(progress); setUploadProgress(progress);
}; };
const uploader = new Blossom(url, pub); const uploader = new Blossom(ServerUrl, pub);
// Use compression by default for video and image files, unless explicitly disabled // Use compression by default for video and image files, unless explicitly disabled
const useCompression = shouldCompress(file) && !noCompress; const useCompression = shouldCompress(file) && !noCompress;
const result = useCompression const result = useCompression
@ -75,11 +72,11 @@ export default function Upload() {
async function handleFileSelection() { async function handleFileSelection() {
if (isUploading) return; if (isUploading) return;
try { try {
const files = await openFiles(); const files = await openFiles();
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
// Start uploading each file immediately // Start uploading each file immediately
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
@ -99,7 +96,7 @@ export default function Upload() {
if (!pub) return; if (!pub) return;
try { try {
setError(undefined); setError(undefined);
const uploader = new Nip96(url, pub); const uploader = new Nip96(ServerUrl, pub);
await uploader.loadInfo(); await uploader.loadInfo();
const result = await uploader.listFiles(n, 50); const result = await uploader.listFiles(n, 50);
setListedFiles(result); setListedFiles(result);
@ -115,14 +112,14 @@ export default function Upload() {
} }
} }
}, },
[pub, url], [pub],
); );
async function deleteFile(id: string) { async function deleteFile(id: string) {
if (!pub) return; if (!pub) return;
try { try {
setError(undefined); setError(undefined);
const uploader = new Blossom(url, pub); const uploader = new Blossom(ServerUrl, pub);
await uploader.delete(id); await uploader.delete(id);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
@ -143,10 +140,10 @@ export default function Upload() {
useEffect(() => { useEffect(() => {
if (pub && !self) { if (pub && !self) {
const r96 = new Route96(url, pub); const r96 = new Route96(ServerUrl, pub);
r96.getSelf().then((v) => setSelf(v.data)); r96.getSelf().then((v) => setSelf(v.data));
} }
}, [pub, self, url]); }, [pub, self]);
if (!login) { if (!login) {
return ( return (
@ -189,8 +186,8 @@ export default function Upload() {
{/* Upload Progress */} {/* Upload Progress */}
{isUploading && uploadProgress && ( {isUploading && uploadProgress && (
<ProgressBar <ProgressBar
progress={uploadProgress} progress={uploadProgress}
/> />
)} )}
@ -240,13 +237,12 @@ export default function Upload() {
</div> </div>
<div className="w-full bg-gray-700 rounded-full h-2.5"> <div className="w-full bg-gray-700 rounded-full h-2.5">
<div <div
className={`h-2.5 rounded-full transition-all duration-300 ${ className={`h-2.5 rounded-full transition-all duration-300 ${self.total_size / self.total_available_quota > 0.8
self.total_size / self.total_available_quota > 0.8
? "bg-red-500" ? "bg-red-500"
: self.total_size / self.total_available_quota > 0.6 : self.total_size / self.total_available_quota > 0.6
? "bg-yellow-500" ? "bg-yellow-500"
: "bg-green-500" : "bg-green-500"
}`} }`}
style={{ style={{
width: `${Math.min(100, (self.total_size / self.total_available_quota) * 100)}%`, width: `${Math.min(100, (self.total_size / self.total_available_quota) * 100)}%`,
}} }}
@ -261,13 +257,12 @@ export default function Upload() {
% used % used
</span> </span>
<span <span
className={`${ className={`${self.total_size / self.total_available_quota > 0.8
self.total_size / self.total_available_quota > 0.8
? "text-red-400" ? "text-red-400"
: self.total_size / self.total_available_quota > 0.6 : self.total_size / self.total_available_quota > 0.6
? "text-yellow-400" ? "text-yellow-400"
: "text-green-400" : "text-green-400"
}`} }`}
> >
{FormatBytes( {FormatBytes(
Math.max( Math.max(
@ -332,7 +327,7 @@ export default function Upload() {
{showPaymentFlow && pub && ( {showPaymentFlow && pub && (
<div className="card"> <div className="card">
<PaymentFlow <PaymentFlow
route96={new Route96(url, pub)} route96={new Route96(ServerUrl, pub)}
onPaymentRequested={(pr) => { onPaymentRequested={(pr) => {
console.log("Payment requested:", pr); console.log("Payment requested:", pr);
}} }}
@ -342,9 +337,9 @@ export default function Upload() {
)} )}
{/* Mirror Suggestions */} {/* Mirror Suggestions */}
{blossomServers.length > 1 && ( {blossomServers && blossomServers.length > 1 && (
<MirrorSuggestions <MirrorSuggestions
servers={blossomServers} servers={blossomServers}
/> />
)} )}