feat: media uploads
All checks were successful
/ upload (release) Successful in 5m37s

This commit is contained in:
pavel 2026-02-27 15:38:46 +01:00
commit e1dd679c47
19 changed files with 1807 additions and 73 deletions

View file

@ -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
View file

@ -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
View 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 {}

View file

@ -1,3 +1,4 @@
pub mod attachments;
pub mod channels;
pub mod direct_messages;
pub mod guild_members;

View file

@ -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,

View file

@ -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
View 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()
}

View file

@ -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())

View file

@ -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(

View file

@ -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)

View 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,
}

View file

@ -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),
]
}
}

View file

@ -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,
}