This commit is contained in:
parent
cfef57d8a1
commit
e1dd679c47
19 changed files with 1807 additions and 73 deletions
|
|
@ -19,3 +19,10 @@ TURN_PASSWORD=replace-me
|
|||
# 32+ random chars; used to sign session cookies
|
||||
SESSION_SECRET=replace-with-long-random-secret
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# Cloudflare R2 media uploads
|
||||
R2_ACCOUNT_ID=replace-me
|
||||
R2_ACCESS_KEY_ID=replace-me
|
||||
R2_SECRET_ACCESS_KEY=replace-me
|
||||
R2_BUCKET=chattz-media
|
||||
R2_PUBLIC_BASE_URL=https://media.example.com
|
||||
|
|
|
|||
815
Cargo.lock
generated
815
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,8 @@ edition = "2024"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rt-tokio", "rustls"] }
|
||||
aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "rustls"] }
|
||||
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ pub struct Settings {
|
|||
pub turn_urls: Vec<String>,
|
||||
pub turn_username: Option<String>,
|
||||
pub turn_password: Option<String>,
|
||||
pub media: Option<MediaSettings>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MediaSettings {
|
||||
pub account_id: String,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
pub bucket: String,
|
||||
pub public_base_url: String,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
|
@ -33,7 +43,8 @@ impl Settings {
|
|||
oidc_token_url: required("OIDC_TOKEN_URL")?,
|
||||
oidc_userinfo_url: required("OIDC_USERINFO_URL")?,
|
||||
oidc_redirect_url: required("OIDC_REDIRECT_URL")?,
|
||||
oidc_scopes: std::env::var("OIDC_SCOPES").unwrap_or_else(|_| "openid profile email".to_string()),
|
||||
oidc_scopes: std::env::var("OIDC_SCOPES")
|
||||
.unwrap_or_else(|_| "openid profile email".to_string()),
|
||||
session_secret: required("SESSION_SECRET")?,
|
||||
cookie_secure: std::env::var("COOKIE_SECURE")
|
||||
.unwrap_or_else(|_| "false".into())
|
||||
|
|
@ -43,10 +54,42 @@ impl Settings {
|
|||
turn_urls: parse_csv_env("TURN_URLS", ""),
|
||||
turn_username: optional("TURN_USERNAME"),
|
||||
turn_password: optional("TURN_PASSWORD"),
|
||||
media: MediaSettings::from_env()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSettings {
|
||||
fn from_env() -> Result<Option<Self>> {
|
||||
let account_id = optional("R2_ACCOUNT_ID");
|
||||
let access_key_id = optional("R2_ACCESS_KEY_ID");
|
||||
let secret_access_key = optional("R2_SECRET_ACCESS_KEY");
|
||||
let bucket = optional("R2_BUCKET");
|
||||
let public_base_url = optional("R2_PUBLIC_BASE_URL");
|
||||
|
||||
if account_id.is_none()
|
||||
&& access_key_id.is_none()
|
||||
&& secret_access_key.is_none()
|
||||
&& bucket.is_none()
|
||||
&& public_base_url.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Self {
|
||||
account_id: account_id.context("missing env var R2_ACCOUNT_ID")?,
|
||||
access_key_id: access_key_id.context("missing env var R2_ACCESS_KEY_ID")?,
|
||||
secret_access_key: secret_access_key.context("missing env var R2_SECRET_ACCESS_KEY")?,
|
||||
bucket: bucket.context("missing env var R2_BUCKET")?,
|
||||
public_base_url: public_base_url.context("missing env var R2_PUBLIC_BASE_URL")?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn endpoint_url(&self) -> String {
|
||||
format!("https://{}.r2.cloudflarestorage.com", self.account_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn required(name: &str) -> Result<String> {
|
||||
std::env::var(name).with_context(|| format!("missing env var {name}"))
|
||||
}
|
||||
|
|
|
|||
193
src/db.rs
193
src/db.rs
|
|
@ -9,12 +9,12 @@ use uuid::Uuid;
|
|||
|
||||
use crate::{
|
||||
entity::{
|
||||
channels, direct_messages, guild_members, guilds, invites, messages, soundboard_sounds,
|
||||
users,
|
||||
attachments, channels, direct_messages, guild_members, guilds, invites, messages,
|
||||
soundboard_sounds, users,
|
||||
},
|
||||
models::{
|
||||
BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor,
|
||||
SoundboardSound, User,
|
||||
Attachment, BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite,
|
||||
MessageWithAuthor, SoundboardSound, User,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -350,6 +350,63 @@ pub async fn create_message(
|
|||
author_user_id: model.author_user_id,
|
||||
author_display_name: user.display_name,
|
||||
body: model.body,
|
||||
attachments: Vec::new(),
|
||||
created_at: model.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_message_with_attachment(
|
||||
db: &DatabaseConnection,
|
||||
channel_id: Uuid,
|
||||
author_user_id: Uuid,
|
||||
body: &str,
|
||||
object_key: &str,
|
||||
media_url: &str,
|
||||
mime_type: &str,
|
||||
size_bytes: i64,
|
||||
original_filename: &str,
|
||||
) -> Result<MessageWithAuthor> {
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let model = messages::Entity::insert(messages::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
channel_id: Set(channel_id),
|
||||
author_user_id: Set(author_user_id),
|
||||
body: Set(body.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(&txn)
|
||||
.await?;
|
||||
|
||||
let attachment = attachments::Entity::insert(attachments::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
channel_message_id: Set(Some(model.id)),
|
||||
direct_message_id: Set(None),
|
||||
uploader_user_id: Set(author_user_id),
|
||||
object_key: Set(object_key.to_string()),
|
||||
media_url: Set(media_url.to_string()),
|
||||
mime_type: Set(mime_type.to_string()),
|
||||
size_bytes: Set(size_bytes),
|
||||
original_filename: Set(original_filename.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(&txn)
|
||||
.await?;
|
||||
|
||||
let user = users::Entity::find_by_id(author_user_id)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("author not found"))?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(MessageWithAuthor {
|
||||
id: model.id,
|
||||
channel_id: model.channel_id,
|
||||
author_user_id: model.author_user_id,
|
||||
author_display_name: user.display_name,
|
||||
body: model.body,
|
||||
attachments: vec![map_attachment(attachment)],
|
||||
created_at: model.created_at,
|
||||
})
|
||||
}
|
||||
|
|
@ -367,6 +424,10 @@ pub async fn list_messages(
|
|||
.all(db)
|
||||
.await?;
|
||||
|
||||
let attachment_map =
|
||||
list_attachments_for_channel_messages(db, rows.iter().map(|(msg, _)| msg.id).collect())
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(msg, user)| {
|
||||
|
|
@ -379,6 +440,7 @@ pub async fn list_messages(
|
|||
author_user_id: msg.author_user_id,
|
||||
author_display_name,
|
||||
body: msg.body,
|
||||
attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(),
|
||||
created_at: msg.created_at,
|
||||
}
|
||||
})
|
||||
|
|
@ -412,6 +474,63 @@ pub async fn create_direct_message(
|
|||
recipient_user_id: model.recipient_user_id,
|
||||
author_display_name: user.display_name,
|
||||
body: model.body,
|
||||
attachments: Vec::new(),
|
||||
created_at: model.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_direct_message_with_attachment(
|
||||
db: &DatabaseConnection,
|
||||
sender_user_id: Uuid,
|
||||
recipient_user_id: Uuid,
|
||||
body: &str,
|
||||
object_key: &str,
|
||||
media_url: &str,
|
||||
mime_type: &str,
|
||||
size_bytes: i64,
|
||||
original_filename: &str,
|
||||
) -> Result<DmMessageWithAuthor> {
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let model = direct_messages::Entity::insert(direct_messages::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
sender_user_id: Set(sender_user_id),
|
||||
recipient_user_id: Set(recipient_user_id),
|
||||
body: Set(body.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(&txn)
|
||||
.await?;
|
||||
|
||||
let attachment = attachments::Entity::insert(attachments::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
channel_message_id: Set(None),
|
||||
direct_message_id: Set(Some(model.id)),
|
||||
uploader_user_id: Set(sender_user_id),
|
||||
object_key: Set(object_key.to_string()),
|
||||
media_url: Set(media_url.to_string()),
|
||||
mime_type: Set(mime_type.to_string()),
|
||||
size_bytes: Set(size_bytes),
|
||||
original_filename: Set(original_filename.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(&txn)
|
||||
.await?;
|
||||
|
||||
let user = users::Entity::find_by_id(sender_user_id)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("sender not found"))?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(DmMessageWithAuthor {
|
||||
id: model.id,
|
||||
author_user_id: model.sender_user_id,
|
||||
recipient_user_id: model.recipient_user_id,
|
||||
author_display_name: user.display_name,
|
||||
body: model.body,
|
||||
attachments: vec![map_attachment(attachment)],
|
||||
created_at: model.created_at,
|
||||
})
|
||||
}
|
||||
|
|
@ -454,6 +573,9 @@ pub async fn list_direct_messages(
|
|||
.map(|u| (u.id, u.display_name))
|
||||
.collect();
|
||||
|
||||
let attachment_map =
|
||||
list_attachments_for_direct_messages(db, rows.iter().map(|msg| msg.id).collect()).await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|msg| DmMessageWithAuthor {
|
||||
|
|
@ -465,11 +587,64 @@ pub async fn list_direct_messages(
|
|||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown User".to_string()),
|
||||
body: msg.body,
|
||||
attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(),
|
||||
created_at: msg.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_attachments_for_channel_messages(
|
||||
db: &DatabaseConnection,
|
||||
message_ids: Vec<Uuid>,
|
||||
) -> Result<std::collections::HashMap<Uuid, Vec<Attachment>>> {
|
||||
if message_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let rows = attachments::Entity::find()
|
||||
.filter(attachments::Column::ChannelMessageId.is_in(message_ids))
|
||||
.order_by_asc(attachments::Column::CreatedAt)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut grouped = std::collections::HashMap::<Uuid, Vec<Attachment>>::new();
|
||||
for row in rows {
|
||||
if let Some(message_id) = row.channel_message_id {
|
||||
grouped
|
||||
.entry(message_id)
|
||||
.or_default()
|
||||
.push(map_attachment(row));
|
||||
}
|
||||
}
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
async fn list_attachments_for_direct_messages(
|
||||
db: &DatabaseConnection,
|
||||
message_ids: Vec<Uuid>,
|
||||
) -> Result<std::collections::HashMap<Uuid, Vec<Attachment>>> {
|
||||
if message_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let rows = attachments::Entity::find()
|
||||
.filter(attachments::Column::DirectMessageId.is_in(message_ids))
|
||||
.order_by_asc(attachments::Column::CreatedAt)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut grouped = std::collections::HashMap::<Uuid, Vec<Attachment>>::new();
|
||||
for row in rows {
|
||||
if let Some(message_id) = row.direct_message_id {
|
||||
grouped
|
||||
.entry(message_id)
|
||||
.or_default()
|
||||
.push(map_attachment(row));
|
||||
}
|
||||
}
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
pub async fn list_dm_conversations(
|
||||
db: &DatabaseConnection,
|
||||
current_user_id: Uuid,
|
||||
|
|
@ -543,6 +718,16 @@ fn map_guild(model: guilds::Model) -> Guild {
|
|||
}
|
||||
}
|
||||
|
||||
fn map_attachment(model: attachments::Model) -> Attachment {
|
||||
Attachment {
|
||||
id: model.id,
|
||||
media_url: model.media_url,
|
||||
mime_type: model.mime_type,
|
||||
size_bytes: model.size_bytes,
|
||||
original_filename: model.original_filename,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_channel(model: channels::Model) -> Channel {
|
||||
Channel {
|
||||
id: model.id,
|
||||
|
|
|
|||
66
src/entity/attachments.rs
Normal file
66
src/entity/attachments.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "attachments")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub channel_message_id: Option<Uuid>,
|
||||
pub direct_message_id: Option<Uuid>,
|
||||
pub uploader_user_id: Uuid,
|
||||
pub object_key: String,
|
||||
pub media_url: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: i64,
|
||||
pub original_filename: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::messages::Entity",
|
||||
from = "Column::ChannelMessageId",
|
||||
to = "super::messages::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Messages,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::direct_messages::Entity",
|
||||
from = "Column::DirectMessageId",
|
||||
to = "super::direct_messages::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
DirectMessages,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::UploaderUserId",
|
||||
to = "super::users::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "NoAction"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::messages::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Messages.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::direct_messages::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::DirectMessages.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod attachments;
|
||||
pub mod channels;
|
||||
pub mod direct_messages;
|
||||
pub mod guild_members;
|
||||
|
|
|
|||
261
src/handlers.rs
261
src/handlers.rs
|
|
@ -1,10 +1,11 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State, WebSocketUpgrade},
|
||||
extract::{Multipart, Path, Query, State, WebSocketUpgrade},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::Datelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ use crate::{
|
|||
AppState,
|
||||
auth::{self, ApiError, AuthUser},
|
||||
chat, db,
|
||||
models::{Guild, SoundboardSound},
|
||||
models::{DmMessageWithAuthor, Guild, MessageWithAuthor, SoundboardSound},
|
||||
voice,
|
||||
};
|
||||
use tracing::info;
|
||||
|
|
@ -30,6 +31,10 @@ pub fn routes() -> Router<AppState> {
|
|||
"/dms/{other_user_id}/messages",
|
||||
get(list_dm_messages).post(send_dm_message),
|
||||
)
|
||||
.route(
|
||||
"/dms/{other_user_id}/attachments",
|
||||
post(upload_dm_attachment),
|
||||
)
|
||||
.route("/presence", get(presence_list))
|
||||
.route("/rtc-config", get(rtc_config))
|
||||
.route("/guilds", get(list_guilds).post(create_guild))
|
||||
|
|
@ -54,6 +59,10 @@ pub fn routes() -> Router<AppState> {
|
|||
"/channels/{channel_id}/messages",
|
||||
get(list_messages).post(send_message),
|
||||
)
|
||||
.route(
|
||||
"/channels/{channel_id}/attachments",
|
||||
post(upload_channel_attachment),
|
||||
)
|
||||
.route("/channels/{channel_id}/voice/ws", get(voice_ws))
|
||||
.route("/ws", get(chat_ws))
|
||||
}
|
||||
|
|
@ -680,6 +689,89 @@ async fn send_dm_message(
|
|||
Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true })))
|
||||
}
|
||||
|
||||
async fn upload_channel_attachment(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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 message = db::create_message_with_attachment(
|
||||
&state.db,
|
||||
channel_id,
|
||||
user.id,
|
||||
"",
|
||||
&uploaded.object_key,
|
||||
&uploaded.media_url,
|
||||
&uploaded.mime_type,
|
||||
uploaded.size_bytes,
|
||||
&uploaded.original_filename,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to create message attachment: {e}")))?;
|
||||
|
||||
let guild_id = db::guild_id_for_channel(&state.db, channel_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))?
|
||||
.ok_or_else(|| ApiError::internal("channel not found after attachment upload"))?;
|
||||
|
||||
let members = db::list_guild_member_ids(&state.db, guild_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?;
|
||||
|
||||
broadcast_channel_message(&state, members, channel_id, &message).await;
|
||||
Ok((StatusCode::CREATED, Json(message)))
|
||||
}
|
||||
|
||||
async fn upload_dm_attachment(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(other_user_id): Path<Uuid>,
|
||||
multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if other_user_id == user.id {
|
||||
return Err(ApiError::bad_request("cannot send dm to yourself"));
|
||||
}
|
||||
|
||||
let other_user_exists = db::user_exists(&state.db, other_user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("user check failed: {e}")))?;
|
||||
if !other_user_exists {
|
||||
return Err(ApiError {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "user not found".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let uploaded = upload_media_from_multipart(
|
||||
&state,
|
||||
multipart,
|
||||
dm_object_key_prefix(user.id, other_user_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let message = db::create_direct_message_with_attachment(
|
||||
&state.db,
|
||||
user.id,
|
||||
other_user_id,
|
||||
"",
|
||||
&uploaded.object_key,
|
||||
&uploaded.media_url,
|
||||
&uploaded.mime_type,
|
||||
uploaded.size_bytes,
|
||||
&uploaded.original_filename,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to create dm attachment: {e}")))?;
|
||||
|
||||
broadcast_dm_message(&state, user.id, other_user_id, &message).await;
|
||||
Ok((StatusCode::CREATED, Json(message)))
|
||||
}
|
||||
|
||||
async fn voice_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -794,6 +886,171 @@ async fn upload_sound(
|
|||
Ok(Json(sound))
|
||||
}
|
||||
|
||||
const MAX_MEDIA_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
|
||||
struct UploadedMedia {
|
||||
object_key: String,
|
||||
media_url: String,
|
||||
mime_type: String,
|
||||
size_bytes: i64,
|
||||
original_filename: String,
|
||||
}
|
||||
|
||||
async fn upload_media_from_multipart(
|
||||
state: &AppState,
|
||||
mut multipart: Multipart,
|
||||
object_key_prefix: String,
|
||||
) -> Result<UploadedMedia, ApiError> {
|
||||
let storage = state
|
||||
.media
|
||||
.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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 media_url = storage
|
||||
.upload_object(&object_key, file_data, &mime_type, &original_filename)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||
|
||||
Ok(UploadedMedia {
|
||||
object_key,
|
||||
media_url,
|
||||
mime_type,
|
||||
size_bytes,
|
||||
original_filename,
|
||||
})
|
||||
}
|
||||
|
||||
async fn broadcast_channel_message(
|
||||
state: &AppState,
|
||||
members: Vec<Uuid>,
|
||||
channel_id: Uuid,
|
||||
message: &MessageWithAuthor,
|
||||
) {
|
||||
state
|
||||
.chat
|
||||
.broadcast_to_many(
|
||||
members,
|
||||
chat::ServerEvent::MessageCreated {
|
||||
channel_id,
|
||||
message: serde_json::to_value(message).unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn broadcast_dm_message(
|
||||
state: &AppState,
|
||||
current_user_id: Uuid,
|
||||
other_user_id: Uuid,
|
||||
message: &DmMessageWithAuthor,
|
||||
) {
|
||||
state
|
||||
.chat
|
||||
.broadcast_to_user(
|
||||
current_user_id,
|
||||
chat::ServerEvent::DmCreated {
|
||||
other_user_id,
|
||||
message: serde_json::to_value(message).unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
state
|
||||
.chat
|
||||
.broadcast_to_user(
|
||||
other_user_id,
|
||||
chat::ServerEvent::DmCreated {
|
||||
other_user_id: current_user_id,
|
||||
message: serde_json::to_value(message).unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn sanitize_file_name(file_name: &str) -> String {
|
||||
let sanitized: String = file_name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let trimmed = sanitized.trim_matches('_').trim();
|
||||
if trimmed.is_empty() {
|
||||
"upload.bin".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_object_key_prefix(channel_id: Uuid) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
format!(
|
||||
"channels/{}/{:04}/{:02}",
|
||||
channel_id,
|
||||
now.year(),
|
||||
now.month()
|
||||
)
|
||||
}
|
||||
|
||||
fn dm_object_key_prefix(user_a: Uuid, user_b: Uuid) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
let (left, right) = if user_a <= user_b {
|
||||
(user_a, user_b)
|
||||
} else {
|
||||
(user_b, user_a)
|
||||
};
|
||||
format!(
|
||||
"dms/{}-{}/{:04}/{:02}",
|
||||
left,
|
||||
right,
|
||||
now.year(),
|
||||
now.month()
|
||||
)
|
||||
}
|
||||
|
||||
async fn delete_sound_post(
|
||||
state: State<AppState>,
|
||||
user: AuthUser,
|
||||
|
|
|
|||
12
src/main.rs
12
src/main.rs
|
|
@ -4,6 +4,7 @@ mod config;
|
|||
mod db;
|
||||
mod entity;
|
||||
mod handlers;
|
||||
mod media;
|
||||
mod migration;
|
||||
mod models;
|
||||
mod voice;
|
||||
|
|
@ -17,7 +18,9 @@ use sea_orm_migration::MigratorTrait;
|
|||
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{config::Settings, handlers::routes, migration::Migrator, voice::VoiceHub};
|
||||
use crate::{
|
||||
config::Settings, handlers::routes, media::MediaStorage, migration::Migrator, voice::VoiceHub,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
|
|
@ -26,6 +29,7 @@ pub struct AppState {
|
|||
pub http: reqwest::Client,
|
||||
pub voice: Arc<VoiceHub>,
|
||||
pub chat: Arc<chat::ChatHub>,
|
||||
pub media: Option<Arc<MediaStorage>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -43,12 +47,18 @@ async fn main() -> anyhow::Result<()> {
|
|||
.await
|
||||
.with_context(|| "failed to run migrations")?;
|
||||
|
||||
let media = match settings.media.as_ref() {
|
||||
Some(media_settings) => Some(Arc::new(MediaStorage::new(media_settings).await?)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
settings,
|
||||
http: reqwest::Client::new(),
|
||||
voice: Arc::new(VoiceHub::default()),
|
||||
chat: Arc::new(chat::ChatHub::default()),
|
||||
media,
|
||||
};
|
||||
let port = state.settings.port;
|
||||
|
||||
|
|
|
|||
74
src/media.rs
Normal file
74
src/media.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_sdk_s3::{
|
||||
Client,
|
||||
config::{Credentials, Region},
|
||||
primitives::ByteStream,
|
||||
};
|
||||
|
||||
use crate::config::MediaSettings;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MediaStorage {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
impl MediaStorage {
|
||||
pub async fn new(settings: &MediaSettings) -> Result<Self> {
|
||||
let shared_config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.region(Region::new("auto"))
|
||||
.credentials_provider(Credentials::new(
|
||||
settings.access_key_id.clone(),
|
||||
settings.secret_access_key.clone(),
|
||||
None,
|
||||
None,
|
||||
"chattz-r2",
|
||||
))
|
||||
.load()
|
||||
.await;
|
||||
|
||||
let config = aws_sdk_s3::config::Builder::from(&shared_config)
|
||||
.endpoint_url(settings.endpoint_url())
|
||||
.force_path_style(true)
|
||||
.build();
|
||||
|
||||
Ok(Self {
|
||||
client: Client::from_conf(config),
|
||||
bucket: settings.bucket.clone(),
|
||||
public_base_url: settings.public_base_url.trim_end_matches('/').to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upload_object(
|
||||
&self,
|
||||
object_key: &str,
|
||||
bytes: Vec<u8>,
|
||||
content_type: &str,
|
||||
original_filename: &str,
|
||||
) -> Result<String> {
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key)
|
||||
.body(ByteStream::from(bytes))
|
||||
.content_type(content_type)
|
||||
.content_disposition(format!(
|
||||
"inline; filename=\"{}\"",
|
||||
sanitize_header_value(original_filename)
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to upload object to R2: {e}"))?;
|
||||
|
||||
Ok(format!("{}/{}", self.public_base_url, object_key))
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_header_value(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|c| *c != '\\' && *c != '"' && !c.is_control())
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -11,13 +11,13 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Users::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
|
||||
.col(
|
||||
ColumnDef::new(Users::Id)
|
||||
.uuid()
|
||||
ColumnDef::new(Users::OidcSub)
|
||||
.string()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Users::OidcSub).string().not_null().unique_key())
|
||||
.col(ColumnDef::new(Users::Email).string())
|
||||
.col(ColumnDef::new(Users::DisplayName).string().not_null())
|
||||
.col(ColumnDef::new(Users::AvatarUrl).string())
|
||||
|
|
@ -42,12 +42,7 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Guilds::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Guilds::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Guilds::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Guilds::Name).string().not_null())
|
||||
.col(ColumnDef::new(Guilds::OwnerUserId).uuid().not_null())
|
||||
.col(
|
||||
|
|
@ -108,12 +103,7 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Channels::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Channels::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Channels::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Channels::GuildId).uuid().not_null())
|
||||
.col(ColumnDef::new(Channels::Name).string().not_null())
|
||||
.col(
|
||||
|
|
@ -138,12 +128,7 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Messages::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Messages::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Messages::ChannelId).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::AuthorUserId).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::Body).text().not_null())
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Invites::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Invites::Code).string().not_null().primary_key())
|
||||
.col(
|
||||
ColumnDef::new(Invites::Code)
|
||||
.string()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Invites::GuildId).uuid().not_null())
|
||||
.col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null())
|
||||
.col(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,16 @@ impl MigrationTrait for Migration {
|
|||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(DirectMessages::SenderUserId).uuid().not_null())
|
||||
.col(ColumnDef::new(DirectMessages::RecipientUserId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(DirectMessages::SenderUserId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(DirectMessages::RecipientUserId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(DirectMessages::Body).text().not_null())
|
||||
.col(
|
||||
ColumnDef::new(DirectMessages::CreatedAt)
|
||||
|
|
|
|||
122
src/migration/m20260227_000006_attachments.rs
Normal file
122
src/migration/m20260227_000006_attachments.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Attachments::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Attachments::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Attachments::ChannelMessageId).uuid().null())
|
||||
.col(ColumnDef::new(Attachments::DirectMessageId).uuid().null())
|
||||
.col(ColumnDef::new(Attachments::UploaderUserId).uuid().not_null())
|
||||
.col(ColumnDef::new(Attachments::ObjectKey).string().not_null())
|
||||
.col(ColumnDef::new(Attachments::MediaUrl).string().not_null())
|
||||
.col(ColumnDef::new(Attachments::MimeType).string().not_null())
|
||||
.col(ColumnDef::new(Attachments::SizeBytes).big_integer().not_null())
|
||||
.col(ColumnDef::new(Attachments::OriginalFilename).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Attachments::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_attachments_channel_message")
|
||||
.from(Attachments::Table, Attachments::ChannelMessageId)
|
||||
.to(Messages::Table, Messages::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_attachments_direct_message")
|
||||
.from(Attachments::Table, Attachments::DirectMessageId)
|
||||
.to(DirectMessages::Table, DirectMessages::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_attachments_uploader")
|
||||
.from(Attachments::Table, Attachments::UploaderUserId)
|
||||
.to(Users::Table, Users::Id),
|
||||
)
|
||||
.check(
|
||||
Expr::cust(
|
||||
"(channel_message_id IS NOT NULL AND direct_message_id IS NULL) OR (channel_message_id IS NULL AND direct_message_id IS NOT NULL)",
|
||||
),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_attachments_channel_message")
|
||||
.table(Attachments::Table)
|
||||
.col(Attachments::ChannelMessageId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_attachments_direct_message")
|
||||
.table(Attachments::Table)
|
||||
.col(Attachments::DirectMessageId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Attachments::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Attachments {
|
||||
Table,
|
||||
Id,
|
||||
ChannelMessageId,
|
||||
DirectMessageId,
|
||||
UploaderUserId,
|
||||
ObjectKey,
|
||||
MediaUrl,
|
||||
MimeType,
|
||||
SizeBytes,
|
||||
OriginalFilename,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Messages {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum DirectMessages {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ mod m20260213_000002_invites;
|
|||
mod m20260213_000003_channel_kind;
|
||||
mod m20260213_000004_direct_messages;
|
||||
mod m20260224_000005_soundboard;
|
||||
mod m20260227_000006_attachments;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ impl MigratorTrait for Migrator {
|
|||
Box::new(m20260213_000003_channel_kind::Migration),
|
||||
Box::new(m20260213_000004_direct_messages::Migration),
|
||||
Box::new(m20260224_000005_soundboard::Migration),
|
||||
Box::new(m20260227_000006_attachments::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ pub struct Message {
|
|||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Attachment {
|
||||
pub id: Uuid,
|
||||
pub media_url: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: i64,
|
||||
pub original_filename: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BasicUser {
|
||||
pub id: Uuid,
|
||||
|
|
@ -46,13 +55,14 @@ pub struct BasicUser {
|
|||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MessageWithAuthor {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub author_user_id: Uuid,
|
||||
pub author_display_name: String,
|
||||
pub body: String,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +81,7 @@ pub struct DmMessageWithAuthor {
|
|||
pub recipient_user_id: Uuid,
|
||||
pub author_display_name: String,
|
||||
pub body: String,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,10 @@
|
|||
|
||||
<div class="chat-input-wrapper">
|
||||
<form id="message-form" class="message-form">
|
||||
<input id="media-file-input" type="file" class="hidden-file-input" accept="image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.tar,.gz,.json,.csv,.md" />
|
||||
<button type="button" id="media-upload-btn" class="input-action-btn" title="Upload File">
|
||||
<i data-lucide="paperclip"></i>
|
||||
</button>
|
||||
<button type="button" id="gif-btn" class="input-action-btn" title="Open GIF Picker">
|
||||
<i data-lucide="image"></i>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -183,6 +183,8 @@ const el = {
|
|||
messageList: document.getElementById("message-list"),
|
||||
messageForm: document.getElementById("message-form"),
|
||||
messageBody: document.getElementById("message-body"),
|
||||
mediaUploadBtn: document.getElementById("media-upload-btn"),
|
||||
mediaFileInput: document.getElementById("media-file-input"),
|
||||
|
||||
// User Panel
|
||||
userName: document.getElementById("user-name"),
|
||||
|
|
@ -343,6 +345,43 @@ function formatMessageBody(body) {
|
|||
return escaped;
|
||||
}
|
||||
|
||||
function formatBytes(size) {
|
||||
if (!Number.isFinite(size) || size < 1024) return `${size || 0} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let value = size;
|
||||
let unitIndex = -1;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function renderAttachments(attachments = []) {
|
||||
if (!attachments.length) return "";
|
||||
|
||||
return attachments.map((attachment) => {
|
||||
const url = escapeHtml(attachment.media_url);
|
||||
const mime = attachment.mime_type || "application/octet-stream";
|
||||
const fileName = escapeHtml(attachment.original_filename || "file");
|
||||
const size = formatBytes(attachment.size_bytes);
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
return `<a class="msg-attachment msg-attachment-image" href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${fileName}" loading="lazy" /></a>`;
|
||||
}
|
||||
|
||||
if (mime.startsWith("video/")) {
|
||||
return `<div class="msg-attachment msg-attachment-video"><video controls preload="metadata" src="${url}"></video></div>`;
|
||||
}
|
||||
|
||||
if (mime.startsWith("audio/")) {
|
||||
return `<div class="msg-attachment msg-attachment-audio"><audio controls preload="metadata" src="${url}"></audio><a href="${url}" target="_blank" rel="noopener noreferrer">${fileName}</a></div>`;
|
||||
}
|
||||
|
||||
return `<a class="msg-attachment msg-attachment-file" href="${url}" target="_blank" rel="noopener noreferrer"><i data-lucide="file"></i><span>${fileName}</span><small>${size}</small></a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
const d = new Date(isoString);
|
||||
const now = new Date();
|
||||
|
|
@ -609,9 +648,13 @@ function renderMessages(messages) {
|
|||
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
|
||||
|
||||
const displayName = m.author_display_name || "Unknown User";
|
||||
const formattedBody = formatMessageBody(m.body || "");
|
||||
const attachmentsHtml = renderAttachments(m.attachments);
|
||||
const bodyHtml = formattedBody ? `<div class="msg-body">${formattedBody}</div>` : "";
|
||||
const contentHtml = `${bodyHtml}${attachmentsHtml}`;
|
||||
|
||||
if (isGrouped) {
|
||||
row.innerHTML = `<div class="msg-content"><div class="msg-body">${formatMessageBody(m.body)}</div></div>`;
|
||||
row.innerHTML = `<div class="msg-content">${contentHtml}</div>`;
|
||||
} else {
|
||||
row.innerHTML = `
|
||||
<div class="msg-avatar">${shortName(displayName)}</div>
|
||||
|
|
@ -620,7 +663,7 @@ function renderMessages(messages) {
|
|||
<span class="msg-author">${escapeHtml(displayName)}</span>
|
||||
<span class="msg-time">${formatDate(m.created_at)}</span>
|
||||
</div>
|
||||
<div class="msg-body">${formatMessageBody(m.body)}</div>
|
||||
${contentHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -644,6 +687,15 @@ function renderMessages(messages) {
|
|||
gifImage.addEventListener("load", scrollMessagesToBottom, { once: true });
|
||||
gifImage.addEventListener("error", scrollMessagesToBottom, { once: true });
|
||||
}
|
||||
|
||||
for (const media of el.messageList.querySelectorAll(".msg-attachment img, .msg-attachment video, .msg-attachment audio")) {
|
||||
if ("complete" in media && media.complete) continue;
|
||||
media.addEventListener("loadeddata", scrollMessagesToBottom, { once: true });
|
||||
media.addEventListener("load", scrollMessagesToBottom, { once: true });
|
||||
media.addEventListener("error", scrollMessagesToBottom, { once: true });
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderMembers() {
|
||||
|
|
@ -1862,6 +1914,26 @@ async function init() {
|
|||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
el.mediaUploadBtn.onclick = () => {
|
||||
if (!state.selectedTextChannelId && !state.selectedDmUserId) {
|
||||
alert("Select a chat first.");
|
||||
return;
|
||||
}
|
||||
el.mediaFileInput.click();
|
||||
};
|
||||
|
||||
el.mediaFileInput.onchange = async () => {
|
||||
const file = el.mediaFileInput.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await uploadMediaFile(file);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
el.mediaFileInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Voice/Soundboard Controls
|
||||
el.voiceVideoBtn.onclick = toggleVideo;
|
||||
el.voiceScreenBtn.onclick = toggleScreenShare;
|
||||
|
|
@ -1962,6 +2034,54 @@ async function init() {
|
|||
}
|
||||
}
|
||||
|
||||
async function uploadMediaFile(file) {
|
||||
const token = storageGet("chattz_token");
|
||||
if (!token) throw new Error("You need to log in again.");
|
||||
|
||||
const path = state.selectedTextChannelId
|
||||
? `/channels/${state.selectedTextChannelId}/attachments`
|
||||
: state.selectedDmUserId
|
||||
? `/dms/${state.selectedDmUserId}/attachments`
|
||||
: 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...";
|
||||
|
||||
const fullUrl = API_BASE_URL ? `${API_BASE_URL}${path}` : path;
|
||||
try {
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "upload failed";
|
||||
try {
|
||||
const payload = await response.json();
|
||||
detail = payload.error || detail;
|
||||
} catch { }
|
||||
throw new Error(`${response.status}: ${detail}`);
|
||||
}
|
||||
|
||||
const messages = state.selectedTextChannelId
|
||||
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
|
||||
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
await loadDMConversations();
|
||||
renderDMs();
|
||||
} finally {
|
||||
el.mediaUploadBtn.disabled = false;
|
||||
el.mediaUploadBtn.title = "Upload File";
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Initial Data Loading & App Start
|
||||
try {
|
||||
state.me = await api("/me");
|
||||
|
|
|
|||
|
|
@ -885,6 +885,10 @@ select {
|
|||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════ */
|
||||
/* GIF Picker Modal */
|
||||
/* ═══════════════════════════════════════════════════════════ */
|
||||
|
|
@ -996,6 +1000,91 @@ select {
|
|||
object-fit: contain;
|
||||
}
|
||||
|
||||
.msg-attachment {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.msg-attachment-image {
|
||||
max-width: min(440px, 100%);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.msg-attachment-image img {
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.msg-attachment-video {
|
||||
max-width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.msg-attachment-video video {
|
||||
width: 100%;
|
||||
max-height: 420px;
|
||||
border-radius: var(--radius-md);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.msg-attachment-audio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
max-width: min(420px, 100%);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.msg-attachment-audio audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.msg-attachment-audio a {
|
||||
color: var(--text-link);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.msg-attachment-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
max-width: min(420px, 100%);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.msg-attachment-file:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.msg-attachment-file i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-link);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-attachment-file span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg-attachment-file small {
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════ */
|
||||
/* Sound Board */
|
||||
/* ═══════════════════════════════════════════════════════════ */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue