From 21cc1ed714fc614475c8e850a0412a09445125b3 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 11 Jun 2025 14:42:03 +0100 Subject: [PATCH] fix: storage calculation refactor: improve UI --- src/db.rs | 58 ++++- src/payments.rs | 2 +- src/routes/blossom.rs | 64 +++-- src/routes/nip96.rs | 61 +++-- ui_src/src/components/payment.tsx | 48 +++- ui_src/src/components/profile.tsx | 17 +- ui_src/src/hooks/publisher.ts | 2 +- ui_src/src/upload/admin.ts | 2 +- ui_src/src/upload/blossom.ts | 40 ++-- ui_src/src/views/admin.tsx | 116 ++++----- ui_src/src/views/files.tsx | 39 +++- ui_src/src/views/header.tsx | 34 +-- ui_src/src/views/reports.tsx | 39 +++- ui_src/src/views/upload.tsx | 377 ++++++++++++++++++++++-------- 14 files changed, 641 insertions(+), 258 deletions(-) diff --git a/src/db.rs b/src/db.rs index 569ca51..2957f0f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -165,6 +165,13 @@ impl Database { .await } + pub async fn get_user_by_id(&self, user_id: u64) -> Result { + sqlx::query_as("select * from users where id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await + } + pub async fn get_user_stats(&self, id: u64) -> Result { sqlx::query_as( "select cast(count(user_uploads.file) as unsigned integer) as file_count, \ @@ -347,14 +354,49 @@ impl Database { .execute(&mut *tx) .await?; - // TODO: check space is not downgraded - - sqlx::query("update users set paid_until = TIMESTAMPADD(DAY, ?, IFNULL(paid_until, current_timestamp)), paid_size = ? where id = ?") - .bind(payment.days_value) - .bind(payment.size_value) - .bind(payment.user_id) - .execute(&mut *tx) - .await?; + // Calculate time extension based on fractional quota value + // If user upgrades from 5GB to 10GB, their remaining time gets halved + // If user pays for 1GB on a 5GB plan, they get 1/5 of the normal time + let current_user = self.get_user_by_id(payment.user_id).await?; + + if let Some(paid_until) = current_user.paid_until { + if paid_until > chrono::Utc::now() { + // User has active subscription - calculate fractional time extension + let time_fraction = if current_user.paid_size > 0 { + payment.size_value as f64 / current_user.paid_size as f64 + } else { + 1.0 // If no existing quota, treat as 100% + }; + + let adjusted_days = (payment.days_value as f64 * time_fraction) as u64; + + // Extend subscription time and upgrade quota if larger + let new_quota_size = std::cmp::max(current_user.paid_size, payment.size_value); + + sqlx::query("update users set paid_until = TIMESTAMPADD(DAY, ?, paid_until), paid_size = ? where id = ?") + .bind(adjusted_days) + .bind(new_quota_size) + .bind(payment.user_id) + .execute(&mut *tx) + .await?; + } else { + // Expired subscription - set new quota and time + sqlx::query("update users set paid_until = TIMESTAMPADD(DAY, ?, current_timestamp), paid_size = ? where id = ?") + .bind(payment.days_value) + .bind(payment.size_value) + .bind(payment.user_id) + .execute(&mut *tx) + .await?; + } + } else { + // No existing subscription - set new quota + sqlx::query("update users set paid_until = TIMESTAMPADD(DAY, ?, current_timestamp), paid_size = ? where id = ?") + .bind(payment.days_value) + .bind(payment.size_value) + .bind(payment.user_id) + .execute(&mut *tx) + .await?; + } tx.commit().await?; diff --git a/src/payments.rs b/src/payments.rs index ab95db0..b910a71 100644 --- a/src/payments.rs +++ b/src/payments.rs @@ -30,7 +30,7 @@ pub enum PaymentUnit { impl PaymentUnit { /// Get the total size from a number of units pub fn to_size(&self, units: f32) -> u64 { - (1000f32 * 1000f32 * 1000f32 * units) as u64 + (1024f32 * 1024f32 * 1024f32 * units) as u64 } } diff --git a/src/routes/blossom.rs b/src/routes/blossom.rs index b86f222..d9fc221 100644 --- a/src/routes/blossom.rs +++ b/src/routes/blossom.rs @@ -251,12 +251,13 @@ async fn mirror( process_stream( StreamReader::new(rsp.bytes_stream().map(|result| { - result.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)) + result.map_err(std::io::Error::other) })), &mime_type, &None, &pubkey, false, + 0, // No size info for mirror fs, db, settings, @@ -345,7 +346,7 @@ async fn process_upload( None } }); - let size = auth.event.tags.iter().find_map(|t| { + let size_tag = auth.event.tags.iter().find_map(|t| { if t.kind() == TagKind::Size { t.content().and_then(|v| v.parse::().ok()) } else { @@ -353,15 +354,10 @@ async fn process_upload( } }); - let size = match size { - Some(z) => { - if z > settings.max_upload_bytes { - return BlossomResponse::error("File too large"); - } - z - } - None => return BlossomResponse::error("Size tag is required"), - }; + let size = size_tag.or(auth.x_content_length).unwrap_or(0); + if size > 0 && size > settings.max_upload_bytes { + return BlossomResponse::error("File too large"); + } // check whitelist if let Some(e) = check_whitelist(&auth, settings) { @@ -378,13 +374,15 @@ async fn process_upload( .unwrap_or(104857600); // Default to 100MB let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); - match db - .check_user_quota(&pubkey_vec, size, free_quota) - .await - { - Ok(false) => return BlossomResponse::error("Upload would exceed quota"), - Err(_) => return BlossomResponse::error("Failed to check quota"), - Ok(true) => {} // Quota check passed + if size > 0 { + match db + .check_user_quota(&pubkey_vec, size, free_quota) + .await + { + Ok(false) => return BlossomResponse::error("Upload would exceed quota"), + Err(_) => return BlossomResponse::error("Failed to check quota"), + Ok(true) => {} // Quota check passed + } } } @@ -396,6 +394,7 @@ async fn process_upload( &name, &auth.event.pubkey.to_bytes().to_vec(), compress, + size, fs, db, settings, @@ -409,6 +408,7 @@ async fn process_stream<'p, S>( name: &Option<&str>, pubkey: &Vec, compress: bool, + size: u64, fs: &State, db: &State, settings: &State, @@ -441,6 +441,34 @@ where return BlossomResponse::error(format!("Failed to save file (db): {}", e)); } }; + + // Post-upload quota check if we didn't have size information before upload + #[cfg(feature = "payments")] + if size == 0 { + let free_quota = settings + .payments + .as_ref() + .and_then(|p| p.free_quota_bytes) + .unwrap_or(104857600); // Default to 100MB + + match db.check_user_quota(pubkey, upload.size, free_quota).await { + Ok(false) => { + // Clean up the uploaded file if quota exceeded + if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await { + log::warn!("Failed to cleanup quota-exceeding file: {}", e); + } + return BlossomResponse::error("Upload would exceed quota"); + } + Err(_) => { + // Clean up on quota check error + if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await { + log::warn!("Failed to cleanup file after quota check error: {}", e); + } + return BlossomResponse::error("Failed to check quota"); + } + Ok(true) => {} // Quota check passed + } + } if let Err(e) = db.add_file(&upload, user_id).await { error!("{}", e); BlossomResponse::error(format!("Error saving file (db): {}", e)) diff --git a/src/routes/nip96.rs b/src/routes/nip96.rs index 801b19f..611ecdf 100644 --- a/src/routes/nip96.rs +++ b/src/routes/nip96.rs @@ -174,12 +174,8 @@ async fn upload( settings: &State, form: Form>, ) -> Nip96Response { - if let Some(size) = auth.content_length { - if size > settings.max_upload_bytes { - return Nip96Response::error("File too large"); - } - } - if form.size > settings.max_upload_bytes { + let upload_size = auth.content_length.or(Some(form.size)).unwrap_or(0); + if upload_size > 0 && upload_size > settings.max_upload_bytes { return Nip96Response::error("File too large"); } let file = match form.file.open().await { @@ -193,7 +189,8 @@ async fn upload( } // account for upload speeds as slow as 1MB/s (8 Mbps) - let mbs = form.size / 1.megabytes().as_u64(); + let size_for_timing = if upload_size > 0 { upload_size } else { form.size }; + let mbs = size_for_timing / 1.megabytes().as_u64(); let max_time = 60.max(mbs); if auth.event.created_at < Timestamp::now().sub(Duration::from_secs(max_time)) { return Nip96Response::error("Auth event timestamp out of range"); @@ -215,10 +212,12 @@ async fn upload( .and_then(|p| p.free_quota_bytes) .unwrap_or(104857600); // Default to 100MB - match db.check_user_quota(&pubkey_vec, form.size, free_quota).await { - Ok(false) => return Nip96Response::error("Upload would exceed quota"), - Err(_) => return Nip96Response::error("Failed to check quota"), - Ok(true) => {} // Quota check passed + if upload_size > 0 { + match db.check_user_quota(&pubkey_vec, upload_size, free_quota).await { + Ok(false) => return Nip96Response::error("Upload would exceed quota"), + Err(_) => return Nip96Response::error("Failed to check quota"), + Ok(true) => {} // Quota check passed + } } } let upload = match fs @@ -227,6 +226,16 @@ async fn upload( { Ok(FileSystemResult::NewFile(blob)) => { let mut upload: FileUpload = (&blob).into(); + + // Validate file size after upload if no pre-upload size was available + if upload_size == 0 && upload.size > settings.max_upload_bytes { + // Clean up the uploaded file + if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await { + log::warn!("Failed to cleanup oversized file: {}", e); + } + return Nip96Response::error("File too large"); + } + upload.name = form.caption.map(|cap| cap.to_string()); upload.alt = form.alt.as_ref().map(|s| s.to_string()); upload @@ -236,7 +245,7 @@ async fn upload( _ => return Nip96Response::error("File not found"), }, Err(e) => { - error!("{}", e.to_string()); + error!("{}", e); return Nip96Response::error(&format!("Could not save file: {}", e)); } }; @@ -246,8 +255,34 @@ async fn upload( Err(e) => return Nip96Response::error(&format!("Could not save user: {}", e)), }; + // Post-upload quota check if we didn't have size information before upload + #[cfg(feature = "payments")] + if upload_size == 0 { + let free_quota = settings.payments.as_ref() + .and_then(|p| p.free_quota_bytes) + .unwrap_or(104857600); // Default to 100MB + + match db.check_user_quota(&pubkey_vec, upload.size, free_quota).await { + Ok(false) => { + // Clean up the uploaded file if quota exceeded + if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await { + log::warn!("Failed to cleanup quota-exceeding file: {}", e); + } + return Nip96Response::error("Upload would exceed quota"); + } + Err(_) => { + // Clean up on quota check error + if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await { + log::warn!("Failed to cleanup file after quota check error: {}", e); + } + return Nip96Response::error("Failed to check quota"); + } + Ok(true) => {} // Quota check passed + } + } + if let Err(e) = db.add_file(&upload, user_id).await { - error!("{}", e.to_string()); + error!("{}", e); return Nip96Response::error(&format!("Could not save file (db): {}", e)); } Nip96Response::UploadResult(Json(Nip96UploadResult::from_upload(settings, &upload))) diff --git a/ui_src/src/components/payment.tsx b/ui_src/src/components/payment.tsx index d231cb0..5b66272 100644 --- a/ui_src/src/components/payment.tsx +++ b/ui_src/src/components/payment.tsx @@ -1,13 +1,23 @@ import { useState, useEffect } from "react"; import Button from "./button"; -import { PaymentInfo, PaymentRequest, Route96 } from "../upload/admin"; +import { + PaymentInfo, + PaymentRequest, + Route96, + AdminSelf, +} from "../upload/admin"; interface PaymentFlowProps { route96: Route96; onPaymentRequested?: (paymentRequest: string) => void; + userInfo?: AdminSelf; } -export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlowProps) { +export default function PaymentFlow({ + route96, + onPaymentRequested, + userInfo, +}: PaymentFlowProps) { const [paymentInfo, setPaymentInfo] = useState(null); const [gigabytes, setGigabytes] = useState(1); const [months, setMonths] = useState(1); @@ -21,6 +31,17 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow } }, [paymentInfo]); + // Set default gigabytes to user's current quota + useEffect(() => { + if (userInfo?.quota && userInfo.quota > 0) { + // Convert from bytes to GB using 1024^3 (MiB) + const currentQuotaGB = Math.round(userInfo.quota / (1024 * 1024 * 1024)); + if (currentQuotaGB > 0) { + setGigabytes(currentQuotaGB); + } + } + }, [userInfo]); + async function loadPaymentInfo() { try { const info = await route96.getPaymentInfo(); @@ -36,10 +57,10 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow async function requestPayment() { if (!paymentInfo) return; - + setLoading(true); setError(""); - + try { const request: PaymentRequest = { units: gigabytes, quantity: months }; const response = await route96.requestPayment(request); @@ -68,8 +89,11 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow const totalCostSats = Math.round(totalCostBTC * 100000000); // Convert BTC to sats function formatStorageUnit(unit: string): string { - if (unit.toLowerCase().includes('gbspace') || unit.toLowerCase().includes('gb')) { - return 'GB'; + if ( + unit.toLowerCase().includes("gbspace") || + unit.toLowerCase().includes("gb") + ) { + return "GB"; } return unit; } @@ -77,17 +101,18 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow return (

Top Up Account

- +
- {gigabytes} {formatStorageUnit(paymentInfo.unit)} for {months} month{months > 1 ? 's' : ''} + {gigabytes} {formatStorageUnit(paymentInfo.unit)} for {months} month + {months > 1 ? "s" : ""}
{totalCostSats.toLocaleString()} sats
- +
- +
-
@@ -235,4 +245,4 @@ export default function Admin() { )}
); -} \ No newline at end of file +} diff --git a/ui_src/src/views/files.tsx b/ui_src/src/views/files.tsx index 1caf99e..d82dbbf 100644 --- a/ui_src/src/views/files.tsx +++ b/ui_src/src/views/files.tsx @@ -86,7 +86,8 @@ export default function FileList({ "rounded-l-md": x === start, "rounded-r-md": x + 1 === n, "bg-blue-600 text-white border-blue-600": page === x, - "bg-white text-gray-700 border-gray-300 hover:bg-gray-50": page !== x, + "bg-white text-gray-700 border-gray-300 hover:bg-gray-50": + page !== x, }, )} > @@ -124,7 +125,11 @@ export default function FileList({
{info.type}
- + View {onDelete && ( @@ -141,7 +146,11 @@ export default function FileList({
{info.uploader && info.uploader.map((a, idx) => ( - + ))} {renderInner(info)} @@ -204,13 +213,21 @@ export default function FileList({ {info.uploader && ( {info.uploader.map((a, idx) => ( - + ))} )}
- + View {onDelete && ( @@ -242,8 +259,8 @@ export default function FileList({
- + {viewType === "grid" ? showGrid() : showList()} - + {pages !== undefined && pages > 1 && (
diff --git a/ui_src/src/views/header.tsx b/ui_src/src/views/header.tsx index 13d7b76..87591fb 100644 --- a/ui_src/src/views/header.tsx +++ b/ui_src/src/views/header.tsx @@ -15,7 +15,8 @@ export default function Header() { const [self, setSelf] = useState(); const url = - import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`; + import.meta.env.VITE_API_URL || + `${window.location.protocol}//${window.location.host}`; async function tryLogin() { try { @@ -33,38 +34,41 @@ export default function Header() { useEffect(() => { if (pub && !self) { const r96 = new Route96(url, pub); - r96.getSelf().then((v) => setSelf(v.data)).catch(() => {}); + r96 + .getSelf() + .then((v) => setSelf(v.data)) + .catch(() => {}); } }, [pub, self, url]); return ( -
-
+
+
route96
- +
)} {showPaymentFlow && pub && (
- { console.log("Payment requested:", pr); }} + userInfo={self} />
)} @@ -281,7 +358,7 @@ export default function Upload() { )}
- + {listedFiles && ( 0 && (

Upload Results

-
-            {JSON.stringify(results, undefined, 2)}
-          
+
+ {results.map((result: any, index) => ( +
+
+
+

+ ✅ Upload Successful +

+

+ {new Date( + (result.uploaded || Date.now() / 1000) * 1000, + ).toLocaleString()} +

+
+
+ + {result.type || "Unknown type"} + +
+
+ +
+
+

File Size

+

+ {FormatBytes(result.size || 0)} +

+
+ {result.nip94?.find((tag: any[]) => tag[0] === "dim") && ( +
+

Dimensions

+

+ { + result.nip94.find( + (tag: any[]) => tag[0] === "dim", + )?.[1] + } +

+
+ )} +
+ +
+
+

File URL

+
+ + {result.url} + + +
+
+ + {result.nip94?.find((tag: any[]) => tag[0] === "thumb") && ( +
+

+ Thumbnail URL +

+
+ + { + result.nip94.find( + (tag: any[]) => tag[0] === "thumb", + )?.[1] + } + + +
+
+ )} + +
+

+ File Hash (SHA256) +

+ + {result.sha256} + +
+
+ +
+ + Show raw JSON data + +
+                    {JSON.stringify(result, undefined, 2)}
+                  
+
+
+ ))} +
)}