Initial commit: EifelDC - Discord-like Matrix chat platform
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Rust Tests (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Build Tauri (macOS) (push) Has been cancelled
CI / Build Tauri (macOS Intel) (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled

Includes server (Rust/Axum API proxy with voice management),
Tauri desktop client with Svelte UI, bot-sdk, Docker infra
(Synapse, PostgreSQL, Coturn, Nginx), and CI/CD pipeline.
This commit is contained in:
root
2026-04-28 08:23:23 +02:00
commit 0978d0c2e9
82 changed files with 12417 additions and 0 deletions

22
server/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "eifeldc-server"
version = "0.1.0"
edition = "2021"
[dependencies]
matrix-sdk = { workspace = true }
matrix-sdk-base = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true }
axum = { version = "0.7", features = ["ws"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }

27
server/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM rust:1.82-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY server/Cargo.toml server/Cargo.toml
RUN mkdir -p server/src && echo "fn main() {}" > server/src/main.rs
RUN cargo build --release -p eifeldc-server && rm -rf server/src
COPY server/src server/src
RUN touch server/src/main.rs && cargo build --release -p eifeldc-server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
RUN useradd -m -d /home/eifeldc eifeldc
USER eifeldc
COPY --from=builder /app/target/release/eifeldc-server /usr/local/bin/eifeldc-server
ENV EIFELDC_STATIC_DIR=/usr/share/eifeldc/client
ENV RUST_LOG=eifeldc_server=info,tower_http=info
EXPOSE 3000
CMD ["eifeldc-server"]

4
server/src/lib.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod routes;
pub mod state;
pub use state::ServerState;

25
server/src/main.rs Normal file
View File

@@ -0,0 +1,25 @@
use eifeldc_server::routes::api_router;
use eifeldc_server::ServerState;
use tower_http::services::ServeDir;
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let state = ServerState::new();
let api = api_router(state);
let static_dir = std::env::var("EIFELDC_STATIC_DIR")
.unwrap_or_else(|_| "client/src-ui/dist".to_string());
let app = api
.fallback_service(ServeDir::new(&static_dir));
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
tracing::info!("EifelDC Web Server listening on http://{}", addr);
tracing::info!("Serving static files from {}", static_dir);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

160
server/src/routes/auth.rs Normal file
View File

@@ -0,0 +1,160 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
Json,
};
use matrix_sdk::Client;
use serde::{Deserialize, Serialize};
use crate::state::VoiceManager;
#[derive(Deserialize)]
pub struct LoginRequest {
pub homeserver: String,
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResult {
pub success: bool,
pub user_id: String,
pub token: Option<String>,
pub error: Option<String>,
}
#[derive(Deserialize)]
pub struct RegisterRequest {
pub homeserver: String,
pub username: String,
pub password: String,
}
pub async fn login(
State(state): State<crate::state::ServerState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResult>, StatusCode> {
let client = Client::builder()
.homeserver_url(&req.homeserver)
.build()
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
client
.matrix_auth()
.login_username(&req.username, &req.password)
.send()
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
let user_id = client
.user_id()
.map(|u| u.to_string())
.unwrap_or_default();
let token = uuid::Uuid::new_v4().to_string();
let mut s = state.write().await;
s.sessions.insert(token.clone(), crate::state::Session {
client,
user_id: user_id.clone(),
voice_manager: VoiceManager::new(),
});
Ok(Json(LoginResult {
success: true,
user_id,
token: Some(token),
error: None,
}))
}
pub async fn register(
State(state): State<crate::state::ServerState>,
Json(req): Json<RegisterRequest>,
) -> Result<Json<LoginResult>, StatusCode> {
let client = Client::builder()
.homeserver_url(&req.homeserver)
.build()
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
let mut request = matrix_sdk::ruma::api::client::account::register::v3::Request::new();
request.username = Some(req.username);
request.password = Some(req.password);
client
.matrix_auth()
.register(request)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
let user_id = client
.user_id()
.map(|u| u.to_string())
.unwrap_or_default();
let token = uuid::Uuid::new_v4().to_string();
let mut s = state.write().await;
s.sessions.insert(token.clone(), crate::state::Session {
client,
user_id: user_id.clone(),
voice_manager: VoiceManager::new(),
});
Ok(Json(LoginResult {
success: true,
user_id,
token: Some(token),
error: None,
}))
}
pub async fn logout(
State(state): State<crate::state::ServerState>,
headers: HeaderMap,
) -> Result<Json<bool>, StatusCode> {
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
let mut s = state.write().await;
if let Some(session) = s.sessions.remove(&token) {
let _ = session.client.matrix_auth().logout().await;
}
Ok(Json(true))
}
pub async fn get_current_user(
State(state): State<crate::state::ServerState>,
headers: HeaderMap,
) -> Result<Json<Option<String>>, StatusCode> {
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let user_id = s.sessions.get(&token).map(|s| s.user_id.clone());
Ok(Json(user_id))
}
pub fn extract_token(headers: &HeaderMap) -> Option<String> {
headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.to_string())
}
pub async fn auth_middleware(
headers: HeaderMap,
State(state): State<crate::state::ServerState>,
request: axum::extract::Request,
next: middleware::Next,
) -> Result<axum::response::Response, StatusCode> {
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
if !s.sessions.contains_key(&token) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(request).await)
}

View File

@@ -0,0 +1,86 @@
use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use crate::ServerState;
use super::auth::extract_token;
#[derive(Serialize)]
pub struct CustomEmoji {
pub id: String,
pub name: String,
pub url: String,
pub category: String,
pub animated: bool,
}
#[derive(Serialize)]
pub struct StickerPack {
pub id: String,
pub name: String,
pub stickers: Vec<Sticker>,
}
#[derive(Serialize)]
pub struct Sticker {
pub id: String,
pub name: String,
pub url: String,
}
pub async fn get_custom_emoji(
State(state): State<ServerState>,
headers: HeaderMap,
Path(_room_id): Path<String>,
) -> Result<Json<Vec<CustomEmoji>>, axum::http::StatusCode> {
let _token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
Ok(Json(Vec::new()))
}
#[derive(Deserialize)]
pub struct UploadEmojiRequest {
pub name: String,
pub image_path: String,
}
pub async fn upload_emoji(
State(state): State<ServerState>,
headers: HeaderMap,
Path(room_id): Path<String>,
Json(req): Json<UploadEmojiRequest>,
) -> Result<Json<CustomEmoji>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let _room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let path = std::path::Path::new(&req.image_path);
if !path.exists() {
return Err(axum::http::StatusCode::BAD_REQUEST);
}
let mime_type = match path.extension().and_then(|e| e.to_str()) {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => "image/png",
};
let data = std::fs::read(path).map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
let content_type = mime_type.parse::<matrix_sdk::ruma::mime::Mime>().map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
let response = session.client.media().upload(&content_type, data).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(CustomEmoji {
id: format!("emoji_{}", chrono::Utc::now().timestamp()),
name: req.name,
url: response.content_uri.to_string(),
category: "custom".to_string(),
animated: mime_type == "image/gif",
}))
}

