feat: implement upload error handling and rate limiting improvements

This commit is contained in:
2026-01-16 11:23:14 +01:00
parent e90c4576a5
commit 1d75df2d41
5 changed files with 94 additions and 27 deletions

View File

@@ -25,6 +25,7 @@
</div> </div>
</div> </div>
</div> </div>
<div id="uploadError" class="upload-error" style="display: none" role="status" aria-live="polite"></div>
<div class="duration-container"> <div class="duration-container">
<label for="durationSlider">Duration: <span id="durationValue">5</span> min</label> <label for="durationSlider">Duration: <span id="durationValue">5</span> min</label>
@@ -72,6 +73,17 @@
const linkContainer = document.getElementById("linkContainer"); const linkContainer = document.getElementById("linkContainer");
const uploadedLink = document.getElementById("uploadedLink"); const uploadedLink = document.getElementById("uploadedLink");
const clipboardMessage = document.getElementById("clipboardMessage"); const clipboardMessage = document.getElementById("clipboardMessage");
const uploadError = document.getElementById("uploadError");
function formatRetryAfter(seconds) {
const safeSeconds = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safeSeconds / 60);
const remainder = safeSeconds % 60;
if (minutes > 0) {
return `${minutes}m ${remainder}s`;
}
return `${remainder}s`;
}
// Update duration display // Update duration display
durationSlider.addEventListener("input", function () { durationSlider.addEventListener("input", function () {
@@ -115,6 +127,8 @@
}; };
try { try {
uploadError.style.display = "none";
uploadError.textContent = "";
const response = await fetch("/api/upload", { const response = await fetch("/api/upload", {
method: "POST", method: "POST",
headers: { headers: {
@@ -123,10 +137,33 @@
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const result = await response.json(); let result = null;
console.log( try {
`✅ Upload received!\n${JSON.stringify(result, null, 2)}` result = await response.json();
); } catch (parseError) {
result = null;
}
if (!response.ok) {
const retryAfterSeconds = result && Number.isFinite(Number(result.retry_after_seconds))
? Number(result.retry_after_seconds)
: null;
let errorMessage =
(result && result.error) ||
`Upload failed (${response.status})`;
if (retryAfterSeconds !== null) {
errorMessage += ` Try again in ${formatRetryAfter(retryAfterSeconds)}.`;
}
uploadError.textContent = errorMessage;
uploadError.style.display = "block";
return;
}
if (!result || !result.link) {
uploadError.textContent = "Upload failed (invalid response)";
uploadError.style.display = "block";
return;
}
// Hide duration controls and buttons // Hide duration controls and buttons
document.querySelector('label[for="durationSlider"]').style.display = document.querySelector('label[for="durationSlider"]').style.display =
@@ -175,7 +212,8 @@
}; };
} }
} catch (error) { } catch (error) {
console.log(`❌ Error: ${error.message}`); uploadError.textContent = `Upload failed (${error.message})`;
uploadError.style.display = "block";
} }
} }
@@ -201,6 +239,8 @@
resetBtn.style.display = "none"; resetBtn.style.display = "none";
linkContainer.style.display = "none"; linkContainer.style.display = "none";
clipboardMessage.style.display = "none"; clipboardMessage.style.display = "none";
uploadError.style.display = "none";
uploadError.textContent = "";
uploadZone.focus(); uploadZone.focus();
}); });

View File

