Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
47
server/Cargo.toml
Normal 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
20
server/Dockerfile
Normal 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"]
|
||||
66
server/migrations/20240101000001_initial.sql
Normal file
66
server/migrations/20240101000001_initial.sql
Normal 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);
|
||||
17
server/migrations/20240101000002_relay_nodes.sql
Normal file
17
server/migrations/20240101000002_relay_nodes.sql
Normal 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);
|
||||
54
server/migrations/20240101000003_add_user_ban.sql
Normal file
54
server/migrations/20240101000003_add_user_ban.sql
Normal 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
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(),
|
||||
}
|
||||
}
|
||||
59
server/src/auth_middleware.rs
Normal file
59
server/src/auth_middleware.rs
Normal 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
1
server/src/db/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub type DbPool = sqlx::PgPool;
|
||||
79
server/src/main.rs
Normal file
79
server/src/main.rs
Normal 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(())
|
||||
}
|
||||
34
server/src/presence/mod.rs
Normal file
34
server/src/presence/mod.rs
Normal 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
2
server/src/relay/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod server;
|
||||
pub mod quic_server;
|
||||
24
server/src/relay/quic_server.rs
Normal file
24
server/src/relay/quic_server.rs
Normal 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
133
server/src/relay/server.rs
Normal 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(())
|
||||
}
|
||||
129
server/src/signaling/handler.rs
Normal file
129
server/src/signaling/handler.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
43
server/src/signaling/mod.rs
Normal file
43
server/src/signaling/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
12
server/src/signaling/session.rs
Normal file
12
server/src/signaling/session.rs
Normal 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
46
server/src/state.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user