View File

@@ -0,0 +1,84 @@
use axum::{
extract::{Path, State, Query},
http::HeaderMap,
Json,
};
use serde::{Serialize, Deserialize};
use crate::ServerState;
use super::auth::extract_token;
#[derive(Serialize)]
pub struct MessageInfo {
pub event_id: String,
pub sender: String,
pub body: String,
pub timestamp: u64,
pub reply_to: Option<String>,
}
#[derive(Deserialize)]
pub struct MessagesQuery {
pub limit: Option<u32>,
pub from: Option<String>,
}
pub async fn get_room_messages(
State(state): State<ServerState>,
headers: HeaderMap,
Path(room_id): Path<String>,
Query(query): Query<MessagesQuery>,
) -> Result<Json<Vec<MessageInfo>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let limit = query.limit.unwrap_or(50);
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new();
options.limit = limit.into();
options.from = query.from.map(|t| t.into());
let messages = room.messages(options).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
let mut result = Vec::new();
for msg in messages.chunk {
if let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(ev) = msg {
result.push(MessageInfo {
event_id: ev.event_id().to_string(),
sender: ev.sender().to_string(),
body: ev.content().body().to_string(),
timestamp: ev.origin_server_ts().0,
reply_to: ev.content().in_reply_to().map(|r| r.event_id.to_string()),
});
}
}
Ok(Json(result))
}
#[derive(Deserialize)]
pub struct SendMessageRequest {
pub message: String,
}
pub async fn send_message(
State(state): State<ServerState>,
headers: HeaderMap,
Path(room_id): Path<String>,
Json(req): Json<SendMessageRequest>,
) -> Result<Json<String>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&req.message);
let txn_id = matrix_sdk::ruma::TransactionId::new();
let response = room.send(content, Some(&txn_id)).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(response.event_id.to_string()))
}

