Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
284
server/src/api/admin.rs
Normal file
284
server/src/api/admin.rs
Normal 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
257
server/src/api/auth.rs
Normal 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
297
server/src/api/download.rs
Normal 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
178
server/src/api/friends.rs
Normal 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
147
server/src/api/health.rs
Normal 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
75
server/src/api/mod.rs
Normal 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))
|
||||
}
|
||||
127
server/src/api/relay_nodes.rs
Normal file
127
server/src/api/relay_nodes.rs
Normal 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(®ion)
|
||||
.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
595
server/src/api/rooms.rs
Normal 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
69
server/src/api/users.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user