discord/src/handlers.rs
pavel 33da605c03
All checks were successful
/ upload (release) Successful in 27s
soundboard
2026-02-24 01:41:24 +01:00

795 lines
23 KiB
Rust

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},
chat, db,
models::{Guild, SoundboardSound},
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("/presence", get(presence_list))
.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(
"/guilds/{guild_id}/sounds",
get(list_sounds).post(upload_sound),
)
.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))
.route("/ws", get(chat_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 presence_list(
State(state): State<AppState>,
_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let users = state.chat.get_online_users().await;
Ok(Json(users))
}
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<Json<Guild>, ApiError> {
let guild = db::join_invite(&state.db, &code, user.id).await?;
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"));
}
let message = db::create_message(&state.db, channel_id, user.id, content)
.await
.map_err(|e| ApiError::internal(&format!("failed to create message: {e}")))?;
// Broadcast to all guild members
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 creation"))?;
let members = db::list_guild_member_ids(&state.db, guild_id)
.await
.map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?;
state
.chat
.broadcast_to_many(
members,
chat::ServerEvent::MessageCreated {
channel_id,
message: serde_json::to_value(&message).unwrap_or_default(),
},
)
.await;
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"));
}
let message = 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}")))?;
// Broadcast to both users
state
.chat
.broadcast_to_user(
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: user.id,
message: serde_json::to_value(&message).unwrap_or_default(),
},
)
.await;
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 chat_ws(
ws: WebSocketUpgrade,
State(state): State<AppState>,
user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id)))
}
async fn list_sounds(
State(state): State<AppState>,
user: AuthUser,
Path(guild_id): Path<Uuid>,
) -> Result<Json<Vec<SoundboardSound>>, ApiError> {
ensure_guild_member(&state, guild_id, user.id).await?;
let sounds = db::list_sounds(&state.db, guild_id).await?;
Ok(Json(sounds))
}
async fn upload_sound(
State(state): State<AppState>,
user: AuthUser,
Path(guild_id): Path<Uuid>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<SoundboardSound>, ApiError> {
ensure_guild_member(&state, guild_id, user.id).await?;
let mut name = None;
let mut icon = None;
let mut file_data = None;
let mut file_name = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?
{
let field_name = field.name().unwrap_or_default().to_string();
if field_name == "name" {
name = Some(
field
.text()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?,
);
} else if field_name == "icon" {
icon = Some(
field
.text()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?,
);
} else if field_name == "file" {
file_name = Some(field.file_name().unwrap_or("sound.mp3").to_string());
file_data = Some(
field
.bytes()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?,
);
}
}
let (name, icon, file_data, file_name) = match (name, icon, file_data, file_name) {
(Some(n), Some(i), Some(d), Some(f)) => (n, i, d, f),
_ => return Err(ApiError::bad_request("missing fields")),
};
let extension = std::path::Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("mp3");
let safe_file_name = format!("{}.{}", Uuid::new_v4(), extension);
let upload_dir = std::path::Path::new("static/uploads/soundboard");
tokio::fs::create_dir_all(upload_dir)
.await
.map_err(|e| ApiError::internal(&e.to_string()))?;
let file_path = upload_dir.join(&safe_file_name);
tokio::fs::write(&file_path, file_data)
.await
.map_err(|e| ApiError::internal(&e.to_string()))?;
let web_path = format!("/static/uploads/soundboard/{}", safe_file_name);
let sound = db::create_sound(&state.db, guild_id, user.id, &name, &icon, &web_path).await?;
Ok(Json(sound))
}
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
}