From 24975c2e0d66dfdf9593c5ba3c9485859dec0c2d Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 16:28:30 +0100 Subject: [PATCH] fix uploads --- Cargo.toml | 2 +- desktop/index.html | 10 ++++ shared-html/index.template.html | 10 ++++ src/handlers.rs | 89 ++++++++++++++++----------------- static/index.html | 10 ++++ static/shared/app-core.js | 37 ++++++++++++-- static/styles.css | 7 +++ 7 files changed, 115 insertions(+), 50 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d7ece96..b6a6c27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ sea-orm-migration = { version = "1.1", default-features = false, features = ["sq serde = { version = "1", features = ["derive"] } serde_json = "1" futures-util = "0.3" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] } tower-http = { version = "0.6", features = ["trace", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/desktop/index.html b/desktop/index.html index cb1de11..e04011c 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -269,6 +269,16 @@ + + diff --git a/shared-html/index.template.html b/shared-html/index.template.html index 9780068..bbf97e8 100644 --- a/shared-html/index.template.html +++ b/shared-html/index.template.html @@ -266,6 +266,16 @@ + + diff --git a/src/handlers.rs b/src/handlers.rs index 5c2f27b..6982750 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,6 +1,7 @@ use axum::{ Json, Router, - extract::{Multipart, Path, Query, State, WebSocketUpgrade}, + body::Bytes, + extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade}, http::{HeaderMap, StatusCode, header}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, @@ -35,6 +36,7 @@ pub fn routes() -> Router { "/dms/{other_user_id}/attachments", post(upload_dm_attachment), ) + .layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES)) .route("/presence", get(presence_list)) .route("/rtc-config", get(rtc_config)) .route("/guilds", get(list_guilds).post(create_guild)) @@ -50,6 +52,7 @@ pub fn routes() -> Router { "/guilds/{guild_id}/sounds", get(list_sounds).post(upload_sound), ) + .layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES)) .route( "/guilds/{guild_id}/sounds/{sound_id}", post(delete_sound_post).delete(delete_sound), @@ -63,6 +66,7 @@ pub fn routes() -> Router { "/channels/{channel_id}/attachments", post(upload_channel_attachment), ) + .layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES)) .route("/channels/{channel_id}/voice/ws", get(voice_ws)) .route("/ws", get(chat_ws)) } @@ -693,12 +697,17 @@ async fn upload_channel_attachment( State(state): State, user: AuthUser, Path(channel_id): Path, - multipart: Multipart, + headers: HeaderMap, + body: Bytes, ) -> Result { ensure_channel_member(&state, channel_id, user.id).await?; - let uploaded = - upload_media_from_multipart(&state, multipart, channel_object_key_prefix(channel_id)) - .await?; + let uploaded = upload_media_from_request( + &state, + &headers, + body, + channel_object_key_prefix(channel_id), + ) + .await?; let message = db::create_message_with_attachment( &state.db, @@ -731,7 +740,8 @@ async fn upload_dm_attachment( State(state): State, user: AuthUser, Path(other_user_id): Path, - multipart: Multipart, + headers: HeaderMap, + body: Bytes, ) -> Result { if other_user_id == user.id { return Err(ApiError::bad_request("cannot send dm to yourself")); @@ -747,9 +757,10 @@ async fn upload_dm_attachment( }); } - let uploaded = upload_media_from_multipart( + let uploaded = upload_media_from_request( &state, - multipart, + &headers, + body, dm_object_key_prefix(user.id, other_user_id), ) .await?; @@ -886,7 +897,7 @@ async fn upload_sound( Ok(Json(sound)) } -const MAX_MEDIA_UPLOAD_BYTES: usize = 25 * 1024 * 1024; +const MAX_MEDIA_UPLOAD_BYTES: usize = 50 * 1024 * 1024; struct UploadedMedia { object_key: String, @@ -896,9 +907,10 @@ struct UploadedMedia { original_filename: String, } -async fn upload_media_from_multipart( +async fn upload_media_from_request( state: &AppState, - mut multipart: Multipart, + headers: &HeaderMap, + body: Bytes, object_key_prefix: String, ) -> Result { let storage = state @@ -906,47 +918,34 @@ async fn upload_media_from_multipart( .as_ref() .ok_or_else(|| ApiError::internal("media storage is not configured"))?; - let mut file_name = None; - let mut file_data = None; - let mut mime_type = None; + let original_filename = headers + .get("x-file-name") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToString::to_string) + .ok_or_else(|| ApiError::bad_request("missing x-file-name header"))?; - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| ApiError::bad_request(&e.to_string()))? - { - if field.name().unwrap_or_default() != "file" { - continue; - } + let mime_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or("application/octet-stream") + .to_string(); - file_name = Some(field.file_name().unwrap_or("upload.bin").to_string()); - mime_type = Some( - field - .content_type() - .unwrap_or("application/octet-stream") - .to_string(), - ); - let bytes = field - .bytes() - .await - .map_err(|e| ApiError::bad_request(&e.to_string()))?; - if bytes.len() > MAX_MEDIA_UPLOAD_BYTES { - return Err(ApiError::bad_request("file exceeds 25MB upload limit")); - } - file_data = Some(bytes.to_vec()); - break; + if body.is_empty() { + return Err(ApiError::bad_request("missing file upload")); + } + if body.len() > MAX_MEDIA_UPLOAD_BYTES { + return Err(ApiError::bad_request("file exceeds 50MB upload limit")); } - - let (original_filename, mime_type, file_data) = match (file_name, mime_type, file_data) { - (Some(name), Some(mime), Some(data)) => (name, mime, data), - _ => return Err(ApiError::bad_request("missing file upload")), - }; let safe_name = sanitize_file_name(&original_filename); let object_key = format!("{}/{}-{}", object_key_prefix, Uuid::new_v4(), safe_name); - let size_bytes = file_data.len() as i64; + let size_bytes = body.len() as i64; let media_url = storage - .upload_object(&object_key, file_data, &mime_type, &original_filename) + .upload_object(&object_key, body.to_vec(), &mime_type, &original_filename) .await .map_err(|e| ApiError::internal(&e.to_string()))?; diff --git a/static/index.html b/static/index.html index 65093cb..96baa20 100644 --- a/static/index.html +++ b/static/index.html @@ -270,6 +270,16 @@ + + diff --git a/static/shared/app-core.js b/static/shared/app-core.js index f7013a9..4779042 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -230,6 +230,9 @@ const el = { soundFile: document.getElementById('sound-file'), soundModalCancel: document.getElementById('sound-modal-cancel'), soundSubmitBtn: document.getElementById('sound-submit-btn'), + uploadLimitModal: document.getElementById('upload-limit-modal'), + uploadLimitMessage: document.getElementById('upload-limit-message'), + uploadLimitOk: document.getElementById('upload-limit-ok'), // Mobile mobileMenuBtn: document.getElementById("mobile-menu-btn"), @@ -357,6 +360,8 @@ function formatBytes(size) { return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } +const MAX_MEDIA_UPLOAD_BYTES = 50 * 1024 * 1024; + function renderAttachments(attachments = []) { if (!attachments.length) return ""; @@ -382,6 +387,16 @@ function renderAttachments(attachments = []) { }).join(""); } +function showMediaUploadLimitError(file) { + const selectedSize = formatBytes(file?.size || 0); + if (el.uploadLimitModal && el.uploadLimitMessage) { + el.uploadLimitMessage.textContent = `Uploads are limited to 50 MB per file. Selected file size: ${selectedSize}.`; + el.uploadLimitModal.classList.remove("hidden"); + return; + } + alert(`Uploads are limited to 50 MB per file.\nSelected file size: ${selectedSize}.`); +} + function formatDate(isoString) { const d = new Date(isoString); const now = new Date(); @@ -1967,6 +1982,17 @@ async function init() { } }; + if (el.uploadLimitOk && el.uploadLimitModal) { + el.uploadLimitOk.onclick = () => { + el.uploadLimitModal.classList.add("hidden"); + }; + el.uploadLimitModal.onclick = (e) => { + if (e.target === el.uploadLimitModal) { + el.uploadLimitModal.classList.add("hidden"); + } + }; + } + // 5. GIF Picker const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0"; const TENOR_CLIENT_KEY = "pavel-discord"; @@ -2039,6 +2065,10 @@ async function init() { async function uploadMediaFile(file) { const token = storageGet("chattz_token"); if (!token) throw new Error("You need to log in again."); + if (file.size > MAX_MEDIA_UPLOAD_BYTES) { + showMediaUploadLimitError(file); + return; + } const path = state.selectedTextChannelId ? `/channels/${state.selectedTextChannelId}/attachments` @@ -2047,9 +2077,6 @@ async function init() { : null; if (!path) throw new Error("Select a chat first."); - const formData = new FormData(); - formData.append("file", file); - el.mediaUploadBtn.disabled = true; el.mediaUploadBtn.title = "Uploading..."; @@ -2059,8 +2086,10 @@ async function init() { method: "POST", headers: { Authorization: `Bearer ${token}`, + "Content-Type": file.type || "application/octet-stream", + "X-File-Name": file.name, }, - body: formData, + body: file, }); if (!response.ok) { diff --git a/static/styles.css b/static/styles.css index aac3dd9..cc5a4ad 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1478,6 +1478,13 @@ select { letter-spacing: -0.3px; } +.modal-copy { + margin: 0; + text-align: center; + color: var(--text-normal); + line-height: 1.5; +} + .form-item { margin-bottom: 20px; }