@@ -91,6 +91,18 @@ h1 .home-link:hover {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.upload-error {
margin: 12px 0 0 0;
padding: 10px 12px;
border: 1px solid #ff6666;
border-radius: 10px;
background-color: rgba(255, 102, 102, 0.12);
color: #ff6666;
font-size: 0.9em;
text-align: center;
box-shadow: 0 4px 12px rgba(255, 102, 102, 0.15);
}
.duration-container .button-row { .duration-container .button-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -1,6 +1,8 @@
use actix_web::http::header;
use actix_web::{HttpRequest, HttpResponse, get, post, web}; use actix_web::{HttpRequest, HttpResponse, get, post, web};
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
use chrono::Utc;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
@@ -9,6 +11,7 @@ use crate::{
data_mgt::{AppState, Asset}, data_mgt::{AppState, Asset},
logs::{LogEvent, LogEventType, log_event}, logs::{LogEvent, LogEventType, log_event},
}; };
use crate::{MAX_ASSET_DURATION, MIN_ASSET_DURATION};
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct UploadRequest { pub struct UploadRequest {
@@ -24,6 +27,8 @@ async fn api_upload(
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
// Check for rate limiting // Check for rate limiting
let now = Utc::now().timestamp_millis();
let connection_info = req.connection_info(); let connection_info = req.connection_info();
let uploader_ip = connection_info let uploader_ip = connection_info
@@ -32,25 +37,34 @@ async fn api_upload(
.or_else(|| connection_info.peer_addr().map(|value| value.to_string())) .or_else(|| connection_info.peer_addr().map(|value| value.to_string()))
.ok_or_else(|| actix_web::error::ErrorBadRequest("Cannot determine client ip"))?; .ok_or_else(|| actix_web::error::ErrorBadRequest("Cannot determine client ip"))?;
match app_state.connection_tracker.is_allowed(&uploader_ip).await {
true => {}
false => {
return Ok(HttpResponse::TooManyRequests().body("Upload limit exceeded"));
}
}
// Convert to bytes // Convert to bytes
let content_bytes = if body.content_type == "text/plain" { let content_bytes = if body.content_type == "text/plain" {
body.content.as_bytes().to_vec() body.content.as_bytes().to_vec()
} else { } else {
match general_purpose::STANDARD.decode(&body.content) { match general_purpose::STANDARD.decode(&body.content) {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(_) => return Ok(HttpResponse::BadRequest().body("Invalid base64 payload")), Err(_) => return Ok(HttpResponse::BadRequest().json(json!({ "error": "Invalid base64 content" }))),
} }
}; };
let clamped_duration = body.duration.clamp(MIN_ASSET_DURATION, MAX_ASSET_DURATION);
let asset_expiration_time = now + (clamped_duration as i64 * 60 * 1000);
let (allowed, retry_after_ms) = app_state
.connection_tracker
.check(&uploader_ip, asset_expiration_time)
.await;
if !allowed {
let retry_after_seconds = retry_after_ms.map(|ms| ((ms + 999) / 1000).max(1));
let response_body = match retry_after_seconds {
Some(seconds) => json!({ "error": "Upload limit exceeded", "retry_after_seconds": seconds }),
None => json!({ "error": "Upload limit exceeded" }),
};
// return Ok(HttpResponse::TooManyRequests().body("Upload limit exceeded"));
return Ok(HttpResponse::TooManyRequests().json(response_body));
}
let asset = crate::data_mgt::Asset::new( let asset = crate::data_mgt::Asset::new(
body.duration, clamped_duration,
body.content_type.clone(), body.content_type.clone(),
content_bytes, content_bytes,
Some(uploader_ip.clone()), Some(uploader_ip.clone()),

View File

@@ -9,7 +9,7 @@ use serde_json::Value;
use crate::MAX_ASSETS; use crate::MAX_ASSETS;
use crate::{ use crate::{
MAX_UPLOADS_PER_HOUR_PER_USER, MAX_UPLOADS_PER_USER,
logs::{LogEventType, log_event}, logs::{LogEventType, log_event},
}; };
@@ -182,19 +182,19 @@ pub struct RateLimiter {
} }
impl RateLimiter { impl RateLimiter {
pub async fn is_allowed(&self, client_ip: &str) -> bool { pub async fn check(&self, client_ip: &str, asset_exp_time: i64) -> (bool, Option<i64>) {
let mut clients = self.clients.lock().await; self.clear_expired().await;
let now = Utc::now().timestamp_millis(); let now = Utc::now().timestamp_millis();
let one_hour_ago = now - Duration::hours(1).num_milliseconds(); let mut clients = self.clients.lock().await;
let entry = clients.entry(client_ip.to_string()).or_insert_with(Vec::new); let entry = clients.entry(client_ip.to_string()).or_insert_with(Vec::new);
entry.retain(|&timestamp| timestamp > one_hour_ago);
let ret_val = if entry.len() < MAX_UPLOADS_PER_HOUR_PER_USER { let ret_val = if entry.len() < MAX_UPLOADS_PER_USER {
entry.push(now); entry.push(asset_exp_time);
true (true, None)
} else { } else {
false let first_to_expire = entry.iter().min().copied().unwrap();
let retry_after_ms = (first_to_expire - now).max(1);
(false, Some(retry_after_ms))
}; };
println!("{:?}", clients); println!("{:?}", clients);
ret_val ret_val
@@ -203,10 +203,9 @@ impl RateLimiter {
pub async fn clear_expired(&self) { pub async fn clear_expired(&self) {
let mut clients = self.clients.lock().await; let mut clients = self.clients.lock().await;
let now = Utc::now().timestamp_millis(); let now = Utc::now().timestamp_millis();
let one_hour_ago = now - Duration::hours(1).num_milliseconds();
for timestamps in clients.values_mut() { for timestamps in clients.values_mut() {
timestamps.retain(|&timestamp| timestamp > one_hour_ago); timestamps.retain(|&timestamp| timestamp > now);
} }
} }
} }

View File

@@ -14,9 +14,11 @@ use std::{env, fs, path::PathBuf, sync::LazyLock};
pub static HTML_DIR: &str = "data/html/"; pub static HTML_DIR: &str = "data/html/";
pub static LOG_DIR: &str = "data/logs/"; pub static LOG_DIR: &str = "data/logs/";
pub static LOG_FILE_NAME: &str = "log.txt"; pub static LOG_FILE_NAME: &str = "log.txt";
pub static MIN_ASSET_DURATION: u32 = 1; // in minutes
pub static MAX_ASSET_DURATION: u32 = 60; // in minutes
pub static MAX_ASSETS: usize = 1000; pub static MAX_ASSETS: usize = 1000;
pub static MAX_ASSET_SIZE_BYTES: usize = 3 * 1024 * 1024; // 3 MB pub static MAX_ASSET_SIZE_BYTES: usize = 3 * 1024 * 1024; // 3 MB
pub static MAX_UPLOADS_PER_HOUR_PER_USER: usize = 10; pub static MAX_UPLOADS_PER_USER: usize = 10;
pub static BIND_ADDR: LazyLock<String> = LazyLock::new(|| match env::var("BIND_ADDR") { pub static BIND_ADDR: LazyLock<String> = LazyLock::new(|| match env::var("BIND_ADDR") {
Ok(addr) => { Ok(addr) => {