init
This commit is contained in:
commit
c7a592933e
32 changed files with 7871 additions and 0 deletions
201
src/auth.rs
Normal file
201
src/auth.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{StatusCode, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AppState, db};
|
||||
|
||||
const SESSION_COOKIE: &str = "chattz_session";
|
||||
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
||||
const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct SessionClaims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
iat: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn unauthorized(msg: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
message: msg.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bad_request(msg: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: msg.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(msg: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: msg.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody<'a> {
|
||||
error: &'a str,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(ErrorBody { error: &self.message })).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ApiError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
Self::internal(&err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthUser
|
||||
where
|
||||
AppState: axum::extract::FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app = AppState::from_ref(state);
|
||||
let token = read_cookie(parts, SESSION_COOKIE).ok_or_else(|| ApiError::unauthorized("missing session"))?;
|
||||
let user_id = verify_session(&token, &app.settings.session_secret)
|
||||
.map_err(|_| ApiError::unauthorized("invalid session"))?;
|
||||
|
||||
let exists = db::user_exists(&app.db, user_id)
|
||||
.await
|
||||
.map_err(|_| ApiError::unauthorized("session user not found"))?;
|
||||
if !exists {
|
||||
return Err(ApiError::unauthorized("session user not found"));
|
||||
}
|
||||
|
||||
Ok(Self { id: user_id })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_oauth_state() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
|
||||
format!(
|
||||
"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}",
|
||||
name = OAUTH_STATE_COOKIE,
|
||||
secure_flag = if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear_oauth_state_cookie(secure: bool) -> String {
|
||||
format!(
|
||||
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
|
||||
name = OAUTH_STATE_COOKIE,
|
||||
secure_flag = if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
||||
raw.split(';').find_map(|pair| {
|
||||
let mut kv = pair.trim().splitn(2, '=');
|
||||
let key = kv.next()?;
|
||||
let value = kv.next()?;
|
||||
(key == OAUTH_STATE_COOKIE).then(|| value.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_session_cookie(user_id: Uuid, secret: &str, secure: bool) -> Result<String> {
|
||||
let now = now_ts();
|
||||
let claims = SessionClaims {
|
||||
sub: user_id.to_string(),
|
||||
iat: now as usize,
|
||||
exp: (now + SESSION_TTL_SECS) as usize,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.context("failed to encode session token")?;
|
||||
|
||||
Ok(format!(
|
||||
"{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={ttl}{secure_flag}",
|
||||
name = SESSION_COOKIE,
|
||||
ttl = SESSION_TTL_SECS,
|
||||
secure_flag = if secure { "; Secure" } else { "" }
|
||||
))
|
||||
}
|
||||
|
||||
pub fn clear_session_cookie(secure: bool) -> String {
|
||||
format!(
|
||||
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
|
||||
name = SESSION_COOKIE,
|
||||
secure_flag = if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_session(token: &str, secret: &str) -> Result<Uuid> {
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
let data = decode::<SessionClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.context("failed to decode session token")?;
|
||||
|
||||
let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?;
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str) -> Result<()> {
|
||||
let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?;
|
||||
if expected != query_state {
|
||||
return Err(anyhow!("invalid oauth state"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_cookie(parts: &Parts, name: &str) -> Option<String> {
|
||||
let raw = parts.headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
||||
raw.split(';').find_map(|pair| {
|
||||
let mut kv = pair.trim().splitn(2, '=');
|
||||
let key = kv.next()?;
|
||||
let value = kv.next()?;
|
||||
(key == name).then(|| value.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn now_ts() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_else(|_| Duration::from_secs(0))
|
||||
.as_secs()
|
||||
}
|
||||
63
src/config.rs
Normal file
63
src/config.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Settings {
|
||||
pub database_url: String,
|
||||
pub oidc_client_id: String,
|
||||
pub oidc_client_secret: String,
|
||||
pub oidc_authorize_url: String,
|
||||
pub oidc_token_url: String,
|
||||
pub oidc_userinfo_url: String,
|
||||
pub oidc_redirect_url: String,
|
||||
pub oidc_scopes: String,
|
||||
pub session_secret: String,
|
||||
pub cookie_secure: bool,
|
||||
pub stun_urls: Vec<String>,
|
||||
pub turn_urls: Vec<String>,
|
||||
pub turn_username: Option<String>,
|
||||
pub turn_password: Option<String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Ok(Self {
|
||||
database_url: required("DATABASE_URL")?,
|
||||
oidc_client_id: required("OIDC_CLIENT_ID")?,
|
||||
oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
|
||||
oidc_authorize_url: required("OIDC_AUTHORIZE_URL")?,
|
||||
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()),
|
||||
session_secret: required("SESSION_SECRET")?,
|
||||
cookie_secure: std::env::var("COOKIE_SECURE")
|
||||
.unwrap_or_else(|_| "false".into())
|
||||
.parse()
|
||||
.context("COOKIE_SECURE must be true/false")?,
|
||||
stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"),
|
||||
turn_urls: parse_csv_env("TURN_URLS", ""),
|
||||
turn_username: optional("TURN_USERNAME"),
|
||||
turn_password: optional("TURN_PASSWORD"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn required(name: &str) -> Result<String> {
|
||||
std::env::var(name).with_context(|| format!("missing env var {name}"))
|
||||
}
|
||||
|
||||
fn optional(name: &str) -> Option<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn parse_csv_env(name: &str, default_value: &str) -> Vec<String> {
|
||||
let raw = std::env::var(name).unwrap_or_else(|_| default_value.to_string());
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
505
src/db.rs
Normal file
505
src/db.rs
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use chrono::{Duration, Utc};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection,
|
||||
Condition, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||
TransactionTrait, sea_query::OnConflict,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
entity::{channels, direct_messages, guild_members, guilds, invites, messages, users},
|
||||
models::{BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, User},
|
||||
};
|
||||
|
||||
pub const CHANNEL_KIND_TEXT: &str = "text";
|
||||
pub const CHANNEL_KIND_VOICE: &str = "voice";
|
||||
|
||||
pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result<bool> {
|
||||
let count = users::Entity::find_by_id(user_id).count(db).await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub async fn upsert_user_from_oidc(
|
||||
db: &DatabaseConnection,
|
||||
oidc_sub: &str,
|
||||
email: Option<&str>,
|
||||
display_name: &str,
|
||||
avatar_url: Option<&str>,
|
||||
) -> Result<User> {
|
||||
let user = users::Entity::insert(users::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
oidc_sub: Set(oidc_sub.to_string()),
|
||||
email: Set(email.map(ToString::to_string)),
|
||||
display_name: Set(display_name.to_string()),
|
||||
avatar_url: Set(avatar_url.map(ToString::to_string)),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(users::Column::OidcSub)
|
||||
.update_columns([
|
||||
users::Column::Email,
|
||||
users::Column::DisplayName,
|
||||
users::Column::AvatarUrl,
|
||||
])
|
||||
.value(users::Column::UpdatedAt, sea_orm::sea_query::Expr::current_timestamp())
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_with_returning(db)
|
||||
.await?;
|
||||
|
||||
Ok(map_user(user))
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(db: &DatabaseConnection, user_id: Uuid) -> Result<Option<User>> {
|
||||
let row = users::Entity::find_by_id(user_id).one(db).await?;
|
||||
Ok(row.map(map_user))
|
||||
}
|
||||
|
||||
pub async fn list_guild_members(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<BasicUser>> {
|
||||
let rows = guild_members::Entity::find()
|
||||
.filter(guild_members::Column::GuildId.eq(guild_id))
|
||||
.find_also_related(users::Entity)
|
||||
.order_by_asc(guild_members::Column::CreatedAt)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|(_, user)| user)
|
||||
.map(|u| BasicUser {
|
||||
id: u.id,
|
||||
display_name: u.display_name,
|
||||
avatar_url: u.avatar_url,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<Guild>> {
|
||||
let rows = guild_members::Entity::find()
|
||||
.filter(guild_members::Column::UserId.eq(user_id))
|
||||
.find_also_related(guilds::Entity)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|(_, guild)| guild.map(map_guild))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &str) -> Result<Guild> {
|
||||
let guild = guilds::Entity::insert(guilds::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
name: Set(name.to_string()),
|
||||
owner_user_id: Set(owner_user_id),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(db)
|
||||
.await?;
|
||||
|
||||
guild_members::Entity::insert(guild_members::ActiveModel {
|
||||
guild_id: Set(guild.id),
|
||||
user_id: Set(owner_user_id),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
Ok(map_guild(guild))
|
||||
}
|
||||
|
||||
pub async fn create_invite(
|
||||
db: &DatabaseConnection,
|
||||
guild_id: Uuid,
|
||||
created_by_user_id: Uuid,
|
||||
max_uses: Option<i32>,
|
||||
expires_in_hours: Option<i64>,
|
||||
) -> Result<Invite> {
|
||||
let expires_at = expires_in_hours.map(|h| (Utc::now() + Duration::hours(h)).fixed_offset());
|
||||
|
||||
for _ in 0..8 {
|
||||
let code = generate_invite_code();
|
||||
let insert = invites::Entity::insert(invites::ActiveModel {
|
||||
code: Set(code.clone()),
|
||||
guild_id: Set(guild_id),
|
||||
created_by_user_id: Set(created_by_user_id),
|
||||
expires_at: Set(expires_at),
|
||||
max_uses: Set(max_uses),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(db)
|
||||
.await;
|
||||
|
||||
match insert {
|
||||
Ok(_) => {
|
||||
let row = invites::Entity::find_by_id(code)
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("created invite missing"))?;
|
||||
return Ok(map_invite(row));
|
||||
}
|
||||
Err(err) if err.to_string().contains("duplicate key") => continue,
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("failed to generate unique invite code"))
|
||||
}
|
||||
|
||||
pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result<Guild> {
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let invite = invites::Entity::find_by_id(code.to_string())
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("invite not found"))?;
|
||||
|
||||
validate_invite(&invite)?;
|
||||
let guild_id = invite.guild_id;
|
||||
|
||||
let already_member = guild_members::Entity::find()
|
||||
.filter(guild_members::Column::GuildId.eq(invite.guild_id))
|
||||
.filter(guild_members::Column::UserId.eq(user_id))
|
||||
.one(&txn)
|
||||
.await?
|
||||
.is_some();
|
||||
|
||||
guild_members::Entity::insert(guild_members::ActiveModel {
|
||||
guild_id: Set(invite.guild_id),
|
||||
user_id: Set(user_id),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
|
||||
if !already_member {
|
||||
increment_invite_use_count(&txn, invite).await?;
|
||||
}
|
||||
|
||||
let guild = guilds::Entity::find_by_id(guild_id)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("guild for invite not found"))?;
|
||||
|
||||
txn.commit().await?;
|
||||
Ok(map_guild(guild))
|
||||
}
|
||||
|
||||
pub async fn create_channel(
|
||||
db: &DatabaseConnection,
|
||||
guild_id: Uuid,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<Channel> {
|
||||
let channel = channels::Entity::insert(channels::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
guild_id: Set(guild_id),
|
||||
name: Set(name.to_string()),
|
||||
kind: Set(kind.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(db)
|
||||
.await?;
|
||||
|
||||
Ok(map_channel(channel))
|
||||
}
|
||||
|
||||
pub async fn get_channel_by_id(db: &DatabaseConnection, channel_id: Uuid) -> Result<Option<Channel>> {
|
||||
let row = channels::Entity::find_by_id(channel_id).one(db).await?;
|
||||
Ok(row.map(map_channel))
|
||||
}
|
||||
|
||||
pub async fn list_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<Channel>> {
|
||||
let channels = channels::Entity::find()
|
||||
.filter(channels::Column::GuildId.eq(guild_id))
|
||||
.order_by_asc(channels::Column::CreatedAt)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(channels.into_iter().map(map_channel).collect())
|
||||
}
|
||||
|
||||
pub async fn list_voice_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<Channel>> {
|
||||
let channels = channels::Entity::find()
|
||||
.filter(channels::Column::GuildId.eq(guild_id))
|
||||
.filter(channels::Column::Kind.eq(CHANNEL_KIND_VOICE))
|
||||
.order_by_asc(channels::Column::CreatedAt)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(channels.into_iter().map(map_channel).collect())
|
||||
}
|
||||
|
||||
pub async fn is_member_of_guild(db: &DatabaseConnection, guild_id: Uuid, user_id: Uuid) -> Result<bool> {
|
||||
let count = guild_members::Entity::find()
|
||||
.filter(guild_members::Column::GuildId.eq(guild_id))
|
||||
.filter(guild_members::Column::UserId.eq(user_id))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub async fn guild_id_for_channel(db: &DatabaseConnection, channel_id: Uuid) -> Result<Option<Uuid>> {
|
||||
let guild_id = channels::Entity::find_by_id(channel_id)
|
||||
.select_only()
|
||||
.column(channels::Column::GuildId)
|
||||
.into_tuple::<Uuid>()
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
Ok(guild_id)
|
||||
}
|
||||
|
||||
pub async fn create_message(
|
||||
db: &DatabaseConnection,
|
||||
channel_id: Uuid,
|
||||
author_user_id: Uuid,
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
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(db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_messages(
|
||||
db: &DatabaseConnection,
|
||||
channel_id: Uuid,
|
||||
limit: i64,
|
||||
) -> Result<Vec<MessageWithAuthor>> {
|
||||
let rows = messages::Entity::find()
|
||||
.filter(messages::Column::ChannelId.eq(channel_id))
|
||||
.find_also_related(users::Entity)
|
||||
.order_by_desc(messages::Column::CreatedAt)
|
||||
.limit(limit as u64)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(msg, user)| {
|
||||
let author_display_name = user
|
||||
.map(|u| u.display_name)
|
||||
.unwrap_or_else(|| "Unknown User".to_string());
|
||||
MessageWithAuthor {
|
||||
id: msg.id,
|
||||
channel_id: msg.channel_id,
|
||||
author_user_id: msg.author_user_id,
|
||||
author_display_name,
|
||||
body: msg.body,
|
||||
created_at: msg.created_at,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn create_direct_message(
|
||||
db: &DatabaseConnection,
|
||||
sender_user_id: Uuid,
|
||||
recipient_user_id: Uuid,
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
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(db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_direct_messages(
|
||||
db: &DatabaseConnection,
|
||||
current_user_id: Uuid,
|
||||
other_user_id: Uuid,
|
||||
limit: i64,
|
||||
) -> Result<Vec<DmMessageWithAuthor>> {
|
||||
let rows = direct_messages::Entity::find()
|
||||
.filter(
|
||||
Condition::any()
|
||||
.add(
|
||||
Condition::all()
|
||||
.add(direct_messages::Column::SenderUserId.eq(current_user_id))
|
||||
.add(direct_messages::Column::RecipientUserId.eq(other_user_id)),
|
||||
)
|
||||
.add(
|
||||
Condition::all()
|
||||
.add(direct_messages::Column::SenderUserId.eq(other_user_id))
|
||||
.add(direct_messages::Column::RecipientUserId.eq(current_user_id)),
|
||||
),
|
||||
)
|
||||
.order_by_desc(direct_messages::Column::CreatedAt)
|
||||
.limit(limit as u64)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut author_ids = rows.iter().map(|m| m.sender_user_id).collect::<Vec<_>>();
|
||||
author_ids.sort_unstable();
|
||||
author_ids.dedup();
|
||||
|
||||
let authors = users::Entity::find()
|
||||
.filter(users::Column::Id.is_in(author_ids))
|
||||
.all(db)
|
||||
.await?;
|
||||
let author_names: std::collections::HashMap<Uuid, String> = authors
|
||||
.into_iter()
|
||||
.map(|u| (u.id, u.display_name))
|
||||
.collect();
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|msg| DmMessageWithAuthor {
|
||||
id: msg.id,
|
||||
author_user_id: msg.sender_user_id,
|
||||
recipient_user_id: msg.recipient_user_id,
|
||||
author_display_name: author_names
|
||||
.get(&msg.sender_user_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown User".to_string()),
|
||||
body: msg.body,
|
||||
created_at: msg.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uuid) -> Result<Vec<DmConversation>> {
|
||||
let rows = direct_messages::Entity::find()
|
||||
.filter(
|
||||
Condition::any()
|
||||
.add(direct_messages::Column::SenderUserId.eq(current_user_id))
|
||||
.add(direct_messages::Column::RecipientUserId.eq(current_user_id)),
|
||||
)
|
||||
.order_by_desc(direct_messages::Column::CreatedAt)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut latest_by_peer = std::collections::HashMap::<Uuid, chrono::DateTime<chrono::FixedOffset>>::new();
|
||||
for row in rows {
|
||||
let peer_id = if row.sender_user_id == current_user_id {
|
||||
row.recipient_user_id
|
||||
} else {
|
||||
row.sender_user_id
|
||||
};
|
||||
latest_by_peer.entry(peer_id).or_insert(row.created_at);
|
||||
}
|
||||
|
||||
if latest_by_peer.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let peer_ids: Vec<Uuid> = latest_by_peer.keys().copied().collect();
|
||||
let peers = users::Entity::find()
|
||||
.filter(users::Column::Id.is_in(peer_ids))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut conversations: Vec<DmConversation> = peers
|
||||
.into_iter()
|
||||
.filter_map(|u| {
|
||||
let last = latest_by_peer.get(&u.id)?;
|
||||
Some(DmConversation {
|
||||
user_id: u.id,
|
||||
display_name: u.display_name,
|
||||
avatar_url: u.avatar_url,
|
||||
last_message_at: *last,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
conversations.sort_by(|a, b| b.last_message_at.cmp(&a.last_message_at));
|
||||
Ok(conversations)
|
||||
}
|
||||
|
||||
fn map_user(model: users::Model) -> User {
|
||||
User {
|
||||
id: model.id,
|
||||
oidc_sub: model.oidc_sub,
|
||||
email: model.email,
|
||||
display_name: model.display_name,
|
||||
avatar_url: model.avatar_url,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_guild(model: guilds::Model) -> Guild {
|
||||
Guild {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
owner_user_id: model.owner_user_id,
|
||||
created_at: model.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_channel(model: channels::Model) -> Channel {
|
||||
Channel {
|
||||
id: model.id,
|
||||
guild_id: model.guild_id,
|
||||
name: model.name,
|
||||
kind: model.kind,
|
||||
created_at: model.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_invite(model: invites::Model) -> Invite {
|
||||
Invite {
|
||||
code: model.code,
|
||||
guild_id: model.guild_id,
|
||||
created_by_user_id: model.created_by_user_id,
|
||||
created_at: model.created_at,
|
||||
expires_at: model.expires_at,
|
||||
max_uses: model.max_uses,
|
||||
use_count: model.use_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_invite_code() -> String {
|
||||
Uuid::new_v4().simple().to_string()[..10].to_uppercase()
|
||||
}
|
||||
|
||||
fn validate_invite(invite: &invites::Model) -> Result<()> {
|
||||
if let Some(expires_at) = invite.expires_at {
|
||||
if expires_at < Utc::now().fixed_offset() {
|
||||
return Err(anyhow!("invite expired"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max_uses) = invite.max_uses {
|
||||
if invite.use_count >= max_uses {
|
||||
return Err(anyhow!("invite exhausted"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn increment_invite_use_count(txn: &DatabaseTransaction, invite: invites::Model) -> Result<()> {
|
||||
let next_count = invite.use_count + 1;
|
||||
let mut active: invites::ActiveModel = invite.into();
|
||||
active.use_count = Set(next_count);
|
||||
active.update(txn).await?;
|
||||
Ok(())
|
||||
}
|
||||
32
src/entity/channels.rs
Normal file
32
src/entity/channels.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channels")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: Uuid,
|
||||
pub guild_id: Uuid,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guilds::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guilds::Column::Id"
|
||||
)]
|
||||
Guilds,
|
||||
#[sea_orm(has_many = "super::messages::Entity")]
|
||||
Messages,
|
||||
}
|
||||
|
||||
impl Related<super::guilds::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guilds.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
31
src/entity/direct_messages.rs
Normal file
31
src/entity/direct_messages.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "direct_messages")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub sender_user_id: Uuid,
|
||||
pub recipient_user_id: Uuid,
|
||||
pub body: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::SenderUserId",
|
||||
to = "super::users::Column::Id"
|
||||
)]
|
||||
SenderUser,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::RecipientUserId",
|
||||
to = "super::users::Column::Id"
|
||||
)]
|
||||
RecipientUser,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
src/entity/guild_members.rs
Normal file
41
src/entity/guild_members.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "guild_members")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub guild_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guilds::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guilds::Column::Id"
|
||||
)]
|
||||
Guilds,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::users::Column::Id"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::guilds::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guilds.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
31
src/entity/guilds.rs
Normal file
31
src/entity/guilds.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "guilds")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub owner_user_id: Uuid,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::OwnerUserId",
|
||||
to = "super::users::Column::Id"
|
||||
)]
|
||||
Users,
|
||||
#[sea_orm(has_many = "super::channels::Entity")]
|
||||
Channels,
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
44
src/entity/invites.rs
Normal file
44
src/entity/invites.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "invites")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub code: String,
|
||||
pub guild_id: Uuid,
|
||||
pub created_by_user_id: Uuid,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub expires_at: Option<DateTimeWithTimeZone>,
|
||||
pub max_uses: Option<i32>,
|
||||
pub use_count: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guilds::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guilds::Column::Id"
|
||||
)]
|
||||
Guilds,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::CreatedByUserId",
|
||||
to = "super::users::Column::Id"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::guilds::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guilds.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
42
src/entity/messages.rs
Normal file
42
src/entity/messages.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "messages")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub author_user_id: Uuid,
|
||||
pub body: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channels::Entity",
|
||||
from = "Column::ChannelId",
|
||||
to = "super::channels::Column::Id"
|
||||
)]
|
||||
Channels,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::AuthorUserId",
|
||||
to = "super::users::Column::Id"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::channels::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channels.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
7
src/entity/mod.rs
Normal file
7
src/entity/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod channels;
|
||||
pub mod direct_messages;
|
||||
pub mod guild_members;
|
||||
pub mod guilds;
|
||||
pub mod invites;
|
||||
pub mod messages;
|
||||
pub mod users;
|
||||
30
src/entity/users.rs
Normal file
30
src/entity/users.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: Uuid,
|
||||
pub oidc_sub: String,
|
||||
pub email: Option<String>,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::guilds::Entity")]
|
||||
Guilds,
|
||||
#[sea_orm(has_many = "super::messages::Entity")]
|
||||
Messages,
|
||||
}
|
||||
|
||||
impl Related<super::guilds::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guilds.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
617
src/handlers.rs
Normal file
617
src/handlers.rs
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State, WebSocketUpgrade},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::{self, ApiError, AuthUser},
|
||||
db, voice,
|
||||
};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/auth/login", get(auth_login))
|
||||
.route("/auth/callback", get(auth_callback))
|
||||
.route("/auth/logout", post(auth_logout))
|
||||
.route("/me", get(me))
|
||||
.route("/dms", get(list_dm_conversations))
|
||||
.route("/dms/{other_user_id}/messages", get(list_dm_messages).post(send_dm_message))
|
||||
.route("/rtc-config", get(rtc_config))
|
||||
.route("/guilds", get(list_guilds).post(create_guild))
|
||||
.route("/guilds/{guild_id}/members", get(list_guild_members))
|
||||
.route("/guilds/{guild_id}/channels", get(list_channels))
|
||||
.route("/guilds/{guild_id}/voice-presence", get(guild_voice_presence))
|
||||
.route("/guilds/{guild_id}/invites", post(create_invite))
|
||||
.route("/invites/{code}/join", post(join_invite))
|
||||
.route("/channels", post(create_channel))
|
||||
.route("/channels/{channel_id}/messages", get(list_messages).post(send_message))
|
||||
.route("/channels/{channel_id}/voice/ws", get(voice_ws))
|
||||
}
|
||||
|
||||
pub async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(include_str!("../static/index.html"))
|
||||
}
|
||||
|
||||
async fn auth_login(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let oauth_state = auth::new_oauth_state();
|
||||
|
||||
let authorize_url = format!(
|
||||
"{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}",
|
||||
state.settings.oidc_authorize_url,
|
||||
urlencoding::encode(&state.settings.oidc_client_id),
|
||||
urlencoding::encode(&state.settings.oidc_redirect_url),
|
||||
urlencoding::encode(&state.settings.oidc_scopes),
|
||||
urlencoding::encode(&oauth_state)
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::SET_COOKIE,
|
||||
auth::make_oauth_state_cookie(&oauth_state, state.settings.cookie_secure)
|
||||
.parse()
|
||||
.map_err(|_| ApiError::internal("failed to set oauth cookie"))?,
|
||||
);
|
||||
|
||||
Ok((headers, Redirect::to(&authorize_url)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthCallbackQuery {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OidcTokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OidcUserInfo {
|
||||
sub: String,
|
||||
email: Option<String>,
|
||||
preferred_username: Option<String>,
|
||||
nickname: Option<String>,
|
||||
given_name: Option<String>,
|
||||
family_name: Option<String>,
|
||||
name: Option<String>,
|
||||
picture: Option<String>,
|
||||
}
|
||||
|
||||
fn normalized_claim(value: Option<&str>) -> Option<String> {
|
||||
let v = value?.trim();
|
||||
if v.is_empty() || v.eq_ignore_ascii_case("null") {
|
||||
None
|
||||
} else {
|
||||
Some(v.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_display_name(profile: &OidcUserInfo) -> String {
|
||||
if let Some(v) = normalized_claim(profile.name.as_deref()) {
|
||||
return v;
|
||||
}
|
||||
if let Some(v) = normalized_claim(profile.nickname.as_deref()) {
|
||||
return v;
|
||||
}
|
||||
if let Some(v) = normalized_claim(profile.preferred_username.as_deref()) {
|
||||
return v;
|
||||
}
|
||||
|
||||
let given = normalized_claim(profile.given_name.as_deref());
|
||||
let family = normalized_claim(profile.family_name.as_deref());
|
||||
match (given, family) {
|
||||
(Some(g), Some(f)) => format!("{g} {f}"),
|
||||
(Some(g), None) => g,
|
||||
(None, Some(f)) => f,
|
||||
(None, None) => "User".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth_callback(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<AuthCallbackQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
auth::validate_oauth_state(auth::read_oauth_state_from_headers(&headers), &query.state)
|
||||
.map_err(|e| ApiError::bad_request(&e.to_string()))?;
|
||||
|
||||
let token_res = state
|
||||
.http
|
||||
.post(&state.settings.oidc_token_url)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", query.code.as_str()),
|
||||
("redirect_uri", state.settings.oidc_redirect_url.as_str()),
|
||||
("client_id", state.settings.oidc_client_id.as_str()),
|
||||
("client_secret", state.settings.oidc_client_secret.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(&format!("token exchange failed: {e}")))?;
|
||||
|
||||
if !token_res.status().is_success() {
|
||||
let text = token_res.text().await.unwrap_or_else(|_| "<no body>".to_string());
|
||||
return Err(ApiError::bad_request(&format!(
|
||||
"token exchange returned non-success: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let token: OidcTokenResponse = token_res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(&format!("invalid token response: {e}")))?;
|
||||
|
||||
let userinfo_res = state
|
||||
.http
|
||||
.get(&state.settings.oidc_userinfo_url)
|
||||
.bearer_auth(&token.access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(&format!("userinfo request failed: {e}")))?;
|
||||
|
||||
if !userinfo_res.status().is_success() {
|
||||
let text = userinfo_res.text().await.unwrap_or_else(|_| "<no body>".to_string());
|
||||
return Err(ApiError::bad_request(&format!(
|
||||
"userinfo returned non-success: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let profile: OidcUserInfo = userinfo_res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(&format!("invalid userinfo response: {e}")))?;
|
||||
|
||||
let display_name = choose_display_name(&profile);
|
||||
|
||||
let user = db::upsert_user_from_oidc(
|
||||
&state.db,
|
||||
&profile.sub,
|
||||
profile.email.as_deref(),
|
||||
&display_name,
|
||||
profile.picture.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
||||
|
||||
let session_cookie =
|
||||
auth::new_session_cookie(user.id, &state.settings.session_secret, state.settings.cookie_secure)
|
||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
session_cookie
|
||||
.parse()
|
||||
.map_err(|_| ApiError::internal("failed to set session cookie"))?,
|
||||
);
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
auth::clear_oauth_state_cookie(state.settings.cookie_secure)
|
||||
.parse()
|
||||
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
|
||||
);
|
||||
|
||||
Ok((headers, Redirect::to("/")))
|
||||
}
|
||||
|
||||
async fn auth_logout(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
header::SET_COOKIE,
|
||||
auth::clear_session_cookie(state.settings.cookie_secure)
|
||||
.parse()
|
||||
.map_err(|_| ApiError::internal("failed to clear session cookie"))?,
|
||||
);
|
||||
|
||||
Ok((StatusCode::NO_CONTENT, headers))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RtcConfigResponse {
|
||||
ice_servers: Vec<RtcIceServer>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RtcIceServer {
|
||||
urls: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
credential: Option<String>,
|
||||
}
|
||||
|
||||
async fn rtc_config(State(state): State<AppState>, _user: AuthUser) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut ice_servers = Vec::new();
|
||||
|
||||
if !state.settings.stun_urls.is_empty() {
|
||||
ice_servers.push(RtcIceServer {
|
||||
urls: state.settings.stun_urls.clone(),
|
||||
username: None,
|
||||
credential: None,
|
||||
});
|
||||
}
|
||||
|
||||
if !state.settings.turn_urls.is_empty() {
|
||||
ice_servers.push(RtcIceServer {
|
||||
urls: state.settings.turn_urls.clone(),
|
||||
username: state.settings.turn_username.clone(),
|
||||
credential: state.settings.turn_password.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if ice_servers.is_empty() {
|
||||
ice_servers.push(RtcIceServer {
|
||||
urls: vec!["stun:stun.l.google.com:19302".to_string()],
|
||||
username: None,
|
||||
credential: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(RtcConfigResponse { ice_servers }))
|
||||
}
|
||||
|
||||
async fn me(State(state): State<AppState>, user: AuthUser) -> Result<impl IntoResponse, ApiError> {
|
||||
let me = db::get_user_by_id(&state.db, user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to load user: {e}")))?
|
||||
.ok_or_else(|| ApiError::unauthorized("no user"))?;
|
||||
|
||||
Ok(Json(me))
|
||||
}
|
||||
|
||||
async fn list_dm_conversations(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let conversations = db::list_dm_conversations(&state.db, user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to list dm conversations: {e}")))?;
|
||||
Ok(Json(conversations))
|
||||
}
|
||||
|
||||
async fn list_guild_members(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(guild_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
ensure_guild_member(&state, guild_id, user.id).await?;
|
||||
let members = db::list_guild_members(&state.db, guild_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?;
|
||||
Ok(Json(members))
|
||||
}
|
||||
|
||||
async fn list_guilds(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let guilds = db::list_guilds_for_user(&state.db, user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to list guilds: {e}")))?;
|
||||
|
||||
Ok(Json(guilds))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateGuildBody {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn create_guild(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(body): Json<CreateGuildBody>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let trimmed = body.name.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 64 {
|
||||
return Err(ApiError::bad_request("guild name must be 1..64 chars"));
|
||||
}
|
||||
|
||||
let guild = db::create_guild(&state.db, user.id, trimmed)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to create guild: {e}")))?;
|
||||
Ok((StatusCode::CREATED, Json(guild)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateInviteBody {
|
||||
max_uses: Option<i32>,
|
||||
expires_in_hours: Option<i64>,
|
||||
}
|
||||
|
||||
async fn create_invite(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(guild_id): Path<Uuid>,
|
||||
Json(body): Json<CreateInviteBody>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
ensure_guild_member(&state, guild_id, user.id).await?;
|
||||
|
||||
if let Some(v) = body.max_uses
|
||||
&& v <= 0
|
||||
{
|
||||
return Err(ApiError::bad_request("max_uses must be > 0"));
|
||||
}
|
||||
|
||||
if let Some(v) = body.expires_in_hours
|
||||
&& v <= 0
|
||||
{
|
||||
return Err(ApiError::bad_request("expires_in_hours must be > 0"));
|
||||
}
|
||||
|
||||
let invite = db::create_invite(
|
||||
&state.db,
|
||||
guild_id,
|
||||
user.id,
|
||||
body.max_uses,
|
||||
body.expires_in_hours,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to create invite: {e}")))?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(invite)))
|
||||
}
|
||||
|
||||
async fn join_invite(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(code): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let guild = db::join_invite(&state.db, &code.to_uppercase(), user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(&format!("failed to join invite: {e}")))?;
|
||||
|
||||
Ok(Json(guild))
|
||||
}
|
||||
|
||||
async fn list_channels(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(guild_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
ensure_guild_member(&state, guild_id, user.id).await?;
|
||||
|
||||
let channels = db::list_channels_for_guild(&state.db, guild_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to list channels: {e}")))?;
|
||||
Ok(Json(channels))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GuildVoicePresenceItem {
|
||||
channel_id: Uuid,
|
||||
participants: Vec<voice::VoiceParticipant>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GuildVoicePresenceResponse {
|
||||
channels: Vec<GuildVoicePresenceItem>,
|
||||
}
|
||||
|
||||
async fn guild_voice_presence(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(guild_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
ensure_guild_member(&state, guild_id, user.id).await?;
|
||||
|
||||
let voice_channels = db::list_voice_channels_for_guild(&state.db, guild_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to list voice channels: {e}")))?;
|
||||
|
||||
let mut channels = Vec::with_capacity(voice_channels.len());
|
||||
for ch in voice_channels {
|
||||
channels.push(GuildVoicePresenceItem {
|
||||
channel_id: ch.id,
|
||||
participants: state.voice.participants(ch.id).await,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(GuildVoicePresenceResponse { channels }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateChannelBody {
|
||||
guild_id: Uuid,
|
||||
name: String,
|
||||
kind: Option<String>,
|
||||
}
|
||||
|
||||
async fn create_channel(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(body): Json<CreateChannelBody>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
ensure_guild_member(&state, body.guild_id, user.id).await?;
|
||||
|
||||
let trimmed = body.name.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 64 {
|
||||
return Err(ApiError::bad_request("channel name must be 1..64 chars"));
|
||||
}
|
||||
|
||||
let kind = body.kind.unwrap_or_else(|| db::CHANNEL_KIND_TEXT.to_string());
|
||||
if kind != db::CHANNEL_KIND_TEXT && kind != db::CHANNEL_KIND_VOICE {
|
||||
return Err(ApiError::bad_request("channel kind must be 'text' or 'voice'"));
|
||||
}
|
||||
|
||||
let channel = db::create_channel(&state.db, body.guild_id, trimmed, &kind)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to create channel: {e}")))?;
|
||||
Ok((StatusCode::CREATED, Json(channel)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListMessagesQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list_messages(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Query(query): Query<ListMessagesQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
ensure_channel_member(&state, channel_id, user.id).await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||
|
||||
let messages = db::list_messages(&state.db, channel_id, limit)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to list messages: {e}")))?;
|
||||
|
||||
Ok(Json(messages))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendMessageBody {
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SendMessageResponse {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Json(body): Json<SendMessageBody>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
ensure_channel_member(&state, channel_id, user.id).await?;
|
||||
|
||||
let content = body.body.trim();
|
||||
if content.is_empty() || content.len() > 4000 {
|
||||
return Err(ApiError::bad_request("message body must be 1..4000 chars"));
|
||||
}
|
||||
|
||||
db::create_message(&state.db, channel_id, user.id, content)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to create message: {e}")))?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true })))
|
||||
}
|
||||
|
||||
async fn list_dm_messages(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(other_user_id): Path<Uuid>,
|
||||
Query(query): Query<ListMessagesQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if other_user_id == user.id {
|
||||
return Err(ApiError::bad_request("cannot open dm with 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 limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||
let messages = db::list_direct_messages(&state.db, user.id, other_user_id, limit)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to list dm messages: {e}")))?;
|
||||
|
||||
Ok(Json(messages))
|
||||
}
|
||||
|
||||
async fn send_dm_message(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(other_user_id): Path<Uuid>,
|
||||
Json(body): Json<SendMessageBody>,
|
||||
) -> 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 content = body.body.trim();
|
||||
if content.is_empty() || content.len() > 4000 {
|
||||
return Err(ApiError::bad_request("message body must be 1..4000 chars"));
|
||||
}
|
||||
|
||||
db::create_direct_message(&state.db, user.id, other_user_id, content)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to create dm message: {e}")))?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true })))
|
||||
}
|
||||
|
||||
async fn voice_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
ensure_channel_member(&state, channel_id, user.id).await?;
|
||||
|
||||
let channel = db::get_channel_by_id(&state.db, channel_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("failed to load channel: {e}")))?
|
||||
.ok_or_else(|| ApiError {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "channel not found".to_string(),
|
||||
})?;
|
||||
|
||||
if channel.kind != db::CHANNEL_KIND_VOICE {
|
||||
return Err(ApiError::bad_request("voice websocket requires a voice channel"));
|
||||
}
|
||||
|
||||
Ok(ws.on_upgrade(move |socket| {
|
||||
voice::handle_socket(state, socket, channel_id, user.id)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ensure_guild_member(state: &AppState, guild_id: Uuid, user_id: Uuid) -> Result<(), ApiError> {
|
||||
let is_member = db::is_member_of_guild(&state.db, guild_id, user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(&format!("membership check failed: {e}")))?;
|
||||
|
||||
if !is_member {
|
||||
return Err(ApiError {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
message: "not a member of this guild".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_channel_member(state: &AppState, channel_id: Uuid, user_id: Uuid) -> Result<(), ApiError> {
|
||||
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 {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "channel not found".to_string(),
|
||||
})?;
|
||||
|
||||
ensure_guild_member(state, guild_id, user_id).await
|
||||
}
|
||||
71
src/main.rs
Normal file
71
src/main.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod entity;
|
||||
mod handlers;
|
||||
mod migration;
|
||||
mod models;
|
||||
mod voice;
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{Router, routing::get};
|
||||
use sea_orm::Database;
|
||||
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};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub settings: Arc<Settings>,
|
||||
pub http: reqwest::Client,
|
||||
pub voice: Arc<VoiceHub>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let settings = Arc::new(Settings::from_env()?);
|
||||
|
||||
let db = Database::connect(&settings.database_url)
|
||||
.await
|
||||
.with_context(|| "failed to connect to postgres")?;
|
||||
|
||||
Migrator::up(&db, None)
|
||||
.await
|
||||
.with_context(|| "failed to run migrations")?;
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
settings,
|
||||
http: reqwest::Client::new(),
|
||||
voice: Arc::new(VoiceHub::default()),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(handlers::health))
|
||||
.nest_service("/static", ServeDir::new("static"))
|
||||
.merge(routes())
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let addr: SocketAddr = "0.0.0.0:3000".parse()?;
|
||||
info!(%addr, "server started");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "chattz=info,tower_http=info".into());
|
||||
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
}
|
||||
274
src/migration/m20260213_000001_init.rs
Normal file
274
src/migration/m20260213_000001_init.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
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(Users::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Users::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_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())
|
||||
.col(
|
||||
ColumnDef::new(Users::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Users::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Guilds::Table)
|
||||
.if_not_exists()
|
||||
.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(
|
||||
ColumnDef::new(Guilds::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_guilds_owner_user")
|
||||
.from(Guilds::Table, Guilds::OwnerUserId)
|
||||
.to(Users::Table, Users::Id),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(GuildMembers::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(GuildMembers::GuildId).uuid().not_null())
|
||||
.col(ColumnDef::new(GuildMembers::UserId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(GuildMembers::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.name("pk_guild_members")
|
||||
.col(GuildMembers::GuildId)
|
||||
.col(GuildMembers::UserId),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_guild_members_guild")
|
||||
.from(GuildMembers::Table, GuildMembers::GuildId)
|
||||
.to(Guilds::Table, Guilds::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_guild_members_user")
|
||||
.from(GuildMembers::Table, GuildMembers::UserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Channels::Table)
|
||||
.if_not_exists()
|
||||
.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(
|
||||
ColumnDef::new(Channels::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_channels_guild")
|
||||
.from(Channels::Table, Channels::GuildId)
|
||||
.to(Guilds::Table, Guilds::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Messages::Table)
|
||||
.if_not_exists()
|
||||
.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())
|
||||
.col(
|
||||
ColumnDef::new(Messages::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_messages_channel")
|
||||
.from(Messages::Table, Messages::ChannelId)
|
||||
.to(Channels::Table, Channels::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_messages_author")
|
||||
.from(Messages::Table, Messages::AuthorUserId)
|
||||
.to(Users::Table, Users::Id),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_guild_members_user_id")
|
||||
.table(GuildMembers::Table)
|
||||
.col(GuildMembers::UserId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_channels_guild_id")
|
||||
.table(Channels::Table)
|
||||
.col(Channels::GuildId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_messages_channel_created_at")
|
||||
.table(Messages::Table)
|
||||
.col(Messages::ChannelId)
|
||||
.col(Messages::CreatedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Messages::Table).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Channels::Table).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(GuildMembers::Table).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Guilds::Table).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Users::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
OidcSub,
|
||||
Email,
|
||||
DisplayName,
|
||||
AvatarUrl,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Guilds {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
OwnerUserId,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum GuildMembers {
|
||||
Table,
|
||||
GuildId,
|
||||
UserId,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Channels {
|
||||
Table,
|
||||
Id,
|
||||
GuildId,
|
||||
Name,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Messages {
|
||||
Table,
|
||||
Id,
|
||||
ChannelId,
|
||||
AuthorUserId,
|
||||
Body,
|
||||
CreatedAt,
|
||||
}
|
||||
91
src/migration/m20260213_000002_invites.rs
Normal file
91
src/migration/m20260213_000002_invites.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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(Invites::Table)
|
||||
.if_not_exists()
|
||||
.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(
|
||||
ColumnDef::new(Invites::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Invites::ExpiresAt).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Invites::MaxUses).integer())
|
||||
.col(
|
||||
ColumnDef::new(Invites::UseCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_invites_guild")
|
||||
.from(Invites::Table, Invites::GuildId)
|
||||
.to(Guilds::Table, Guilds::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_invites_created_by")
|
||||
.from(Invites::Table, Invites::CreatedByUserId)
|
||||
.to(Users::Table, Users::Id),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_invites_guild_id")
|
||||
.table(Invites::Table)
|
||||
.col(Invites::GuildId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Invites::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Invites {
|
||||
Table,
|
||||
Code,
|
||||
GuildId,
|
||||
CreatedByUserId,
|
||||
CreatedAt,
|
||||
ExpiresAt,
|
||||
MaxUses,
|
||||
UseCount,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Guilds {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
65
src/migration/m20260213_000003_channel_kind.rs
Normal file
65
src/migration/m20260213_000003_channel_kind.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Channels::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Channels::Kind)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("text"),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_channels_guild_kind")
|
||||
.table(Channels::Table)
|
||||
.col(Channels::GuildId)
|
||||
.col(Channels::Kind)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_channels_guild_kind")
|
||||
.table(Channels::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Channels::Table)
|
||||
.drop_column(Channels::Kind)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Channels {
|
||||
Table,
|
||||
GuildId,
|
||||
Kind,
|
||||
}
|
||||
95
src/migration/m20260213_000004_direct_messages.rs
Normal file
95
src/migration/m20260213_000004_direct_messages.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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(DirectMessages::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(DirectMessages::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.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)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_direct_messages_sender_user")
|
||||
.from(DirectMessages::Table, DirectMessages::SenderUserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_direct_messages_recipient_user")
|
||||
.from(DirectMessages::Table, DirectMessages::RecipientUserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_direct_messages_sender_created_at")
|
||||
.table(DirectMessages::Table)
|
||||
.col(DirectMessages::SenderUserId)
|
||||
.col(DirectMessages::CreatedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_direct_messages_recipient_created_at")
|
||||
.table(DirectMessages::Table)
|
||||
.col(DirectMessages::RecipientUserId)
|
||||
.col(DirectMessages::CreatedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(DirectMessages::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum DirectMessages {
|
||||
Table,
|
||||
Id,
|
||||
SenderUserId,
|
||||
RecipientUserId,
|
||||
Body,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
20
src/migration/mod.rs
Normal file
20
src/migration/mod.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20260213_000001_init;
|
||||
mod m20260213_000002_invites;
|
||||
mod m20260213_000003_channel_kind;
|
||||
mod m20260213_000004_direct_messages;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20260213_000001_init::Migration),
|
||||
Box::new(m20260213_000002_invites::Migration),
|
||||
Box::new(m20260213_000003_channel_kind::Migration),
|
||||
Box::new(m20260213_000004_direct_messages::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
86
src/models.rs
Normal file
86
src/models.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use sea_orm::prelude::DateTimeWithTimeZone;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub oidc_sub: String,
|
||||
pub email: Option<String>,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Guild {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub owner_user_id: Uuid,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Channel {
|
||||
pub id: Uuid,
|
||||
pub guild_id: Uuid,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Message {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub author_user_id: Uuid,
|
||||
pub body: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BasicUser {
|
||||
pub id: Uuid,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)]
|
||||
pub struct MessageWithAuthor {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub author_user_id: Uuid,
|
||||
pub author_display_name: String,
|
||||
pub body: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DmConversation {
|
||||
pub user_id: Uuid,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub last_message_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DmMessageWithAuthor {
|
||||
pub id: Uuid,
|
||||
pub author_user_id: Uuid,
|
||||
pub recipient_user_id: Uuid,
|
||||
pub author_display_name: String,
|
||||
pub body: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Invite {
|
||||
pub code: String,
|
||||
pub guild_id: Uuid,
|
||||
pub created_by_user_id: Uuid,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub expires_at: Option<DateTimeWithTimeZone>,
|
||||
pub max_uses: Option<i32>,
|
||||
pub use_count: i32,
|
||||
}
|
||||
199
src/voice.rs
Normal file
199
src/voice.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AppState, db};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct VoiceHub {
|
||||
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, ClientHandle>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ClientHandle {
|
||||
display_name: String,
|
||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct VoiceParticipant {
|
||||
pub user_id: Uuid,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ServerEvent {
|
||||
Peers { peers: Vec<VoiceParticipant> },
|
||||
PeerJoined { user_id: Uuid, display_name: String },
|
||||
PeerLeft { user_id: Uuid },
|
||||
Signal {
|
||||
from_user_id: Uuid,
|
||||
kind: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ClientEvent {
|
||||
Signal {
|
||||
to_user_id: Uuid,
|
||||
kind: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl VoiceHub {
|
||||
pub async fn participants(&self, room_id: Uuid) -> Vec<VoiceParticipant> {
|
||||
let rooms = self.rooms.read().await;
|
||||
let Some(room) = rooms.get(&room_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
room.iter()
|
||||
.map(|(user_id, handle)| VoiceParticipant {
|
||||
user_id: *user_id,
|
||||
display_name: handle.display_name.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn join(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
user_id: Uuid,
|
||||
display_name: String,
|
||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||
) -> Vec<VoiceParticipant> {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let room = rooms.entry(room_id).or_default();
|
||||
|
||||
let peers = room
|
||||
.iter()
|
||||
.map(|(peer_id, peer)| VoiceParticipant {
|
||||
user_id: *peer_id,
|
||||
display_name: peer.display_name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
room.insert(
|
||||
user_id,
|
||||
ClientHandle {
|
||||
display_name: display_name.clone(),
|
||||
tx,
|
||||
},
|
||||
);
|
||||
|
||||
for (peer_id, peer) in room.iter() {
|
||||
if *peer_id != user_id {
|
||||
let _ = peer.tx.send(ServerEvent::PeerJoined {
|
||||
user_id,
|
||||
display_name: display_name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
peers
|
||||
}
|
||||
|
||||
async fn leave(&self, room_id: Uuid, user_id: Uuid) {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let Some(room) = rooms.get_mut(&room_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
room.remove(&user_id);
|
||||
|
||||
for peer in room.values() {
|
||||
let _ = peer.tx.send(ServerEvent::PeerLeft { user_id });
|
||||
}
|
||||
|
||||
if room.is_empty() {
|
||||
rooms.remove(&room_id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn relay_signal(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
from_user_id: Uuid,
|
||||
to_user_id: Uuid,
|
||||
kind: String,
|
||||
data: serde_json::Value,
|
||||
) {
|
||||
let rooms = self.rooms.read().await;
|
||||
let Some(room) = rooms.get(&room_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(target) = room.get(&to_user_id) {
|
||||
let _ = target.tx.send(ServerEvent::Signal {
|
||||
from_user_id,
|
||||
kind,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) {
|
||||
let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
|
||||
|
||||
let peers = state
|
||||
.voice
|
||||
.join(room_id, user_id, user.display_name.clone(), tx.clone())
|
||||
.await;
|
||||
let _ = tx.send(ServerEvent::Peers { peers });
|
||||
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
let Ok(payload) = serde_json::to_string(&event) else {
|
||||
continue;
|
||||
};
|
||||
if ws_sender.send(Message::Text(payload.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(Ok(msg)) = ws_receiver.next().await {
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
let parsed = serde_json::from_str::<ClientEvent>(&text);
|
||||
match parsed {
|
||||
Ok(ClientEvent::Signal {
|
||||
to_user_id,
|
||||
kind,
|
||||
data,
|
||||
}) => {
|
||||
state
|
||||
.voice
|
||||
.relay_signal(room_id, user_id, to_user_id, kind, data)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(ServerEvent::Error {
|
||||
message: format!("invalid voice message: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
send_task.abort();
|
||||
state.voice.leave(room_id, user_id).await;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue