feat: implement upload error handling and rate limiting improvements
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
32
src/api.rs
32
src/api.rs
@@ -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()),
|
||||||
|
|||||||
@@ -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(|×tamp| 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(|×tamp| timestamp > one_hour_ago);
|
timestamps.retain(|×tamp| timestamp > now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user