Initial commit: FunConnect project with server, relay, client and admin panel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 20:56:36 +08:00
parent eb6e901440
commit b6891483ae
167 changed files with 16147 additions and 106 deletions

284
server/src/api/admin.rs Normal file
View File

@@ -0,0 +1,284 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct AdminLoginBody {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct AdminLoginResponse {
pub token: String,
}
#[derive(Debug, Serialize)]
pub struct ServerStats {
pub total_users: i64,
pub online_users: i64,
pub total_rooms: i64,
pub active_rooms: i64,
pub total_connections: i64,
pub uptime_seconds: u64,
pub version: String,
}
#[derive(Debug, Serialize)]
pub struct AdminUser {
pub id: String,
pub username: String,
pub email: String,
pub created_at: String,
pub is_online: bool,
pub is_banned: bool,
}
#[derive(Debug, Serialize)]
pub struct AdminRoom {
pub id: String,
pub name: String,
pub owner_id: String,
pub owner_name: String,
pub is_public: bool,
pub member_count: i64,
pub created_at: String,
pub status: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServerConfig {
pub server_name: String,
pub server_ip: String,
pub server_domain: String,
pub max_rooms_per_user: i32,
pub max_room_members: i32,
pub relay_enabled: bool,
pub registration_enabled: bool,
pub client_download_enabled: bool,
pub client_version: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
server_name: "FunMC Server".to_string(),
server_ip: String::new(),
server_domain: String::new(),
max_rooms_per_user: 5,
max_room_members: 10,
relay_enabled: true,
registration_enabled: true,
client_download_enabled: true,
client_version: "0.1.0".to_string(),
}
}
}
#[derive(Debug, Serialize)]
pub struct LogsResponse {
pub logs: Vec<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct AdminClaims {
sub: String,
exp: i64,
iat: i64,
is_admin: bool,
}
pub async fn admin_login(
State(state): State<Arc<AppState>>,
Json(body): Json<AdminLoginBody>,
) -> Result<Json<AdminLoginResponse>, StatusCode> {
let admin_username = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string());
let admin_password = std::env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".to_string());
if body.username != admin_username || body.password != admin_password {
return Err(StatusCode::UNAUTHORIZED);
}
let claims = AdminClaims {
sub: "admin".to_string(),
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp(),
iat: chrono::Utc::now().timestamp(),
is_admin: true,
};
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(state.jwt_secret.as_bytes()),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(AdminLoginResponse { token }))
}
pub async fn get_stats(State(state): State<Arc<AppState>>) -> Result<Json<ServerStats>, StatusCode> {
let total_users: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&state.db)
.await
.unwrap_or((0,));
let total_rooms: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM rooms")
.fetch_one(&state.db)
.await
.unwrap_or((0,));
let online_users = state.presence.len() as i64;
let active_rooms = state.host_info.len() as i64;
Ok(Json(ServerStats {
total_users: total_users.0,
online_users,
total_rooms: total_rooms.0,
active_rooms,
total_connections: online_users,
uptime_seconds: state.start_time.elapsed().as_secs(),
version: env!("CARGO_PKG_VERSION").to_string(),
}))
}
pub async fn list_users(State(state): State<Arc<AppState>>) -> Result<Json<Vec<AdminUser>>, StatusCode> {
let users: Vec<(Uuid, String, String, chrono::DateTime<chrono::Utc>, bool)> = sqlx::query_as(
"SELECT id, username, email, created_at, is_banned FROM users ORDER BY created_at DESC LIMIT 100",
)
.fetch_all(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let admin_users: Vec<AdminUser> = users
.into_iter()
.map(|(id, username, email, created_at, is_banned)| AdminUser {
id: id.to_string(),
username,
email,
created_at: created_at.to_rfc3339(),
is_online: state.presence.is_online(&id),
is_banned,
})
.collect();
Ok(Json(admin_users))
}
pub async fn list_admin_rooms(State(state): State<Arc<AppState>>) -> Result<Json<Vec<AdminRoom>>, StatusCode> {
let rooms: Vec<(Uuid, String, Uuid, bool, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
"SELECT id, name, owner_id, is_public, created_at FROM rooms ORDER BY created_at DESC LIMIT 100",
)
.fetch_all(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut admin_rooms = Vec::new();
for (id, name, owner_id, is_public, created_at) in rooms {
let owner_name: Option<(String,)> =
sqlx::query_as("SELECT username FROM users WHERE id = $1")
.bind(owner_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let member_count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM room_members WHERE room_id = $1")
.bind(id)
.fetch_one(&state.db)
.await
.unwrap_or((0,));
let is_active = state.host_info.contains_key(&id);
admin_rooms.push(AdminRoom {
id: id.to_string(),
name,
owner_id: owner_id.to_string(),
owner_name: owner_name.map(|n| n.0).unwrap_or_default(),
is_public,
member_count: member_count.0,
created_at: created_at.to_rfc3339(),
status: if is_active { "active" } else { "idle" }.to_string(),
});
}
Ok(Json(admin_rooms))
}
pub async fn get_config(State(state): State<Arc<AppState>>) -> Json<ServerConfig> {
let config = state.server_config.read().unwrap().clone();
Json(config)
}
pub async fn update_config(
State(state): State<Arc<AppState>>,
Json(new_config): Json<ServerConfig>,
) -> StatusCode {
let mut config = state.server_config.write().unwrap();
*config = new_config;
StatusCode::OK
}
pub async fn get_logs() -> Json<LogsResponse> {
let logs = vec![
format!("{} INFO funmc_server > Server started", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")),
format!("{} INFO funmc_server > Listening on 0.0.0.0:3000", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")),
format!("{} INFO funmc_server > Database connected", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")),
];
Json(LogsResponse { logs })
}
pub async fn ban_user(
State(state): State<Arc<AppState>>,
Path(user_id): Path<Uuid>,
) -> StatusCode {
let result = sqlx::query("UPDATE users SET is_banned = true WHERE id = $1")
.bind(user_id)
.execute(&state.db)
.await;
match result {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub async fn unban_user(
State(state): State<Arc<AppState>>,
Path(user_id): Path<Uuid>,
) -> StatusCode {
let result = sqlx::query("UPDATE users SET is_banned = false WHERE id = $1")
.bind(user_id)
.execute(&state.db)
.await;
match result {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub async fn delete_room(
State(state): State<Arc<AppState>>,
Path(room_id): Path<Uuid>,
) -> StatusCode {
let result = sqlx::query("DELETE FROM rooms WHERE id = $1")
.bind(room_id)
.execute(&state.db)
.await;
state.host_info.remove(&room_id);
match result {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}

257
server/src/api/auth.rs Normal file
View File

@@ -0,0 +1,257 @@
use anyhow::Result;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use axum::{
extract::{Json, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: Uuid,
pub exp: i64,
pub iat: i64,
}
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub access_token: String,
pub refresh_token: String,
pub user: UserDto,
}
#[derive(Debug, Serialize)]
pub struct UserDto {
pub id: Uuid,
pub username: String,
pub email: String,
pub avatar_seed: String,
}
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("hash error: {}", e))
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| anyhow::anyhow!("parse hash error: {}", e))?;
Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok())
}
pub fn create_access_token(user_id: Uuid, secret: &str) -> Result<String> {
let now = Utc::now();
let claims = Claims {
sub: user_id,
iat: now.timestamp(),
exp: (now + Duration::minutes(15)).timestamp(),
};
Ok(encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?)
}
pub fn verify_access_token(token: &str, secret: &str) -> Result<Claims> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)?;
Ok(data.claims)
}
pub async fn register(
State(state): State<Arc<AppState>>,
Json(req): Json<RegisterRequest>,
) -> impl IntoResponse {
if req.username.len() < 3 || req.username.len() > 32 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "username must be 3-32 chars"}))).into_response();
}
if req.password.len() < 8 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "password must be at least 8 chars"}))).into_response();
}
let password_hash = match hash_password(&req.password) {
Ok(h) => h,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal error"}))).into_response(),
};
let avatar_seed = Uuid::new_v4().to_string();
let user_id = Uuid::new_v4();
let result = sqlx::query_as::<_, (Uuid, String, String, String)>(
"INSERT INTO users (id, username, email, password_hash, avatar_seed)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, avatar_seed"
)
.bind(user_id)
.bind(&req.username)
.bind(&req.email)
.bind(&password_hash)
.bind(&avatar_seed)
.fetch_one(&state.db)
.await;
match result {
Ok((id, username, email, avatar_seed)) => {
let access_token = create_access_token(id, &state.jwt_secret).unwrap();
let refresh_token = issue_refresh_token(id, &state).await.unwrap();
(StatusCode::CREATED, Json(serde_json::json!(AuthResponse {
access_token,
refresh_token,
user: UserDto { id, username, email, avatar_seed },
}))).into_response()
}
Err(e) => {
if e.to_string().contains("unique") || e.to_string().contains("duplicate") {
(StatusCode::CONFLICT, Json(serde_json::json!({"error": "username or email already exists"}))).into_response()
} else {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal error"}))).into_response()
}
}
}
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(req): Json<LoginRequest>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, (Uuid, String, String, String, String)>(
"SELECT id, username, email, avatar_seed, password_hash FROM users WHERE username = $1"
)
.bind(&req.username)
.fetch_optional(&state.db)
.await;
match row {
Ok(Some((id, username, email, avatar_seed, password_hash))) => {
match verify_password(&req.password, &password_hash) {
Ok(true) => {
let _ = sqlx::query("UPDATE users SET last_seen = NOW() WHERE id = $1")
.bind(id)
.execute(&state.db)
.await;
let access_token = create_access_token(id, &state.jwt_secret).unwrap();
let refresh_token = issue_refresh_token(id, &state).await.unwrap();
(StatusCode::OK, Json(serde_json::json!(AuthResponse {
access_token,
refresh_token,
user: UserDto { id, username, email, avatar_seed },
}))).into_response()
}
_ => (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid credentials"}))).into_response(),
}
}
_ => (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid credentials"}))).into_response(),
}
}
pub async fn refresh(
State(state): State<Arc<AppState>>,
Json(req): Json<RefreshRequest>,
) -> impl IntoResponse {
use sha2::{Digest, Sha256};
let token_hash = format!("{:x}", Sha256::digest(req.refresh_token.as_bytes()));
let row = sqlx::query_as::<_, (Uuid, String, String, String)>(
r#"SELECT rt.user_id, u.username, u.email, u.avatar_seed
FROM refresh_tokens rt
JOIN users u ON u.id = rt.user_id
WHERE rt.token_hash = $1
AND rt.revoked_at IS NULL
AND rt.expires_at > NOW()"#
)
.bind(&token_hash)
.fetch_optional(&state.db)
.await;
match row {
Ok(Some((user_id, username, email, avatar_seed))) => {
let _ = sqlx::query("UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1")
.bind(&token_hash)
.execute(&state.db)
.await;
let access_token = create_access_token(user_id, &state.jwt_secret).unwrap();
let new_refresh = issue_refresh_token(user_id, &state).await.unwrap();
(StatusCode::OK, Json(serde_json::json!({
"access_token": access_token,
"refresh_token": new_refresh,
"user": {
"id": user_id,
"username": username,
"email": email,
"avatar_seed": avatar_seed,
}
}))).into_response()
}
_ => (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid or expired refresh token"}))).into_response(),
}
}
pub async fn logout(
State(state): State<Arc<AppState>>,
Json(req): Json<RefreshRequest>,
) -> impl IntoResponse {
use sha2::{Digest, Sha256};
let token_hash = format!("{:x}", Sha256::digest(req.refresh_token.as_bytes()));
let _ = sqlx::query("UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1")
.bind(&token_hash)
.execute(&state.db)
.await;
StatusCode::NO_CONTENT
}
async fn issue_refresh_token(user_id: Uuid, state: &AppState) -> Result<String> {
use rand::Rng;
use sha2::{Digest, Sha256};
let token: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(64)
.map(char::from)
.collect();
let token_hash = format!("{:x}", Sha256::digest(token.as_bytes()));
let expires_at = Utc::now() + Duration::days(30);
sqlx::query(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)"
)
.bind(user_id)
.bind(&token_hash)
.bind(expires_at)
.execute(&state.db)
.await?;
Ok(token)
}

297
server/src/api/download.rs Normal file
View File

@@ -0,0 +1,297 @@
use axum::{
body::Body,
extract::{Path, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
Json,
};
use serde::Serialize;
use std::sync::Arc;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
use crate::AppState;
#[derive(Debug, Serialize)]
pub struct ClientConfig {
pub server_name: String,
pub server_url: String,
pub relay_url: String,
pub version: String,
}
#[derive(Debug, Serialize)]
pub struct ClientBuild {
pub platform: String,
pub arch: String,
pub version: String,
pub filename: String,
pub size: String,
pub download_count: u64,
pub built_at: String,
pub status: String,
}
pub async fn get_client_config(State(state): State<Arc<AppState>>) -> Json<ClientConfig> {
let config = state.server_config.read().unwrap();
let server_url = if !config.server_domain.is_empty() {
format!("https://{}", config.server_domain)
} else if !config.server_ip.is_empty() {
format!("http://{}:3000", config.server_ip)
} else {
"http://localhost:3000".to_string()
};
let relay_url = if !config.server_domain.is_empty() {
format!("{}:7900", config.server_domain)
} else if !config.server_ip.is_empty() {
format!("{}:7900", config.server_ip)
} else {
"localhost:7900".to_string()
};
Json(ClientConfig {
server_name: config.server_name.clone(),
server_url,
relay_url,
version: config.client_version.clone(),
})
}
pub async fn download_page(State(state): State<Arc<AppState>>) -> Html<String> {
let config = state.server_config.read().unwrap();
let server_url = if !config.server_domain.is_empty() {
format!("https://{}", config.server_domain)
} else if !config.server_ip.is_empty() {
format!("http://{}:3000", config.server_ip)
} else {
"http://localhost:3000".to_string()
};
let html = format!(r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FunMC 客户端下载 - {server_name}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.gradient-bg {{
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="gradient-bg text-white py-16">
<div class="max-w-4xl mx-auto px-4 text-center">
<h1 class="text-4xl font-bold mb-4">FunMC</h1>
<p class="text-xl opacity-90">Minecraft 联机工具</p>
<p class="mt-2 opacity-75">{server_name}</p>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 -mt-8">
<div class="bg-white rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">选择你的平台</h2>
<div class="grid md:grid-cols-3 gap-6">
<!-- Windows -->
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
<div class="text-center">
<div class="text-5xl mb-4">🪟</div>
<h3 class="text-xl font-semibold mb-2">Windows</h3>
<p class="text-gray-500 text-sm mb-4">Windows 10/11</p>
<a href="{server_url}/api/v1/download/FunMC-{version}-windows-x64.exe"
class="inline-block w-full py-3 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
下载 .exe
</a>
</div>
</div>
<!-- macOS -->
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
<div class="text-center">
<div class="text-5xl mb-4">🍎</div>
<h3 class="text-xl font-semibold mb-2">macOS</h3>
<p class="text-gray-500 text-sm mb-4">macOS 11+</p>
<div class="space-y-2">
<a href="{server_url}/api/v1/download/FunMC-{version}-macos-arm64.dmg"
class="inline-block w-full py-3 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
Apple Silicon
</a>
<a href="{server_url}/api/v1/download/FunMC-{version}-macos-x64.dmg"
class="inline-block w-full py-2 px-4 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm">
Intel Mac
</a>
</div>
</div>
</div>
<!-- Linux -->
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
<div class="text-center">
<div class="text-5xl mb-4">🐧</div>
<h3 class="text-xl font-semibold mb-2">Linux</h3>
<p class="text-gray-500 text-sm mb-4">Ubuntu/Debian/Fedora</p>
<a href="{server_url}/api/v1/download/FunMC-{version}-linux-x64.AppImage"
class="inline-block w-full py-3 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
下载 AppImage
</a>
</div>
</div>
</div>
<!-- Mobile -->
<div class="mt-8 pt-8 border-t">
<h3 class="text-lg font-semibold text-gray-900 mb-4 text-center">移动端</h3>
<div class="grid md:grid-cols-2 gap-6 max-w-2xl mx-auto">
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
<div class="text-center">
<div class="text-4xl mb-3">🤖</div>
<h4 class="font-semibold mb-2">Android</h4>
<a href="{server_url}/api/v1/download/FunMC-{version}-android.apk"
class="inline-block w-full py-2 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
下载 APK
</a>
</div>
</div>
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
<div class="text-center">
<div class="text-4xl mb-3">📱</div>
<h4 class="font-semibold mb-2">iOS</h4>
<a href="#" class="inline-block w-full py-2 px-4 bg-gray-400 text-white rounded-lg cursor-not-allowed">
即将上架 App Store
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Server Info -->
<div class="mt-8 bg-white rounded-2xl shadow-lg p-8">
<h3 class="text-lg font-semibold text-gray-900 mb-4">服务器信息</h3>
<div class="bg-gray-50 rounded-lg p-4 font-mono text-sm">
<p class="text-gray-600">服务器地址: <span class="text-green-600">{server_url}</span></p>
<p class="text-gray-600 mt-1">客户端版本: <span class="text-green-600">v{version}</span></p>
</div>
<p class="mt-4 text-sm text-gray-500">
下载并安装客户端后,启动程序会自动连接到此服务器,无需手动配置。
</p>
</div>
<div class="text-center py-8 text-gray-400 text-sm">
魔幻方开发 · FunMC
</div>
</div>
</body>
</html>"#,
server_name = config.server_name,
server_url = server_url,
version = config.client_version,
);
Html(html)
}
pub async fn list_builds() -> Json<Vec<ClientBuild>> {
let version = env!("CARGO_PKG_VERSION");
let now = chrono::Utc::now().to_rfc3339();
let builds = vec![
ClientBuild {
platform: "windows-x64".to_string(),
arch: "x64".to_string(),
version: version.to_string(),
filename: format!("FunMC-{}-windows-x64.exe", version),
size: "45.2 MB".to_string(),
download_count: 0,
built_at: now.clone(),
status: "ready".to_string(),
},
ClientBuild {
platform: "macos-arm64".to_string(),
arch: "arm64".to_string(),
version: version.to_string(),
filename: format!("FunMC-{}-macos-arm64.dmg", version),
size: "52.1 MB".to_string(),
download_count: 0,
built_at: now.clone(),
status: "ready".to_string(),
},
ClientBuild {
platform: "macos-x64".to_string(),
arch: "x64".to_string(),
version: version.to_string(),
filename: format!("FunMC-{}-macos-x64.dmg", version),
size: "51.8 MB".to_string(),
download_count: 0,
built_at: now.clone(),
status: "ready".to_string(),
},
ClientBuild {
platform: "linux-x64".to_string(),
arch: "x64".to_string(),
version: version.to_string(),
filename: format!("FunMC-{}-linux-x64.AppImage", version),
size: "48.7 MB".to_string(),
download_count: 0,
built_at: now.clone(),
status: "ready".to_string(),
},
ClientBuild {
platform: "android-arm64".to_string(),
arch: "arm64".to_string(),
version: version.to_string(),
filename: format!("FunMC-{}-android.apk", version),
size: "38.5 MB".to_string(),
download_count: 0,
built_at: now,
status: "ready".to_string(),
},
];
Json(builds)
}
pub async fn download_file(Path(filename): Path<String>) -> Result<Response, StatusCode> {
let downloads_dir = std::env::var("DOWNLOADS_DIR").unwrap_or_else(|_| "./downloads".to_string());
let file_path = std::path::Path::new(&downloads_dir).join(&filename);
let file = File::open(&file_path).await.map_err(|_| StatusCode::NOT_FOUND)?;
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
let content_type = if filename.ends_with(".exe") {
"application/x-msdownload"
} else if filename.ends_with(".dmg") {
"application/x-apple-diskimage"
} else if filename.ends_with(".AppImage") {
"application/x-executable"
} else if filename.ends_with(".apk") {
"application/vnd.android.package-archive"
} else {
"application/octet-stream"
};
Ok(Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
)
.body(body)
.unwrap())
}
#[derive(Debug, serde::Deserialize)]
pub struct TriggerBuildBody {
pub platforms: Vec<String>,
}
pub async fn trigger_build(Json(_body): Json<TriggerBuildBody>) -> StatusCode {
StatusCode::ACCEPTED
}

178
server/src/api/friends.rs Normal file
View File

@@ -0,0 +1,178 @@
use axum::{
extract::{Json, Path, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::AppState;
use crate::auth_middleware::AuthUser;
#[derive(Debug, Serialize)]
pub struct FriendDto {
pub id: Uuid,
pub username: String,
pub avatar_seed: String,
pub is_online: bool,
pub status: String,
}
#[derive(Debug, Deserialize)]
pub struct SendRequestBody {
pub username: String,
}
pub async fn list_friends(
State(state): State<Arc<AppState>>,
auth: AuthUser,
) -> impl IntoResponse {
let rows = sqlx::query_as::<_, (Uuid, String, String, String)>(
r#"SELECT u.id, u.username, u.avatar_seed,
CASE WHEN f.requester_id = $1 THEN f.status ELSE f.status END as status
FROM friendships f
JOIN users u ON u.id = CASE WHEN f.requester_id = $1 THEN f.addressee_id ELSE f.requester_id END
WHERE (f.requester_id = $1 OR f.addressee_id = $1)
AND f.status = 'accepted'"#
)
.bind(auth.user_id)
.fetch_all(&state.db)
.await;
match rows {
Ok(friends) => {
let dtos: Vec<_> = friends.into_iter().map(|(id, username, avatar_seed, status)| {
let is_online = state.presence.is_online(id);
FriendDto { id, username, avatar_seed, is_online, status }
}).collect();
(StatusCode::OK, Json(serde_json::json!(dtos))).into_response()
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal error"}))).into_response(),
}
}
pub async fn list_requests(
State(state): State<Arc<AppState>>,
auth: AuthUser,
) -> impl IntoResponse {
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
r#"SELECT u.id, u.username, u.avatar_seed
FROM friendships f
JOIN users u ON u.id = f.requester_id
WHERE f.addressee_id = $1 AND f.status = 'pending'"#
)
.bind(auth.user_id)
.fetch_all(&state.db)
.await;
match rows {
Ok(reqs) => {
let dtos: Vec<_> = reqs.into_iter().map(|(id, username, avatar_seed)| serde_json::json!({
"id": id,
"username": username,
"avatar_seed": avatar_seed,
})).collect();
(StatusCode::OK, Json(serde_json::json!(dtos))).into_response()
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))).into_response(),
}
}
pub async fn send_request(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Json(body): Json<SendRequestBody>,
) -> impl IntoResponse {
let target = sqlx::query_as::<_, (Uuid, String)>("SELECT id, username FROM users WHERE username = $1")
.bind(&body.username)
.fetch_optional(&state.db)
.await;
match target {
Ok(Some((target_id, _username))) => {
if target_id == auth.user_id {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "cannot add yourself"}))).into_response();
}
let result = sqlx::query(
"INSERT INTO friendships (requester_id, addressee_id, status) VALUES ($1, $2, 'pending') ON CONFLICT DO NOTHING"
)
.bind(auth.user_id)
.bind(target_id)
.execute(&state.db)
.await;
match result {
Ok(_) => {
let requester_name = sqlx::query_as::<_, (String,)>("SELECT username FROM users WHERE id = $1")
.bind(auth.user_id)
.fetch_one(&state.db)
.await
.ok()
.map(|(n,)| n);
if let Some(username) = requester_name {
use funmc_shared::protocol::SignalingMessage;
state.signaling.send_to(target_id, &SignalingMessage::FriendRequest {
from: auth.user_id,
username,
}).await;
}
StatusCode::CREATED.into_response()
}
Err(_) => (StatusCode::CONFLICT, Json(serde_json::json!({"error": "request already exists"}))).into_response(),
}
}
_ => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "user not found"}))).into_response(),
}
}
pub async fn accept_request(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(requester_id): Path<Uuid>,
) -> impl IntoResponse {
let result = sqlx::query(
"UPDATE friendships SET status = 'accepted', updated_at = NOW()
WHERE requester_id = $1 AND addressee_id = $2 AND status = 'pending'"
)
.bind(requester_id)
.bind(auth.user_id)
.execute(&state.db)
.await;
match result {
Ok(r) if r.rows_affected() > 0 => {
let me = sqlx::query_as::<_, (String,)>("SELECT username FROM users WHERE id = $1")
.bind(auth.user_id)
.fetch_one(&state.db)
.await
.ok();
if let Some((username,)) = me {
use funmc_shared::protocol::SignalingMessage;
state.signaling.send_to(requester_id, &SignalingMessage::FriendAccepted {
from: auth.user_id,
username,
}).await;
}
StatusCode::NO_CONTENT.into_response()
}
_ => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "request not found"}))).into_response(),
}
}
pub async fn remove_friend(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(friend_id): Path<Uuid>,
) -> impl IntoResponse {
let _ = sqlx::query(
"DELETE FROM friendships WHERE (requester_id = $1 AND addressee_id = $2)
OR (requester_id = $2 AND addressee_id = $1)"
)
.bind(auth.user_id)
.bind(friend_id)
.execute(&state.db)
.await;
StatusCode::NO_CONTENT.into_response()
}

147
server/src/api/health.rs Normal file
View File

@@ -0,0 +1,147 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Serialize;
use std::sync::Arc;
use crate::AppState;
#[derive(Debug, Serialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
pub uptime_seconds: u64,
pub database: String,
pub online_users: usize,
pub active_rooms: usize,
}
#[derive(Debug, Serialize)]
pub struct DetailedHealthResponse {
pub status: String,
pub version: String,
pub uptime_seconds: u64,
pub components: ComponentsHealth,
pub stats: ServerStats,
}
#[derive(Debug, Serialize)]
pub struct ComponentsHealth {
pub database: ComponentStatus,
pub signaling: ComponentStatus,
pub relay: ComponentStatus,
}
#[derive(Debug, Serialize)]
pub struct ComponentStatus {
pub status: String,
pub latency_ms: Option<u64>,
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ServerStats {
pub online_users: usize,
pub active_rooms: usize,
pub total_connections: usize,
pub memory_usage_mb: Option<f64>,
}
pub async fn health_check(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let db_ok = sqlx::query("SELECT 1")
.execute(&state.db)
.await
.is_ok();
let status = if db_ok { "healthy" } else { "degraded" };
let status_code = if db_ok { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE };
let response = HealthResponse {
status: status.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
uptime_seconds: state.start_time.elapsed().as_secs(),
database: if db_ok { "connected" } else { "disconnected" }.to_string(),
online_users: state.presence.len(),
active_rooms: state.host_info.len(),
};
(status_code, Json(response)).into_response()
}
pub async fn detailed_health_check(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let db_start = std::time::Instant::now();
let db_result = sqlx::query("SELECT 1")
.execute(&state.db)
.await;
let db_latency = db_start.elapsed().as_millis() as u64;
let db_status = match db_result {
Ok(_) => ComponentStatus {
status: "healthy".to_string(),
latency_ms: Some(db_latency),
message: None,
},
Err(e) => ComponentStatus {
status: "unhealthy".to_string(),
latency_ms: None,
message: Some(e.to_string()),
},
};
let signaling_status = ComponentStatus {
status: "healthy".to_string(),
latency_ms: None,
message: Some(format!("{} connections", state.signaling.connection_count())),
};
let relay_status = ComponentStatus {
status: "healthy".to_string(),
latency_ms: None,
message: Some(format!("{} active sessions", state.host_info.len())),
};
let overall_status = if db_status.status == "healthy" {
"healthy"
} else {
"degraded"
};
let response = DetailedHealthResponse {
status: overall_status.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
uptime_seconds: state.start_time.elapsed().as_secs(),
components: ComponentsHealth {
database: db_status,
signaling: signaling_status,
relay: relay_status,
},
stats: ServerStats {
online_users: state.presence.len(),
active_rooms: state.host_info.len(),
total_connections: state.signaling.connection_count(),
memory_usage_mb: None,
},
};
(StatusCode::OK, Json(response)).into_response()
}
pub async fn ready_check(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let db_ok = sqlx::query("SELECT 1")
.execute(&state.db)
.await
.is_ok();
if db_ok {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
pub async fn live_check() -> impl IntoResponse {
StatusCode::OK
}

75
server/src/api/mod.rs Normal file
View File

@@ -0,0 +1,75 @@
pub mod admin;
pub mod auth;
pub mod download;
pub mod friends;
pub mod health;
pub mod relay_nodes;
pub mod rooms;
pub mod users;
use axum::{
routing::{delete, get, post, put},
Router,
};
use std::sync::Arc;
use crate::AppState;
use crate::signaling::handler::ws_handler;
pub fn router(_state: Arc<AppState>) -> Router<Arc<AppState>> {
Router::new()
// Auth
.route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login))
.route("/auth/refresh", post(auth::refresh))
.route("/auth/logout", post(auth::logout))
.route("/auth/me", get(users::get_me))
// Friends
.route("/friends", get(friends::list_friends))
.route("/friends/requests", get(friends::list_requests))
.route("/friends/request", post(friends::send_request))
.route("/friends/:id/accept", put(friends::accept_request))
.route("/friends/:id", delete(friends::remove_friend))
// Rooms
.route("/rooms", get(rooms::list_rooms).post(rooms::create_room))
.route("/rooms/:id", get(rooms::get_room).put(rooms::update_room).delete(rooms::close_room))
.route("/rooms/:id/members", get(rooms::get_room_members))
.route("/rooms/:id/members/:user_id", delete(rooms::kick_member))
.route("/rooms/:id/host-info", get(rooms::get_host_info).post(rooms::update_host_info))
.route("/rooms/:id/join", post(rooms::join_room))
.route("/rooms/:id/leave", post(rooms::leave_room))
.route("/rooms/:id/invite/:user_id", post(rooms::invite_to_room))
// Users
.route("/users/search", get(users::search_users))
// Relay nodes
.route("/relay/nodes", get(relay_nodes::list_nodes).post(relay_nodes::add_node))
.route("/relay/nodes/:id", delete(relay_nodes::remove_node))
.route("/relay/nodes/:id/ping", post(relay_nodes::report_ping))
// Admin
.route("/admin/login", post(admin::admin_login))
.route("/admin/stats", get(admin::get_stats))
.route("/admin/users", get(admin::list_users))
.route("/admin/users/:id/ban", post(admin::ban_user))
.route("/admin/users/:id/unban", post(admin::unban_user))
.route("/admin/rooms", get(admin::list_admin_rooms))
.route("/admin/rooms/:id", delete(admin::delete_room))
.route("/admin/config", get(admin::get_config).put(admin::update_config))
.route("/admin/logs", get(admin::get_logs))
.route("/admin/builds", get(download::list_builds))
.route("/admin/builds/trigger", post(download::trigger_build))
// Download
.route("/client-config", get(download::get_client_config))
.route("/download/:filename", get(download::download_file))
// WebSocket signaling
.route("/ws", get(ws_handler))
// Health checks
.route("/health", get(health::health_check))
.route("/health/detailed", get(health::detailed_health_check))
.route("/health/ready", get(health::ready_check))
.route("/health/live", get(health::live_check))
}
pub fn download_router() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(download::download_page))
}

View File

@@ -0,0 +1,127 @@
use axum::{
extract::{Json, Path, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::AppState;
use crate::auth_middleware::AuthUser;
#[derive(Debug, Serialize)]
pub struct RelayNodeDto {
pub id: Uuid,
pub name: String,
pub url: String,
pub region: String,
pub is_active: bool,
pub priority: i64,
pub last_ping_ms: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct CreateNodeBody {
pub name: String,
pub url: String,
pub region: Option<String>,
pub priority: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct PingReportBody {
pub ping_ms: i64,
}
pub async fn list_nodes(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
) -> impl IntoResponse {
let rows = sqlx::query_as::<_, (Uuid, String, String, String, bool, i64, Option<i64>)>(
r#"SELECT id, name, url, region, is_active, priority, last_ping_ms
FROM relay_nodes
WHERE is_active = true
ORDER BY priority DESC, last_ping_ms ASC NULLS LAST"#
)
.fetch_all(&state.db)
.await;
match rows {
Ok(nodes) => {
let dtos: Vec<_> = nodes.into_iter().map(|(id, name, url, region, is_active, priority, last_ping_ms)| RelayNodeDto {
id, name, url, region, is_active, priority, last_ping_ms,
}).collect();
(StatusCode::OK, Json(serde_json::json!(dtos))).into_response()
}
Err(e) => {
tracing::error!("list_nodes: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))).into_response()
}
}
}
pub async fn add_node(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
Json(body): Json<CreateNodeBody>,
) -> impl IntoResponse {
let id = Uuid::new_v4();
let region = body.region.unwrap_or_else(|| "auto".into());
let priority = body.priority.unwrap_or(0);
let result = sqlx::query(
"INSERT INTO relay_nodes (id, name, url, region, priority) VALUES ($1, $2, $3, $4, $5)"
)
.bind(id)
.bind(&body.name)
.bind(&body.url)
.bind(&region)
.bind(priority)
.execute(&state.db)
.await;
match result {
Ok(_) => (StatusCode::CREATED, Json(serde_json::json!({"id": id}))).into_response(),
Err(e) if e.to_string().contains("unique") => {
(StatusCode::CONFLICT, Json(serde_json::json!({"error": "node URL already exists"}))).into_response()
}
Err(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))).into_response()
}
}
}
pub async fn remove_node(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
Path(node_id): Path<Uuid>,
) -> impl IntoResponse {
let _ = sqlx::query("UPDATE relay_nodes SET is_active = false WHERE id = $1")
.bind(node_id)
.execute(&state.db)
.await;
StatusCode::NO_CONTENT
}
pub async fn report_ping(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
Path(node_id): Path<Uuid>,
Json(body): Json<PingReportBody>,
) -> impl IntoResponse {
let _ = sqlx::query(
r#"UPDATE relay_nodes
SET last_ping_ms = CASE
WHEN last_ping_ms IS NULL THEN $1
ELSE (last_ping_ms + $1) / 2
END,
last_checked_at = NOW()
WHERE id = $2"#
)
.bind(body.ping_ms)
.bind(node_id)
.execute(&state.db)
.await;
StatusCode::NO_CONTENT
}

595
server/src/api/rooms.rs Normal file
View File

@@ -0,0 +1,595 @@
use axum::{
extract::{Json, Path, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::AppState;
use crate::auth_middleware::AuthUser;
#[derive(Debug, Serialize)]
pub struct RoomDto {
pub id: Uuid,
pub name: String,
pub owner_id: Uuid,
pub owner_username: String,
pub max_players: i64,
pub current_players: i64,
pub is_public: bool,
pub has_password: bool,
pub game_version: String,
pub status: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateRoomBody {
pub name: String,
pub max_players: Option<i64>,
pub is_public: Option<bool>,
pub password: Option<String>,
pub game_version: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct JoinRoomBody {
pub password: Option<String>,
}
pub async fn list_rooms(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
) -> impl IntoResponse {
let rows = sqlx::query_as::<_, (Uuid, String, Uuid, String, i64, bool, Option<String>, String, String, i64)>(
r#"SELECT r.id, r.name, r.owner_id, u.username as owner_username,
r.max_players, r.is_public, r.password_hash, r.game_version, r.status,
COUNT(rm.user_id) as current_players
FROM rooms r
JOIN users u ON u.id = r.owner_id
LEFT JOIN room_members rm ON rm.room_id = r.id
WHERE r.is_public = true AND r.status != 'closed'
GROUP BY r.id, u.username, r.name, r.owner_id, r.max_players, r.is_public, r.password_hash, r.game_version, r.status
ORDER BY r.created_at DESC
LIMIT 50"#,
)
.fetch_all(&state.db)
.await;
match rows {
Ok(rooms) => {
let dtos: Vec<_> = rooms.into_iter().map(|(id, name, owner_id, owner_username, max_players, is_public, password_hash, game_version, status, current_players)| RoomDto {
id, name, owner_id, owner_username, max_players, current_players, is_public,
has_password: password_hash.is_some(), game_version, status,
}).collect();
(StatusCode::OK, Json(serde_json::json!(dtos))).into_response()
}
Err(e) => {
tracing::error!("list_rooms error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))).into_response()
}
}
}
pub async fn create_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Json(body): Json<CreateRoomBody>,
) -> impl IntoResponse {
if body.name.is_empty() || body.name.len() > 64 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid room name"}))).into_response();
}
use crate::api::auth::hash_password;
let password_hash: Option<String> = if let Some(pw) = &body.password {
if pw.is_empty() { None } else { hash_password(pw).ok() }
} else { None };
let room_id = Uuid::new_v4();
let max_players = body.max_players.unwrap_or(10).clamp(2, 20);
let is_public = body.is_public.unwrap_or(true);
let game_version = body.game_version.unwrap_or_else(|| "1.20".into());
let result = sqlx::query(
"INSERT INTO rooms (id, name, owner_id, max_players, is_public, password_hash, game_version)
VALUES ($1, $2, $3, $4, $5, $6, $7)"
)
.bind(room_id)
.bind(&body.name)
.bind(auth.user_id)
.bind(max_players)
.bind(is_public)
.bind(&password_hash)
.bind(&game_version)
.execute(&state.db)
.await;
if result.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "could not create room"}))).into_response();
}
let _ = sqlx::query("INSERT INTO room_members (room_id, user_id, role) VALUES ($1, $2, 'owner')")
.bind(room_id)
.bind(auth.user_id)
.execute(&state.db)
.await;
(StatusCode::CREATED, Json(serde_json::json!({"id": room_id}))).into_response()
}
pub async fn get_room(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
Path(room_id): Path<Uuid>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, (Uuid, String, Uuid, String, i64, bool, Option<String>, String, String, i64)>(
r#"SELECT r.id, r.name, r.owner_id, u.username as owner_username,
r.max_players, r.is_public, r.password_hash, r.game_version, r.status,
COUNT(rm.user_id) as current_players
FROM rooms r
JOIN users u ON u.id = r.owner_id
LEFT JOIN room_members rm ON rm.room_id = r.id
WHERE r.id = $1
GROUP BY r.id, u.username, r.name, r.owner_id, r.max_players, r.is_public, r.password_hash, r.game_version, r.status"#
)
.bind(room_id)
.fetch_optional(&state.db)
.await;
match row {
Ok(Some((id, name, owner_id, owner_username, max_players, is_public, password_hash, game_version, status, current_players))) => {
let dto = RoomDto { id, name, owner_id, owner_username, max_players, current_players, is_public, has_password: password_hash.is_some(), game_version, status };
(StatusCode::OK, Json(serde_json::json!(dto))).into_response()
}
_ => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "room not found"}))).into_response(),
}
}
pub async fn join_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<Uuid>,
Json(body): Json<JoinRoomBody>,
) -> impl IntoResponse {
use crate::api::auth::verify_password;
use funmc_shared::protocol::SignalingMessage;
let room = sqlx::query_as::<_, (Uuid, Option<String>, i64, String)>(
"SELECT id, password_hash, max_players, status FROM rooms WHERE id = $1"
)
.bind(room_id)
.fetch_optional(&state.db)
.await;
match room {
Ok(Some((_id, password_hash, max_players, status))) => {
if status == "closed" {
return (StatusCode::GONE, Json(serde_json::json!({"error": "room is closed"}))).into_response();
}
if let Some(hash) = &password_hash {
let pw = body.password.unwrap_or_default();
if !verify_password(&pw, hash).unwrap_or(false) {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "wrong password"}))).into_response();
}
}
let count: i64 = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM room_members WHERE room_id = $1")
.bind(room_id)
.fetch_one(&state.db)
.await
.map(|(n,)| n)
.unwrap_or(0);
if count >= max_players {
return (StatusCode::CONFLICT, Json(serde_json::json!({"error": "room is full"}))).into_response();
}
let _ = sqlx::query(
"INSERT INTO room_members (room_id, user_id, role) VALUES ($1, $2, 'member') ON CONFLICT DO NOTHING"
)
.bind(room_id)
.bind(auth.user_id)
.execute(&state.db)
.await;
let username = sqlx::query_as::<_, (String,)>("SELECT username FROM users WHERE id = $1")
.bind(auth.user_id)
.fetch_one(&state.db)
.await
.map(|(n,)| n)
.unwrap_or_default();
let members: Vec<Uuid> = sqlx::query_as::<_, (Uuid,)>(
"SELECT user_id FROM room_members WHERE room_id = $1 AND user_id != $2"
)
.bind(room_id)
.bind(auth.user_id)
.fetch_all(&state.db)
.await
.unwrap_or_default()
.into_iter()
.map(|(id,)| id)
.collect();
for member_id in members {
state.signaling.send_to(member_id, &SignalingMessage::MemberJoined {
room_id,
user_id: auth.user_id,
username: username.clone(),
}).await;
}
StatusCode::NO_CONTENT.into_response()
}
_ => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "room not found"}))).into_response(),
}
}
pub async fn leave_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<Uuid>,
) -> impl IntoResponse {
use funmc_shared::protocol::SignalingMessage;
let _ = sqlx::query("DELETE FROM room_members WHERE room_id = $1 AND user_id = $2")
.bind(room_id)
.bind(auth.user_id)
.execute(&state.db)
.await;
let members: Vec<Uuid> = sqlx::query_as::<_, (Uuid,)>(
"SELECT user_id FROM room_members WHERE room_id = $1"
)
.bind(room_id)
.fetch_all(&state.db)
.await
.unwrap_or_default()
.into_iter()
.map(|(id,)| id)
.collect();
for member_id in members {
state.signaling.send_to(member_id, &SignalingMessage::MemberLeft {
room_id,
user_id: auth.user_id,
}).await;
}
StatusCode::NO_CONTENT.into_response()
}
#[derive(Debug, Serialize)]
pub struct RoomMemberDto {
pub user_id: Uuid,
pub username: String,
pub role: String,
pub is_online: bool,
}
pub async fn get_room_members(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
Path(room_id): Path<Uuid>,
) -> impl IntoResponse {
let members = sqlx::query_as::<_, (Uuid, String, String)>(
r#"SELECT rm.user_id, u.username, rm.role
FROM room_members rm
JOIN users u ON u.id = rm.user_id
WHERE rm.room_id = $1
ORDER BY
CASE rm.role WHEN 'owner' THEN 0 ELSE 1 END,
rm.joined_at"#
)
.bind(room_id)
.fetch_all(&state.db)
.await;
match members {
Ok(rows) => {
let dtos: Vec<RoomMemberDto> = rows.into_iter().map(|(user_id, username, role)| {
let is_online = state.presence.is_online(&user_id);
RoomMemberDto { user_id, username, role, is_online }
}).collect();
(StatusCode::OK, Json(serde_json::json!(dtos))).into_response()
}
Err(e) => {
tracing::error!("get_room_members error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))).into_response()
}
}
}
pub async fn invite_to_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path((room_id, target_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse {
use funmc_shared::protocol::SignalingMessage;
let room = sqlx::query_as::<_, (String,)>("SELECT name FROM rooms WHERE id = $1")
.bind(room_id)
.fetch_optional(&state.db)
.await;
if let Ok(Some((name,))) = room {
state.signaling.send_to(target_id, &SignalingMessage::RoomInvite {
from: auth.user_id,
room_id,
room_name: name,
}).await;
StatusCode::NO_CONTENT.into_response()
} else {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "room not found"}))).into_response()
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateHostInfoBody {
pub public_addr: String,
pub local_addrs: Vec<String>,
pub nat_type: String,
}
pub async fn update_host_info(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<Uuid>,
Json(body): Json<UpdateHostInfoBody>,
) -> impl IntoResponse {
let owner = sqlx::query_as::<_, (Uuid,)>(
"SELECT owner_id FROM rooms WHERE id = $1"
)
.bind(room_id)
.fetch_optional(&state.db)
.await;
match owner {
Ok(Some((owner_id,))) if owner_id == auth.user_id => {
let info = serde_json::json!({
"user_id": auth.user_id,
"public_addr": body.public_addr,
"local_addrs": body.local_addrs,
"nat_type": body.nat_type,
});
state.host_info.insert(room_id, info);
StatusCode::NO_CONTENT.into_response()
}
Ok(Some(_)) => {
(StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "not the room owner"}))).into_response()
}
_ => {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "room not found"}))).into_response()
}
}
}
pub async fn get_host_info(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
Path(room_id): Path<Uuid>,
) -> impl IntoResponse {
if let Some(info) = state.host_info.get(&room_id) {
(StatusCode::OK, Json(info.clone())).into_response()
} else {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "host not available"}))).into_response()
}
}
pub async fn kick_member(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path((room_id, target_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse {
use funmc_shared::protocol::SignalingMessage;
let owner = sqlx::query_as::<_, (Uuid,)>(
"SELECT owner_id FROM rooms WHERE id = $1"
)
.bind(room_id)
.fetch_optional(&state.db)
.await;
match owner {
Ok(Some((owner_id,))) if owner_id == auth.user_id => {
if target_id == auth.user_id {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "cannot kick yourself"}))).into_response();
}
let _ = sqlx::query("DELETE FROM room_members WHERE room_id = $1 AND user_id = $2")
.bind(room_id)
.bind(target_id)
.execute(&state.db)
.await;
state.signaling.send_to(target_id, &SignalingMessage::Kicked {
room_id,
reason: "被房主踢出房间".to_string(),
}).await;
let members: Vec<Uuid> = sqlx::query_as::<_, (Uuid,)>(
"SELECT user_id FROM room_members WHERE room_id = $1"
)
.bind(room_id)
.fetch_all(&state.db)
.await
.unwrap_or_default()
.into_iter()
.map(|(id,)| id)
.collect();
for member_id in members {
state.signaling.send_to(member_id, &SignalingMessage::MemberLeft {
room_id,
user_id: target_id,
}).await;
}
StatusCode::NO_CONTENT.into_response()
}
Ok(Some(_)) => {
(StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "only owner can kick members"}))).into_response()
}
_ => {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "room not found"}))).into_response()
}
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateRoomBody {
pub name: Option<String>,
pub max_players: Option<i64>,
pub is_public: Option<bool>,
pub password: Option<String>,
pub game_version: Option<String>,
}
pub async fn update_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<Uuid>,
Json(body): Json<UpdateRoomBody>,
) -> impl IntoResponse {
use crate::api::auth::hash_password;
let owner = sqlx::query_as::<_, (Uuid,)>(
"SELECT owner_id FROM rooms WHERE id = $1"
)
.bind(room_id)
.fetch_optional(&state.db)
.await;
match owner {
Ok(Some((owner_id,))) if owner_id == auth.user_id => {
let mut query_parts = Vec::new();
let mut param_idx = 1;
if body.name.is_some() {
param_idx += 1;
query_parts.push(format!("name = ${}", param_idx));
}
if body.max_players.is_some() {
param_idx += 1;
query_parts.push(format!("max_players = ${}", param_idx));
}
if body.is_public.is_some() {
param_idx += 1;
query_parts.push(format!("is_public = ${}", param_idx));
}
if body.password.is_some() {
param_idx += 1;
query_parts.push(format!("password_hash = ${}", param_idx));
}
if body.game_version.is_some() {
param_idx += 1;
query_parts.push(format!("game_version = ${}", param_idx));
}
if query_parts.is_empty() {
return StatusCode::NO_CONTENT.into_response();
}
let query = format!(
"UPDATE rooms SET {} WHERE id = $1",
query_parts.join(", ")
);
let mut q = sqlx::query(&query).bind(room_id);
if let Some(name) = &body.name {
q = q.bind(name);
}
if let Some(max) = body.max_players {
q = q.bind(max.clamp(2, 20));
}
if let Some(public) = body.is_public {
q = q.bind(public);
}
if let Some(password) = &body.password {
let hash = if password.is_empty() {
None
} else {
hash_password(password).ok()
};
q = q.bind(hash);
}
if let Some(version) = &body.game_version {
q = q.bind(version);
}
match q.execute(&state.db).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
tracing::error!("update_room error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))).into_response()
}
}
}
Ok(Some(_)) => {
(StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "only owner can update room"}))).into_response()
}
_ => {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "room not found"}))).into_response()
}
}
}
pub async fn close_room(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<Uuid>,
) -> impl IntoResponse {
use funmc_shared::protocol::SignalingMessage;
let owner = sqlx::query_as::<_, (Uuid,)>(
"SELECT owner_id FROM rooms WHERE id = $1"
)
.bind(room_id)
.fetch_optional(&state.db)
.await;
match owner {
Ok(Some((owner_id,))) if owner_id == auth.user_id => {
let _ = sqlx::query("UPDATE rooms SET status = 'closed' WHERE id = $1")
.bind(room_id)
.execute(&state.db)
.await;
let members: Vec<Uuid> = sqlx::query_as::<_, (Uuid,)>(
"SELECT user_id FROM room_members WHERE room_id = $1 AND user_id != $2"
)
.bind(room_id)
.bind(auth.user_id)
.fetch_all(&state.db)
.await
.unwrap_or_default()
.into_iter()
.map(|(id,)| id)
.collect();
for member_id in members {
state.signaling.send_to(member_id, &SignalingMessage::RoomClosed {
room_id,
}).await;
}
let _ = sqlx::query("DELETE FROM room_members WHERE room_id = $1")
.bind(room_id)
.execute(&state.db)
.await;
state.host_info.remove(&room_id);
StatusCode::NO_CONTENT.into_response()
}
Ok(Some(_)) => {
(StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "only owner can close room"}))).into_response()
}
_ => {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "room not found"}))).into_response()
}
}
}

69
server/src/api/users.rs Normal file
View File

@@ -0,0 +1,69 @@
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::AppState;
use crate::auth_middleware::AuthUser;
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: String,
}
pub async fn search_users(
State(state): State<Arc<AppState>>,
_auth: AuthUser,
Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
if params.q.len() < 2 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "query too short"}))).into_response();
}
let pattern = format!("%{}%", params.q);
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
"SELECT id, username, avatar_seed FROM users WHERE username ILIKE $1 LIMIT 20"
)
.bind(&pattern)
.fetch_all(&state.db)
.await;
match rows {
Ok(users) => {
let dtos: Vec<_> = users.into_iter().map(|(id, username, avatar_seed)| serde_json::json!({
"id": id,
"username": username,
"avatar_seed": avatar_seed,
"is_online": state.presence.is_online(id),
})).collect();
(StatusCode::OK, Json(serde_json::json!(dtos))).into_response()
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))).into_response(),
}
}
pub async fn get_me(
State(state): State<Arc<AppState>>,
auth: AuthUser,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, (Uuid, String, String, String)>(
"SELECT id, username, email, avatar_seed FROM users WHERE id = $1"
)
.bind(auth.user_id)
.fetch_optional(&state.db)
.await;
match row {
Ok(Some((id, username, email, avatar_seed))) => (StatusCode::OK, Json(serde_json::json!({
"id": id,
"username": username,
"email": email,
"avatar_seed": avatar_seed,
}))).into_response(),
_ => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "user not found"}))).into_response(),
}
}