63
server/src/routes/mod.rs Normal file
View File

@@ -0,0 +1,63 @@
pub mod auth;
pub mod rooms;
pub mod messages;
pub mod presence;
pub mod voice;
pub mod emoji;
pub mod roles;
use axum::{
Router,
routing::{get, post},
middleware,
http::HeaderMap,
};
use tower_http::cors::{CorsLayer, Any};
use crate::ServerState;
use crate::routes::auth::auth_middleware;
pub fn api_router(state: ServerState) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.nest("/api", api_routes(state))
.layer(cors)
}
fn api_routes(state: ServerState) -> Router {
let public = Router::new()
.route("/login", post(auth::login))
.route("/register", post(auth::register))
.route("/current-user", get(auth::get_current_user));
let protected = Router::new()
.route("/logout", post(auth::logout))
.route("/rooms", get(rooms::get_joined_rooms))
.route("/rooms/create", post(rooms::create_room))
.route("/rooms/join", post(rooms::join_room))
.route("/rooms/{room_id}/leave", post(rooms::leave_room))
.route("/rooms/{room_id}/members", get(rooms::get_room_members))
.route("/rooms/{room_id}/messages", get(messages::get_room_messages))
.route("/rooms/{room_id}/send", post(messages::send_message))
.route("/presence/set", post(presence::set_presence))
.route("/presence/{user_id}", get(presence::get_presence))
.route("/voice/join", post(voice::join_voice_channel))
.route("/voice/leave", post(voice::leave_voice_channel))
.route("/voice/toggle-mute", post(voice::toggle_mute))
.route("/voice/toggle-deafen", post(voice::toggle_deafen))
.route("/rooms/{room_id}/roles", get(roles::get_roles))
.route("/rooms/{room_id}/roles/assign", post(roles::assign_role))
.route("/rooms/{room_id}/roles/remove", post(roles::remove_role))
.route("/rooms/{room_id}/permissions/{user_id}", get(roles::get_permissions))
.route("/rooms/{room_id}/emoji", get(emoji::get_custom_emoji))
.route("/rooms/{room_id}/emoji/upload", post(emoji::upload_emoji))
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
Router::new()
.merge(public)
.merge(protected)
.with_state(state)
}

View File

@@ -0,0 +1,77 @@
use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use crate::ServerState;
use super::auth::extract_token;
#[derive(Deserialize)]
pub struct SetPresenceRequest {
pub status: String,
pub status_msg: Option<String>,
}
#[derive(Serialize)]
pub struct PresenceInfo {
pub user_id: String,
pub status: String,
pub status_msg: Option<String>,
pub last_active: Option<u64>,
}
pub async fn set_presence(
State(state): State<ServerState>,
headers: HeaderMap,
Json(req): Json<SetPresenceRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let presence_state = match req.status.as_str() {
"online" => matrix_sdk::ruma::presence::PresenceState::Online,
"away" => matrix_sdk::ruma::presence::PresenceState::Away,
"unavailable" => matrix_sdk::ruma::presence::PresenceState::Unavailable,
_ => matrix_sdk::ruma::presence::PresenceState::Online,
};
let user_id = session.client.user_id().ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
let mut request = matrix_sdk::ruma::api::client::presence::set_presence::v3::Request::new(user_id.to_owned());
request.presence = presence_state;
request.status_msg = req.status_msg;
session.client.send(request, None).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(true))
}
pub async fn get_presence(
State(state): State<ServerState>,
headers: HeaderMap,
Path(user_id): Path<String>,
) -> Result<Json<PresenceInfo>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let uid: matrix_sdk::ruma::OwnedUserId = user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let request = matrix_sdk::ruma::api::client::presence::get_presence::v3::Request::new(uid.to_owned());
let response = session.client.send(request, None).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
let status_str = match response.presence {
matrix_sdk::ruma::presence::PresenceState::Online => "online",
matrix_sdk::ruma::presence::PresenceState::Away => "away",
matrix_sdk::ruma::presence::PresenceState::Unavailable => "unavailable",
_ => "offline",
};
Ok(Json(PresenceInfo {
user_id,
status: status_str.to_string(),
status_msg: response.status_msg,
last_active: response.last_active_ago.map(|d| d.as_secs()),
}))
}

159
server/src/routes/roles.rs Normal file
View File

@@ -0,0 +1,159 @@
use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use crate::ServerState;
use super::auth::extract_token;
#[derive(Serialize, Deserialize, Clone)]
pub struct Role {
pub id: String,
pub name: String,
pub color: String,
pub permissions: Vec<String>,
pub position: i32,
}
#[derive(Serialize)]
pub struct Permissions {
pub can_send_messages: bool,
pub can_delete_messages: bool,
pub can_manage_channels: bool,
pub can_manage_roles: bool,
pub can_kick: bool,
pub can_ban: bool,
pub can_manage_emoji: bool,
pub can_manage_threads: bool,
pub can_voice_connect: bool,
pub can_voice_stream: bool,
}
fn power_level_to_permissions(power_level: i64) -> Permissions {
Permissions {
can_send_messages: power_level >= 0,
can_delete_messages: power_level >= 50,
can_manage_channels: power_level >= 50,
can_manage_roles: power_level >= 75,
can_kick: power_level >= 50,
can_ban: power_level >= 75,
can_manage_emoji: power_level >= 50,
can_manage_threads: power_level >= 0,
can_voice_connect: power_level >= 0,
can_voice_stream: power_level >= 25,
}
}
pub async fn get_roles(
State(state): State<ServerState>,
headers: HeaderMap,
Path(room_id): Path<String>,
) -> Result<Json<Vec<Role>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let mut roles = Vec::new();
if let Ok(power_levels) = room.power_levels().await {
for (uid, power_level) in &power_levels.users {
roles.push(Role {
id: format!("role_{}", uid),
name: uid.to_string(),
color: if *power_level >= 100 { "#ed4245".to_string() } else if *power_level >= 50 { "#fee75c".to_string() } else { "#5865f2".to_string() },
permissions: vec![],
position: *power_level as i32,
});
}
}
Ok(Json(roles))
}
#[derive(Deserialize)]
pub struct AssignRoleRequest {
pub user_id: String,
pub role_id: String,
}
pub async fn assign_role(
State(state): State<ServerState>,
headers: HeaderMap,
Path(room_id): Path<String>,
Json(req): Json<AssignRoleRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let uid: matrix_sdk::ruma::OwnedUserId = req.user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let power_level: i64 = match req.role_id.as_str() {
"admin" => 100,
"moderator" => 50,
_ => 0,
};
let mut content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::new();
content.users.insert(uid.to_owned(), power_level.into());
room.send_state_event(content).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(true))
}
#[derive(Deserialize)]
pub struct RemoveRoleRequest {
pub user_id: String,
pub role_id: String,
}
pub async fn remove_role(
State(state): State<ServerState>,
headers: HeaderMap,
Path(room_id): Path<String>,
Json(req): Json<RemoveRoleRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let uid: matrix_sdk::ruma::OwnedUserId = req.user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
if let Ok(mut power_levels) = room.power_levels().await {
power_levels.users.remove(&uid);
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::from(power_levels);
room.send_state_event(content).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
}
Ok(Json(true))
}
pub async fn get_permissions(
State(state): State<ServerState>,
headers: HeaderMap,
Path((room_id, user_id)): Path<(String, String)>,
) -> Result<Json<Permissions>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let uid: matrix_sdk::ruma::OwnedUserId = user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let user_power = if let Ok(power_levels) = room.power_levels().await {
power_levels.users.get(&uid).copied().map(|p| p.into()).unwrap_or(power_levels.users_default as i64)
} else {
0
};
Ok(Json(power_level_to_permissions(user_power)))
}

134
server/src/routes/rooms.rs Normal file
View File

@@ -0,0 +1,134 @@
use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::Serialize;
use crate::ServerState;
use super::auth::extract_token;
#[derive(Serialize)]
pub struct RoomInfo {
pub room_id: String,
pub name: String,
pub avatar_url: Option<String>,
pub is_encrypted: bool,
pub member_count: u64,
pub topic: Option<String>,
}
pub async fn get_joined_rooms(
State(state): State<ServerState>,
headers: HeaderMap,
) -> Result<Json<Vec<RoomInfo>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rooms = session.client.joined_rooms();
let mut result = Vec::new();
for room in rooms {
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
let avatar_url = room.avatar_url().map(|u| u.to_string());
let member_count = room.joined_members().len() as u64;
let topic = room.topic().map(|t| t.to_string());
let is_encrypted = room.is_encrypted().await.unwrap_or(false);
result.push(RoomInfo {
room_id: room.room_id().to_string(),
name,
avatar_url,
is_encrypted,
member_count,
topic,
});
}
Ok(Json(result))
}
#[derive(serde::Deserialize)]
pub struct CreateRoomRequest {
pub name: String,
pub topic: Option<String>,
pub visibility: Option<String>,
}
pub async fn create_room(
State(state): State<ServerState>,
headers: HeaderMap,
Json(req): Json<CreateRoomRequest>,
) -> Result<Json<String>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let vis = match req.visibility.as_deref() {
Some("public") => matrix_sdk::ruma::Space::Public,
_ => matrix_sdk::ruma::Space::Private,
};
let mut request = matrix_sdk::ruma::api::client::room::create_room::v3::Request::new();
request.name = Some(req.name);
request.topic = req.topic;
request.visibility = vis;
let response = session.client.create_room(request).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(response.room_id.to_string()))
}
#[derive(serde::Deserialize)]
pub struct JoinRoomRequest {
pub room_id_or_alias: String,
}
pub async fn join_room(
State(state): State<ServerState>,
headers: HeaderMap,
Json(req): Json<JoinRoomRequest>,
) -> Result<Json<String>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let room_id = session.client
.join_room_by_id_or_alias(
&req.room_id_or_alias.try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?,
&[],
)
.await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(room_id.to_string()))
}
pub async fn leave_room(
State(state): State<ServerState>,
headers: HeaderMap,
Path(room_id): Path<String>,
) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
room.leave().await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(true))
}
pub async fn get_room_members(
State(state): State<ServerState>,
headers: HeaderMap,
Path(room_id): Path<String>,
) -> Result<Json<Vec<String>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await;
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let members = room.joined_members();
Ok(Json(members.iter().map(|m| m.user_id().to_string()).collect()))
}

View File

@@ -0,0 +1,80 @@
use axum::{
extract::State,
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use crate::ServerState;
use super::auth::extract_token;
#[derive(Deserialize)]
pub struct VoiceRequest {
pub room_id: String,
}
#[derive(Serialize)]
pub struct VoiceStateInfo {
pub room_id: String,
pub muted: bool,
pub deafened: bool,
pub streaming: bool,
}
pub async fn join_voice_channel(
State(state): State<ServerState>,
headers: HeaderMap,
Json(req): Json<VoiceRequest>,
) -> Result<Json<VoiceStateInfo>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let mut s = state.write().await;
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
session.voice_manager.join_channel(req.room_id.clone(), session.user_id.clone());
Ok(Json(VoiceStateInfo {
room_id: req.room_id,
muted: false,
deafened: false,
streaming: false,
}))
}
pub async fn leave_voice_channel(
State(state): State<ServerState>,
headers: HeaderMap,
Json(req): Json<VoiceRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let mut s = state.write().await;
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
Ok(Json(session.voice_manager.leave_channel(&req.room_id, &session.user_id)))
}
pub async fn toggle_mute(
State(state): State<ServerState>,
headers: HeaderMap,
Json(req): Json<VoiceRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let mut s = state.write().await;
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
session.voice_manager.toggle_mute(&req.room_id, &session.user_id)
.ok_or(axum::http::StatusCode::BAD_REQUEST)
.map(Json)
}
pub async fn toggle_deafen(
State(state): State<ServerState>,
headers: HeaderMap,
Json(req): Json<VoiceRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let mut s = state.write().await;
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
session.voice_manager.toggle_deafen(&req.room_id, &session.user_id)
.ok_or(axum::http::StatusCode::BAD_REQUEST)
.map(Json)
}

117
server/src/state.rs Normal file
View File

@@ -0,0 +1,117 @@
use std::sync::Arc;
use tokio::sync::RwLock;
use matrix_sdk::Client;
use std::collections::HashMap;
pub struct Session {
pub client: Client,
pub user_id: String,
pub voice_manager: VoiceManager,
}
pub struct VoiceManager {
channels: HashMap<String, VoiceChannel>,
active_channel: Option<String>,
}
pub struct VoiceChannel {
pub room_id: String,
pub participants: Vec<VoiceParticipant>,
}
pub struct VoiceParticipant {
pub user_id: String,
pub muted: bool,
pub deafened: bool,
pub streaming: bool,
}
impl VoiceManager {
pub fn new() -> Self {
Self {
channels: HashMap::new(),
active_channel: None,
}
}
pub fn join_channel(&mut self, room_id: String, user_id: String) -> bool {
if let Some(ref old_channel) = self.active_channel {
self.leave_channel_internal(old_channel, &user_id);
}
let channel = self.channels.entry(room_id.clone()).or_insert_with(|| VoiceChannel {
room_id: room_id.clone(),
participants: Vec::new(),
});
if channel.participants.iter().any(|p| p.user_id == user_id) {
return false;
}
channel.participants.push(VoiceParticipant {
user_id,
muted: false,
deafened: false,
streaming: false,
});
self.active_channel = Some(room_id);
true
}
fn leave_channel_internal(&mut self, room_id: &str, user_id: &str) {
if let Some(channel) = self.channels.get_mut(room_id) {
channel.participants.retain(|p| p.user_id != user_id);
if channel.participants.is_empty() {
self.channels.remove(room_id);
}
}
}
pub fn leave_channel(&mut self, room_id: &str, user_id: &str) -> bool {
self.leave_channel_internal(room_id, user_id);
if self.active_channel.as_deref() == Some(room_id) {
self.active_channel = None;
}
true
}
pub fn toggle_mute(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
if let Some(channel) = self.channels.get_mut(room_id) {
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
participant.muted = !participant.muted;
return Some(participant.muted);
}
}
None
}
pub fn toggle_deafen(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
if let Some(channel) = self.channels.get_mut(room_id) {
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
participant.deafened = !participant.deafened;
if participant.deafened {
participant.muted = true;
}
return Some(participant.deafened);
}
}
None
}
}
pub struct ServerStateInner {
pub sessions: HashMap<String, Session>,
}
impl ServerStateInner {
pub fn new() -> Self {
Self {
sessions: HashMap::new(),
}
}
}
pub type ServerState = Arc<RwLock<ServerStateInner>>;
impl ServerState {
pub fn new() -> Self {
Arc::new(RwLock::new(ServerStateInner::new()))
}
}