parent
6882b49336
commit
24975c2e0d
7 changed files with 117 additions and 52 deletions
|
|
@ -17,7 +17,7 @@ sea-orm-migration = { version = "1.1", default-features = false, features = ["sq
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
futures-util = "0.3"
|
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"] }
|
tower-http = { version = "0.6", features = ["trace", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-limit-modal" class="modal-container hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Upload Too Large</h2>
|
||||||
|
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="app.js?v=20260227-shared-core-1"></script>
|
<script type="module" src="app.js?v=20260227-shared-core-1"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-limit-modal" class="modal-container hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Upload Too Large</h2>
|
||||||
|
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="{{app_src}}"></script>
|
<script type="module" src="{{app_src}}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Multipart, Path, Query, State, WebSocketUpgrade},
|
body::Bytes,
|
||||||
|
extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade},
|
||||||
http::{HeaderMap, StatusCode, header},
|
http::{HeaderMap, StatusCode, header},
|
||||||
response::{Html, IntoResponse, Redirect},
|
response::{Html, IntoResponse, Redirect},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
|
|
@ -35,6 +36,7 @@ pub fn routes() -> Router<AppState> {
|
||||||
"/dms/{other_user_id}/attachments",
|
"/dms/{other_user_id}/attachments",
|
||||||
post(upload_dm_attachment),
|
post(upload_dm_attachment),
|
||||||
)
|
)
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
|
||||||
.route("/presence", get(presence_list))
|
.route("/presence", get(presence_list))
|
||||||
.route("/rtc-config", get(rtc_config))
|
.route("/rtc-config", get(rtc_config))
|
||||||
.route("/guilds", get(list_guilds).post(create_guild))
|
.route("/guilds", get(list_guilds).post(create_guild))
|
||||||
|
|
@ -50,6 +52,7 @@ pub fn routes() -> Router<AppState> {
|
||||||
"/guilds/{guild_id}/sounds",
|
"/guilds/{guild_id}/sounds",
|
||||||
get(list_sounds).post(upload_sound),
|
get(list_sounds).post(upload_sound),
|
||||||
)
|
)
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
|
||||||
.route(
|
.route(
|
||||||
"/guilds/{guild_id}/sounds/{sound_id}",
|
"/guilds/{guild_id}/sounds/{sound_id}",
|
||||||
post(delete_sound_post).delete(delete_sound),
|
post(delete_sound_post).delete(delete_sound),
|
||||||
|
|
@ -63,6 +66,7 @@ pub fn routes() -> Router<AppState> {
|
||||||
"/channels/{channel_id}/attachments",
|
"/channels/{channel_id}/attachments",
|
||||||
post(upload_channel_attachment),
|
post(upload_channel_attachment),
|
||||||
)
|
)
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES))
|
||||||
.route("/channels/{channel_id}/voice/ws", get(voice_ws))
|
.route("/channels/{channel_id}/voice/ws", get(voice_ws))
|
||||||
.route("/ws", get(chat_ws))
|
.route("/ws", get(chat_ws))
|
||||||
}
|
}
|
||||||
|
|
@ -693,11 +697,16 @@ async fn upload_channel_attachment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
Path(channel_id): Path<Uuid>,
|
Path(channel_id): Path<Uuid>,
|
||||||
multipart: Multipart,
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
ensure_channel_member(&state, channel_id, user.id).await?;
|
ensure_channel_member(&state, channel_id, user.id).await?;
|
||||||
let uploaded =
|
let uploaded = upload_media_from_request(
|
||||||
upload_media_from_multipart(&state, multipart, channel_object_key_prefix(channel_id))
|
&state,
|
||||||
|
&headers,
|
||||||
|
body,
|
||||||
|
channel_object_key_prefix(channel_id),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let message = db::create_message_with_attachment(
|
let message = db::create_message_with_attachment(
|
||||||
|
|
@ -731,7 +740,8 @@ async fn upload_dm_attachment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
Path(other_user_id): Path<Uuid>,
|
Path(other_user_id): Path<Uuid>,
|
||||||
multipart: Multipart,
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
if other_user_id == user.id {
|
if other_user_id == user.id {
|
||||||
return Err(ApiError::bad_request("cannot send dm to yourself"));
|
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,
|
&state,
|
||||||
multipart,
|
&headers,
|
||||||
|
body,
|
||||||
dm_object_key_prefix(user.id, other_user_id),
|
dm_object_key_prefix(user.id, other_user_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -886,7 +897,7 @@ async fn upload_sound(
|
||||||
Ok(Json(sound))
|
Ok(Json(sound))
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_MEDIA_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
const MAX_MEDIA_UPLOAD_BYTES: usize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
struct UploadedMedia {
|
struct UploadedMedia {
|
||||||
object_key: String,
|
object_key: String,
|
||||||
|
|
@ -896,9 +907,10 @@ struct UploadedMedia {
|
||||||
original_filename: String,
|
original_filename: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_media_from_multipart(
|
async fn upload_media_from_request(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
mut multipart: Multipart,
|
headers: &HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
object_key_prefix: String,
|
object_key_prefix: String,
|
||||||
) -> Result<UploadedMedia, ApiError> {
|
) -> Result<UploadedMedia, ApiError> {
|
||||||
let storage = state
|
let storage = state
|
||||||
|
|
@ -906,47 +918,34 @@ async fn upload_media_from_multipart(
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::internal("media storage is not configured"))?;
|
.ok_or_else(|| ApiError::internal("media storage is not configured"))?;
|
||||||
|
|
||||||
let mut file_name = None;
|
let original_filename = headers
|
||||||
let mut file_data = None;
|
.get("x-file-name")
|
||||||
let mut mime_type = None;
|
.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
|
let mime_type = headers
|
||||||
.next_field()
|
.get(header::CONTENT_TYPE)
|
||||||
.await
|
.and_then(|v| v.to_str().ok())
|
||||||
.map_err(|e| ApiError::bad_request(&e.to_string()))?
|
.map(str::trim)
|
||||||
{
|
.filter(|v| !v.is_empty())
|
||||||
if field.name().unwrap_or_default() != "file" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
file_name = Some(field.file_name().unwrap_or("upload.bin").to_string());
|
|
||||||
mime_type = Some(
|
|
||||||
field
|
|
||||||
.content_type()
|
|
||||||
.unwrap_or("application/octet-stream")
|
.unwrap_or("application/octet-stream")
|
||||||
.to_string(),
|
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (original_filename, mime_type, file_data) = match (file_name, mime_type, file_data) {
|
if body.is_empty() {
|
||||||
(Some(name), Some(mime), Some(data)) => (name, mime, data),
|
return Err(ApiError::bad_request("missing file upload"));
|
||||||
_ => 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 safe_name = sanitize_file_name(&original_filename);
|
let safe_name = sanitize_file_name(&original_filename);
|
||||||
let object_key = format!("{}/{}-{}", object_key_prefix, Uuid::new_v4(), safe_name);
|
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
|
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
|
.await
|
||||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-limit-modal" class="modal-container hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Upload Too Large</h2>
|
||||||
|
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/static/app.js?v=20260227-shared-core-1"></script>
|
<script type="module" src="/static/app.js?v=20260227-shared-core-1"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,9 @@ const el = {
|
||||||
soundFile: document.getElementById('sound-file'),
|
soundFile: document.getElementById('sound-file'),
|
||||||
soundModalCancel: document.getElementById('sound-modal-cancel'),
|
soundModalCancel: document.getElementById('sound-modal-cancel'),
|
||||||
soundSubmitBtn: document.getElementById('sound-submit-btn'),
|
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
|
// Mobile
|
||||||
mobileMenuBtn: document.getElementById("mobile-menu-btn"),
|
mobileMenuBtn: document.getElementById("mobile-menu-btn"),
|
||||||
|
|
@ -357,6 +360,8 @@ function formatBytes(size) {
|
||||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_MEDIA_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
function renderAttachments(attachments = []) {
|
function renderAttachments(attachments = []) {
|
||||||
if (!attachments.length) return "";
|
if (!attachments.length) return "";
|
||||||
|
|
||||||
|
|
@ -382,6 +387,16 @@ function renderAttachments(attachments = []) {
|
||||||
}).join("");
|
}).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) {
|
function formatDate(isoString) {
|
||||||
const d = new Date(isoString);
|
const d = new Date(isoString);
|
||||||
const now = new Date();
|
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
|
// 5. GIF Picker
|
||||||
const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0";
|
const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0";
|
||||||
const TENOR_CLIENT_KEY = "pavel-discord";
|
const TENOR_CLIENT_KEY = "pavel-discord";
|
||||||
|
|
@ -2039,6 +2065,10 @@ async function init() {
|
||||||
async function uploadMediaFile(file) {
|
async function uploadMediaFile(file) {
|
||||||
const token = storageGet("chattz_token");
|
const token = storageGet("chattz_token");
|
||||||
if (!token) throw new Error("You need to log in again.");
|
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
|
const path = state.selectedTextChannelId
|
||||||
? `/channels/${state.selectedTextChannelId}/attachments`
|
? `/channels/${state.selectedTextChannelId}/attachments`
|
||||||
|
|
@ -2047,9 +2077,6 @@ async function init() {
|
||||||
: null;
|
: null;
|
||||||
if (!path) throw new Error("Select a chat first.");
|
if (!path) throw new Error("Select a chat first.");
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
el.mediaUploadBtn.disabled = true;
|
el.mediaUploadBtn.disabled = true;
|
||||||
el.mediaUploadBtn.title = "Uploading...";
|
el.mediaUploadBtn.title = "Uploading...";
|
||||||
|
|
||||||
|
|
@ -2059,8 +2086,10 @@ async function init() {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": file.type || "application/octet-stream",
|
||||||
|
"X-File-Name": file.name,
|
||||||
},
|
},
|
||||||
body: formData,
|
body: file,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -1478,6 +1478,13 @@ select {
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-copy {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-normal);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.form-item {
|
.form-item {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue