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

View File

@@ -1,31 +1,14 @@
# FunMC Relay Server Configuration
# FunMC 主服务端环境配置
# 复制此文件为 .env 并修改配置
# Relay TCP port for Minecraft traffic
RELAY_PORT=25565
# 数据库连接
DATABASE_URL=postgres://postgres:password@localhost/funmc
# HTTP API port
API_PORT=3000
# JWT 密钥(必须与客户端和中继服务器保持一致)
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# Node identification
NODE_ID=
NODE_NAME=relay-node-1
# HTTP API 监听地址
LISTEN_ADDR=0.0.0.0:3000
# Master node configuration
IS_MASTER=true
MASTER_URL=http://master-host:3000
# Security
SECRET=your-secret-key-here
# Limits
MAX_ROOMS=100
MAX_PLAYERS_PER_ROOM=20
# Heartbeat interval in ms
HEARTBEAT_INTERVAL=10000
# Logging
LOG_LEVEL=info
# Public host (for worker nodes to report to master)
PUBLIC_HOST=0.0.0.0
# 日志级别
RUST_LOG=funmc_server=debug,tower_http=info

47
server/Cargo.toml Normal file
View File

@@ -0,0 +1,47 @@
[package]
name = "funmc-server"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "server"
path = "src/main.rs"
[dependencies]
funmc-shared = { path = "../shared" }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
axum = { version = "0.7", features = ["ws", "macros"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace", "fs"] }
tokio-util = { version = "0.7", features = ["io"] }
hyper = "1"
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-rustls", "uuid", "chrono", "migrate"] }
jsonwebtoken = "9"
argon2 = "0.5"
rand = "0.8"
rand_core = { version = "0.6", features = ["std"] }
dashmap = "5"
tokio-tungstenite = "0.21"
futures = "0.3"
futures-util = "0.3"
# QUIC relay
quinn = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
rcgen = "0.13"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
sha2 = "0.10"

20
server/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM rust:1.79-slim-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock* ./
COPY shared/ shared/
COPY server/ server/
# Build only server to avoid client (Tauri) deps in Docker
RUN cargo build --release -p funmc-server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/server /usr/local/bin/funmc-server
COPY --from=builder /app/server/migrations /migrations
EXPOSE 3000
CMD ["funmc-server"]

View File

@@ -0,0 +1,66 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(32) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
avatar_seed VARCHAR(64) NOT NULL DEFAULT '',
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE friendships (
requester_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
addressee_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'blocked')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (requester_id, addressee_id)
);
CREATE TABLE rooms (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(64) NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
max_players INTEGER NOT NULL DEFAULT 10,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
password_hash TEXT,
game_version VARCHAR(32) NOT NULL DEFAULT '1.20',
status VARCHAR(16) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_game', 'closed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE room_members (
room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(16) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
device_id VARCHAR(128),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE relay_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
room_id UUID REFERENCES rooms(id) ON DELETE SET NULL,
initiator_id UUID REFERENCES users(id) ON DELETE SET NULL,
peer_id UUID REFERENCES users(id) ON DELETE SET NULL,
session_type VARCHAR(8) NOT NULL DEFAULT 'relay' CHECK (session_type IN ('p2p', 'relay')),
bytes_transferred BIGINT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ
);
CREATE INDEX idx_friendships_addressee ON friendships(addressee_id);
CREATE INDEX idx_room_members_user ON room_members(user_id);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_relay_sessions_room ON relay_sessions(room_id);

View File

@@ -0,0 +1,17 @@
-- Relay server nodes registry
CREATE TABLE relay_nodes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(64) NOT NULL,
url TEXT NOT NULL UNIQUE,
region VARCHAR(32) NOT NULL DEFAULT 'auto',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
priority INTEGER NOT NULL DEFAULT 0,
last_ping_ms INTEGER, -- measured RTT in ms
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed with official relay nodes
INSERT INTO relay_nodes (name, url, region, priority) VALUES
('官方节点 - 主线路', 'funmc.com:7900', 'auto', 100),
('官方节点 - 备用线路', 'funmc.com:7901', 'auto', 50);

View File

@@ -0,0 +1,54 @@
-- 添加用户封禁字段
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_banned BOOLEAN NOT NULL DEFAULT FALSE;
-- 添加房间邀请码
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS invite_code VARCHAR(8);
-- 添加用户最后在线 IP用于安全审计
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_ip VARCHAR(45);
-- 创建房间邀请表
CREATE TABLE IF NOT EXISTS room_invites (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
inviter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invitee_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'expired')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
-- 创建服务器配置表(持久化配置)
CREATE TABLE IF NOT EXISTS server_config (
key VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 创建操作日志表(管理审计)
CREATE TABLE IF NOT EXISTS admin_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
admin_user VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target_type VARCHAR(32),
target_id UUID,
details JSONB,
ip_address VARCHAR(45),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 创建下载统计表
CREATE TABLE IF NOT EXISTS download_stats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
filename VARCHAR(255) NOT NULL,
platform VARCHAR(32) NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
downloaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_room_invites_invitee ON room_invites(invitee_id);
CREATE INDEX IF NOT EXISTS idx_room_invites_room ON room_invites(room_id);
CREATE INDEX IF NOT EXISTS idx_admin_logs_created ON admin_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_stats_filename ON download_stats(filename);

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(),
}
}

View File

@@ -0,0 +1,59 @@
use axum::{
extract::{FromRef, FromRequestParts},
http::{header, StatusCode},
};
use std::sync::Arc;
use uuid::Uuid;
use crate::AppState;
use crate::api::auth::verify_access_token;
#[derive(Debug, Clone)]
pub struct AuthUser {
pub user_id: Uuid,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
Arc<AppState>: FromRef<S>,
{
type Rejection = (StatusCode, axum::Json<serde_json::Value>);
fn from_request_parts<'life0, 'life1, 'async_trait>(
parts: &'life0 mut axum::http::request::Parts,
state: &'life1 S,
) -> ::core::pin::Pin<Box<
dyn ::core::future::Future<Output = Result<Self, Self::Rejection>>
+ ::core::marker::Send
+ 'async_trait,
>>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
let state = Arc::<AppState>::from_ref(state);
let auth_header = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match auth_header {
Some(token) => match verify_access_token(token, &state.jwt_secret) {
Ok(claims) => Ok(AuthUser { user_id: claims.sub }),
Err(_) => Err((
StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "invalid or expired token"})),
)),
},
None => Err((
StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "missing authorization header"})),
)),
}
})
}
}

1
server/src/db/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub type DbPool = sqlx::PgPool;

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

@@ -0,0 +1,79 @@
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod api;
mod auth_middleware;
mod db;
mod presence;
mod relay;
mod signaling;
mod state;
pub use state::AppState;
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "funmc_server=debug,tower_http=info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:password@localhost/funmc".into());
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "dev-secret-change-in-production".into());
let state = Arc::new(AppState::new(pool, jwt_secret.clone()));
// Start QUIC relay server in background
let jwt_for_relay = jwt_secret.clone();
tokio::spawn(async move {
if let Err(e) = relay::server::RelayServer::start(jwt_for_relay).await {
tracing::error!("QUIC relay server error: {}", e);
}
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// Admin panel static files (default to dist directory for production)
let admin_dir = std::env::var("ADMIN_PANEL_DIR").unwrap_or_else(|_| "./admin-panel/dist".into());
let app = axum::Router::new()
.nest("/api/v1", api::router(state.clone()))
.nest("/download", api::download_router())
.nest_service("/admin", ServeDir::new(&admin_dir).append_index_html_on_directories(true))
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state);
let addr = std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into());
tracing::info!("HTTP API listening on {}", addr);
tracing::info!("Admin panel at http://{}/admin", addr);
tracing::info!("Download page at http://{}/download", addr);
tracing::info!("QUIC relay listening on port 3001");
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

@@ -0,0 +1,34 @@
use dashmap::DashSet;
use uuid::Uuid;
pub struct PresenceTracker {
online: DashSet<Uuid>,
}
impl PresenceTracker {
pub fn new() -> Self {
Self {
online: DashSet::new(),
}
}
pub fn mark_online(&self, user_id: Uuid) {
self.online.insert(user_id);
}
pub fn mark_offline(&self, user_id: Uuid) {
self.online.remove(&user_id);
}
pub fn is_online(&self, user_id: &Uuid) -> bool {
self.online.contains(user_id)
}
pub fn online_count(&self) -> usize {
self.online.len()
}
pub fn len(&self) -> usize {
self.online.len()
}
}

2
server/src/relay/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod server;
pub mod quic_server;

View File

@@ -0,0 +1,24 @@
use anyhow::Result;
use quinn::ServerConfig;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use std::sync::Arc;
pub fn make_server_config() -> Result<ServerConfig> {
let cert = rcgen::generate_simple_self_signed(vec!["funmc-relay".to_string()])?;
let cert_der = CertificateDer::from(cert.cert);
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()));
let mut tls = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert_der], key_der)?;
tls.alpn_protocols = vec![b"funmc".to_vec()];
let mut sc = ServerConfig::with_crypto(Arc::new(
quinn::crypto::rustls::QuicServerConfig::try_from(tls)?,
));
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(std::time::Duration::from_secs(60).try_into()?));
transport.keep_alive_interval(Some(std::time::Duration::from_secs(10)));
sc.transport_config(Arc::new(transport));
Ok(sc)
}

133
server/src/relay/server.rs Normal file
View File

@@ -0,0 +1,133 @@
/// QUIC relay server — server-side component
///
/// Listens on UDP port 3001 (QUIC), accepts peers for a room,
/// and routes bidirectional MC traffic between host and clients.
use anyhow::Result;
use dashmap::DashMap;
use quinn::{Connection, Endpoint};
use std::net::SocketAddr;
use std::sync::Arc;
use uuid::Uuid;
use crate::api::auth::verify_access_token;
use crate::relay::quic_server::make_server_config;
const RELAY_QUIC_PORT: u16 = 3001;
type RoomPeers = Arc<DashMap<Uuid, Arc<DashMap<Uuid, Connection>>>>;
pub struct RelayServer;
impl RelayServer {
pub async fn start(jwt_secret: String) -> Result<()> {
let bind_addr: SocketAddr = format!("0.0.0.0:{}", RELAY_QUIC_PORT).parse()?;
let sc = make_server_config()?;
let endpoint = Endpoint::server(sc, bind_addr)?;
let rooms: RoomPeers = Arc::new(DashMap::new());
tracing::info!("QUIC relay server listening on :{}", RELAY_QUIC_PORT);
while let Some(inc) = endpoint.accept().await {
let rooms2 = rooms.clone();
let secret = jwt_secret.clone();
tokio::spawn(async move {
match inc.await {
Ok(conn) => {
if let Err(e) = handle_relay_peer(conn, rooms2, secret).await {
tracing::debug!("relay peer: {}", e);
}
}
Err(e) => tracing::debug!("relay incoming error: {}", e),
}
});
}
Ok(())
}
}
async fn handle_relay_peer(
conn: Connection,
rooms: RoomPeers,
jwt_secret: String,
) -> Result<()> {
// Expect first unidirectional stream as handshake
let mut stream = conn.accept_uni().await?;
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).await?;
let len = u32::from_be_bytes(len_buf) as usize;
if len > 65536 {
return Err(anyhow::anyhow!("handshake too large"));
}
let mut msg_buf = vec![0u8; len];
stream.read_exact(&mut msg_buf).await?;
let handshake: serde_json::Value = serde_json::from_slice(&msg_buf)?;
let token = handshake["token"].as_str().unwrap_or("");
let room_id_str = handshake["room_id"].as_str().unwrap_or("");
let claims = verify_access_token(token, &jwt_secret)?;
let user_id = claims.sub;
let room_id = Uuid::parse_str(room_id_str)?;
tracing::info!("Relay: user {} joined room {}", user_id, room_id);
// Register peer
let room = rooms
.entry(room_id)
.or_insert_with(|| Arc::new(DashMap::new()))
.clone();
room.insert(user_id, conn.clone());
// Accept bidirectional streams and relay to all other room members
loop {
match conn.accept_bi().await {
Ok((_send, recv)) => {
let room2 = room.clone();
let uid = user_id;
tokio::spawn(async move {
let _ = relay_stream(recv, room2, uid).await;
});
}
Err(_) => break,
}
}
room.remove(&user_id);
if room.is_empty() {
rooms.remove(&room_id);
}
tracing::info!("Relay: user {} left room {}", user_id, room_id);
Ok(())
}
/// Read data from one peer's recv stream, forward to all other peers' connections
async fn relay_stream(
mut recv: quinn::RecvStream,
room: Arc<DashMap<Uuid, Connection>>,
sender_id: Uuid,
) -> Result<()> {
let mut buf = vec![0u8; 65536];
loop {
let n = match recv.read(&mut buf).await? {
Some(n) => n,
None => break,
};
let data = buf[..n].to_vec();
for entry in room.iter() {
if *entry.key() == sender_id {
continue;
}
let conn = entry.value().clone();
let data2 = data.clone();
tokio::spawn(async move {
if let Ok((mut s, _)) = conn.open_bi().await {
let _ = s.write_all(&data2).await;
let _ = s.finish();
}
});
}
}
Ok(())
}

View File

@@ -0,0 +1,129 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use funmc_shared::protocol::SignalingMessage;
use sqlx;
use std::sync::Arc;
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::AppState;
use crate::api::auth::verify_access_token;
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
// Token passed as query param: /api/v1/ws?token=<jwt>
let token = params.get("token").cloned().unwrap_or_default();
let state_clone = state.clone();
ws.on_upgrade(move |socket| handle_socket(socket, state_clone, token))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, token: String) {
let user_id = match verify_access_token(&token, &state.jwt_secret) {
Ok(claims) => claims.sub,
Err(_) => {
tracing::warn!("WebSocket connection with invalid token");
return;
}
};
tracing::info!("WebSocket connected: {}", user_id);
state.presence.mark_online(user_id);
let (mut ws_tx, mut ws_rx) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<SignalingMessage>();
state.signaling.register(user_id, tx);
// Task: forward messages from hub to WebSocket
let send_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
let json = match serde_json::to_string(&msg) {
Ok(j) => j,
Err(_) => continue,
};
if ws_tx.send(Message::Text(json)).await.is_err() {
break;
}
}
});
// Receive messages from WebSocket client
while let Some(Ok(msg)) = ws_rx.next().await {
match msg {
Message::Text(text) => {
if let Ok(signal) = serde_json::from_str::<SignalingMessage>(&text) {
handle_signal(&state, user_id, signal).await;
}
}
Message::Close(_) => break,
_ => {}
}
}
// Cleanup
send_task.abort();
state.signaling.unregister(user_id);
state.presence.mark_offline(user_id);
tracing::info!("WebSocket disconnected: {}", user_id);
}
async fn handle_signal(state: &AppState, from: Uuid, msg: SignalingMessage) {
match msg {
SignalingMessage::Offer { to, .. }
| SignalingMessage::Answer { to, .. }
| SignalingMessage::IceCandidate { to, .. } => {
state.signaling.send_to(to, &msg).await;
}
SignalingMessage::Ping => {
state.signaling.send_to(from, &SignalingMessage::Pong).await;
}
SignalingMessage::SendChat { room_id, content } => {
// Get username from database
let username = sqlx::query_as::<_, (String,)>(
"SELECT username FROM users WHERE id = $1"
)
.bind(from)
.fetch_one(&state.db)
.await
.map(|(n,)| n)
.unwrap_or_else(|_| "Unknown".to_string());
// Get all room members
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();
// Create chat message
let chat_msg = SignalingMessage::ChatMessage {
room_id,
from,
username,
content,
timestamp: Utc::now().timestamp_millis(),
};
// Broadcast to all room members
for member_id in members {
state.signaling.send_to(member_id, &chat_msg).await;
}
}
_ => {}
}
}

View File

@@ -0,0 +1,43 @@
pub mod handler;
pub mod session;
use dashmap::DashMap;
use funmc_shared::protocol::SignalingMessage;
use tokio::sync::mpsc;
use uuid::Uuid;
pub type SessionTx = mpsc::UnboundedSender<SignalingMessage>;
pub struct SignalingHub {
sessions: DashMap<Uuid, SessionTx>,
}
impl SignalingHub {
pub fn new() -> Self {
Self {
sessions: DashMap::new(),
}
}
pub fn register(&self, user_id: Uuid, tx: SessionTx) {
self.sessions.insert(user_id, tx);
}
pub fn unregister(&self, user_id: Uuid) {
self.sessions.remove(&user_id);
}
pub async fn send_to(&self, user_id: Uuid, msg: &SignalingMessage) {
if let Some(tx) = self.sessions.get(&user_id) {
let _ = tx.send(msg.clone());
}
}
pub fn is_connected(&self, user_id: Uuid) -> bool {
self.sessions.contains_key(&user_id)
}
pub fn connection_count(&self) -> usize {
self.sessions.len()
}
}

View File

@@ -0,0 +1,12 @@
// Session state for individual WebSocket connections
// This module is reserved for future per-session state
// Currently sessions are managed directly in handler.rs
use uuid::Uuid;
use funmc_shared::protocol::SignalingMessage;
use tokio::sync::mpsc;
pub struct SignalingSession {
pub user_id: Uuid,
pub tx: mpsc::UnboundedSender<SignalingMessage>,
}

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

@@ -0,0 +1,46 @@
use crate::api::admin::ServerConfig;
use crate::db::DbPool;
use crate::presence::PresenceTracker;
use crate::signaling::SignalingHub;
use dashmap::DashMap;
use std::sync::{Arc, RwLock};
use std::time::Instant;
use uuid::Uuid;
pub struct AppState {
pub db: DbPool,
pub jwt_secret: String,
pub presence: Arc<PresenceTracker>,
pub signaling: Arc<SignalingHub>,
pub host_info: DashMap<Uuid, serde_json::Value>,
pub server_config: RwLock<ServerConfig>,
pub start_time: Instant,
}
impl AppState {
pub fn new(db: DbPool, jwt_secret: String) -> Self {
let presence = Arc::new(PresenceTracker::new());
let signaling = Arc::new(SignalingHub::new());
let mut config = ServerConfig::default();
if let Ok(ip) = std::env::var("SERVER_IP") {
config.server_ip = ip;
}
if let Ok(domain) = std::env::var("SERVER_DOMAIN") {
config.server_domain = domain;
}
if let Ok(name) = std::env::var("SERVER_NAME") {
config.server_name = name;
}
Self {
db,
jwt_secret,
presence,
signaling,
host_info: DashMap::new(),
server_config: RwLock::new(config),
start_time: Instant::now(),
}
}
}