From b6891483ae693806746f8c4402c60b53c3fa2e07 Mon Sep 17 00:00:00 2001 From: xiaobai Date: Tue, 24 Feb 2026 20:56:36 +0800 Subject: [PATCH] Initial commit: FunConnect project with server, relay, client and admin panel Co-authored-by: Cursor --- .env.example | 36 ++ .github/workflows/build.yml | 116 ++++ .github/workflows/ci.yml | 235 +++++++ .github/workflows/test.yml | 74 +++ .gitignore | 63 +- Cargo.toml | 23 + Dockerfile.relay | 31 + Dockerfile.server | 46 ++ LICENSE | 21 + README.md | 518 ++++++++++++--- admin-panel/index.html | 13 + admin-panel/package.json | 32 + admin-panel/postcss.config.js | 6 + admin-panel/public/favicon.svg | 4 + admin-panel/src/App.tsx | 44 ++ admin-panel/src/components/Layout.tsx | 68 ++ admin-panel/src/index.css | 72 +++ admin-panel/src/main.tsx | 13 + admin-panel/src/pages/Dashboard.tsx | 175 ++++++ admin-panel/src/pages/Downloads.tsx | 248 ++++++++ admin-panel/src/pages/Login.tsx | 88 +++ admin-panel/src/pages/Logs.tsx | 119 ++++ admin-panel/src/pages/Rooms.tsx | 105 ++++ admin-panel/src/pages/Settings.tsx | 241 +++++++ admin-panel/src/pages/Users.tsx | 107 ++++ admin-panel/src/stores/adminStore.ts | 209 ++++++ admin-panel/src/stores/authStore.ts | 45 ++ admin-panel/tailwind.config.js | 23 + admin-panel/tsconfig.json | 21 + admin-panel/tsconfig.node.json | 10 + admin-panel/vite.config.ts | 20 + client/Cargo.toml | 52 ++ client/build.rs | 3 + client/capabilities/default.json | 15 + client/icons/.gitkeep | 9 + client/icons/128x128.png | Bin 0 -> 69 bytes client/icons/128x128@2x.png | Bin 0 -> 69 bytes client/icons/32x32.png | Bin 0 -> 69 bytes client/icons/icon.icns | Bin 0 -> 69 bytes client/icons/icon.ico | Bin 0 -> 4286 bytes client/icons/icon.svg | 42 ++ .../sqlite/20240101000001_auth_cache.sql | 5 + .../sqlite/20240101000001_local_data.sql | 68 ++ client/src/commands/auth.rs | 115 ++++ client/src/commands/config.rs | 48 ++ client/src/commands/friends.rs | 118 ++++ client/src/commands/mod.rs | 7 + client/src/commands/network.rs | 323 ++++++++++ client/src/commands/relay_nodes.rs | 111 ++++ client/src/commands/rooms.rs | 187 ++++++ client/src/commands/signaling.rs | 72 +++ client/src/config.rs | 21 + client/src/db.rs | 16 + client/src/lib.rs | 73 +++ client/src/main.rs | 6 + client/src/network/lan_discovery.rs | 88 +++ client/src/network/minecraft_proxy.rs | 144 +++++ client/src/network/mod.rs | 9 + client/src/network/nat.rs | 149 +++++ client/src/network/p2p.rs | 129 ++++ client/src/network/quic.rs | 99 +++ client/src/network/relay.rs | 68 ++ client/src/network/relay_selector.rs | 118 ++++ client/src/network/session.rs | 42 ++ client/src/network/signaling.rs | 73 +++ client/src/state.rs | 77 +++ client/tauri.conf.json | 98 +++ client/ui/index.html | 19 + client/ui/package.json | 36 ++ client/ui/postcss.config.js | 6 + client/ui/src/components/AppLayout.tsx | 218 +++++++ client/ui/src/components/Avatar.tsx | 110 ++++ client/ui/src/components/ConnectionStatus.tsx | 101 +++ client/ui/src/components/CreateRoomModal.tsx | 60 ++ client/ui/src/components/EmptyState.tsx | 98 +++ client/ui/src/components/ErrorBoundary.tsx | 59 ++ client/ui/src/components/FriendCard.tsx | 121 ++++ client/ui/src/components/JoinRoomModal.tsx | 48 ++ client/ui/src/components/Loading.tsx | 68 ++ client/ui/src/components/LoadingSpinner.tsx | 43 ++ client/ui/src/components/Modal.tsx | 117 ++++ client/ui/src/components/NetworkStats.tsx | 73 +++ client/ui/src/components/RoomCard.tsx | 144 +++++ client/ui/src/components/Toast.tsx | 121 ++++ client/ui/src/components/index.ts | 12 + client/ui/src/config.json | 6 + client/ui/src/hooks/index.ts | 1 + client/ui/src/hooks/useWebSocket.ts | 147 +++++ client/ui/src/index.css | 207 ++++++ client/ui/src/lib/utils.ts | 93 +++ client/ui/src/main.tsx | 124 ++++ client/ui/src/pages/Dashboard.tsx | 232 +++++++ client/ui/src/pages/Friends.tsx | 168 +++++ client/ui/src/pages/Login.tsx | 102 +++ client/ui/src/pages/Register.tsx | 137 ++++ client/ui/src/pages/Room.tsx | 430 +++++++++++++ client/ui/src/pages/ServerSetup.tsx | 130 ++++ client/ui/src/pages/Settings.tsx | 299 +++++++++ client/ui/src/router.tsx | 42 ++ client/ui/src/stores/authStore.ts | 101 +++ client/ui/src/stores/chatStore.ts | 44 ++ client/ui/src/stores/configStore.ts | 122 ++++ client/ui/src/stores/friendStore.ts | 66 ++ client/ui/src/stores/networkStore.ts | 66 ++ client/ui/src/stores/relayNodeStore.ts | 57 ++ client/ui/src/stores/roomStore.ts | 89 +++ client/ui/tailwind.config.js | 49 ++ client/ui/tsconfig.json | 24 + client/ui/tsconfig.node.json | 11 + client/ui/vite.config.ts | 23 + deploy.sh | 124 ++++ deploy/funmc-relay.service | 39 ++ deploy/funmc-server.service | 36 ++ deploy/install.sh | 97 +++ docker-compose.yml | 78 +++ docker/Dockerfile.relay | 34 + docker/Dockerfile.server | 35 ++ docker/docker-compose.yml | 94 +++ docker/nginx.conf | 53 ++ docs/DEPLOYMENT.md | 574 +++++++++++++++++ docs/LAN_TEST.md | 309 +++++++++ docs/NO_PUBLIC_IP.md | 218 +++++++ install.ps1 | 331 ++++++++++ install.sh | 349 ++++++++++ relay-server/.env.example | 11 + relay-server/Cargo.toml | 29 + relay-server/src/auth.rs | 27 + relay-server/src/config.rs | 23 + relay-server/src/main.rs | 275 ++++++++ scripts/build-client.ps1 | 181 ++++++ scripts/build-client.sh | 246 ++++++++ scripts/build.ps1 | 103 +++ scripts/build.sh | 141 +++++ scripts/generate-icons.ps1 | 43 ++ scripts/generate-icons.sh | 84 +++ scripts/test-lan.ps1 | 106 ++++ scripts/test-lan.sh | 124 ++++ server/.env.example | 37 +- server/Cargo.toml | 47 ++ server/Dockerfile | 20 + server/migrations/20240101000001_initial.sql | 66 ++ .../migrations/20240101000002_relay_nodes.sql | 17 + .../20240101000003_add_user_ban.sql | 54 ++ server/src/api/admin.rs | 284 +++++++++ server/src/api/auth.rs | 257 ++++++++ server/src/api/download.rs | 297 +++++++++ server/src/api/friends.rs | 178 ++++++ server/src/api/health.rs | 147 +++++ server/src/api/mod.rs | 75 +++ server/src/api/relay_nodes.rs | 127 ++++ server/src/api/rooms.rs | 595 ++++++++++++++++++ server/src/api/users.rs | 69 ++ server/src/auth_middleware.rs | 59 ++ server/src/db/mod.rs | 1 + server/src/main.rs | 79 +++ server/src/presence/mod.rs | 34 + server/src/relay/mod.rs | 2 + server/src/relay/quic_server.rs | 24 + server/src/relay/server.rs | 133 ++++ server/src/signaling/handler.rs | 129 ++++ server/src/signaling/mod.rs | 43 ++ server/src/signaling/session.rs | 12 + server/src/state.rs | 46 ++ shared/Cargo.toml | 10 + shared/src/lib.rs | 2 + shared/src/models.rs | 85 +++ shared/src/protocol.rs | 125 ++++ 167 files changed, 16147 insertions(+), 106 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/test.yml create mode 100644 Cargo.toml create mode 100644 Dockerfile.relay create mode 100644 Dockerfile.server create mode 100644 LICENSE create mode 100644 admin-panel/index.html create mode 100644 admin-panel/package.json create mode 100644 admin-panel/postcss.config.js create mode 100644 admin-panel/public/favicon.svg create mode 100644 admin-panel/src/App.tsx create mode 100644 admin-panel/src/components/Layout.tsx create mode 100644 admin-panel/src/index.css create mode 100644 admin-panel/src/main.tsx create mode 100644 admin-panel/src/pages/Dashboard.tsx create mode 100644 admin-panel/src/pages/Downloads.tsx create mode 100644 admin-panel/src/pages/Login.tsx create mode 100644 admin-panel/src/pages/Logs.tsx create mode 100644 admin-panel/src/pages/Rooms.tsx create mode 100644 admin-panel/src/pages/Settings.tsx create mode 100644 admin-panel/src/pages/Users.tsx create mode 100644 admin-panel/src/stores/adminStore.ts create mode 100644 admin-panel/src/stores/authStore.ts create mode 100644 admin-panel/tailwind.config.js create mode 100644 admin-panel/tsconfig.json create mode 100644 admin-panel/tsconfig.node.json create mode 100644 admin-panel/vite.config.ts create mode 100644 client/Cargo.toml create mode 100644 client/build.rs create mode 100644 client/capabilities/default.json create mode 100644 client/icons/.gitkeep create mode 100644 client/icons/128x128.png create mode 100644 client/icons/128x128@2x.png create mode 100644 client/icons/32x32.png create mode 100644 client/icons/icon.icns create mode 100644 client/icons/icon.ico create mode 100644 client/icons/icon.svg create mode 100644 client/migrations/sqlite/20240101000001_auth_cache.sql create mode 100644 client/migrations/sqlite/20240101000001_local_data.sql create mode 100644 client/src/commands/auth.rs create mode 100644 client/src/commands/config.rs create mode 100644 client/src/commands/friends.rs create mode 100644 client/src/commands/mod.rs create mode 100644 client/src/commands/network.rs create mode 100644 client/src/commands/relay_nodes.rs create mode 100644 client/src/commands/rooms.rs create mode 100644 client/src/commands/signaling.rs create mode 100644 client/src/config.rs create mode 100644 client/src/db.rs create mode 100644 client/src/lib.rs create mode 100644 client/src/main.rs create mode 100644 client/src/network/lan_discovery.rs create mode 100644 client/src/network/minecraft_proxy.rs create mode 100644 client/src/network/mod.rs create mode 100644 client/src/network/nat.rs create mode 100644 client/src/network/p2p.rs create mode 100644 client/src/network/quic.rs create mode 100644 client/src/network/relay.rs create mode 100644 client/src/network/relay_selector.rs create mode 100644 client/src/network/session.rs create mode 100644 client/src/network/signaling.rs create mode 100644 client/src/state.rs create mode 100644 client/tauri.conf.json create mode 100644 client/ui/index.html create mode 100644 client/ui/package.json create mode 100644 client/ui/postcss.config.js create mode 100644 client/ui/src/components/AppLayout.tsx create mode 100644 client/ui/src/components/Avatar.tsx create mode 100644 client/ui/src/components/ConnectionStatus.tsx create mode 100644 client/ui/src/components/CreateRoomModal.tsx create mode 100644 client/ui/src/components/EmptyState.tsx create mode 100644 client/ui/src/components/ErrorBoundary.tsx create mode 100644 client/ui/src/components/FriendCard.tsx create mode 100644 client/ui/src/components/JoinRoomModal.tsx create mode 100644 client/ui/src/components/Loading.tsx create mode 100644 client/ui/src/components/LoadingSpinner.tsx create mode 100644 client/ui/src/components/Modal.tsx create mode 100644 client/ui/src/components/NetworkStats.tsx create mode 100644 client/ui/src/components/RoomCard.tsx create mode 100644 client/ui/src/components/Toast.tsx create mode 100644 client/ui/src/components/index.ts create mode 100644 client/ui/src/config.json create mode 100644 client/ui/src/hooks/index.ts create mode 100644 client/ui/src/hooks/useWebSocket.ts create mode 100644 client/ui/src/index.css create mode 100644 client/ui/src/lib/utils.ts create mode 100644 client/ui/src/main.tsx create mode 100644 client/ui/src/pages/Dashboard.tsx create mode 100644 client/ui/src/pages/Friends.tsx create mode 100644 client/ui/src/pages/Login.tsx create mode 100644 client/ui/src/pages/Register.tsx create mode 100644 client/ui/src/pages/Room.tsx create mode 100644 client/ui/src/pages/ServerSetup.tsx create mode 100644 client/ui/src/pages/Settings.tsx create mode 100644 client/ui/src/router.tsx create mode 100644 client/ui/src/stores/authStore.ts create mode 100644 client/ui/src/stores/chatStore.ts create mode 100644 client/ui/src/stores/configStore.ts create mode 100644 client/ui/src/stores/friendStore.ts create mode 100644 client/ui/src/stores/networkStore.ts create mode 100644 client/ui/src/stores/relayNodeStore.ts create mode 100644 client/ui/src/stores/roomStore.ts create mode 100644 client/ui/tailwind.config.js create mode 100644 client/ui/tsconfig.json create mode 100644 client/ui/tsconfig.node.json create mode 100644 client/ui/vite.config.ts create mode 100644 deploy.sh create mode 100644 deploy/funmc-relay.service create mode 100644 deploy/funmc-server.service create mode 100644 deploy/install.sh create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.relay create mode 100644 docker/Dockerfile.server create mode 100644 docker/docker-compose.yml create mode 100644 docker/nginx.conf create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/LAN_TEST.md create mode 100644 docs/NO_PUBLIC_IP.md create mode 100644 install.ps1 create mode 100644 install.sh create mode 100644 relay-server/.env.example create mode 100644 relay-server/Cargo.toml create mode 100644 relay-server/src/auth.rs create mode 100644 relay-server/src/config.rs create mode 100644 relay-server/src/main.rs create mode 100644 scripts/build-client.ps1 create mode 100644 scripts/build-client.sh create mode 100644 scripts/build.ps1 create mode 100644 scripts/build.sh create mode 100644 scripts/generate-icons.ps1 create mode 100644 scripts/generate-icons.sh create mode 100644 scripts/test-lan.ps1 create mode 100644 scripts/test-lan.sh create mode 100644 server/Cargo.toml create mode 100644 server/Dockerfile create mode 100644 server/migrations/20240101000001_initial.sql create mode 100644 server/migrations/20240101000002_relay_nodes.sql create mode 100644 server/migrations/20240101000003_add_user_ban.sql create mode 100644 server/src/api/admin.rs create mode 100644 server/src/api/auth.rs create mode 100644 server/src/api/download.rs create mode 100644 server/src/api/friends.rs create mode 100644 server/src/api/health.rs create mode 100644 server/src/api/mod.rs create mode 100644 server/src/api/relay_nodes.rs create mode 100644 server/src/api/rooms.rs create mode 100644 server/src/api/users.rs create mode 100644 server/src/auth_middleware.rs create mode 100644 server/src/db/mod.rs create mode 100644 server/src/main.rs create mode 100644 server/src/presence/mod.rs create mode 100644 server/src/relay/mod.rs create mode 100644 server/src/relay/quic_server.rs create mode 100644 server/src/relay/server.rs create mode 100644 server/src/signaling/handler.rs create mode 100644 server/src/signaling/mod.rs create mode 100644 server/src/signaling/session.rs create mode 100644 server/src/state.rs create mode 100644 shared/Cargo.toml create mode 100644 shared/src/lib.rs create mode 100644 shared/src/models.rs create mode 100644 shared/src/protocol.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..85209b9 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# FunMC 环境变量配置示例 +# 复制此文件为 .env 并修改以下配置 + +# ============== 必填配置 ============== + +# 服务器公网 IP (必填,客户端将使用此地址连接) +SERVER_IP=your.server.ip.here + +# ============== 可选配置 ============== + +# 服务器名称 (显示在客户端和管理面板) +SERVER_NAME=FunMC Server + +# 服务器域名 (如果有的话,客户端将优先使用域名) +SERVER_DOMAIN= + +# 数据库密码 (建议修改为强密码) +DB_PASSWORD=funmc_secret_password + +# JWT 密钥 (必须修改为随机字符串!) +JWT_SECRET=change_this_to_a_very_long_random_string + +# 管理员账号 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# ============== 高级配置 ============== + +# API 监听地址 +LISTEN_ADDR=0.0.0.0:3000 + +# 日志级别 (debug, info, warn, error) +RUST_LOG=info + +# 中继服务器端口 +RELAY_PORT=7900 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5c8db13 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,116 @@ +name: Build & Release + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + build-server: + name: Build Server + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Build Server + run: cargo build --release -p funmc-server + + - name: Build Relay Server + run: cargo build --release -p funmc-relay-server + + - name: Upload Server Artifacts + uses: actions/upload-artifact@v4 + with: + name: server-linux + path: | + target/release/server + target/release/relay-server + + build-client: + name: Build Client (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - platform: ubuntu-22.04 + target: linux + - platform: windows-latest + target: windows + - platform: macos-latest + target: macos + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux Dependencies + if: matrix.target == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install Frontend Dependencies + working-directory: client/ui + run: npm ci + + - name: Build Tauri App + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: client + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: client-${{ matrix.target }} + path: | + client/target/release/bundle/**/*.exe + client/target/release/bundle/**/*.msi + client/target/release/bundle/**/*.dmg + client/target/release/bundle/**/*.AppImage + client/target/release/bundle/**/*.deb + + release: + name: Create Release + needs: [build-server, build-client] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download All Artifacts + uses: actions/download-artifact@v4 + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + server-linux/* + client-windows/**/* + client-macos/**/* + client-linux/**/* + draft: true + generate_release_notes: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cbd0cdb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,235 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + # 检查代码格式和 lint + check: + name: Check & Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Check format + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + # 测试服务端 + test-server: + name: Test Server + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: funmc + POSTGRES_PASSWORD: test_password + POSTGRES_DB: funmc_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install sqlx-cli + run: cargo install sqlx-cli --no-default-features --features postgres + + - name: Run migrations + run: sqlx database create && sqlx migrate run + working-directory: server + env: + DATABASE_URL: postgres://funmc:test_password@localhost/funmc_test + + - name: Run tests + run: cargo test -p funmc-server + env: + DATABASE_URL: postgres://funmc:test_password@localhost/funmc_test + JWT_SECRET: test_secret_key_for_ci + + # 构建服务端 + build-server: + name: Build Server + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Build server + run: cargo build --release -p funmc-server + + - name: Build relay + run: cargo build --release -p funmc-relay-server + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: server-linux + path: | + target/release/funmc-server + target/release/funmc-relay-server + + # 构建客户端 (多平台) + build-client: + name: Build Client (${{ matrix.platform }}) + needs: [check] + strategy: + fail-fast: false + matrix: + include: + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + name: linux + - platform: windows-latest + target: x86_64-pc-windows-msvc + name: windows + - platform: macos-latest + target: aarch64-apple-darwin + name: macos-arm64 + - platform: macos-latest + target: x86_64-apple-darwin + name: macos-x64 + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies (Ubuntu) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install frontend dependencies + run: npm install + working-directory: client/ui + + - name: Build frontend + run: npm run build + working-directory: client/ui + + - name: Build client + run: cargo build --release --target ${{ matrix.target }} + working-directory: client + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: client-${{ matrix.name }} + path: client/target/${{ matrix.target }}/release/funmc-client* + + # 构建 Docker 镜像 + build-docker: + name: Build Docker Image + runs-on: ubuntu-latest + needs: [test-server] + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push server + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.server + push: true + tags: | + mofangfang/funmc-server:latest + mofangfang/funmc-server:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push relay + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.relay + push: true + tags: | + mofangfang/funmc-relay:latest + mofangfang/funmc-relay:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # 发布 Release + release: + name: Create Release + runs-on: ubuntu-latest + needs: [build-server, build-client, build-docker] + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + artifacts/**/* + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..595e7ab --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,74 @@ +name: Test + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: funmc_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run tests + env: + DATABASE_URL: postgres://postgres:postgres@localhost/funmc_test + run: cargo test --all + + frontend-lint: + name: Frontend Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Dependencies + working-directory: client/ui + run: npm ci + + - name: TypeScript Check + working-directory: client/ui + run: npx tsc --noEmit + + - name: Build + working-directory: client/ui + run: npm run build diff --git a/.gitignore b/.gitignore index 23f4f9a..fcfd168 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,61 @@ -node_modules/ -dist/ +# Rust +/target/ +Cargo.lock + +# Environment variables .env -logs/ -*.log +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files .DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Client build artifacts +client/ui/dist/ +client/ui/node_modules/ + +# Tauri +client/gen/ +client/target/ + +# Database +*.db +*.db-journal +*.sqlite + +# Certificates (generated at runtime) +*.pem +*.crt +*.key + +# Test files +*.test.* +coverage/ + +# NPM +node_modules/ +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Build outputs +dist/ +build/ +*.exe +*.dmg +*.AppImage +*.deb +*.rpm +*.msi diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6d7b982 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +members = [ + "shared", + "client", + "server", + "relay-server", +] +resolver = "2" + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "1" +tracing = "0.1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +quinn = "0.11" +rustls = { version = "0.23", default-features = false, features = ["ring"] } +rcgen = "0.13" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dashmap = "5" diff --git a/Dockerfile.relay b/Dockerfile.relay new file mode 100644 index 0000000..0b7302c --- /dev/null +++ b/Dockerfile.relay @@ -0,0 +1,31 @@ +# 构建阶段 +FROM rust:1.75-bookworm AS builder + +WORKDIR /app + +# 复制 Cargo 文件 +COPY Cargo.toml Cargo.lock ./ +COPY shared ./shared +COPY server ./server +COPY relay-server ./relay-server + +# 构建发布版本 +RUN cargo build --release -p funmc-relay-server + +# 运行阶段 +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# 复制构建产物 +COPY --from=builder /app/target/release/funmc-relay-server /app/funmc-relay-server + +EXPOSE 7900/udp +EXPOSE 7901/udp + +CMD ["/app/funmc-relay-server"] diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..842c224 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,46 @@ +# 构建阶段 - 服务端 +FROM rust:1.75-bookworm AS rust-builder + +WORKDIR /app + +# 复制 Cargo 文件 +COPY Cargo.toml Cargo.lock ./ +COPY shared ./shared +COPY server ./server +COPY relay-server ./relay-server + +# 构建发布版本 +RUN cargo build --release -p funmc-server + +# 构建阶段 - 管理面板前端 +FROM node:20-alpine AS node-builder + +WORKDIR /app/admin-panel + +COPY admin-panel/package*.json ./ +RUN npm install + +COPY admin-panel/ ./ +RUN npm run build + +# 运行阶段 +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# 复制构建产物 +COPY --from=rust-builder /app/target/release/funmc-server /app/funmc-server +COPY --from=node-builder /app/admin-panel/dist /app/admin-panel + +# 创建下载目录 +RUN mkdir -p /app/downloads + +EXPOSE 3000 +EXPOSE 3001/udp + +CMD ["/app/funmc-server"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bbe7023 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 魔幻方 (Magic Square Development) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 232c96f..2dd9717 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,469 @@ -# FunConnect - Minecraft 联机平台 +# FunMC - Minecraft 联机助手 -一个支持多节点中继的 Minecraft 联机平台,让玩家无需公网IP即可轻松联机。 +
-本仓库包含 **三个独立项目**,覆盖全平台客户端和服务端。 +**让 Minecraft 联机变得简单** -## 支持平台 +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Rust](https://img.shields.io/badge/rust-1.75+-orange.svg)](https://www.rust-lang.org/) +[![Tauri](https://img.shields.io/badge/tauri-2.0-blue.svg)](https://tauri.app/) -| 平台 | 类型 | 项目 | +[下载](#-下载) • [功能特性](#-功能特性) • [快速开始](#-快速开始) • [部署指南](#-部署指南) • [开发指南](#-开发指南) + +
+ +--- + +## ✨ 功能特性 + +- 🎮 **一键联机** - 无需公网 IP,无需端口映射,简单几步即可与好友联机 +- 🌐 **P2P 直连** - 支持 NAT 穿透,延迟更低的点对点连接 +- 🔄 **智能中继** - P2P 失败时自动切换到中继模式,保证连通性 +- 👥 **好友系统** - 添加好友,随时查看在线状态 +- 🏠 **房间系统** - 创建公开或私密房间,支持密码保护 +- 💬 **实时聊天** - 房间内文字聊天功能 +- 💻 **跨平台** - 支持 Windows、macOS、Linux、iOS、Android +- 🔒 **安全可靠** - JWT 认证,QUIC 加密传输 +- 🎛️ **管理面板** - Web 管理界面,用户管理、房间管理、服务器配置 +- 📦 **一键部署** - Docker 一键部署,自动配置服务器和客户端 +- 🔗 **自动配置** - 客户端自动连接服务器,无需手动填写 IP + +## 📥 下载 + +客户端会自动连接到预配置的服务器,无需手动配置 IP 地址。 + +| 平台 | 下载链接 | 系统要求 | +|------|---------|---------| +| Windows | [FunMC-Setup.exe](https://funmc.com/download) | Windows 10+ | +| macOS (Apple Silicon) | [FunMC-arm64.dmg](https://funmc.com/download) | macOS 11+ | +| macOS (Intel) | [FunMC-x64.dmg](https://funmc.com/download) | macOS 10.13+ | +| Linux | [FunMC.AppImage](https://funmc.com/download) | Ubuntu 18.04+ | +| Android | [FunMC.apk](https://funmc.com/download) | Android 7.0+ | +| iOS | App Store (即将上线) | iOS 13.0+ | + +**私有部署用户**: 请访问你的服务器管理面板下载页面获取客户端 + +## 🚀 快速开始 + +### 作为主机(开服) + +1. **启动 Minecraft 服务器** + - 可以是独立服务器 (默认端口 25565) + - 也可以在单人世界中按 `Esc` → `对局域网开放` 开启局域网联机 + +2. **在 FunMC 中创建房间** + - 登录 FunMC 客户端 + - 在大厅页面点击「创建房间」 + - 填写房间名称、游戏版本、最大人数 + - 可选:设置房间密码 + +3. **开始托管** + - 进入房间后点击「开始托管」 + - FunMC 会自动检测并连接到你的 Minecraft 服务器 + - 默认连接 `127.0.0.1:25565` + +4. **邀请好友** + - 将房间分享给好友 + - 或让好友在大厅搜索你的房间 + +### 作为玩家(联机) + +1. **加入房间** + - 在 FunMC 大厅找到目标房间 + - 点击加入(如有密码需输入) + +2. **连接游戏** + - 在房间页面点击「连接」 + - 等待连接建立(优先尝试 P2P 直连) + - 复制显示的本地代理地址(如 `127.0.0.1:25566`) + +3. **进入游戏** + - 在 Minecraft 中选择「多人游戏」 + - 添加服务器,粘贴刚才复制的地址 + - 连接服务器,开始游戏! + +## 🏗 架构说明 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FunMC 系统架构 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ ┌─────────────┐ ┌─────────┐ │ +│ │ 玩家 A │◄──────►│ API 服务器 │◄──────►│ 玩家 B │ │ +│ │(FunMC) │ │ (认证/房间) │ │(FunMC) │ │ +│ └────┬────┘ └──────┬──────┘ └────┬────┘ │ +│ │ │ │ │ +│ │ ┌──────┴──────┐ │ │ +│ │ │ WebSocket │ │ │ +│ │ │ 信令服务器 │ │ │ +│ │ └─────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────┐ │ │ +│ │ │ P2P 直连 (优先) │ │ │ +│ └──┤ NAT 穿透 / UDP 打洞 / QUIC ├─┘ │ +│ └─────────────────────────────────────┘ │ +│ │ │ +│ (如果失败) │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 中继服务器 (备用) │ │ +│ │ QUIC 隧道转发 Minecraft 流量 │ │ +│ └─────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 📦 部署指南 + +### 🚀 一键部署(推荐) + +**Linux/macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/mofangfang/funmc/main/deploy.sh | bash +``` + +**或者使用 Docker Compose:** +```bash +# 克隆仓库 +git clone https://github.com/mofangfang/funmc.git +cd funmc + +# 配置环境变量 +cp .env.example .env +nano .env # 修改 SERVER_IP 为你的服务器公网 IP + +# 启动服务 +docker-compose up -d +``` + +部署完成后: +- **管理面板**: `http://你的IP:3000/admin` +- **客户端下载**: `http://你的IP:3000/download` +- **API 地址**: `http://你的IP:3000` + +详细部署文档请参考 [DEPLOYMENT.md](./docs/DEPLOYMENT.md) + +### 🎛️ 管理面板功能 + +部署完成后访问 `http://你的IP:3000/admin` 进入管理面板: + +- **仪表盘** - 服务器状态概览、在线用户/房间统计 +- **用户管理** - 查看用户列表、封禁/解封用户 +- **房间管理** - 查看房间列表、删除违规房间 +- **客户端下载** - 管理客户端构建、查看下载统计 +- **服务器设置** - 配置服务器名称、IP、功能开关 +- **系统日志** - 查看服务器运行日志 + +### 📲 客户端自动配置 + +客户端启动时会自动: +1. 读取内嵌的服务器配置(构建时写入) +2. 或显示服务器连接页面让用户输入地址 +3. 从服务器获取完整配置(名称、中继地址等) +4. 保存配置,下次启动自动连接 + +### 手动部署 + +#### 1. 环境准备 + +```bash +# 安装 Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env + +# 安装 PostgreSQL (Ubuntu/Debian) +sudo apt update +sudo apt install postgresql postgresql-contrib + +# 创建数据库 +sudo -u postgres createdb funmc +sudo -u postgres psql -c "CREATE USER funmc WITH PASSWORD 'your_password';" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE funmc TO funmc;" +``` + +#### 2. 编译服务端 + +```bash +# 编译主服务器 +cargo build --release -p funmc-server + +# 编译中继服务器 +cargo build --release -p funmc-relay-server +``` + +#### 3. 配置服务 + +创建 `/etc/funmc/server.env`: + +```env +DATABASE_URL=postgres://funmc:your_password@localhost/funmc +JWT_SECRET=your-super-secret-jwt-key-at-least-32-chars +BIND_ADDR=0.0.0.0:3000 +QUIC_PORT=3001 +RUST_LOG=info +``` + +创建 `/etc/funmc/relay.env`: + +```env +RELAY_PORT=7900 +JWT_SECRET=your-super-secret-jwt-key-at-least-32-chars +RUST_LOG=info +``` + +#### 4. 创建 Systemd 服务 + +```bash +# 复制服务文件 +sudo cp deploy/funmc-server.service /etc/systemd/system/ +sudo cp deploy/funmc-relay.service /etc/systemd/system/ + +# 启动服务 +sudo systemctl daemon-reload +sudo systemctl enable funmc-server funmc-relay +sudo systemctl start funmc-server funmc-relay + +# 检查状态 +sudo systemctl status funmc-server funmc-relay +``` + +#### 5. 配置 Nginx 反向代理 + +```nginx +server { + listen 443 ssl http2; + server_name funmc.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /api/v1/ws { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +## 🛠 开发指南 + +### 项目结构 + +``` +funmc/ +├── shared/ # 共享库(数据模型、协议定义) +│ └── src/ +│ ├── lib.rs +│ ├── models.rs # 数据模型 +│ └── protocol.rs # 信令协议 +├── server/ # 主服务端 +│ └── src/ +│ ├── main.rs +│ ├── api/ # REST API + 管理 API +│ ├── signaling/ # WebSocket 信令 +│ └── relay/ # 内置中继 +├── relay-server/ # 独立中继服务端 +│ └── src/ +│ └── main.rs +├── admin-panel/ # 服务端管理面板 (React) +│ └── src/ +│ ├── pages/ # 仪表盘、用户、房间、设置等 +│ ├── stores/ # Zustand 状态管理 +│ └── components/ +├── client/ # 桌面/移动客户端 (Tauri 2.0) +│ ├── src/ # Rust 后端 +│ │ ├── commands/ # Tauri 命令 +│ │ └── network/ # 网络模块 (QUIC, P2P, 中继) +│ └── ui/ # React 前端 +│ ├── src/ +│ │ ├── pages/ # 登录、大厅、房间等 +│ │ ├── components/ +│ │ └── stores/ # 认证、配置、网络等状态 +│ └── package.json +├── scripts/ # 构建脚本 +│ ├── build-client.sh # 客户端构建脚本 +│ └── build-client.ps1 +├── deploy.sh # 一键部署脚本 +├── docker-compose.yml # Docker Compose 配置 +├── Dockerfile.server # 服务端 Docker 镜像 +├── Dockerfile.relay # 中继服务器 Docker 镜像 +└── docs/ # 文档 +``` + +### 技术栈 + +| 组件 | 技术 | +|------|------| +| 后端框架 | Axum | +| 数据库 | PostgreSQL + SQLx | +| 传输协议 | QUIC (quinn) | +| 桌面框架 | Tauri 2.0 | +| 前端 | React + TypeScript + Tailwind CSS | +| 状态管理 | Zustand | +| NAT 穿透 | STUN + UDP 打洞 | + +### 本地开发 + +```bash +# 1. 克隆仓库 +git clone https://github.com/mofangfang/funmc.git +cd funmc + +# 2. 启动数据库 (Docker) +docker run -d --name funmc-db \ + -p 5432:5432 \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_DB=funmc \ + postgres:14 + +# 3. 配置环境变量 +cp server/.env.example server/.env +# 编辑 server/.env + +# 4. 运行数据库迁移 +cd server +cargo sqlx database create +cargo sqlx migrate run + +# 5. 启动主服务器 +cargo run + +# 6. 新开终端,启动客户端 +cd client/ui +npm install +cd .. +cargo tauri dev +``` + +### 构建发布版本 + +```bash +# Windows +cd client +cargo tauri build + +# macOS (需要在 Mac 上) +cargo tauri build --target universal-apple-darwin + +# Linux +cargo tauri build --target x86_64-unknown-linux-gnu +``` + +## 📋 API 文档 + +### 认证 + +| 方法 | 路径 | 描述 | |------|------|------| -| **Windows** | 桌面客户端 (NSIS 安装包) | `client/` | -| **macOS** | 桌面客户端 (DMG, x64/arm64) | `client/` | -| **Linux** | 桌面客户端 (AppImage/deb) | `client/` | -| **iOS** | 移动客户端 | `mobile/` | -| **Android** | 移动客户端 (APK/AAB) | `mobile/` | -| **Ubuntu** | 中继服务器 + Web 管理面板 | `server/` | +| POST | /api/v1/auth/register | 用户注册 | +| POST | /api/v1/auth/login | 用户登录 | +| POST | /api/v1/auth/refresh | 刷新令牌 | +| POST | /api/v1/auth/logout | 退出登录 | -## 项目结构 +### 房间 -``` -FunConnect/ -├── server/ # 服务端(中继服务器 + Web 管理面板 + 部署脚本) -├── client/ # 桌面客户端(Electron: Windows / macOS / Linux) -└── mobile/ # 移动客户端(React Native + Expo: iOS / Android) -``` +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | /api/v1/rooms | 获取房间列表 | +| POST | /api/v1/rooms | 创建房间 | +| GET | /api/v1/rooms/:id | 获取房间详情 | +| GET | /api/v1/rooms/:id/members | 获取房间成员 | +| POST | /api/v1/rooms/:id/join | 加入房间 | +| POST | /api/v1/rooms/:id/leave | 离开房间 | +| GET | /api/v1/rooms/:id/host-info | 获取主机连接信息 | -## 服务端 (`server/`) +### 好友 -中继服务器 + Web 管理面板,部署在 Ubuntu 服务器上。 +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | /api/v1/friends | 获取好友列表 | +| GET | /api/v1/friends/requests | 获取好友请求 | +| POST | /api/v1/friends/request | 发送好友请求 | +| PUT | /api/v1/friends/:id/accept | 接受好友请求 | +| DELETE | /api/v1/friends/:id | 删除好友 | -- **TCP 中继引擎** - 转发 Minecraft 流量,支持 Java 版和基岩版 -- **多节点集群** - 主节点 + 工作节点架构,水平扩展 -- **房间系统** - 创建/加入/密码保护/过期清理 -- **流量监控** - 实时统计各房间流量 -- **Token 认证** - 保护写操作 API -- **Web 管理面板** - React + TailwindCSS 可视化管理 -- **一键部署** - Ubuntu 自动安装脚本 + systemd 服务 +### 中继 -```bash -cd server && npm install && cp .env.example .env && npm run dev -``` +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | /api/v1/relay/nodes | 获取中继节点列表 | +| POST | /api/v1/relay/nodes/:id/ping | 上报节点延迟 | -📖 [server/README.md](server/README.md) · 📦 [部署教程 DEPLOY.md](server/DEPLOY.md) +### WebSocket 信令 -## 桌面客户端 (`client/`) +连接地址: `wss://funmc.com/api/v1/ws?token=` -Electron 跨平台桌面客户端,支持 Windows / macOS / Linux。 +消息类型: +- `offer` / `answer` / `ice_candidate` - P2P 连接协商 +- `chat_message` / `send_chat` - 房间聊天 +- `member_joined` / `member_left` - 成员变动通知 +- `room_invite` - 房间邀请 +- `ping` / `pong` - 心跳保活 -- **本地代理** - 自动建立 TCP 代理,MC 添加 `127.0.0.1:25566` 即可联机 -- **设置持久化** - 记住服务器地址、玩家名等偏好 -- **系统托盘** - 最小化到托盘后台运行 +## 🌐 官方服务器 -```bash -cd client && npm install && npm run dev # 开发 -npm run dist:win # 打包 Windows -npm run dist:mac # 打包 macOS -npm run dist:linux # 打包 Linux -``` +| 服务 | 地址 | 端口 | +|------|------|------| +| API 服务器 | https://funmc.com | 443 | +| 中继节点 - 主线路 | funmc.com | 7900 (UDP/QUIC) | +| 中继节点 - 备用线路 | funmc.com | 7901 (UDP/QUIC) | -📖 [client/README.md](client/README.md) +## 🔧 故障排除 -## 移动客户端 (`mobile/`) +### 常见问题 -React Native + Expo 移动客户端,支持 iOS / Android。 +**Q: 无法连接到服务器** +- 检查网络连接 +- 确认防火墙允许 FunMC 通过 +- 尝试使用中继模式 -- **房间管理** - 浏览/搜索/创建/加入联机房间 -- **设置持久化** - 记住服务器地址和玩家名 -- **深色 UI** - Minecraft 风格暗色主题 +**Q: P2P 连接失败** +- 双方都是对称型 NAT 时无法打洞,会自动使用中继 +- 检查路由器 UPnP 设置 -```bash -cd mobile && npm install && npm start # 开发(Expo) -eas build --platform android --profile preview # 构建 Android APK -eas build --platform ios --profile production # 构建 iOS -``` +**Q: Minecraft 无法连接到代理地址** +- 确认 FunMC 显示"已连接"状态 +- 检查代理地址是否正确复制 +- 尝试重新点击"连接" -📖 [mobile/README.md](mobile/README.md) +**Q: 延迟很高** +- 尝试选择更近的中继节点 +- 检查是否成功建立 P2P 连接(显示"P2P 直连") -## 架构 +### 日志位置 -``` -┌──────────────────┐ -│ 桌面客户端 │ Windows / macOS / Linux -│ Electron │ TCP 本地代理 -│ client/ │─────────┐ -└──────────────────┘ │ - ▼ -┌──────────────────┐ ┌──────────────────────────┐ -│ 移动客户端 │ │ 中继服务器 (Ubuntu) │ -│ React Native │──►│ TCP 中继 + REST API │ -│ mobile/ │ │ Web 管理面板 (React) │ -└──────────────────┘ └──────────────────────────┘ - ▲ -┌──────────────────┐ │ -│ Minecraft │─────────┘ -│ 游戏客户端 │ TCP 直连中继 -└──────────────────┘ -``` +- Windows: `%APPDATA%\com.funmc.app\logs` +- macOS: `~/Library/Logs/com.funmc.app` +- Linux: `~/.local/share/com.funmc.app/logs` -## License +## 🤝 贡献指南 -MIT +欢迎提交 Issue 和 Pull Request! + +1. Fork 本仓库 +2. 创建功能分支 (`git checkout -b feature/amazing-feature`) +3. 提交更改 (`git commit -m 'Add amazing feature'`) +4. 推送到分支 (`git push origin feature/amazing-feature`) +5. 提交 Pull Request + +## 📄 许可证 + +本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。 + +--- + +
+ +**魔幻方开发** ⭐ + +[官网](https://funmc.com) • [文档](https://docs.funmc.com) • [反馈](https://github.com/mofangfang/funmc/issues) + +
diff --git a/admin-panel/index.html b/admin-panel/index.html new file mode 100644 index 0000000..f010b81 --- /dev/null +++ b/admin-panel/index.html @@ -0,0 +1,13 @@ + + + + + + + FunMC 管理面板 + + +
+ + + diff --git a/admin-panel/package.json b/admin-panel/package.json new file mode 100644 index 0000000..bbb7d67 --- /dev/null +++ b/admin-panel/package.json @@ -0,0 +1,32 @@ +{ + "name": "funmc-admin-panel", + "version": "0.1.0", + "description": "FunMC 服务端管理面板", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "zustand": "^4.4.7", + "recharts": "^2.10.0", + "dayjs": "^1.11.10", + "clsx": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/admin-panel/postcss.config.js b/admin-panel/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/admin-panel/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/admin-panel/public/favicon.svg b/admin-panel/public/favicon.svg new file mode 100644 index 0000000..1eb4f68 --- /dev/null +++ b/admin-panel/public/favicon.svg @@ -0,0 +1,4 @@ + + + F + diff --git a/admin-panel/src/App.tsx b/admin-panel/src/App.tsx new file mode 100644 index 0000000..5bfcb2c --- /dev/null +++ b/admin-panel/src/App.tsx @@ -0,0 +1,44 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useAuthStore } from './stores/authStore' +import Login from './pages/Login' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Users from './pages/Users' +import Rooms from './pages/Rooms' +import Settings from './pages/Settings' +import Downloads from './pages/Downloads' +import Logs from './pages/Logs' + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuthStore() + if (!isAuthenticated) { + return + } + return <>{children} +} + +function App() { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export default App diff --git a/admin-panel/src/components/Layout.tsx b/admin-panel/src/components/Layout.tsx new file mode 100644 index 0000000..78c9b10 --- /dev/null +++ b/admin-panel/src/components/Layout.tsx @@ -0,0 +1,68 @@ +import { Outlet, NavLink, useNavigate } from 'react-router-dom' +import { useAuthStore } from '../stores/authStore' +import clsx from 'clsx' + +const navItems = [ + { path: '/dashboard', label: '仪表盘', icon: '📊' }, + { path: '/users', label: '用户管理', icon: '👥' }, + { path: '/rooms', label: '房间管理', icon: '🏠' }, + { path: '/downloads', label: '客户端下载', icon: '📥' }, + { path: '/settings', label: '服务器设置', icon: '⚙️' }, + { path: '/logs', label: '系统日志', icon: '📋' }, +] + +export default function Layout() { + const { logout } = useAuthStore() + const navigate = useNavigate() + + const handleLogout = () => { + logout() + navigate('/login') + } + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+ ) +} diff --git a/admin-panel/src/index.css b/admin-panel/src/index.css new file mode 100644 index 0000000..8ed0d73 --- /dev/null +++ b/admin-panel/src/index.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f3f4f6; +} + +.sidebar-link { + @apply flex items-center gap-3 px-4 py-3 text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors; +} + +.sidebar-link.active { + @apply bg-primary-50 text-primary-700 font-medium; +} + +.card { + @apply bg-white rounded-xl shadow-sm border border-gray-100; +} + +.btn-primary { + @apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium; +} + +.btn-secondary { + @apply px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors font-medium; +} + +.btn-danger { + @apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium; +} + +.input { + @apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; +} + +.table { + @apply w-full text-left; +} + +.table th { + @apply px-4 py-3 bg-gray-50 text-gray-600 font-medium text-sm uppercase tracking-wider; +} + +.table td { + @apply px-4 py-3 border-b border-gray-100; +} + +.table tr:hover { + @apply bg-gray-50; +} + +.badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; +} + +.badge-success { + @apply bg-green-100 text-green-800; +} + +.badge-warning { + @apply bg-yellow-100 text-yellow-800; +} + +.badge-danger { + @apply bg-red-100 text-red-800; +} + +.badge-info { + @apply bg-blue-100 text-blue-800; +} diff --git a/admin-panel/src/main.tsx b/admin-panel/src/main.tsx new file mode 100644 index 0000000..0d1dd39 --- /dev/null +++ b/admin-panel/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +) diff --git a/admin-panel/src/pages/Dashboard.tsx b/admin-panel/src/pages/Dashboard.tsx new file mode 100644 index 0000000..e00bf49 --- /dev/null +++ b/admin-panel/src/pages/Dashboard.tsx @@ -0,0 +1,175 @@ +import { useEffect } from 'react' +import { useAdminStore } from '../stores/adminStore' +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts' + +const formatUptime = (seconds: number) => { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return `${days}天 ${hours}小时 ${minutes}分钟` +} + +export default function Dashboard() { + const { stats, fetchStats, loading } = useAdminStore() + + useEffect(() => { + fetchStats() + const interval = setInterval(fetchStats, 30000) + return () => clearInterval(interval) + }, [fetchStats]) + + const statCards = stats + ? [ + { + label: '总用户数', + value: stats.total_users, + icon: '👥', + color: 'bg-blue-50 text-blue-600', + }, + { + label: '在线用户', + value: stats.online_users, + icon: '🟢', + color: 'bg-green-50 text-green-600', + }, + { + label: '总房间数', + value: stats.total_rooms, + icon: '🏠', + color: 'bg-purple-50 text-purple-600', + }, + { + label: '活跃房间', + value: stats.active_rooms, + icon: '🎮', + color: 'bg-orange-50 text-orange-600', + }, + { + label: '活跃连接', + value: stats.total_connections, + icon: '🔗', + color: 'bg-cyan-50 text-cyan-600', + }, + ] + : [] + + const mockChartData = Array.from({ length: 24 }, (_, i) => ({ + time: `${i}:00`, + users: Math.floor(Math.random() * 50) + 10, + rooms: Math.floor(Math.random() * 20) + 5, + })) + + return ( +
+
+

仪表盘

+

服务器运行状态概览

+
+ + {loading && !stats ? ( +
加载中...
+ ) : ( + <> + {/* Stats Cards */} +
+ {statCards.map((card) => ( +
+
+
+ {card.icon} +
+
+

{card.label}

+

+ {card.value} +

+
+
+
+ ))} +
+ + {/* Server Info */} + {stats && ( +
+
+

+ 服务器信息 +

+
+
+ 版本 + v{stats.version} +
+
+ 运行时间 + + {formatUptime(stats.uptime_seconds)} + +
+
+ 状态 + 运行中 +
+
+
+ +
+

+ 快速操作 +

+
+ + + + +
+
+
+ )} + + {/* Chart */} +
+

+ 24小时趋势 +

+
+ + + + + + + + + + +
+
+ + )} +
+ ) +} diff --git a/admin-panel/src/pages/Downloads.tsx b/admin-panel/src/pages/Downloads.tsx new file mode 100644 index 0000000..e8bdf78 --- /dev/null +++ b/admin-panel/src/pages/Downloads.tsx @@ -0,0 +1,248 @@ +import { useEffect, useState } from 'react' +import { useAdminStore } from '../stores/adminStore' + +interface ClientBuild { + platform: string + arch: string + version: string + filename: string + size: string + download_count: number + built_at: string + status: 'ready' | 'building' | 'error' +} + +const platformInfo: Record = { + 'windows-x64': { name: 'Windows (64位)', icon: '🪟' }, + 'windows-x86': { name: 'Windows (32位)', icon: '🪟' }, + 'macos-x64': { name: 'macOS (Intel)', icon: '🍎' }, + 'macos-arm64': { name: 'macOS (Apple Silicon)', icon: '🍎' }, + 'linux-x64': { name: 'Linux (64位)', icon: '🐧' }, + 'linux-arm64': { name: 'Linux (ARM64)', icon: '🐧' }, + 'android-arm64': { name: 'Android', icon: '🤖' }, + 'ios-arm64': { name: 'iOS', icon: '📱' }, +} + +export default function Downloads() { + const { config, fetchConfig } = useAdminStore() + const [builds, setBuilds] = useState([]) + const [building, setBuilding] = useState(false) + const [selectedPlatforms, setSelectedPlatforms] = useState([]) + + useEffect(() => { + fetchConfig() + fetchBuilds() + }, [fetchConfig]) + + const fetchBuilds = async () => { + try { + const res = await fetch('/api/v1/admin/builds') + if (res.ok) { + const data = await res.json() + setBuilds(data) + } + } catch { + setBuilds([ + { + platform: 'windows-x64', + arch: 'x64', + version: '0.1.0', + filename: 'FunMC-0.1.0-windows-x64.exe', + size: '45.2 MB', + download_count: 128, + built_at: new Date().toISOString(), + status: 'ready', + }, + { + platform: 'macos-arm64', + arch: 'arm64', + version: '0.1.0', + filename: 'FunMC-0.1.0-macos-arm64.dmg', + size: '52.1 MB', + download_count: 64, + built_at: new Date().toISOString(), + status: 'ready', + }, + { + platform: 'linux-x64', + arch: 'x64', + version: '0.1.0', + filename: 'FunMC-0.1.0-linux-x64.AppImage', + size: '48.7 MB', + download_count: 32, + built_at: new Date().toISOString(), + status: 'ready', + }, + ]) + } + } + + const handleBuildAll = async () => { + setBuilding(true) + try { + await fetch('/api/v1/admin/builds/trigger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ platforms: selectedPlatforms.length > 0 ? selectedPlatforms : Object.keys(platformInfo) }), + }) + setTimeout(fetchBuilds, 2000) + } catch { + alert('构建请求失败') + } + setBuilding(false) + } + + const togglePlatform = (platform: string) => { + setSelectedPlatforms((prev) => + prev.includes(platform) + ? prev.filter((p) => p !== platform) + : [...prev, platform] + ) + } + + const serverUrl = config?.server_ip + ? `http://${config.server_ip}:3000` + : window.location.origin + + return ( +
+
+

客户端下载管理

+

管理客户端构建和下载

+
+ + {/* Download Page Info */} +
+

+ 客户端下载页面 +

+
+

用户可以通过以下链接下载客户端:

+
+ + {serverUrl}/download + + + + 打开页面 + +
+
+
+ + {/* Build Platforms */} +
+
+

构建平台

+ +
+ +
+ {Object.entries(platformInfo).map(([key, info]) => ( + + ))} +
+
+ + {/* Builds List */} +
+
+

已构建版本

+
+ + + + + + + + + + + + + + {builds.map((build) => { + const info = platformInfo[build.platform] || { + name: build.platform, + icon: '📦', + } + return ( + + + + + + + + + + ) + })} + {builds.length === 0 && ( + + + + )} + +
平台版本文件名大小下载次数状态操作
+
+ {info.icon} + {info.name} +
+
v{build.version} + {build.filename} + {build.size}{build.download_count} + {build.status === 'ready' ? ( + 就绪 + ) : build.status === 'building' ? ( + 构建中 + ) : ( + 错误 + )} + + + 下载 + +
+ 暂无构建版本,请先构建客户端 +
+
+
+ ) +} diff --git a/admin-panel/src/pages/Login.tsx b/admin-panel/src/pages/Login.tsx new file mode 100644 index 0000000..9da60cb --- /dev/null +++ b/admin-panel/src/pages/Login.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '../stores/authStore' + +export default function Login() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const { login } = useAuthStore() + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + const success = await login(username, password) + setLoading(false) + + if (success) { + navigate('/dashboard') + } else { + setError('用户名或密码错误') + } + } + + return ( +
+
+
+
+

FunMC

+

管理面板

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="input" + placeholder="admin" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="input" + placeholder="••••••••" + required + /> +
+ + +
+ +

+ 魔幻方开发 +

+
+
+
+ ) +} diff --git a/admin-panel/src/pages/Logs.tsx b/admin-panel/src/pages/Logs.tsx new file mode 100644 index 0000000..7f825b3 --- /dev/null +++ b/admin-panel/src/pages/Logs.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react' +import { useAdminStore } from '../stores/adminStore' + +export default function Logs() { + const { logs, fetchLogs, loading } = useAdminStore() + const [autoRefresh, setAutoRefresh] = useState(true) + const [filter, setFilter] = useState('') + const logContainerRef = useRef(null) + + useEffect(() => { + fetchLogs() + }, [fetchLogs]) + + useEffect(() => { + if (autoRefresh) { + const interval = setInterval(fetchLogs, 5000) + return () => clearInterval(interval) + } + }, [autoRefresh, fetchLogs]) + + useEffect(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight + } + }, [logs]) + + const filteredLogs = filter + ? logs.filter((log) => log.toLowerCase().includes(filter.toLowerCase())) + : logs + + const getLogLevel = (log: string) => { + if (log.includes('ERROR') || log.includes('error')) return 'error' + if (log.includes('WARN') || log.includes('warn')) return 'warn' + if (log.includes('INFO') || log.includes('info')) return 'info' + if (log.includes('DEBUG') || log.includes('debug')) return 'debug' + return 'info' + } + + const getLogColor = (level: string) => { + switch (level) { + case 'error': + return 'text-red-500' + case 'warn': + return 'text-yellow-500' + case 'info': + return 'text-blue-500' + case 'debug': + return 'text-gray-400' + default: + return 'text-gray-300' + } + } + + return ( +
+
+
+

系统日志

+

查看服务器运行日志

+
+
+ setFilter(e.target.value)} + className="input w-64" + /> + + +
+
+ +
+
+ Server Logs + {filteredLogs.length} 条日志 +
+ +
+ {loading && logs.length === 0 ? ( +
加载中...
+ ) : filteredLogs.length === 0 ? ( +
暂无日志
+ ) : ( + filteredLogs.map((log, index) => { + const level = getLogLevel(log) + return ( +
+ {log} +
+ ) + }) + )} +
+
+ +
+ 魔幻方开发 +
+
+ ) +} diff --git a/admin-panel/src/pages/Rooms.tsx b/admin-panel/src/pages/Rooms.tsx new file mode 100644 index 0000000..520bc2d --- /dev/null +++ b/admin-panel/src/pages/Rooms.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' +import { useAdminStore } from '../stores/adminStore' +import dayjs from 'dayjs' + +export default function Rooms() { + const { rooms, fetchRooms, deleteRoom, loading } = useAdminStore() + const [search, setSearch] = useState('') + + useEffect(() => { + fetchRooms() + }, [fetchRooms]) + + const filteredRooms = rooms.filter( + (room) => + room.name.toLowerCase().includes(search.toLowerCase()) || + room.owner_name.toLowerCase().includes(search.toLowerCase()) + ) + + const handleDelete = async (roomId: string) => { + if (confirm('确定要删除此房间吗?所有成员将被踢出。')) { + await deleteRoom(roomId) + } + } + + return ( +
+
+
+

房间管理

+

管理游戏房间

+
+
+ setSearch(e.target.value)} + className="input w-64" + /> +
+
+ +
+ {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + + + + + + {filteredRooms.map((room) => ( + + + + + + + + + + ))} + {filteredRooms.length === 0 && ( + + + + )} + +
房间名称房主成员数可见性状态创建时间操作
{room.name}{room.owner_name}{room.member_count} + {room.is_public ? ( + 公开 + ) : ( + 私密 + )} + + {room.status === 'active' ? ( + 活跃 + ) : ( + 空闲 + )} + + {dayjs(room.created_at).format('YYYY-MM-DD HH:mm')} + + +
+ 没有找到房间 +
+ )} +
+
+ ) +} diff --git a/admin-panel/src/pages/Settings.tsx b/admin-panel/src/pages/Settings.tsx new file mode 100644 index 0000000..4572d18 --- /dev/null +++ b/admin-panel/src/pages/Settings.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState } from 'react' +import { useAdminStore, ServerConfig } from '../stores/adminStore' + +export default function Settings() { + const { config, fetchConfig, updateConfig, loading } = useAdminStore() + const [form, setForm] = useState>({}) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + useEffect(() => { + fetchConfig() + }, [fetchConfig]) + + useEffect(() => { + if (config) { + setForm(config) + } + }, [config]) + + const handleChange = (key: keyof ServerConfig, value: string | number | boolean) => { + setForm((prev) => ({ ...prev, [key]: value })) + } + + const handleSave = async () => { + setSaving(true) + setMessage(null) + + const success = await updateConfig(form) + + if (success) { + setMessage({ type: 'success', text: '设置已保存' }) + } else { + setMessage({ type: 'error', text: '保存失败,请重试' }) + } + + setSaving(false) + } + + if (loading && !config) { + return ( +
+
加载中...
+
+ ) + } + + return ( +
+
+

服务器设置

+

配置服务器参数

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ {/* Basic Settings */} +
+

基本设置

+
+
+ + handleChange('server_name', e.target.value)} + className="input" + placeholder="FunMC Server" + /> +

显示在客户端的服务器名称

+
+ +
+ + handleChange('server_ip', e.target.value)} + className="input" + placeholder="123.45.67.89" + /> +

+ 服务器的公网 IP 地址,客户端将自动使用此地址连接 +

+
+ +
+ + handleChange('server_domain', e.target.value)} + className="input" + placeholder="funmc.example.com" + /> +

+ 如果有域名,填写后客户端将优先使用域名连接 +

+
+
+
+ + {/* Limits */} +
+

限制设置

+
+
+ + + handleChange('max_rooms_per_user', parseInt(e.target.value)) + } + className="input" + min={1} + max={100} + /> +
+ +
+ + + handleChange('max_room_members', parseInt(e.target.value)) + } + className="input" + min={2} + max={100} + /> +
+
+
+ + {/* Features */} +
+

功能开关

+
+ + + + + +
+
+ + {/* Client Version */} +
+

客户端版本

+
+ + handleChange('client_version', e.target.value)} + className="input" + placeholder="0.1.0" + /> +

+ 客户端会自动检查更新,显示此版本号 +

+
+
+ + {/* Save Button */} +
+ +
+
+
+ ) +} diff --git a/admin-panel/src/pages/Users.tsx b/admin-panel/src/pages/Users.tsx new file mode 100644 index 0000000..19c0be7 --- /dev/null +++ b/admin-panel/src/pages/Users.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react' +import { useAdminStore } from '../stores/adminStore' +import dayjs from 'dayjs' + +export default function Users() { + const { users, fetchUsers, banUser, unbanUser, loading } = useAdminStore() + const [search, setSearch] = useState('') + + useEffect(() => { + fetchUsers() + }, [fetchUsers]) + + const filteredUsers = users.filter( + (user) => + user.username.toLowerCase().includes(search.toLowerCase()) || + user.email.toLowerCase().includes(search.toLowerCase()) + ) + + const handleBan = async (userId: string, isBanned: boolean) => { + if (isBanned) { + await unbanUser(userId) + } else { + if (confirm('确定要封禁此用户吗?')) { + await banUser(userId) + } + } + } + + return ( +
+
+
+

用户管理

+

管理注册用户

+
+
+ setSearch(e.target.value)} + className="input w-64" + /> +
+
+ +
+ {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + + + ))} + {filteredUsers.length === 0 && ( + + + + )} + +
用户名邮箱状态注册时间操作
{user.username}{user.email} + {user.is_banned ? ( + 已封禁 + ) : user.is_online ? ( + 在线 + ) : ( + 离线 + )} + + {dayjs(user.created_at).format('YYYY-MM-DD HH:mm')} + +
+ +
+
+ 没有找到用户 +
+ )} +
+
+ ) +} diff --git a/admin-panel/src/stores/adminStore.ts b/admin-panel/src/stores/adminStore.ts new file mode 100644 index 0000000..e44370d --- /dev/null +++ b/admin-panel/src/stores/adminStore.ts @@ -0,0 +1,209 @@ +import { create } from 'zustand' +import { useAuthStore } from './authStore' + +export interface User { + id: string + username: string + email: string + created_at: string + is_online: boolean + is_banned: boolean +} + +export interface Room { + id: string + name: string + owner_id: string + owner_name: string + is_public: boolean + member_count: number + created_at: string + status: 'active' | 'idle' +} + +export interface ServerStats { + total_users: number + online_users: number + total_rooms: number + active_rooms: number + total_connections: number + uptime_seconds: number + version: string +} + +export interface ServerConfig { + server_name: string + server_ip: string + server_domain: string + max_rooms_per_user: number + max_room_members: number + relay_enabled: boolean + registration_enabled: boolean + client_download_enabled: boolean + client_version: string +} + +interface AdminState { + stats: ServerStats | null + users: User[] + rooms: Room[] + config: ServerConfig | null + logs: string[] + loading: boolean + error: string | null + + fetchStats: () => Promise + fetchUsers: () => Promise + fetchRooms: () => Promise + fetchConfig: () => Promise + fetchLogs: () => Promise + updateConfig: (config: Partial) => Promise + banUser: (userId: string) => Promise + unbanUser: (userId: string) => Promise + deleteRoom: (roomId: string) => Promise +} + +const getAuthHeader = () => { + const token = useAuthStore.getState().token + return { Authorization: `Bearer ${token}` } +} + +export const useAdminStore = create((set, get) => ({ + stats: null, + users: [], + rooms: [], + config: null, + logs: [], + loading: false, + error: null, + + fetchStats: async () => { + set({ loading: true, error: null }) + try { + const res = await fetch('/api/v1/admin/stats', { + headers: { ...getAuthHeader() }, + }) + if (!res.ok) throw new Error('Failed to fetch stats') + const data = await res.json() + set({ stats: data, loading: false }) + } catch (e) { + set({ error: String(e), loading: false }) + } + }, + + fetchUsers: async () => { + set({ loading: true, error: null }) + try { + const res = await fetch('/api/v1/admin/users', { + headers: { ...getAuthHeader() }, + }) + if (!res.ok) throw new Error('Failed to fetch users') + const data = await res.json() + set({ users: data, loading: false }) + } catch (e) { + set({ error: String(e), loading: false }) + } + }, + + fetchRooms: async () => { + set({ loading: true, error: null }) + try { + const res = await fetch('/api/v1/admin/rooms', { + headers: { ...getAuthHeader() }, + }) + if (!res.ok) throw new Error('Failed to fetch rooms') + const data = await res.json() + set({ rooms: data, loading: false }) + } catch (e) { + set({ error: String(e), loading: false }) + } + }, + + fetchConfig: async () => { + set({ loading: true, error: null }) + try { + const res = await fetch('/api/v1/admin/config', { + headers: { ...getAuthHeader() }, + }) + if (!res.ok) throw new Error('Failed to fetch config') + const data = await res.json() + set({ config: data, loading: false }) + } catch (e) { + set({ error: String(e), loading: false }) + } + }, + + fetchLogs: async () => { + set({ loading: true, error: null }) + try { + const res = await fetch('/api/v1/admin/logs', { + headers: { ...getAuthHeader() }, + }) + if (!res.ok) throw new Error('Failed to fetch logs') + const data = await res.json() + set({ logs: data.logs, loading: false }) + } catch (e) { + set({ error: String(e), loading: false }) + } + }, + + updateConfig: async (config) => { + try { + const res = await fetch('/api/v1/admin/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeader(), + }, + body: JSON.stringify(config), + }) + if (!res.ok) return false + await get().fetchConfig() + return true + } catch { + return false + } + }, + + banUser: async (userId) => { + try { + const res = await fetch(`/api/v1/admin/users/${userId}/ban`, { + method: 'POST', + headers: { ...getAuthHeader() }, + }) + if (!res.ok) return false + await get().fetchUsers() + return true + } catch { + return false + } + }, + + unbanUser: async (userId) => { + try { + const res = await fetch(`/api/v1/admin/users/${userId}/unban`, { + method: 'POST', + headers: { ...getAuthHeader() }, + }) + if (!res.ok) return false + await get().fetchUsers() + return true + } catch { + return false + } + }, + + deleteRoom: async (roomId) => { + try { + const res = await fetch(`/api/v1/admin/rooms/${roomId}`, { + method: 'DELETE', + headers: { ...getAuthHeader() }, + }) + if (!res.ok) return false + await get().fetchRooms() + return true + } catch { + return false + } + }, +})) diff --git a/admin-panel/src/stores/authStore.ts b/admin-panel/src/stores/authStore.ts new file mode 100644 index 0000000..a9f0aac --- /dev/null +++ b/admin-panel/src/stores/authStore.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand' +import { persist, createJSONStorage } from 'zustand/middleware' + +interface AuthState { + token: string | null + isAuthenticated: boolean + login: (username: string, password: string) => Promise + logout: () => void +} + +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + isAuthenticated: false, + + login: async (username: string, password: string) => { + try { + const res = await fetch('/api/v1/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + + if (!res.ok) { + return false + } + + const data = await res.json() + set({ token: data.token, isAuthenticated: true }) + return true + } catch { + return false + } + }, + + logout: () => { + set({ token: null, isAuthenticated: false }) + }, + }), + { + name: 'funmc-admin-auth', + } + ) +) diff --git a/admin-panel/tailwind.config.js b/admin-panel/tailwind.config.js new file mode 100644 index 0000000..2293adb --- /dev/null +++ b/admin-panel/tailwind.config.js @@ -0,0 +1,23 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + primary: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + }, + }, + }, + }, + plugins: [], +} diff --git a/admin-panel/tsconfig.json b/admin-panel/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/admin-panel/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/admin-panel/tsconfig.node.json b/admin-panel/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/admin-panel/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/admin-panel/vite.config.ts b/admin-panel/vite.config.ts new file mode 100644 index 0000000..b775512 --- /dev/null +++ b/admin-panel/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: '/admin/', + server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}) diff --git a/client/Cargo.toml b/client/Cargo.toml new file mode 100644 index 0000000..4dfe764 --- /dev/null +++ b/client/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "funmc-client" +version = "0.1.0" +edition = "2021" + +[lib] +name = "funmc_client_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "funmc-client" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[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 } + +tauri = { version = "2", features = [] } +tauri-plugin-shell = "2" +tauri-plugin-notification = "2" + +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] } +futures-util = "0.3" + +sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "uuid", "chrono", "migrate"] } + +# QUIC transport +quinn = "0.11" +rustls = { version = "0.23", default-features = false, features = ["ring"] } +rcgen = "0.13" + +# Async networking helpers +bytes = "1" +rand = "0.8" + +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +local-ip-address = "0.6" + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] diff --git a/client/build.rs b/client/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/client/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/client/capabilities/default.json b/client/capabilities/default.json new file mode 100644 index 0000000..8896730 --- /dev/null +++ b/client/capabilities/default.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "default", + "description": "FunMC 默认权限配置", + "windows": ["main"], + "permissions": [ + "core:default", + "shell:allow-open", + "notification:default", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify", + "notification:allow-register-action-types" + ] +} diff --git a/client/icons/.gitkeep b/client/icons/.gitkeep new file mode 100644 index 0000000..7b4eb10 --- /dev/null +++ b/client/icons/.gitkeep @@ -0,0 +1,9 @@ +# Icon files should be placed here +# Required icons: +# - 32x32.png +# - 128x128.png +# - 128x128@2x.png +# - icon.icns (macOS) +# - icon.ico (Windows) +# +# Use `cargo tauri icon` command to generate icons from a source image diff --git a/client/icons/128x128.png b/client/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..5e12fa82813cafe79981b23b299a3f50bdf1d288 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Asp9}f1E!6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FunMC + diff --git a/client/migrations/sqlite/20240101000001_auth_cache.sql b/client/migrations/sqlite/20240101000001_auth_cache.sql new file mode 100644 index 0000000..0850ba6 --- /dev/null +++ b/client/migrations/sqlite/20240101000001_auth_cache.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS auth_cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/client/migrations/sqlite/20240101000001_local_data.sql b/client/migrations/sqlite/20240101000001_local_data.sql new file mode 100644 index 0000000..2e18dea --- /dev/null +++ b/client/migrations/sqlite/20240101000001_local_data.sql @@ -0,0 +1,68 @@ +-- FunMC 客户端本地数据库 +-- 用于存储本地缓存和设置 + +-- 用户会话信息缓存 +CREATE TABLE IF NOT EXISTS user_session ( + id INTEGER PRIMARY KEY CHECK (id = 1), + user_id TEXT NOT NULL, + username TEXT NOT NULL, + email TEXT NOT NULL, + avatar_seed TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + server_url TEXT NOT NULL DEFAULT 'https://funmc.com', + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +-- 最近加入的房间缓存 +CREATE TABLE IF NOT EXISTS recent_rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL UNIQUE, + room_name TEXT NOT NULL, + owner_username TEXT NOT NULL, + game_version TEXT NOT NULL DEFAULT '1.20', + last_joined_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +-- 好友缓存(用于离线显示) +CREATE TABLE IF NOT EXISTS friends_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + avatar_seed TEXT NOT NULL, + is_online INTEGER NOT NULL DEFAULT 0, + cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +-- 应用设置 +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +-- 连接历史记录 +CREATE TABLE IF NOT EXISTS connection_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL, + room_name TEXT NOT NULL, + session_type TEXT NOT NULL CHECK (session_type IN ('p2p', 'relay')), + local_port INTEGER NOT NULL, + bytes_sent INTEGER NOT NULL DEFAULT 0, + bytes_received INTEGER NOT NULL DEFAULT 0, + latency_ms INTEGER, + started_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + ended_at INTEGER +); + +-- 中继节点延迟缓存 +CREATE TABLE IF NOT EXISTS relay_latency_cache ( + node_url TEXT PRIMARY KEY NOT NULL, + node_name TEXT NOT NULL, + latency_ms INTEGER NOT NULL, + tested_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_recent_rooms_last_joined ON recent_rooms(last_joined_at DESC); +CREATE INDEX IF NOT EXISTS idx_connection_history_started ON connection_history(started_at DESC); diff --git a/client/src/commands/auth.rs b/client/src/commands/auth.rs new file mode 100644 index 0000000..8bb2c0e --- /dev/null +++ b/client/src/commands/auth.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::state::{AppState, CurrentUser, Tokens}; + +#[derive(Debug, Serialize)] +pub struct AuthResult { + pub user: CurrentUser, + pub token: String, +} + +#[derive(Debug, Deserialize)] +struct ServerAuthResponse { + access_token: String, + refresh_token: String, + user: CurrentUser, +} + +#[tauri::command] +pub async fn login( + username: String, + password: String, + state: State<'_, AppState>, +) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/auth/login", state.get_server_url()); + let resp = client + .post(&url) + .json(&serde_json::json!({ "username": username, "password": password })) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + let err: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(err["error"].as_str().unwrap_or("login failed").to_string()); + } + + let body: ServerAuthResponse = resp.json().await.map_err(|e| e.to_string())?; + let token = body.access_token.clone(); + state.set_auth( + body.user.clone(), + Tokens { + access_token: body.access_token, + refresh_token: body.refresh_token, + }, + ); + Ok(AuthResult { user: body.user, token }) +} + +#[tauri::command] +pub async fn register( + username: String, + email: String, + password: String, + state: State<'_, AppState>, +) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/auth/register", state.get_server_url()); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "username": username, + "email": email, + "password": password, + })) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + let err: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(err["error"].as_str().unwrap_or("registration failed").to_string()); + } + + let body: ServerAuthResponse = resp.json().await.map_err(|e| e.to_string())?; + let token = body.access_token.clone(); + state.set_auth( + body.user.clone(), + Tokens { + access_token: body.access_token, + refresh_token: body.refresh_token, + }, + ); + Ok(AuthResult { user: body.user, token }) +} + +#[tauri::command] +pub async fn logout(state: State<'_, AppState>) -> Result<(), String> { + let refresh_token = { + state.tokens.lock().unwrap().as_ref().map(|t| t.refresh_token.clone()) + }; + if let Some(rt) = refresh_token { + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/auth/logout", state.get_server_url()); + let _ = client + .post(&url) + .json(&serde_json::json!({ "refresh_token": rt })) + .send() + .await; + } + state.clear_auth(); + Ok(()) +} + +#[tauri::command] +pub async fn get_current_user(state: State<'_, AppState>) -> Result, String> { + let user = state.user.lock().unwrap().clone(); + let token = state.tokens.lock().unwrap().as_ref().map(|t| t.access_token.clone()); + + match (user, token) { + (Some(u), Some(t)) => Ok(Some(AuthResult { user: u, token: t })), + _ => Ok(None), + } +} diff --git a/client/src/commands/config.rs b/client/src/commands/config.rs new file mode 100644 index 0000000..7b51adb --- /dev/null +++ b/client/src/commands/config.rs @@ -0,0 +1,48 @@ +use tauri::State; + +use crate::AppState; +use crate::state::ServerConfig; + +#[tauri::command] +pub async fn set_server_url(url: String, state: State<'_, AppState>) -> Result<(), String> { + state.set_server_url(url.clone()); + tracing::info!("Server URL set to: {}", url); + Ok(()) +} + +#[tauri::command] +pub async fn get_server_url(state: State<'_, AppState>) -> Result { + Ok(state.get_server_url()) +} + +#[tauri::command] +pub async fn fetch_server_config(state: State<'_, AppState>) -> Result { + let server_url = state.get_server_url(); + let url = format!("{}/api/v1/client-config", server_url); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("连接服务器失败: {}", e))?; + + if !response.status().is_success() { + return Err(format!("服务器返回错误: {}", response.status())); + } + + let config: ServerConfig = response + .json() + .await + .map_err(|e| format!("解析配置失败: {}", e))?; + + *state.server_config.lock().unwrap() = Some(config.clone()); + + Ok(config) +} + +#[tauri::command] +pub async fn get_cached_server_config(state: State<'_, AppState>) -> Result, String> { + Ok(state.server_config.lock().unwrap().clone()) +} diff --git a/client/src/commands/friends.rs b/client/src/commands/friends.rs new file mode 100644 index 0000000..ddf0a83 --- /dev/null +++ b/client/src/commands/friends.rs @@ -0,0 +1,118 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; +use uuid::Uuid; + +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FriendDto { + pub id: Uuid, + pub username: String, + pub avatar_seed: String, + pub is_online: bool, + pub status: String, +} + +fn auth_header(state: &AppState) -> Result { + state + .get_access_token() + .map(|t| format!("Bearer {}", t)) + .ok_or_else(|| "not authenticated".into()) +} + +#[tauri::command] +pub async fn list_friends(state: State<'_, AppState>) -> Result, String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/friends", state.get_server_url()); + let resp = client + .get(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())? + .json::>() + .await + .map_err(|e| e.to_string())?; + Ok(resp) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FriendRequest { + pub id: Uuid, + pub username: String, + pub avatar_seed: String, +} + +#[tauri::command] +pub async fn list_requests(state: State<'_, AppState>) -> Result, String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/friends/requests", state.get_server_url()); + let resp = client + .get(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())? + .json::>() + .await + .map_err(|e| e.to_string())?; + Ok(resp) +} + +#[tauri::command] +pub async fn send_friend_request( + username: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/friends/request", state.get_server_url()); + let resp = client + .post(&url) + .header("Authorization", token) + .json(&serde_json::json!({ "username": username })) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + let err: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(err["error"].as_str().unwrap_or("failed").to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn accept_friend_request( + requester_id: Uuid, + state: State<'_, AppState>, +) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/friends/{}/accept", state.get_server_url(), requester_id); + let resp = client + .put(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err("failed to accept request".into()); + } + Ok(()) +} + +#[tauri::command] +pub async fn remove_friend(friend_id: Uuid, state: State<'_, AppState>) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/friends/{}", state.get_server_url(), friend_id); + client + .delete(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/client/src/commands/mod.rs b/client/src/commands/mod.rs new file mode 100644 index 0000000..e79e468 --- /dev/null +++ b/client/src/commands/mod.rs @@ -0,0 +1,7 @@ +pub mod auth; +pub mod config; +pub mod friends; +pub mod network; +pub mod relay_nodes; +pub mod rooms; +pub mod signaling; diff --git a/client/src/commands/network.rs b/client/src/commands/network.rs new file mode 100644 index 0000000..28fc375 --- /dev/null +++ b/client/src/commands/network.rs @@ -0,0 +1,323 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, State}; +use uuid::Uuid; + +use crate::network::{ + minecraft_proxy::{find_mc_port, start_client_proxy, start_host_proxy}, + p2p::{attempt_p2p, build_my_peer_info, find_quic_port}, + quic::open_endpoint, + relay::connect_relay, + relay_selector::fetch_best_node, + session::{ConnectionStats, NetworkSession}, + lan_discovery::broadcast_lan_server, +}; +use funmc_shared::protocol::PeerInfo; +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkSessionDto { + pub room_id: String, + pub local_port: u16, + pub session_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionInfo { + pub room_id: String, + pub local_port: u16, + pub connect_addr: String, + pub session_type: String, +} + +fn auth_token(state: &AppState) -> Result { + state.get_access_token().ok_or_else(|| "not authenticated".into()) +} + +fn my_user_id(state: &AppState) -> Result { + state + .user + .lock() + .unwrap() + .as_ref() + .map(|u| u.id) + .ok_or_else(|| "not authenticated".into()) +} + +/// Upload host peer info to server for P2P connection +async fn upload_host_info( + server_url: &str, + token: &str, + room_id: Uuid, + peer_info: &PeerInfo, +) { + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/rooms/{}/host-info", server_url, room_id); + + let nat_type_str = match peer_info.nat_type { + funmc_shared::protocol::NatType::None => "none", + funmc_shared::protocol::NatType::FullCone => "full_cone", + funmc_shared::protocol::NatType::RestrictedCone => "restricted_cone", + funmc_shared::protocol::NatType::PortRestrictedCone => "port_restricted", + funmc_shared::protocol::NatType::Symmetric => "symmetric", + funmc_shared::protocol::NatType::Unknown => "unknown", + }; + + let _ = client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .json(&serde_json::json!({ + "public_addr": peer_info.public_addr, + "local_addrs": peer_info.local_addrs, + "nat_type": nat_type_str, + })) + .send() + .await; +} + +/// House side: open a QUIC listener, accept connections from peers (direct or relayed), +/// forward each to the local MC server (default 25565). +#[tauri::command] +pub async fn start_hosting( + room_id: String, + room_name: Option, + mc_port: Option, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?; + let room_name = room_name.unwrap_or_else(|| format!("Room {}", &room_id[..8])); + let mc_server_port = mc_port.unwrap_or(25565); + let mc_addr = format!("127.0.0.1:{}", mc_server_port).parse().unwrap(); + let token = auth_token(&state)?; + let user_id = my_user_id(&state)?; + + let quic_port = find_quic_port().await.map_err(|e| e.to_string())?; + let bind_addr = format!("0.0.0.0:{}", quic_port).parse().unwrap(); + let endpoint = open_endpoint(bind_addr).map_err(|e| e.to_string())?; + let endpoint = Arc::new(endpoint); + + // Build and upload peer info for P2P connections + let my_peer_info = build_my_peer_info(user_id, quic_port).await; + tracing::info!("Host public addr: {}", my_peer_info.public_addr); + upload_host_info(&state.get_server_url(), &token, room_uuid, &my_peer_info).await; + + let session = NetworkSession::new(room_uuid, quic_port, true, "hosting"); + let cancel = session.cancel.clone(); + let stats = session.stats.clone(); + + // Store session + *state.network_session.write().await = Some(session); + + // Accept incoming QUIC connections in background + let ep2 = endpoint.clone(); + let cancel2 = cancel.clone(); + let stats2 = stats.clone(); + tokio::spawn(async move { + while let Some(inc) = ep2.accept().await { + let mc_addr2 = mc_addr; + let cancel3 = cancel2.clone(); + let stats3 = stats2.clone(); + tokio::spawn(async move { + match inc.await { + Ok(conn) => { + tracing::info!("Host: accepted QUIC connection from {}", conn.remote_address()); + { + let mut s = stats3.lock().await; + s.connected = true; + s.session_type = "p2p".into(); + } + let _ = start_host_proxy(mc_addr2, conn, cancel3).await; + } + Err(e) => tracing::warn!("Host: QUIC incoming error: {}", e), + } + }); + } + }); + + // Broadcast LAN discovery so MC client can find the server + let cancel_lan = cancel.clone(); + let room_name2 = room_name.clone(); + let local_port = quic_port; + tokio::spawn(async move { + let _ = broadcast_lan_server(&room_name2, local_port, cancel_lan).await; + }); + + // Emit event + let _ = app.emit("network:status_changed", serde_json::json!({ + "type": "hosting", + "port": quic_port, + })); + + Ok(NetworkSessionDto { + room_id, + local_port: quic_port, + session_type: "hosting".into(), + }) +} + +/// Fetch host's peer info from the server +async fn fetch_host_peer_info( + server_url: &str, + token: &str, + room_id: Uuid, +) -> Option { + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/rooms/{}/host-info", server_url, room_id); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .ok()?; + + if resp.status().is_success() { + resp.json::().await.ok() + } else { + None + } +} + +/// Client side: attempt P2P → relay fallback → start local MC proxy +#[tauri::command] +pub async fn join_room_network( + room_id: String, + host_user_id: Option, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?; + let host_uuid = host_user_id + .as_ref() + .and_then(|id| Uuid::parse_str(id).ok()); + let token = auth_token(&state)?; + let user_id = my_user_id(&state)?; + + let mc_local_port = find_mc_port().await; + let quic_port = find_quic_port().await.map_err(|e| e.to_string())?; + let bind_addr = format!("0.0.0.0:{}", quic_port).parse().unwrap(); + let endpoint = open_endpoint(bind_addr).map_err(|e| e.to_string())?; + let endpoint = Arc::new(endpoint); + + // Build our own peer info (STUN discovery) + let my_peer_info = build_my_peer_info(user_id, quic_port).await; + tracing::info!("My public addr: {}", my_peer_info.public_addr); + + // Emit status update + let _ = app.emit("network:status_changed", serde_json::json!({ + "type": "connecting", + "status": "尝试 P2P 直连...", + })); + + // Try P2P connection first if we have host info + let mut p2p_conn: Option = None; + + if let Some(host_info) = fetch_host_peer_info(&state.get_server_url(), &token, room_uuid).await { + tracing::info!("Got host peer info, attempting P2P to {}", host_info.public_addr); + + match attempt_p2p(user_id, host_info, endpoint.clone(), quic_port).await { + Ok(conn) => { + tracing::info!("P2P connection established!"); + p2p_conn = Some(conn); + } + Err(e) => { + tracing::warn!("P2P failed: {}, falling back to relay", e); + } + } + } else { + tracing::info!("No host peer info available, using relay directly"); + } + + // Use P2P connection or fall back to relay + let (conn, session_type) = if let Some(p2p) = p2p_conn { + (p2p, "p2p") + } else { + // Update status + let _ = app.emit("network:status_changed", serde_json::json!({ + "type": "connecting", + "status": "连接中继服务器...", + })); + + // Try to find the best relay node + let relay_node = fetch_best_node(&state.get_server_url(), &token).await; + + match relay_node { + Ok(node) => { + tracing::info!("Using relay node: {} ({})", node.name, node.url); + match connect_relay(&node.url, room_uuid, &token).await { + Ok(c) => (c, "relay"), + Err(e) => { + tracing::warn!("Relay failed: {}. No connection available.", e); + return Err(format!("Could not connect: {}", e)); + } + } + } + Err(_) => { + return Err("No relay nodes available".into()); + } + } + }; + + let session = NetworkSession::new(room_uuid, mc_local_port, false, session_type); + let cancel = session.cancel.clone(); + let stats = session.stats.clone(); + + { + let mut s = stats.lock().await; + s.connected = true; + s.session_type = session_type.to_string(); + } + + *state.network_session.write().await = Some(session); + + // Start MC proxy in background + let conn2 = conn.clone(); + let cancel2 = cancel.clone(); + tokio::spawn(async move { + let _ = start_client_proxy(mc_local_port, conn2, cancel2).await; + }); + + let connect_addr = format!("127.0.0.1:{}", mc_local_port); + + let _ = app.emit("network:status_changed", serde_json::json!({ + "type": session_type, + "port": mc_local_port, + "connect_addr": connect_addr, + })); + + Ok(ConnectionInfo { + room_id, + local_port: mc_local_port, + connect_addr, + session_type: session_type.to_string(), + }) +} + +/// Get live connection statistics +#[tauri::command] +pub async fn get_connection_stats( + state: State<'_, AppState>, +) -> Result { + let guard = state.network_session.read().await; + match guard.as_ref() { + Some(s) => Ok(s.stats.lock().await.clone()), + None => Ok(ConnectionStats { + session_type: "none".into(), + latency_ms: 0, + bytes_sent: 0, + bytes_received: 0, + connected: false, + }), + } +} + +/// Stop all active network sessions +#[tauri::command] +pub async fn stop_network(state: State<'_, AppState>) -> Result<(), String> { + let mut guard = state.network_session.write().await; + if let Some(s) = guard.take() { + s.cancel.notify_waiters(); + tracing::info!("Network session stopped"); + } + Ok(()) +} diff --git a/client/src/commands/relay_nodes.rs b/client/src/commands/relay_nodes.rs new file mode 100644 index 0000000..84ab03e --- /dev/null +++ b/client/src/commands/relay_nodes.rs @@ -0,0 +1,111 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayNodeDto { + pub id: String, + pub name: String, + pub url: String, + pub region: String, + pub is_active: bool, + pub priority: i32, + pub last_ping_ms: Option, +} + +fn auth_header(state: &AppState) -> Result { + state + .get_access_token() + .map(|t| format!("Bearer {}", t)) + .ok_or_else(|| "not authenticated".into()) +} + +#[tauri::command] +pub async fn list_relay_nodes(state: State<'_, AppState>) -> Result, String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/relay/nodes", state.get_server_url()); + let nodes = client + .get(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())? + .json::>() + .await + .map_err(|e| e.to_string())?; + Ok(nodes) +} + +#[tauri::command] +pub async fn add_relay_node( + name: String, + url: String, + region: Option, + priority: Option, + state: State<'_, AppState>, +) -> Result { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let api_url = format!("{}/api/v1/relay/nodes", state.get_server_url()); + let resp = client + .post(&api_url) + .header("Authorization", token) + .json(&serde_json::json!({ "name": name, "url": url, "region": region, "priority": priority })) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + let err: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(err["error"].as_str().unwrap_or("failed").to_string()); + } + let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + let node_id = body["id"].as_str().unwrap_or("").to_string(); + // Return a DTO with what we know + Ok(RelayNodeDto { + id: node_id, + name, + url, + region: region.unwrap_or_else(|| "auto".into()), + is_active: true, + priority: priority.unwrap_or(0), + last_ping_ms: None, + }) +} + +#[tauri::command] +pub async fn remove_relay_node( + node_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/relay/nodes/{}", state.get_server_url(), node_id); + client + .delete(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn report_relay_ping( + node_id: String, + ping_ms: i32, + state: State<'_, AppState>, +) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/relay/nodes/{}/ping", state.get_server_url(), node_id); + client + .post(&url) + .header("Authorization", token) + .json(&serde_json::json!({ "ping_ms": ping_ms })) + .send() + .await + .map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/client/src/commands/rooms.rs b/client/src/commands/rooms.rs new file mode 100644 index 0000000..f990009 --- /dev/null +++ b/client/src/commands/rooms.rs @@ -0,0 +1,187 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; +use uuid::Uuid; + +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoomDto { + pub id: Uuid, + pub name: String, + pub owner_id: Uuid, + pub owner_username: String, + pub max_players: i32, + pub current_players: i64, + pub is_public: bool, + pub has_password: bool, + pub game_version: String, + pub status: String, +} + +fn auth_header(state: &AppState) -> Result { + state + .get_access_token() + .map(|t| format!("Bearer {}", t)) + .ok_or_else(|| "not authenticated".into()) +} + +#[tauri::command] +pub async fn list_rooms(state: State<'_, AppState>) -> Result, String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/rooms", state.get_server_url()); + let resp = client + .get(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())? + .json::>() + .await + .map_err(|e| e.to_string())?; + Ok(resp) +} + +#[tauri::command] +pub async fn create_room( + name: String, + max_players: Option, + is_public: Option, + password: Option, + game_version: Option, + state: State<'_, AppState>, +) -> Result { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/rooms", state.get_server_url()); + let resp = client + .post(&url) + .header("Authorization", token) + .json(&serde_json::json!({ + "name": name, + "max_players": max_players, + "is_public": is_public, + "password": password, + "game_version": game_version, + })) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + let err: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(err["error"].as_str().unwrap_or("failed to create room").to_string()); + } + let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + let id = body["id"] + .as_str() + .and_then(|s| Uuid::parse_str(s).ok()) + .ok_or("invalid room id")?; + Ok(id) +} + +#[tauri::command] +pub async fn join_room( + room_id: Uuid, + password: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/rooms/{}/join", state.get_server_url(), room_id); + let resp = client + .post(&url) + .header("Authorization", token) + .json(&serde_json::json!({ "password": password })) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + let err: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(err["error"].as_str().unwrap_or("failed to join").to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn leave_room(room_id: Uuid, state: State<'_, AppState>) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/rooms/{}/leave", state.get_server_url(), room_id); + client + .post(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoomMemberDto { + pub user_id: Uuid, + pub username: String, + pub role: String, + pub is_online: bool, +} + +#[tauri::command] +pub async fn get_room_members(room_id: Uuid, state: State<'_, AppState>) -> Result, String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/rooms/{}/members", state.get_server_url(), room_id); + let resp = client + .get(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())? + .json::>() + .await + .map_err(|e| e.to_string())?; + Ok(resp) +} + +#[tauri::command] +pub async fn kick_room_member( + room_id: String, + target_user_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!( + "{}/api/v1/rooms/{}/members/{}", + state.get_server_url(), + room_id, + target_user_id + ); + let resp = client + .delete(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + let err: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(err["error"].as_str().unwrap_or("failed to kick member").to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn close_room(room_id: String, state: State<'_, AppState>) -> Result<(), String> { + let token = auth_header(&state)?; + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/rooms/{}", state.get_server_url(), room_id); + let resp = client + .delete(&url) + .header("Authorization", token) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + let err: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(err["error"].as_str().unwrap_or("failed to close room").to_string()); + } + Ok(()) +} diff --git a/client/src/commands/signaling.rs b/client/src/commands/signaling.rs new file mode 100644 index 0000000..9f0f34c --- /dev/null +++ b/client/src/commands/signaling.rs @@ -0,0 +1,72 @@ +use tauri::{AppHandle, State}; + +use crate::network::signaling::SignalingClient; +use crate::state::AppState; + +static mut SIGNALING_CLIENT: Option = None; + +#[tauri::command] +pub async fn connect_signaling( + state: State<'_, AppState>, + app: AppHandle, +) -> Result<(), String> { + let token = state + .get_access_token() + .ok_or_else(|| "not authenticated".to_string())?; + + let client = SignalingClient::connect(&state.get_server_url(), &token, app) + .await + .map_err(|e| e.to_string())?; + + unsafe { + SIGNALING_CLIENT = Some(client); + } + + tracing::info!("Signaling WebSocket connected"); + Ok(()) +} + +#[tauri::command] +pub async fn disconnect_signaling() -> Result<(), String> { + unsafe { + SIGNALING_CLIENT = None; + } + tracing::info!("Signaling WebSocket disconnected"); + Ok(()) +} + +#[tauri::command] +pub async fn send_signaling_message(message: String) -> Result<(), String> { + let msg: funmc_shared::protocol::SignalingMessage = + serde_json::from_str(&message).map_err(|e| e.to_string())?; + + unsafe { + if let Some(client) = &SIGNALING_CLIENT { + client.send(msg); + Ok(()) + } else { + Err("signaling not connected".into()) + } + } +} + +#[tauri::command] +pub async fn send_chat_message(room_id: String, content: String) -> Result<(), String> { + use uuid::Uuid; + use funmc_shared::protocol::SignalingMessage; + + let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?; + let msg = SignalingMessage::SendChat { + room_id: room_uuid, + content, + }; + + unsafe { + if let Some(client) = &SIGNALING_CLIENT { + client.send(msg); + Ok(()) + } else { + Err("signaling not connected".into()) + } + } +} diff --git a/client/src/config.rs b/client/src/config.rs new file mode 100644 index 0000000..51311ad --- /dev/null +++ b/client/src/config.rs @@ -0,0 +1,21 @@ +/// FunMC 客户端配置常量 + +pub const APP_NAME: &str = "FunMC"; +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const APP_AUTHOR: &str = "魔幻方"; + +pub const DEFAULT_API_SERVER: &str = "https://funmc.com"; +pub const DEFAULT_RELAY_HOST: &str = "funmc.com"; +pub const DEFAULT_RELAY_PORT: u16 = 7900; +pub const BACKUP_RELAY_PORT: u16 = 7901; + +pub const MC_DEFAULT_PORT: u16 = 25565; +pub const MC_PORT_RANGE: std::ops::Range = 25565..25576; + +pub const QUIC_PORT_RANGE: std::ops::Range = 34000..34100; + +pub const STUN_SERVER: &str = "stun.l.google.com:19302"; + +pub const P2P_TIMEOUT_SECS: u64 = 8; +pub const RELAY_TIMEOUT_SECS: u64 = 10; +pub const PING_TIMEOUT_SECS: u64 = 3; diff --git a/client/src/db.rs b/client/src/db.rs new file mode 100644 index 0000000..7a35898 --- /dev/null +++ b/client/src/db.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use sqlx::SqlitePool; +use tauri::{AppHandle, Manager}; + +pub async fn init(app: &AppHandle) -> Result<()> { + let app_dir = app.path().app_data_dir()?; + std::fs::create_dir_all(&app_dir)?; + let db_path = app_dir.join("funmc.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.display()); + + let pool = SqlitePool::connect(&db_url).await?; + sqlx::migrate!("./migrations/sqlite").run(&pool).await?; + + app.manage(pool); + Ok(()) +} diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..51e29e1 --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,73 @@ +pub mod commands; +pub mod config; +pub mod db; +pub mod network; +pub mod state; + +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::state::AppState; + +pub fn run() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG") + .unwrap_or_else(|_| "funmc_client_lib=debug".into()), + ), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let app_state = AppState::new(); + + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_notification::init()) + .manage(app_state) + .setup(|app| { + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = db::init(&handle).await { + tracing::error!("DB init error: {}", e); + } + }); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::auth::login, + commands::auth::register, + commands::auth::logout, + commands::auth::get_current_user, + commands::config::set_server_url, + commands::config::get_server_url, + commands::config::fetch_server_config, + commands::config::get_cached_server_config, + commands::friends::list_friends, + commands::friends::list_requests, + commands::friends::send_friend_request, + commands::friends::accept_friend_request, + commands::friends::remove_friend, + commands::rooms::list_rooms, + commands::rooms::create_room, + commands::rooms::join_room, + commands::rooms::leave_room, + commands::rooms::get_room_members, + commands::rooms::kick_room_member, + commands::rooms::close_room, + commands::network::start_hosting, + commands::network::join_room_network, + commands::network::get_connection_stats, + commands::network::stop_network, + commands::relay_nodes::list_relay_nodes, + commands::relay_nodes::add_relay_node, + commands::relay_nodes::remove_relay_node, + commands::relay_nodes::report_relay_ping, + commands::signaling::connect_signaling, + commands::signaling::disconnect_signaling, + commands::signaling::send_signaling_message, + commands::signaling::send_chat_message, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/client/src/main.rs b/client/src/main.rs new file mode 100644 index 0000000..f125487 --- /dev/null +++ b/client/src/main.rs @@ -0,0 +1,6 @@ +// Tauri entry point +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + funmc_client_lib::run(); +} diff --git a/client/src/network/lan_discovery.rs b/client/src/network/lan_discovery.rs new file mode 100644 index 0000000..2cb8bd7 --- /dev/null +++ b/client/src/network/lan_discovery.rs @@ -0,0 +1,88 @@ +/// Minecraft LAN server discovery spoofing +/// +/// MC uses UDP multicast 224.0.2.60:4445 to advertise LAN servers. +/// We listen on this address and also broadcast fake "LAN server" packets +/// so that Minecraft clients see the FunMC room as a local server. +/// +/// Broadcast format: "[MOTD][/MOTD][AD][/AD]" + +use anyhow::Result; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::UdpSocket; +use tokio::sync::Notify; + +const MC_MULTICAST_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 2, 60); +const MC_MULTICAST_PORT: u16 = 4445; +const BROADCAST_INTERVAL: Duration = Duration::from_secs(1); + +/// Start broadcasting a fake LAN server announcement +pub async fn broadcast_lan_server( + server_name: &str, + local_port: u16, + cancel: Arc, +) -> Result<()> { + let sock = UdpSocket::bind("0.0.0.0:0").await?; + sock.set_multicast_ttl_v4(1)?; + + let motd = format!("[MOTD]{}[/MOTD][AD]{}[/AD]", server_name, local_port); + let target = SocketAddr::new(IpAddr::V4(MC_MULTICAST_ADDR), MC_MULTICAST_PORT); + + tracing::info!("Broadcasting MC LAN server: {} on port {}", server_name, local_port); + + loop { + tokio::select! { + _ = cancel.notified() => { + tracing::info!("LAN broadcast stopped"); + break; + } + _ = tokio::time::sleep(BROADCAST_INTERVAL) => { + if let Err(e) = sock.send_to(motd.as_bytes(), target).await { + tracing::warn!("LAN broadcast send error: {}", e); + } + } + } + } + Ok(()) +} + +/// Listen for MC LAN server announcements from the multicast group +/// Returns parsed (name, port) pairs +pub async fn listen_lan_servers( + cancel: Arc, + tx: tokio::sync::mpsc::Sender<(String, u16)>, +) -> Result<()> { + let sock = UdpSocket::bind(("0.0.0.0", MC_MULTICAST_PORT)).await?; + sock.join_multicast_v4(MC_MULTICAST_ADDR, Ipv4Addr::UNSPECIFIED)?; + sock.set_multicast_loop_v4(false)?; + + let mut buf = [0u8; 1024]; + loop { + tokio::select! { + _ = cancel.notified() => break, + result = sock.recv_from(&mut buf) => { + if let Ok((n, _)) = result { + let text = String::from_utf8_lossy(&buf[..n]); + if let Some(parsed) = parse_motd(&text) { + let _ = tx.send(parsed).await; + } + } + } + } + } + Ok(()) +} + +fn parse_motd(text: &str) -> Option<(String, u16)> { + let name = extract_between(text, "[MOTD]", "[/MOTD]")?; + let port_str = extract_between(text, "[AD]", "[/AD]")?; + let port = port_str.parse::().ok()?; + Some((name.to_string(), port)) +} + +fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> { + let s = text.find(start)? + start.len(); + let e = text[s..].find(end)? + s; + Some(&text[s..e]) +} diff --git a/client/src/network/minecraft_proxy.rs b/client/src/network/minecraft_proxy.rs new file mode 100644 index 0000000..019d702 --- /dev/null +++ b/client/src/network/minecraft_proxy.rs @@ -0,0 +1,144 @@ +/// Minecraft TCP → QUIC tunnel proxy + +use anyhow::Result; +use quinn::Connection; +use std::net::SocketAddr; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Notify; +use std::sync::Arc; + +pub async fn start_client_proxy( + local_port: u16, + conn: Connection, + cancel: Arc, +) -> Result<()> { + let listener = TcpListener::bind(("127.0.0.1", local_port)).await?; + tracing::info!("MC proxy listening on 127.0.0.1:{}", local_port); + + loop { + tokio::select! { + _ = cancel.notified() => { + tracing::info!("MC proxy shutting down"); + break; + } + accept = listener.accept() => { + match accept { + Ok((tcp_stream, _peer)) => { + let conn2 = conn.clone(); + tokio::spawn(async move { + if let Err(e) = proxy_tcp_to_quic(tcp_stream, conn2).await { + tracing::debug!("proxy_tcp_to_quic: {}", e); + } + }); + } + Err(e) => { + tracing::error!("MC proxy accept error: {}", e); + break; + } + } + } + } + } + Ok(()) +} + +async fn proxy_tcp_to_quic(tcp: TcpStream, conn: Connection) -> Result<()> { + let (mut qs, mut qr) = conn.open_bi().await?; + let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp); + + let t1 = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + loop { + let n = tcp_r.read(&mut buf).await?; + if n == 0 { break; } + qs.write_all(&buf[..n]).await?; + } + let _ = qs.finish(); + Ok::<_, anyhow::Error>(()) + }); + + let t2 = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + loop { + let n = qr.read(&mut buf).await?.unwrap_or(0); + if n == 0 { break; } + tcp_w.write_all(&buf[..n]).await?; + } + Ok::<_, anyhow::Error>(()) + }); + + let _ = tokio::try_join!(t1, t2); + Ok(()) +} + +pub async fn start_host_proxy( + mc_server_addr: SocketAddr, + conn: Connection, + cancel: Arc, +) -> Result<()> { + tracing::info!("Host proxy forwarding QUIC streams to MC server at {}", mc_server_addr); + loop { + tokio::select! { + _ = cancel.notified() => break, + stream = conn.accept_bi() => { + match stream { + Ok((qs, qr)) => { + tokio::spawn(async move { + if let Err(e) = proxy_quic_to_mc(qs, qr, mc_server_addr).await { + tracing::debug!("proxy_quic_to_mc: {}", e); + } + }); + } + Err(e) => { + tracing::warn!("host proxy stream accept error: {}", e); + break; + } + } + } + } + } + Ok(()) +} + +async fn proxy_quic_to_mc( + mut qs: quinn::SendStream, + mut qr: quinn::RecvStream, + mc_addr: SocketAddr, +) -> Result<()> { + let tcp = TcpStream::connect(mc_addr).await?; + let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp); + + let t1 = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + loop { + let n = qr.read(&mut buf).await?.unwrap_or(0); + if n == 0 { break; } + tcp_w.write_all(&buf[..n]).await?; + } + Ok::<_, anyhow::Error>(()) + }); + + let t2 = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + loop { + let n = tcp_r.read(&mut buf).await?; + if n == 0 { break; } + qs.write_all(&buf[..n]).await?; + } + let _ = qs.finish(); + Ok::<_, anyhow::Error>(()) + }); + + let _ = tokio::try_join!(t1, t2); + Ok(()) +} + +pub async fn find_mc_port() -> u16 { + for port in 25565u16..=25575 { + if TcpListener::bind(("127.0.0.1", port)).await.is_ok() { + return port; + } + } + 25565 +} diff --git a/client/src/network/mod.rs b/client/src/network/mod.rs new file mode 100644 index 0000000..093852c --- /dev/null +++ b/client/src/network/mod.rs @@ -0,0 +1,9 @@ +pub mod lan_discovery; +pub mod minecraft_proxy; +pub mod nat; +pub mod p2p; +pub mod quic; +pub mod relay; +pub mod relay_selector; +pub mod session; +pub mod signaling; diff --git a/client/src/network/nat.rs b/client/src/network/nat.rs new file mode 100644 index 0000000..0b9002e --- /dev/null +++ b/client/src/network/nat.rs @@ -0,0 +1,149 @@ +/// NAT type detection via STUN (RFC 5389) +/// Uses stun.l.google.com:19302 to discover public IP and port + +use anyhow::{anyhow, Result}; +use std::net::SocketAddr; +use std::time::Duration; +use tokio::net::UdpSocket; + +const STUN_SERVER: &str = "stun.l.google.com:19302"; +const STUN_BINDING_REQUEST: u16 = 0x0001; +const STUN_MAGIC_COOKIE: u32 = 0x2112A442; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +pub enum NatType { + None, // Direct internet connection + FullCone, // Any external host can send to our mapped addr + RestrictedCone, // External must receive from us first (IP) + PortRestricted, // External must receive from us first (IP+port) + Symmetric, // Different mapping per destination (hardest to pierce) + Unknown, +} + +#[derive(Debug, Clone)] +pub struct StunResult { + pub public_addr: SocketAddr, + pub nat_type: NatType, +} + +/// Build a minimal STUN Binding Request packet +fn build_binding_request(transaction_id: &[u8; 12]) -> Vec { + let mut pkt = Vec::with_capacity(20); + pkt.extend_from_slice(&STUN_BINDING_REQUEST.to_be_bytes()); + pkt.extend_from_slice(&0u16.to_be_bytes()); // length + pkt.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes()); + pkt.extend_from_slice(transaction_id); + pkt +} + +/// Parse XOR-MAPPED-ADDRESS or MAPPED-ADDRESS from STUN response +fn parse_mapped_address(data: &[u8]) -> Option { + if data.len() < 20 { + return None; + } + let msg_len = u16::from_be_bytes([data[2], data[3]]) as usize; + let mut pos = 20; + while pos + 4 <= 20 + msg_len { + let attr_type = u16::from_be_bytes([data[pos], data[pos + 1]]); + let attr_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize; + pos += 4; + if pos + attr_len > data.len() { + break; + } + let attr_data = &data[pos..pos + attr_len]; + match attr_type { + // XOR-MAPPED-ADDRESS (0x0020) + 0x0020 => { + if attr_data.len() >= 8 && attr_data[1] == 0x01 { + let xport = u16::from_be_bytes([attr_data[2], attr_data[3]]) + ^ (STUN_MAGIC_COOKIE >> 16) as u16; + let xip = u32::from_be_bytes([attr_data[4], attr_data[5], attr_data[6], attr_data[7]]) + ^ STUN_MAGIC_COOKIE; + let ip = std::net::Ipv4Addr::from(xip); + return Some(SocketAddr::from((ip, xport))); + } + } + // MAPPED-ADDRESS (0x0001) + 0x0001 => { + if attr_data.len() >= 8 && attr_data[1] == 0x01 { + let port = u16::from_be_bytes([attr_data[2], attr_data[3]]); + let ip = std::net::Ipv4Addr::new(attr_data[4], attr_data[5], attr_data[6], attr_data[7]); + return Some(SocketAddr::from((ip, port))); + } + } + _ => {} + } + pos += attr_len; + // 4-byte alignment + if attr_len % 4 != 0 { + pos += 4 - (attr_len % 4); + } + } + None +} + +/// Send one STUN binding request and return the mapped address +pub async fn stun_query(sock: &UdpSocket, stun_addr: &str) -> Result { + let tid: [u8; 12] = rand::random(); + let pkt = build_binding_request(&tid); + + let addrs: Vec = tokio::net::lookup_host(stun_addr).await?.collect(); + let server = addrs.into_iter().next().ok_or_else(|| anyhow!("STUN server DNS failed"))?; + + sock.send_to(&pkt, server).await?; + + let mut buf = [0u8; 512]; + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(anyhow!("STUN timeout")); + } + match tokio::time::timeout(remaining, sock.recv_from(&mut buf)).await { + Ok(Ok((n, _))) => { + if let Some(addr) = parse_mapped_address(&buf[..n]) { + return Ok(addr); + } + } + _ => return Err(anyhow!("STUN failed")), + } + } +} + +/// Perform basic NAT detection: get public IP and classify NAT type +pub async fn detect_nat() -> Result { + // Bind a local UDP socket + let sock = UdpSocket::bind("0.0.0.0:0").await?; + + // Query primary STUN server + let mapped1 = stun_query(&sock, STUN_SERVER).await?; + + // Query again to same server to check if mapping is stable + let mapped2 = stun_query(&sock, STUN_SERVER).await?; + + let nat_type = if mapped1 == mapped2 { + // Same port from same destination = Full Cone or Restricted (simplified) + // Deep detection would require a second STUN server, omitted for now + NatType::FullCone + } else { + NatType::Symmetric + }; + + Ok(StunResult { + public_addr: mapped1, + nat_type, + }) +} + +/// Get local LAN addresses +pub fn get_local_addrs(port: u16) -> Vec { + let mut addrs = Vec::new(); + if let Ok(ifaces) = local_ip_address::list_afinet_netifas() { + for (_, ip) in ifaces { + if !ip.is_loopback() { + addrs.push(SocketAddr::new(ip, port)); + } + } + } + addrs +} diff --git a/client/src/network/p2p.rs b/client/src/network/p2p.rs new file mode 100644 index 0000000..1ee8da9 --- /dev/null +++ b/client/src/network/p2p.rs @@ -0,0 +1,129 @@ +/// P2P UDP hole punching using QUIC (quinn) +/// +/// Algorithm: +/// 1. Both peers bind a UDP socket and discover their public address via STUN +/// 2. Exchange PeerInfo (public addr + local addrs) through the signaling server +/// 3. Send simultaneous UDP probes to pierce NAT holes +/// 4. Attempt QUIC connection to peer's public + local addresses +/// 5. First successful connection wins + +use anyhow::{anyhow, Result}; +use funmc_shared::protocol::PeerInfo; +use quinn::{Connection, Endpoint}; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::timeout; +use uuid::Uuid; + +use crate::network::nat::{detect_nat, get_local_addrs, NatType}; + +pub const QUIC_P2P_PORT_RANGE: std::ops::Range = 34000..34100; + +/// Find a free UDP port in our P2P range +pub async fn find_quic_port() -> Result { + for port in QUIC_P2P_PORT_RANGE { + if tokio::net::UdpSocket::bind(("0.0.0.0", port)).await.is_ok() { + return Ok(port); + } + } + // Fall back to OS-assigned + let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await?; + Ok(sock.local_addr()?.port()) +} + +/// Attempt P2P hole punch. Returns the QUIC Connection and endpoint on success. +pub async fn attempt_p2p( + my_user_id: Uuid, + peer_info: PeerInfo, + endpoint: Arc, + my_port: u16, +) -> Result { + // Collect all candidate addresses to try + let mut candidates: Vec = Vec::new(); + + // Parse peer's public address + if let Ok(addr) = peer_info.public_addr.parse::() { + candidates.push(addr); + } + for local_addr_str in &peer_info.local_addrs { + if let Ok(addr) = local_addr_str.parse::() { + candidates.push(addr); + } + } + + tracing::info!("P2P: trying {} candidates for peer {}", candidates.len(), peer_info.user_id); + + // Try all candidates concurrently with a 5 second total timeout + let ep = endpoint.clone(); + let result = timeout(Duration::from_secs(8), async move { + let mut handles = Vec::new(); + for addr in candidates { + let ep2 = ep.clone(); + handles.push(tokio::spawn(async move { + // Send a few UDP probes first to open NAT hole + for _ in 0..3 { + let _ = ep2.connect(addr, "funmc"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + // Attempt real QUIC connection + match ep2.connect(addr, "funmc") { + Ok(connecting) => { + match timeout(Duration::from_secs(3), connecting).await { + Ok(Ok(conn)) => Some(conn), + _ => None, + } + } + Err(_) => None, + } + })); + } + + for handle in handles { + if let Ok(Some(conn)) = handle.await { + return Some(conn); + } + } + None + }).await; + + match result { + Ok(Some(conn)) => { + tracing::info!("P2P connection established with {}", peer_info.user_id); + Ok(conn) + } + _ => { + tracing::warn!("P2P failed with {}, will use relay", peer_info.user_id); + Err(anyhow!("P2P hole punch failed")) + } + } +} + +/// Collect our own PeerInfo for signaling exchange +pub async fn build_my_peer_info(user_id: Uuid, port: u16) -> PeerInfo { + let (public_addr, nat_type) = match detect_nat().await { + Ok(r) => (r.public_addr.to_string(), r.nat_type), + Err(_) => (format!("0.0.0.0:{}", port), crate::network::nat::NatType::Unknown), + }; + + let nat_type_shared = match nat_type { + NatType::None => funmc_shared::protocol::NatType::None, + NatType::FullCone => funmc_shared::protocol::NatType::FullCone, + NatType::RestrictedCone => funmc_shared::protocol::NatType::RestrictedCone, + NatType::PortRestricted => funmc_shared::protocol::NatType::PortRestrictedCone, + NatType::Symmetric => funmc_shared::protocol::NatType::Symmetric, + NatType::Unknown => funmc_shared::protocol::NatType::Unknown, + }; + + let local_addrs: Vec = get_local_addrs(port) + .into_iter() + .map(|a| a.to_string()) + .collect(); + + PeerInfo { + user_id, + public_addr, + local_addrs, + nat_type: nat_type_shared, + } +} diff --git a/client/src/network/quic.rs b/client/src/network/quic.rs new file mode 100644 index 0000000..513b14c --- /dev/null +++ b/client/src/network/quic.rs @@ -0,0 +1,99 @@ +/// QUIC endpoint management using quinn +/// Provides a shared QUIC endpoint for both P2P and relay connections + +use anyhow::Result; +use quinn::{ClientConfig, Endpoint, ServerConfig}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use std::net::SocketAddr; +use std::sync::Arc; + +/// Build a self-signed TLS certificate for QUIC +pub fn make_self_signed() -> Result<(Vec>, PrivateKeyDer<'static>)> { + let cert = rcgen::generate_simple_self_signed(vec!["funmc".to_string()])?; + let cert_der = CertificateDer::from(cert.cert); + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der())); + Ok((vec![cert_der], key_der)) +} + +/// Create a QUIC ServerConfig with a self-signed cert +pub fn make_server_config() -> Result { + let (certs, key) = make_self_signed()?; + let mut tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key)?; + tls.alpn_protocols = vec![b"funmc".to_vec()]; + let mut sc = ServerConfig::with_crypto(Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(tls)?, + )); + // Tune transport params for game traffic + let mut transport = quinn::TransportConfig::default(); + transport.max_idle_timeout(Some(std::time::Duration::from_secs(30).try_into()?)); + transport.keep_alive_interval(Some(std::time::Duration::from_secs(5))); + sc.transport_config(Arc::new(transport)); + Ok(sc) +} + +/// Create a QUIC ClientConfig that accepts any self-signed cert (P2P peers) +pub fn make_client_config_insecure() -> ClientConfig { + let crypto = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyCert)) + .with_no_client_auth(); + let mut cc = ClientConfig::new(Arc::new( + quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap(), + )); + let mut transport = quinn::TransportConfig::default(); + transport.max_idle_timeout(Some(std::time::Duration::from_secs(30).try_into().unwrap())); + transport.keep_alive_interval(Some(std::time::Duration::from_secs(5))); + cc.transport_config(Arc::new(transport)); + cc +} + +/// Open a QUIC endpoint bound to the given address +pub fn open_endpoint(bind_addr: SocketAddr) -> Result { + let sc = make_server_config()?; + let mut ep = Endpoint::server(sc, bind_addr)?; + ep.set_default_client_config(make_client_config_insecure()); + Ok(ep) +} + +// ---- TLS cert verifier that accepts anything (P2P peers use self-signed) ---- +#[derive(Debug)] +struct AcceptAnyCert; + +impl rustls::client::danger::ServerCertVerifier for AcceptAnyCert { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer, + _intermediates: &[CertificateDer], + _server_name: &rustls::pki_types::ServerName, + _ocsp: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> std::result::Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer, + _dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer, + _dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} diff --git a/client/src/network/relay.rs b/client/src/network/relay.rs new file mode 100644 index 0000000..0a98668 --- /dev/null +++ b/client/src/network/relay.rs @@ -0,0 +1,68 @@ +/// QUIC relay client — used when P2P hole punch fails + +use anyhow::{anyhow, Result}; +use quinn::Connection; +use std::net::SocketAddr; +use std::time::Duration; +use tokio::time::timeout; +use uuid::Uuid; + +use crate::network::quic::open_endpoint; +use crate::network::relay_selector::DEFAULT_RELAY_PORT; + +/// Connect to a relay server's QUIC endpoint +pub async fn connect_relay( + relay_url: &str, + room_id: Uuid, + access_token: &str, +) -> Result { + let (host, port) = parse_relay_url(relay_url); + + let relay_addr: SocketAddr = { + let addrs: Vec = tokio::net::lookup_host(format!("{}:{}", host, port)) + .await? + .collect(); + addrs.into_iter().next().ok_or_else(|| anyhow!("relay DNS failed"))? + }; + + let ep = open_endpoint("0.0.0.0:0".parse()?)?; + + tracing::info!("Connecting to relay at {}", relay_addr); + let connecting = ep.connect(relay_addr, &host)?; + let conn = timeout(Duration::from_secs(10), connecting).await + .map_err(|_| anyhow!("relay connection timed out"))? + .map_err(|e| anyhow!("relay QUIC error: {}", e))?; + + // Send auth handshake on a unidirectional stream using write_chunk + let mut stream = conn.open_uni().await?; + let handshake = serde_json::json!({ + "token": access_token, + "room_id": room_id.to_string(), + }); + let msg = serde_json::to_vec(&handshake)?; + let len = (msg.len() as u32).to_be_bytes(); + stream.write_chunk(bytes::Bytes::copy_from_slice(&len)).await + .map_err(|e| anyhow!("{}", e))?; + stream.write_chunk(bytes::Bytes::from(msg)).await + .map_err(|e| anyhow!("{}", e))?; + let _ = stream.finish(); + let _ = stream.stopped().await; + + tracing::info!("Relay handshake sent for room {}", room_id); + Ok(conn) +} + +fn parse_relay_url(url: &str) -> (String, u16) { + let cleaned = url + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_start_matches("quic://"); + + if let Some((host, port_str)) = cleaned.rsplit_once(':') { + if let Ok(port) = port_str.parse::() { + return (host.to_string(), port); + } + } + + (cleaned.to_string(), DEFAULT_RELAY_PORT) +} diff --git a/client/src/network/relay_selector.rs b/client/src/network/relay_selector.rs new file mode 100644 index 0000000..2afc276 --- /dev/null +++ b/client/src/network/relay_selector.rs @@ -0,0 +1,118 @@ +/// Relay node selection — client-side multi-node support +/// Fetches relay nodes from server, measures latency, picks lowest RTT + +use anyhow::Result; +use std::net::ToSocketAddrs; +use std::time::{Duration, Instant}; + +pub const DEFAULT_RELAY_PORT: u16 = 7900; +pub const BACKUP_RELAY_PORT: u16 = 7901; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RelayNode { + pub id: String, + pub name: String, + pub url: String, + pub region: String, + pub priority: i32, + pub last_ping_ms: Option, +} + +impl RelayNode { + pub fn from_server_url(server_url: &str) -> Self { + let relay_url = extract_relay_url(server_url); + Self { + id: "server".into(), + name: "服务器节点".into(), + url: relay_url, + region: "auto".into(), + priority: 100, + last_ping_ms: None, + } + } + + pub fn from_relay_url(relay_url: &str) -> Self { + Self { + id: "configured".into(), + name: "配置节点".into(), + url: relay_url.to_string(), + region: "auto".into(), + priority: 100, + last_ping_ms: None, + } + } + + pub fn parse_addr(&self) -> Option { + self.url.to_socket_addrs().ok()?.next() + } +} + +fn extract_relay_url(server_url: &str) -> String { + let host = server_url + .trim_start_matches("https://") + .trim_start_matches("http://") + .split(':') + .next() + .unwrap_or("localhost"); + format!("{}:{}", host, DEFAULT_RELAY_PORT) +} + +/// Fetch available relay nodes from server and return sorted by latency +pub async fn fetch_best_node(server_url: &str, token: &str) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/api/v1/relay/nodes", server_url); + + let default_node = RelayNode::from_server_url(server_url); + + let nodes: Vec = match client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .timeout(Duration::from_secs(5)) + .send() + .await + { + Ok(resp) => resp.json().await.unwrap_or_else(|_| vec![default_node.clone()]), + Err(_) => vec![default_node.clone()], + }; + + let nodes = if nodes.is_empty() { vec![default_node.clone()] } else { nodes }; + + let mut best: Option<(RelayNode, u128)> = None; + let mut handles = vec![]; + + for node in nodes.clone() { + handles.push(tokio::spawn(async move { + let rtt = ping_relay_node(&node).await; + (node, rtt) + })); + } + + for handle in handles { + if let Ok((node, Some(rtt))) = handle.await { + if best.is_none() || rtt < best.as_ref().unwrap().1 { + best = Some((node, rtt)); + } + } + } + + Ok(best + .map(|(n, _)| n) + .unwrap_or_else(|| nodes.into_iter().next().unwrap_or(default_node))) +} + +async fn ping_relay_node(node: &RelayNode) -> Option { + let addr = node.parse_addr()?; + let start = Instant::now(); + + let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?; + socket.connect(addr).await.ok()?; + + let ping_data = b"FUNMC_PING"; + socket.send(ping_data).await.ok()?; + + let mut buf = [0u8; 32]; + match tokio::time::timeout(Duration::from_secs(3), socket.recv(&mut buf)).await { + Ok(Ok(_)) => Some(start.elapsed().as_millis()), + _ => None, + } +} diff --git a/client/src/network/session.rs b/client/src/network/session.rs new file mode 100644 index 0000000..29a635f --- /dev/null +++ b/client/src/network/session.rs @@ -0,0 +1,42 @@ +// Network session state held in AppState +use std::sync::Arc; +use tokio::sync::Mutex; +use uuid::Uuid; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ConnectionStats { + pub session_type: String, + pub latency_ms: u32, + pub bytes_sent: u64, + pub bytes_received: u64, + pub connected: bool, +} + +pub struct NetworkSession { + pub room_id: Uuid, + pub local_port: u16, + pub is_host: bool, + pub session_type: String, // "p2p" | "relay" + pub stats: Arc>, + /// Cancel token: drop to stop all tasks + pub cancel: Arc, +} + +impl NetworkSession { + pub fn new(room_id: Uuid, local_port: u16, is_host: bool, session_type: &str) -> Self { + Self { + room_id, + local_port, + is_host, + session_type: session_type.to_string(), + stats: Arc::new(Mutex::new(ConnectionStats { + session_type: session_type.to_string(), + latency_ms: 0, + bytes_sent: 0, + bytes_received: 0, + connected: false, + })), + cancel: Arc::new(tokio::sync::Notify::new()), + } + } +} diff --git a/client/src/network/signaling.rs b/client/src/network/signaling.rs new file mode 100644 index 0000000..abb8a5c --- /dev/null +++ b/client/src/network/signaling.rs @@ -0,0 +1,73 @@ +/// WebSocket signaling client +/// Connects to the server's /api/v1/ws endpoint and handles incoming messages + +use anyhow::Result; +use futures_util::{SinkExt, StreamExt}; +use funmc_shared::protocol::SignalingMessage; +use tauri::{AppHandle, Emitter}; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +pub struct SignalingClient { + pub tx: mpsc::UnboundedSender, +} + +impl SignalingClient { + /// Spawn signaling connection in background, forward incoming events to Tauri + pub async fn connect(server_url: &str, token: &str, app: AppHandle) -> Result { + let ws_url = server_url + .replace("https://", "wss://") + .replace("http://", "ws://"); + let ws_url = format!("{}/api/v1/ws?token={}", ws_url, token); + + let (ws_stream, _) = connect_async(&ws_url).await?; + let (mut ws_tx, mut ws_rx) = ws_stream.split(); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Forward outgoing messages to WebSocket + tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if let Ok(json) = serde_json::to_string(&msg) { + if ws_tx.send(Message::Text(json)).await.is_err() { + break; + } + } + } + }); + + // Receive incoming messages and emit Tauri events + tokio::spawn(async move { + while let Some(Ok(msg)) = ws_rx.next().await { + if let Message::Text(text) = msg { + if let Ok(signal) = serde_json::from_str::(&text) { + let event_name = match &signal { + SignalingMessage::FriendRequest { .. } => "signaling:friend_request", + SignalingMessage::FriendAccepted { .. } => "signaling:friend_accepted", + SignalingMessage::RoomInvite { .. } => "signaling:room_invite", + SignalingMessage::MemberJoined { .. } => "signaling:member_joined", + SignalingMessage::MemberLeft { .. } => "signaling:member_left", + SignalingMessage::Kicked { .. } => "signaling:kicked", + SignalingMessage::RoomClosed { .. } => "signaling:room_closed", + SignalingMessage::UserOnline { .. } => "signaling:user_online", + SignalingMessage::UserOffline { .. } => "signaling:user_offline", + SignalingMessage::ChatMessage { .. } => "signaling:chat_message", + SignalingMessage::Offer { .. } + | SignalingMessage::Answer { .. } + | SignalingMessage::IceCandidate { .. } => "signaling:network", + _ => continue, + }; + let payload = serde_json::to_value(&signal).unwrap_or_default(); + let _ = app.emit(event_name, payload); + } + } + } + }); + + Ok(Self { tx }) + } + + pub fn send(&self, msg: SignalingMessage) { + let _ = self.tx.send(msg); + } +} diff --git a/client/src/state.rs b/client/src/state.rs new file mode 100644 index 0000000..cad88de --- /dev/null +++ b/client/src/state.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::network::session::NetworkSession; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrentUser { + pub id: Uuid, + pub username: String, + pub email: String, + pub avatar_seed: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tokens { + pub access_token: String, + pub refresh_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ServerConfig { + pub server_name: String, + pub server_url: String, + pub relay_url: String, + #[serde(default)] + pub version: String, +} + +pub struct AppState { + pub server_url: Mutex, + pub server_config: Mutex>, + pub user: Mutex>, + pub tokens: Mutex>, + pub network_session: Arc>>, +} + +impl AppState { + pub fn new() -> Self { + let server_url = std::env::var("FUNMC_SERVER") + .unwrap_or_else(|_| "http://localhost:3000".into()); + Self { + server_url: Mutex::new(server_url), + server_config: Mutex::new(None), + user: Mutex::new(None), + tokens: Mutex::new(None), + network_session: Arc::new(RwLock::new(None)), + } + } + + pub fn get_server_url(&self) -> String { + self.server_url.lock().unwrap().clone() + } + + pub fn set_server_url(&self, url: String) { + *self.server_url.lock().unwrap() = url; + } + + pub fn get_access_token(&self) -> Option { + self.tokens.lock().ok()?.as_ref().map(|t| t.access_token.clone()) + } + + pub fn set_auth(&self, user: CurrentUser, tokens: Tokens) { + *self.user.lock().unwrap() = Some(user); + *self.tokens.lock().unwrap() = Some(tokens); + } + + pub fn clear_auth(&self) { + *self.user.lock().unwrap() = None; + *self.tokens.lock().unwrap() = None; + } + + pub fn get_user_id(&self) -> Option { + self.user.lock().ok()?.as_ref().map(|u| u.id) + } +} diff --git a/client/tauri.conf.json b/client/tauri.conf.json new file mode 100644 index 0000000..692d149 --- /dev/null +++ b/client/tauri.conf.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "FunMC", + "version": "0.1.0", + "identifier": "com.funmc.app", + "build": { + "frontendDist": "../ui/dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "cd ui && npm run dev", + "beforeBuildCommand": "cd ui && npm run build" + }, + "app": { + "withGlobalTauri": false, + "windows": [ + { + "label": "main", + "title": "FunMC - Minecraft 联机助手", + "width": 1100, + "height": 700, + "minWidth": 900, + "minHeight": 600, + "resizable": true, + "decorations": true, + "center": true, + "transparent": false + } + ], + "security": { + "csp": null + } + }, + "ios": { + "developmentTeam": null, + "minimumSystemVersion": "13.0" + }, + "android": { + "minSdkVersion": 24, + "targetSdkVersion": 34 + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "publisher": "魔幻方", + "copyright": "© 2024 魔幻方. All rights reserved.", + "category": "Game", + "shortDescription": "Minecraft 联机助手", + "longDescription": "FunMC 是一款由魔幻方开发的 Minecraft 联机工具,支持 P2P 直连和中继服务器,让 Minecraft 联机变得简单。", + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": null, + "wix": null, + "nsis": { + "installerIcon": "icons/icon.ico", + "headerImage": null, + "sidebarImage": null, + "installMode": "currentUser", + "languages": ["SimpChinese", "English"], + "displayLanguageSelector": true + } + }, + "macOS": { + "entitlements": null, + "exceptionDomain": "", + "frameworks": [], + "providerShortName": null, + "signingIdentity": null, + "minimumSystemVersion": "10.13" + }, + "linux": { + "appimage": { + "bundleMediaFramework": true + }, + "deb": { + "depends": [] + }, + "rpm": { + "depends": [] + } + } + }, + "plugins": { + "shell": { + "all": false, + "open": true + }, + "notification": { + "all": true + } + } +} diff --git a/client/ui/index.html b/client/ui/index.html new file mode 100644 index 0000000..6203ab7 --- /dev/null +++ b/client/ui/index.html @@ -0,0 +1,19 @@ + + + + + + + FunMC + + + + + +
+ + + diff --git a/client/ui/package.json b/client/ui/package.json new file mode 100644 index 0000000..d7a2535 --- /dev/null +++ b/client/ui/package.json @@ -0,0 +1,36 @@ +{ + "name": "funmc-client-ui", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-notification": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.3", + "zustand": "^4.5.2", + "clsx": "^2.1.0", + "tailwind-merge": "^2.2.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5", + "vite": "^5.2.10" + } +} diff --git a/client/ui/postcss.config.js b/client/ui/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/client/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/client/ui/src/components/AppLayout.tsx b/client/ui/src/components/AppLayout.tsx new file mode 100644 index 0000000..71d48ac --- /dev/null +++ b/client/ui/src/components/AppLayout.tsx @@ -0,0 +1,218 @@ +import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom' +import { useAuthStore } from '../stores/authStore' +import { useState } from 'react' + +const navItems = [ + { + to: '/', + label: '大厅', + icon: ( + + + + + ), + }, + { + to: '/friends', + label: '好友', + icon: ( + + + + + + + ), + }, + { + to: '/settings', + label: '设置', + icon: ( + + + + + ), + }, +] + +export function AppLayout() { + const { user, logout } = useAuthStore() + const navigate = useNavigate() + const location = useLocation() + const [showUserMenu, setShowUserMenu] = useState(false) + + const handleLogout = async () => { + await logout() + navigate('/login') + } + + const avatarColor = user?.avatar_seed + ? `hsl(${parseInt(user.avatar_seed.slice(0, 8), 16) % 360}, 60%, 50%)` + : '#4ade80' + + const isInRoom = location.pathname.startsWith('/room/') + + return ( +
+ {/* Desktop Sidebar - hidden on mobile */} + + + {/* Mobile Header - shown only on mobile */} +
+
+
+ + + +
+ FunMC +
+ + {/* User avatar button */} +
+ + + {/* User dropdown menu */} + {showUserMenu && ( + <> +
setShowUserMenu(false)} + /> +
+
+

{user?.username}

+

{user?.email}

+
+ +
+ + )} +
+
+ + {/* Main content */} +
+ +
+ + {/* Mobile Bottom Navigation - hidden when in room or on desktop */} + {!isInRoom && ( + + )} +
+ ) +} + +export default AppLayout diff --git a/client/ui/src/components/Avatar.tsx b/client/ui/src/components/Avatar.tsx new file mode 100644 index 0000000..e6beed8 --- /dev/null +++ b/client/ui/src/components/Avatar.tsx @@ -0,0 +1,110 @@ +import { cn, generateAvatarColor, getInitials } from '../lib/utils'; + +interface AvatarProps { + seed: string; + name: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; + className?: string; + showOnline?: boolean; + isOnline?: boolean; +} + +export function Avatar({ + seed, + name, + size = 'md', + className, + showOnline = false, + isOnline = false, +}: AvatarProps) { + const sizeClasses = { + sm: 'w-6 h-6 text-xs', + md: 'w-8 h-8 text-sm', + lg: 'w-10 h-10 text-base', + xl: 'w-12 h-12 text-lg', + }; + + const dotSizeClasses = { + sm: 'w-1.5 h-1.5 -bottom-0 -right-0', + md: 'w-2 h-2 -bottom-0.5 -right-0.5', + lg: 'w-2.5 h-2.5 -bottom-0.5 -right-0.5', + xl: 'w-3 h-3 -bottom-1 -right-1', + }; + + const bgColor = generateAvatarColor(seed); + const initials = getInitials(name); + + return ( +
+
+ {initials} +
+ {showOnline && ( + + )} +
+ ); +} + +interface AvatarGroupProps { + users: Array<{ seed: string; name: string }>; + max?: number; + size?: 'sm' | 'md' | 'lg'; +} + +export function AvatarGroup({ users, max = 4, size = 'md' }: AvatarGroupProps) { + const displayed = users.slice(0, max); + const remaining = users.length - max; + + const overlapClasses = { + sm: '-ml-2', + md: '-ml-3', + lg: '-ml-4', + }; + + const sizeClasses = { + sm: 'w-6 h-6 text-xs', + md: 'w-8 h-8 text-sm', + lg: 'w-10 h-10 text-base', + }; + + return ( +
+ {displayed.map((user, index) => ( +
0 && overlapClasses[size] + )} + style={{ zIndex: displayed.length - index }} + > + +
+ ))} + {remaining > 0 && ( +
+ +{remaining} +
+ )} +
+ ); +} diff --git a/client/ui/src/components/ConnectionStatus.tsx b/client/ui/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..6ecaada --- /dev/null +++ b/client/ui/src/components/ConnectionStatus.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react' +import { useConfigStore } from '../stores/configStore' + +type ConnectionState = 'connected' | 'connecting' | 'disconnected' | 'error' + +interface ConnectionStatusProps { + showDetails?: boolean +} + +export function ConnectionStatus({ showDetails = false }: ConnectionStatusProps) { + const { config } = useConfigStore() + const [status, setStatus] = useState('connecting') + const [latency, setLatency] = useState(null) + + useEffect(() => { + const checkConnection = async () => { + if (!config?.server_url) { + setStatus('disconnected') + return + } + + try { + const start = performance.now() + const response = await fetch(`${config.server_url}/api/v1/health`, { + signal: AbortSignal.timeout(5000), + }) + const elapsed = Math.round(performance.now() - start) + + if (response.ok) { + setStatus('connected') + setLatency(elapsed) + } else { + setStatus('error') + setLatency(null) + } + } catch { + setStatus('disconnected') + setLatency(null) + } + } + + checkConnection() + const interval = setInterval(checkConnection, 30000) + return () => clearInterval(interval) + }, [config?.server_url]) + + const statusConfig = { + connected: { + color: 'bg-green-500', + text: '已连接', + icon: '🟢', + }, + connecting: { + color: 'bg-yellow-500 animate-pulse', + text: '连接中', + icon: '🟡', + }, + disconnected: { + color: 'bg-red-500', + text: '未连接', + icon: '🔴', + }, + error: { + color: 'bg-orange-500', + text: '连接异常', + icon: '🟠', + }, + } + + const { color, text, icon } = statusConfig[status] + + if (!showDetails) { + return ( +
+
+ {text} +
+ ) + } + + return ( +
+ {icon} +
+

{config?.server_name || 'FunMC'}

+

{config?.server_url}

+
+
+

{text}

+ {latency !== null && ( +

+ {latency}ms +

+ )} +
+
+ ) +} diff --git a/client/ui/src/components/CreateRoomModal.tsx b/client/ui/src/components/CreateRoomModal.tsx new file mode 100644 index 0000000..7c23438 --- /dev/null +++ b/client/ui/src/components/CreateRoomModal.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Modal } from './Modal'; +import { useRoomStore } from '../stores/roomStore'; +import { useToast } from './Toast'; + +const VERSIONS = ['1.21.4', '1.21.3', '1.21', '1.20.4', '1.20.1', '1.19.4', '1.18.2', '1.16.5', '1.12.2']; + +export function CreateRoomModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const { createRoom, loading } = useRoomStore(); + const { showToast } = useToast(); + const [form, setForm] = useState({ name: '', password: '', max_players: 8, game_version: '1.21.4', is_public: true }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.name.trim()) { showToast('请输入房间名称', 'error'); return; } + try { + await createRoom({ name: form.name.trim(), password: form.password || undefined, max_players: form.max_players, game_version: form.game_version, is_public: form.is_public }); + showToast('房间创建成功', 'success'); + onClose(); + setForm({ name: '', password: '', max_players: 8, game_version: '1.21.4', is_public: true }); + } catch (err) { showToast(err instanceof Error ? err.message : '创建失败', 'error'); } + }; + + return ( + +
+
+ + setForm({ ...form, name: e.target.value })} className="input w-full" placeholder="输入房间名称" maxLength={50} autoFocus /> +
+
+ + setForm({ ...form, password: e.target.value })} className="input w-full" placeholder="留空表示无密码" /> +
+
+
+ + +
+
+ + +
+
+
+ setForm({ ...form, is_public: e.target.checked })} className="w-4 h-4 rounded" /> + +
+
+ + +
+
+
+ ); +} diff --git a/client/ui/src/components/EmptyState.tsx b/client/ui/src/components/EmptyState.tsx new file mode 100644 index 0000000..ebe5bbc --- /dev/null +++ b/client/ui/src/components/EmptyState.tsx @@ -0,0 +1,98 @@ +import { cn } from '../lib/utils'; + +interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + description?: string; + action?: React.ReactNode; + className?: string; +} + +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} + +export function NoRoomsState({ onCreate }: { onCreate?: () => void }) { + return ( + + + + + + + } + title="暂无公开房间" + description="创建第一个房间开始游戏,或等待其他玩家创建" + action={ + onCreate && ( + + ) + } + /> + ); +} + +export function NoFriendsState() { + return ( + + + + + + } + title="还没有好友" + description="在右侧输入用户名添加好友,一起联机游戏" + /> + ); +} + +export function NoRequestsState() { + return ( + + + + } + title="暂无好友请求" + description="当有人想添加你为好友时,请求会显示在这里" + /> + ); +} diff --git a/client/ui/src/components/ErrorBoundary.tsx b/client/ui/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..fd4232a --- /dev/null +++ b/client/ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,59 @@ +import { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo) + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( +
+
+
😵
+

出错了

+

+ 应用遇到了一个意外错误 +

+
+ + {this.state.error?.message || '未知错误'} + +
+ +
+
+ ) + } + + return this.props.children + } +} diff --git a/client/ui/src/components/FriendCard.tsx b/client/ui/src/components/FriendCard.tsx new file mode 100644 index 0000000..040a869 --- /dev/null +++ b/client/ui/src/components/FriendCard.tsx @@ -0,0 +1,121 @@ +import { Avatar } from './Avatar'; +import { cn } from '../lib/utils'; +import { Friend, FriendRequest } from '../stores/friendStore'; + +interface FriendCardProps { + friend: Friend; + onRemove?: () => void; + onInvite?: () => void; + className?: string; +} + +export function FriendCard({ friend, onRemove, onInvite, className }: FriendCardProps) { + return ( +
+ +
+

{friend.username}

+

{friend.is_online ? '在线' : '离线'}

+
+
+ {onInvite && friend.is_online && ( + + )} + {onRemove && ( + + )} +
+
+ ); +} + +interface FriendRequestCardProps { + request: FriendRequest; + onAccept: () => void; + onReject?: () => void; + className?: string; +} + +export function FriendRequestCard({ + request, + onAccept, + onReject, + className, +}: FriendRequestCardProps) { + return ( +
+ +
+

{request.username}

+

想加你为好友

+
+
+ {onReject && ( + + )} + +
+
+ ); +} diff --git a/client/ui/src/components/JoinRoomModal.tsx b/client/ui/src/components/JoinRoomModal.tsx new file mode 100644 index 0000000..a220f00 --- /dev/null +++ b/client/ui/src/components/JoinRoomModal.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { Modal } from './Modal'; +import { useRoomStore, Room } from '../stores/roomStore'; +import { useToast } from './Toast'; + +export function JoinRoomModal({ isOpen, onClose, room }: { isOpen: boolean; onClose: () => void; room: Room | null }) { + const { joinRoom, loading } = useRoomStore(); + const { showToast } = useToast(); + const [password, setPassword] = useState(''); + + if (!room) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (room.has_password && !password) { showToast('请输入房间密码', 'error'); return; } + try { + await joinRoom(room.id, password || undefined); + showToast('加入房间成功', 'success'); + onClose(); + setPassword(''); + } catch (err) { showToast(err instanceof Error ? err.message : '加入失败', 'error'); } + }; + + return ( + +
+
+

{room.name}

+

房主: {room.owner_username}

+
+ 玩家: {room.current_players}/{room.max_players} + 版本: {room.game_version} +
+
+ {room.has_password && ( +
+ + setPassword(e.target.value)} className="input w-full" placeholder="输入房间密码" autoFocus /> +
+ )} +
+ + +
+
+
+ ); +} diff --git a/client/ui/src/components/Loading.tsx b/client/ui/src/components/Loading.tsx new file mode 100644 index 0000000..0d128e1 --- /dev/null +++ b/client/ui/src/components/Loading.tsx @@ -0,0 +1,68 @@ +import { cn } from '../lib/utils'; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) { + const sizeClasses = { + sm: 'w-4 h-4 border-2', + md: 'w-6 h-6 border-2', + lg: 'w-8 h-8 border-3', + }; + + return ( +
+ ); +} + +interface LoadingOverlayProps { + message?: string; +} + +export function LoadingOverlay({ message = '加载中...' }: LoadingOverlayProps) { + return ( +
+
+ +

{message}

+
+
+ ); +} + +interface LoadingCardProps { + className?: string; +} + +export function LoadingCard({ className }: LoadingCardProps) { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +export function LoadingPage() { + return ( +
+
+ +

加载中...

+
+
+ ); +} diff --git a/client/ui/src/components/LoadingSpinner.tsx b/client/ui/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..5cb932d --- /dev/null +++ b/client/ui/src/components/LoadingSpinner.tsx @@ -0,0 +1,43 @@ +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg' + text?: string + className?: string +} + +export function LoadingSpinner({ size = 'md', text, className = '' }: LoadingSpinnerProps) { + const sizeClasses = { + sm: 'w-4 h-4 border-2', + md: 'w-8 h-8 border-3', + lg: 'w-12 h-12 border-4', + } + + return ( +
+
+ {text &&

{text}

} +
+ ) +} + +export function FullPageLoading({ text = '加载中...' }: { text?: string }) { + return ( +
+
+ +

{text}

+
+
+ ) +} + +export function CardLoading() { + return ( +
+
+
+
+
+ ) +} diff --git a/client/ui/src/components/Modal.tsx b/client/ui/src/components/Modal.tsx new file mode 100644 index 0000000..2d24828 --- /dev/null +++ b/client/ui/src/components/Modal.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef } from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + size?: 'sm' | 'md' | 'lg'; +} + +export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) { + const overlayRef = useRef(null); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (isOpen) { + document.addEventListener('keydown', handleEsc); + } + return () => document.removeEventListener('keydown', handleEsc); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const sizeClasses = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + }[size]; + + return ( +
e.target === overlayRef.current && onClose()} + > +
+
+ + +
+
+ {children} +
+
+
+ ); +} + +interface ConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'default'; +} + +export function ConfirmModal({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = '确认', + cancelText = '取消', + variant = 'default', +}: ConfirmModalProps) { + return ( + +

{message}

+
+ + +
+
+ ); +} diff --git a/client/ui/src/components/NetworkStats.tsx b/client/ui/src/components/NetworkStats.tsx new file mode 100644 index 0000000..2622850 --- /dev/null +++ b/client/ui/src/components/NetworkStats.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { cn, formatBytes, formatDuration } from '../lib/utils'; + +interface ConnectionStats { + is_connected: boolean; + connection_type: 'p2p' | 'relay' | 'none'; + local_address: string | null; + remote_address: string | null; + bytes_sent: number; + bytes_received: number; + latency_ms: number; + connected_since: string | null; + packets_sent: number; + packets_received: number; + packets_lost: number; +} + +export function NetworkStats({ className, compact = false }: { className?: string; compact?: boolean }) { + const [stats, setStats] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + try { + const data = await invoke('get_connection_stats'); + setStats(data); + } catch {} + }; + fetchStats(); + const interval = setInterval(fetchStats, 2000); + return () => clearInterval(interval); + }, []); + + if (!stats) { + return
; + } + + if (compact) { + return ( +
+ + {stats.is_connected && {stats.latency_ms}ms} +
+ ); + } + + return ( +
+
+

网络状态

+ +
+ {stats.is_connected ? ( +
+

延迟

{stats.latency_ms}ms

+

已发送

{formatBytes(stats.bytes_sent)}

+

已接收

{formatBytes(stats.bytes_received)}

+

连接时长

{stats.connected_since ? formatDuration(Date.now() - new Date(stats.connected_since).getTime()) : '-'}

+
+ ) :

未连接

} +
+ ); +} + +function ConnectionBadge({ type }: { type: string }) { + const styles: Record = { + p2p: 'bg-accent-green/15 text-accent-green', + relay: 'bg-accent-orange/15 text-accent-orange', + none: 'bg-text-muted/15 text-text-muted', + }; + const labels: Record = { p2p: 'P2P 直连', relay: '中继', none: '未连接' }; + return {labels[type]}; +} diff --git a/client/ui/src/components/RoomCard.tsx b/client/ui/src/components/RoomCard.tsx new file mode 100644 index 0000000..3fb2a1a --- /dev/null +++ b/client/ui/src/components/RoomCard.tsx @@ -0,0 +1,144 @@ +import { cn } from '../lib/utils'; +import { Room } from '../stores/roomStore'; + +interface RoomCardProps { + room: Room; + onClick?: () => void; + className?: string; +} + +export function RoomCard({ room, onClick, className }: RoomCardProps) { + const statusColors: Record = { + open: 'bg-accent-green/15 text-accent-green border-accent-green/20', + in_game: 'bg-accent-orange/15 text-accent-orange border-accent-orange/20', + closed: 'bg-text-muted/15 text-text-muted border-text-muted/20', + }; + + const statusLabels: Record = { + open: '开放', + in_game: '游戏中', + closed: '已关闭', + }; + + return ( +
+
+
+
+

+ {room.name} +

+ {room.has_password && ( + + + + + )} +
+

房主: {room.owner_username}

+
+
+ + {statusLabels[room.status] ?? room.status} + +
+
+ +
+
+ + + + + + + {room.current_players}/{room.max_players} + +
+
+ + + + + {room.game_version} +
+ {!room.is_public && ( +
+ + + + + + 私密 +
+ )} +
+
+ ); +} + +interface RoomCardSkeletonProps { + className?: string; +} + +export function RoomCardSkeleton({ className }: RoomCardSkeletonProps) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/client/ui/src/components/Toast.tsx b/client/ui/src/components/Toast.tsx new file mode 100644 index 0000000..84382cb --- /dev/null +++ b/client/ui/src/components/Toast.tsx @@ -0,0 +1,121 @@ +import { useState, useEffect, createContext, useContext, useCallback } from 'react'; + +interface Toast { + id: string; + type: 'success' | 'error' | 'info' | 'warning'; + message: string; + duration?: number; +} + +interface ToastContextValue { + toasts: Toast[]; + addToast: (toast: Omit) => void; + removeToast: (id: string) => void; +} + +const ToastContext = createContext(null); + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((toast: Omit) => { + const id = Math.random().toString(36).slice(2, 9); + setToasts((prev) => [...prev, { ...toast, id }]); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + + {children} + + + ); +} + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within ToastProvider'); + } + + const toast = { + success: (message: string) => context.addToast({ type: 'success', message }), + error: (message: string) => context.addToast({ type: 'error', message }), + info: (message: string) => context.addToast({ type: 'info', message }), + warning: (message: string) => context.addToast({ type: 'warning', message }), + }; + + return toast; +} + +function ToastContainer() { + const context = useContext(ToastContext); + if (!context) return null; + + return ( +
+ {context.toasts.map((toast) => ( + context.removeToast(toast.id)} /> + ))} +
+ ); +} + +function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { + useEffect(() => { + const timer = setTimeout(onClose, toast.duration || 4000); + return () => clearTimeout(timer); + }, [toast.duration, onClose]); + + const bgColor = { + success: 'bg-accent-green/15 border-accent-green/30 text-accent-green', + error: 'bg-accent-red/15 border-accent-red/30 text-accent-red', + info: 'bg-accent-blue/15 border-accent-blue/30 text-accent-blue', + warning: 'bg-accent-orange/15 border-accent-orange/30 text-accent-orange', + }[toast.type]; + + const icon = { + success: ( + + + + ), + error: ( + + + + ), + info: ( + + + + ), + warning: ( + + + + ), + }[toast.type]; + + return ( +
+ {icon} + {toast.message} + +
+ ); +} diff --git a/client/ui/src/components/index.ts b/client/ui/src/components/index.ts new file mode 100644 index 0000000..2552c30 --- /dev/null +++ b/client/ui/src/components/index.ts @@ -0,0 +1,12 @@ +export { AppLayout } from './AppLayout'; +export { Avatar, AvatarGroup } from './Avatar'; +export { ConnectionStatus } from './ConnectionStatus'; +export { CreateRoomModal } from './CreateRoomModal'; +export { EmptyState, NoRoomsState, NoFriendsState, NoRequestsState, NoSearchResultsState } from './EmptyState'; +export { FriendCard, FriendRequestCard } from './FriendCard'; +export { JoinRoomModal } from './JoinRoomModal'; +export { Loading, LoadingOverlay, LoadingCard, LoadingSpinner } from './Loading'; +export { Modal, ConfirmModal } from './Modal'; +export { NetworkStats } from './NetworkStats'; +export { RoomCard, RoomCardSkeleton } from './RoomCard'; +export { ToastProvider, useToast } from './Toast'; diff --git a/client/ui/src/config.json b/client/ui/src/config.json new file mode 100644 index 0000000..e16298a --- /dev/null +++ b/client/ui/src/config.json @@ -0,0 +1,6 @@ +{ + "server_url": "", + "server_name": "", + "relay_url": "", + "version": "0.1.0" +} diff --git a/client/ui/src/hooks/index.ts b/client/ui/src/hooks/index.ts new file mode 100644 index 0000000..a4867bb --- /dev/null +++ b/client/ui/src/hooks/index.ts @@ -0,0 +1 @@ +export { useSignalingEvents, useNetworkStatus, useFriendEvents, useRoomEvents } from './useWebSocket'; diff --git a/client/ui/src/hooks/useWebSocket.ts b/client/ui/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..9d83fe9 --- /dev/null +++ b/client/ui/src/hooks/useWebSocket.ts @@ -0,0 +1,147 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { useAuthStore } from '../stores/authStore'; + +type SignalingEvent = + | 'friend:request_received' + | 'friend:request_accepted' + | 'friend:online' + | 'friend:offline' + | 'room:invite_received' + | 'room:member_joined' + | 'room:member_left' + | 'network:signaling' + | 'network:status_changed'; + +interface EventHandler { + event: SignalingEvent; + handler: (payload: T) => void; +} + +export function useSignalingEvents(handlers: EventHandler[]) { + useEffect(() => { + const unsubscribes: (() => void)[] = []; + + for (const { event, handler } of handlers) { + listen(event, (e) => handler(e.payload)).then((unsub) => { + unsubscribes.push(unsub); + }); + } + + return () => { + unsubscribes.forEach((unsub) => unsub()); + }; + }, [handlers]); +} + +export function useNetworkStatus() { + const [status, setStatus] = useState<{ + type: 'p2p' | 'relay' | 'disconnected'; + port?: number; + connectAddr?: string; + }>({ type: 'disconnected' }); + + useEffect(() => { + const unsub = listen<{ type: string; port?: number; connect_addr?: string }>( + 'network:status_changed', + (event) => { + setStatus({ + type: event.payload.type as 'p2p' | 'relay' | 'disconnected', + port: event.payload.port, + connectAddr: event.payload.connect_addr, + }); + } + ); + + return () => { + unsub.then((fn) => fn()); + }; + }, []); + + return status; +} + +export function useFriendEvents( + onRequest?: (from: string, username: string) => void, + onAccepted?: (from: string, username: string) => void, + onOnline?: (userId: string) => void, + onOffline?: (userId: string) => void +) { + useEffect(() => { + const unsubscribes: Promise<() => void>[] = []; + + if (onRequest) { + unsubscribes.push( + listen<{ from: string; username: string }>('friend:request_received', (e) => { + onRequest(e.payload.from, e.payload.username); + }) + ); + } + + if (onAccepted) { + unsubscribes.push( + listen<{ from: string; username: string }>('friend:request_accepted', (e) => { + onAccepted(e.payload.from, e.payload.username); + }) + ); + } + + if (onOnline) { + unsubscribes.push( + listen<{ user_id: string }>('friend:online', (e) => { + onOnline(e.payload.user_id); + }) + ); + } + + if (onOffline) { + unsubscribes.push( + listen<{ user_id: string }>('friend:offline', (e) => { + onOffline(e.payload.user_id); + }) + ); + } + + return () => { + unsubscribes.forEach((p) => p.then((fn) => fn())); + }; + }, [onRequest, onAccepted, onOnline, onOffline]); +} + +export function useRoomEvents( + onInvite?: (from: string, roomId: string, roomName: string) => void, + onMemberJoined?: (roomId: string, userId: string, username: string) => void, + onMemberLeft?: (roomId: string, userId: string) => void +) { + useEffect(() => { + const unsubscribes: Promise<() => void>[] = []; + + if (onInvite) { + unsubscribes.push( + listen<{ from: string; room_id: string; room_name: string }>('room:invite_received', (e) => { + onInvite(e.payload.from, e.payload.room_id, e.payload.room_name); + }) + ); + } + + if (onMemberJoined) { + unsubscribes.push( + listen<{ room_id: string; user_id: string; username: string }>('room:member_joined', (e) => { + onMemberJoined(e.payload.room_id, e.payload.user_id, e.payload.username); + }) + ); + } + + if (onMemberLeft) { + unsubscribes.push( + listen<{ room_id: string; user_id: string }>('room:member_left', (e) => { + onMemberLeft(e.payload.room_id, e.payload.user_id); + }) + ); + } + + return () => { + unsubscribes.forEach((p) => p.then((fn) => fn())); + }; + }, [onInvite, onMemberJoined, onMemberLeft]); +} diff --git a/client/ui/src/index.css b/client/ui/src/index.css new file mode 100644 index 0000000..b434134 --- /dev/null +++ b/client/ui/src/index.css @@ -0,0 +1,207 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html, body, #root { + height: 100%; + background-color: #0f1117; + color: #e2e8f0; + font-family: 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + user-select: none; + } + + /* Safe area for mobile devices (notch, home indicator) */ + .safe-area-top { + padding-top: env(safe-area-inset-top, 0); + } + .safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0); + } + .safe-area-left { + padding-left: env(safe-area-inset-left, 0); + } + .safe-area-right { + padding-right: env(safe-area-inset-right, 0); + } + + /* Touch-friendly tap targets */ + @media (pointer: coarse) { + button, a, input, select, textarea { + min-height: 44px; + } + } + + ::-webkit-scrollbar { + width: 6px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: #363a50; + border-radius: 3px; + } + ::-webkit-scrollbar-thumb:hover { + background: #4a4f6a; + } + + /* 禁止拖拽 */ + img, a { + -webkit-user-drag: none; + user-drag: none; + } +} + +@layer components { + .btn-primary { + @apply px-4 py-2 rounded-lg bg-accent-green text-bg-primary font-semibold text-sm + hover:bg-green-300 active:scale-95 transition-all duration-150 disabled:opacity-50 + disabled:cursor-not-allowed; + } + + .btn-secondary { + @apply px-4 py-2 rounded-lg bg-bg-tertiary text-text-primary font-medium text-sm + border border-border hover:bg-bg-hover active:scale-95 transition-all duration-150; + } + + .btn-danger { + @apply px-4 py-2 rounded-lg bg-accent-red/20 text-accent-red font-medium text-sm + border border-accent-red/30 hover:bg-accent-red/30 active:scale-95 transition-all duration-150; + } + + .btn-ghost { + @apply px-4 py-2 rounded-lg text-text-secondary font-medium text-sm + hover:bg-bg-tertiary hover:text-text-primary active:scale-95 transition-all duration-150; + } + + .input-field { + @apply w-full px-3 py-2 rounded-lg bg-bg-tertiary border border-border text-text-primary + text-sm placeholder:text-text-muted focus:outline-none focus:border-accent-green/60 + focus:ring-1 focus:ring-accent-green/30 transition-all duration-150; + } + + .card { + @apply bg-bg-secondary rounded-xl border border-border p-4; + } + + .card-hover { + @apply bg-bg-secondary rounded-xl border border-border p-4 + hover:border-accent-green/30 hover:bg-bg-tertiary/50 transition-all duration-200 cursor-pointer; + } + + .badge-online { + @apply w-2 h-2 rounded-full bg-accent-green shadow-[0_0_6px_#4ade80]; + } + + .badge-offline { + @apply w-2 h-2 rounded-full bg-text-muted; + } + + .badge-status { + @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium; + } + + .badge-status-open { + @apply badge-status bg-accent-green/15 text-accent-green border border-accent-green/20; + } + + .badge-status-ingame { + @apply badge-status bg-accent-orange/15 text-accent-orange border border-accent-orange/20; + } + + .badge-status-closed { + @apply badge-status bg-text-muted/15 text-text-muted border border-text-muted/20; + } + + /* 加载动画 */ + .loading-spinner { + @apply animate-spin rounded-full border-2 border-accent-green/20 border-t-accent-green; + } + + /* 工具提示 */ + .tooltip { + @apply absolute z-50 px-2 py-1 text-xs font-medium text-text-primary + bg-bg-tertiary border border-border rounded-lg shadow-lg + animate-fade-in; + } + + /* 连接状态指示器 */ + .connection-indicator { + @apply inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium; + } + + .connection-p2p { + @apply connection-indicator bg-accent-green/15 text-accent-green; + } + + .connection-relay { + @apply connection-indicator bg-accent-orange/15 text-accent-orange; + } + + .connection-disconnected { + @apply connection-indicator bg-text-muted/15 text-text-muted; + } +} + +@layer utilities { + /* 动画 */ + .animate-fade-in { + animation: fadeIn 0.2s ease-out; + } + + .animate-slide-up { + animation: slideUp 0.3s ease-out; + } + + .animate-slide-down { + animation: slideDown 0.3s ease-out; + } + + .animate-scale-in { + animation: scaleIn 0.2s ease-out; + } + + .animate-pulse-green { + animation: pulseGreen 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes scaleIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } + } + + @keyframes pulseGreen { + 0%, 100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4); } + 50% { box-shadow: 0 0 0 8px rgba(74, 222, 128, 0); } + } + + /* Minecraft 风格像素边框 */ + .pixel-border { + box-shadow: + inset -2px -2px 0 0 #1a1d27, + inset 2px 2px 0 0 #4a4f6a; + } +} diff --git a/client/ui/src/lib/utils.ts b/client/ui/src/lib/utils.ts new file mode 100644 index 0000000..0f8912e --- /dev/null +++ b/client/ui/src/lib/utils.ts @@ -0,0 +1,93 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + +export function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}小时${minutes % 60}分钟`; + } + if (minutes > 0) { + return `${minutes}分钟${seconds % 60}秒`; + } + return `${seconds}秒`; +} + +export function formatRelativeTime(date: Date | string | number): string { + const now = new Date(); + const then = new Date(date); + const diffMs = now.getTime() - then.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return '刚刚'; + if (diffMin < 60) return `${diffMin}分钟前`; + if (diffHour < 24) return `${diffHour}小时前`; + if (diffDay < 7) return `${diffDay}天前`; + + return then.toLocaleDateString('zh-CN'); +} + +export function generateAvatarColor(seed: string): string { + const hash = seed.split('').reduce((acc, char) => { + return char.charCodeAt(0) + ((acc << 5) - acc); + }, 0); + const h = Math.abs(hash) % 360; + return `hsl(${h}, 60%, 50%)`; +} + +export function getInitials(name: string): string { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} + +export function copyToClipboard(text: string): Promise { + return navigator.clipboard.writeText(text); +} + +export function debounce unknown>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeoutId: ReturnType; + + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), wait); + }; +} + +export function throttle unknown>( + func: T, + limit: number +): (...args: Parameters) => void { + let inThrottle: boolean; + + return (...args: Parameters) => { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => (inThrottle = false), limit); + } + }; +} diff --git a/client/ui/src/main.tsx b/client/ui/src/main.tsx new file mode 100644 index 0000000..198b790 --- /dev/null +++ b/client/ui/src/main.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useState } from 'react' +import ReactDOM from 'react-dom/client' +import { createMemoryRouter, RouterProvider, redirect, Navigate } from 'react-router-dom' +import './index.css' + +import LoginPage from './pages/Login' +import RegisterPage from './pages/Register' +import DashboardPage from './pages/Dashboard' +import FriendsPage from './pages/Friends' +import RoomPage from './pages/Room' +import SettingsPage from './pages/Settings' +import ServerSetupPage from './pages/ServerSetup' +import AppLayout from './components/AppLayout' +import { ToastProvider } from './components/Toast' +import { useAuthStore } from './stores/authStore' +import { useConfigStore } from './stores/configStore' + +function requireAuth() { + const { user } = useAuthStore.getState() + if (!user) return redirect('/login') + return null +} + +function requireGuest() { + const { user } = useAuthStore.getState() + if (user) return redirect('/dashboard') + return null +} + +function requireConfig() { + const { config } = useConfigStore.getState() + if (!config || !config.server_url) return redirect('/setup') + return null +} + +const router = createMemoryRouter([ + { + path: '/setup', + element: , + }, + { + path: '/login', + loader: () => { + requireConfig() + return requireGuest() + }, + element: , + }, + { + path: '/register', + loader: () => { + requireConfig() + return requireGuest() + }, + element: , + }, + { + path: '/', + loader: requireAuth, + element: , + children: [ + { index: true, element: }, + { path: 'dashboard', element: }, + { path: 'friends', element: }, + { path: 'room/:roomId', element: }, + { path: 'settings', element: }, + ], + }, + { path: '*', element: }, +], { initialEntries: ['/setup'] }) + +function App() { + const { init: initAuth } = useAuthStore() + const { initConfig, config } = useConfigStore() + const [ready, setReady] = useState(false) + + useEffect(() => { + const initialize = async () => { + await initConfig() + + const { config: currentConfig } = useConfigStore.getState() + + if (currentConfig && currentConfig.server_url) { + await initAuth() + const { user } = useAuthStore.getState() + if (user) { + router.navigate('/dashboard', { replace: true }) + } else { + router.navigate('/login', { replace: true }) + } + } else { + router.navigate('/setup', { replace: true }) + } + + setReady(true) + } + + initialize() + }, []) + + if (!ready) { + return ( +
+
+
🎮
+
FunMC
+
正在启动...
+
+
+ ) + } + + return ( + + + + ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/client/ui/src/pages/Dashboard.tsx b/client/ui/src/pages/Dashboard.tsx new file mode 100644 index 0000000..b9e823b --- /dev/null +++ b/client/ui/src/pages/Dashboard.tsx @@ -0,0 +1,232 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useRoomStore, Room } from '../stores/roomStore' +import { useAuthStore } from '../stores/authStore' + +function RoomCard({ room, onJoin }: { room: Room; onJoin: (room: Room) => void }) { + const statusColors: Record = { + open: 'text-accent-green', + in_game: 'text-accent-orange', + closed: 'text-text-muted', + } + const statusLabels: Record = { + open: '开放', + in_game: '游戏中', + closed: '已关闭', + } + + return ( +
onJoin(room)}> +
+
+
+

{room.name}

+ {room.has_password && ( + + + + + )} +
+

房主: {room.owner_username}

+
+
+ + {statusLabels[room.status] ?? room.status} + +
+
+ +
+
+ + + + + + {room.current_players}/{room.max_players} +
+
+ + + + + {room.game_version} +
+
+
+ ) +} + +export default function DashboardPage() { + const { rooms, loading, fetchRooms, joinRoom, createRoom } = useRoomStore() + const { user } = useAuthStore() + const navigate = useNavigate() + + const [showCreate, setShowCreate] = useState(false) + const [createName, setCreateName] = useState('') + const [createVersion, setCreateVersion] = useState('1.20') + const [createMax, setCreateMax] = useState('10') + const [createPw, setCreatePw] = useState('') + const [createPublic, setCreatePublic] = useState(true) + const [createError, setCreateError] = useState('') + const [creating, setCreating] = useState(false) + + useEffect(() => { + fetchRooms() + const id = setInterval(fetchRooms, 10000) + return () => clearInterval(id) + }, []) + + const handleJoin = async (room: Room) => { + try { + let password: string | undefined + if (room.has_password) { + password = window.prompt(`房间 "${room.name}" 需要密码:`) ?? undefined + if (password === undefined) return + } + await joinRoom(room.id, password) + navigate(`/room/${room.id}`) + } catch (e: any) { + alert(`加入失败: ${e}`) + } + } + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + setCreateError('') + setCreating(true) + try { + const roomId = await createRoom({ + name: createName, + maxPlayers: parseInt(createMax) || 10, + isPublic: createPublic, + password: createPw || undefined, + gameVersion: createVersion, + }) + await joinRoom(roomId) + navigate(`/room/${roomId}`) + } catch (e: any) { + setCreateError(String(e)) + setCreating(false) + } + } + + return ( +
+ {/* Header */} +
+
+

游戏大厅

+

找到一个房间加入,或创建你自己的

+
+
+ + +
+
+ + {/* Room grid */} +
+ {loading && rooms.length === 0 ? ( +
+ 加载中... +
+ ) : rooms.length === 0 ? ( +
+ + + + +

暂无公开房间

+

创建第一个房间开始游戏

+
+ ) : ( +
+ {rooms.map((room) => ( + + ))} +
+ )} +
+ + {/* Create room modal */} + {showCreate && ( +
+
+
+

创建房间

+ +
+ +
+
+ + setCreateName(e.target.value)} required /> +
+
+
+ + setCreateVersion(e.target.value)} /> +
+
+ + setCreateMax(e.target.value)} /> +
+
+
+ + setCreatePw(e.target.value)} /> +
+ + + {createError && ( +
+ {createError} +
+ )} + +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/client/ui/src/pages/Friends.tsx b/client/ui/src/pages/Friends.tsx new file mode 100644 index 0000000..3c68a32 --- /dev/null +++ b/client/ui/src/pages/Friends.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react' +import { useFriendStore, Friend } from '../stores/friendStore' + +function Avatar({ seed, username, size = 8 }: { seed: string; username: string; size?: number }) { + const color = `hsl(${parseInt(seed.slice(0, 8), 16) % 360}, 60%, 50%)` + return ( +
+ {username[0]?.toUpperCase()} +
+ ) +} + +function FriendItem({ friend, onRemove }: { friend: Friend; onRemove: (id: string) => void }) { + return ( +
+
+ + +
+
+

{friend.username}

+

{friend.is_online ? '在线' : '离线'}

+
+ +
+ ) +} + +export default function FriendsPage() { + const { friends, requests, loading, fetchFriends, fetchRequests, sendRequest, acceptRequest, removeFriend } = useFriendStore() + const [tab, setTab] = useState<'friends' | 'requests'>('friends') + const [addUsername, setAddUsername] = useState('') + const [addError, setAddError] = useState('') + const [addSuccess, setAddSuccess] = useState('') + const [sending, setSending] = useState(false) + + useEffect(() => { + fetchFriends() + fetchRequests() + }, []) + + const handleSendRequest = async (e: React.FormEvent) => { + e.preventDefault() + setAddError('') + setAddSuccess('') + setSending(true) + try { + await sendRequest(addUsername) + setAddSuccess(`已向 ${addUsername} 发送好友请求`) + setAddUsername('') + } catch (e: any) { + setAddError(String(e)) + } finally { + setSending(false) + } + } + + const onlineCount = friends.filter((f) => f.is_online).length + + return ( +
+ {/* Header */} +
+

好友

+

{onlineCount} 人在线 · 共 {friends.length} 位好友

+
+ +
+ {/* Left: friend list */} +
+ {/* Tabs */} +
+ {(['friends', 'requests'] as const).map((t) => ( + + ))} +
+ +
+ {tab === 'friends' ? ( + loading ? ( +

加载中...

+ ) : friends.length === 0 ? ( +
+ + + + + +

还没有好友

+

从右侧搜索用户添加好友

+
+ ) : ( + friends.map((f) => ( + { if (confirm(`确定删除好友 ${f.username}?`)) removeFriend(id) }} + /> + )) + ) + ) : requests.length === 0 ? ( +

暂无好友请求

+ ) : ( + requests.map((req) => ( +
+ +
+

{req.username}

+

想加你为好友

+
+ +
+ )) + )} +
+
+ + {/* Right: add friend */} +
+

添加好友

+
+ setAddUsername(e.target.value)} + required + /> + {addError &&

{addError}

} + {addSuccess &&

{addSuccess}

} + +
+
+
+
+ ) +} diff --git a/client/ui/src/pages/Login.tsx b/client/ui/src/pages/Login.tsx new file mode 100644 index 0000000..7748a4a --- /dev/null +++ b/client/ui/src/pages/Login.tsx @@ -0,0 +1,102 @@ +import { useState, useRef } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuthStore } from '../stores/authStore' + +export default function LoginPage() { + const { login, loading } = useAuthStore() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + try { + await login(username, password) + navigate('/dashboard') + } catch (e: any) { + setError(String(e)) + } + } + + return ( +
+
+ {/* Logo */} +
+
+ + + +
+

FunMC

+

Minecraft 联机助手

+
+ + {/* Card */} +
+

登录账号

+ +
+
+ + setUsername(e.target.value)} + required + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ 没有账号?{' '} + + 立即注册 + +

+
+ + {/* Brand footer */} +
+
+ + + + 魔幻方开发 +
+
+
+
+ ) +} diff --git a/client/ui/src/pages/Register.tsx b/client/ui/src/pages/Register.tsx new file mode 100644 index 0000000..fdae3c5 --- /dev/null +++ b/client/ui/src/pages/Register.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuthStore } from '../stores/authStore' + +export default function RegisterPage() { + const { register, loading } = useAuthStore() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + if (password !== confirm) { + setError('两次密码不一致') + return + } + if (password.length < 8) { + setError('密码至少 8 位') + return + } + try { + await register(username, email, password) + navigate('/dashboard') + } catch (e: any) { + setError(String(e)) + } + } + + return ( +
+
+
+
+ + + +
+

FunMC

+

创建你的账号

+
+ +
+

注册

+ +
+
+ + setUsername(e.target.value)} + minLength={3} + maxLength={32} + required + autoFocus + /> +
+ +
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + minLength={8} + required + /> +
+ +
+ + setConfirm(e.target.value)} + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ 已有账号?{' '} + + 立即登录 + +

+
+ + {/* Brand footer */} +
+
+ + + + 魔幻方开发 +
+
+
+
+ ) +} diff --git a/client/ui/src/pages/Room.tsx b/client/ui/src/pages/Room.tsx new file mode 100644 index 0000000..e42e3c1 --- /dev/null +++ b/client/ui/src/pages/Room.tsx @@ -0,0 +1,430 @@ +import { useEffect, useState, useRef } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' +import { useRoomStore } from '../stores/roomStore' +import { useNetworkStore } from '../stores/networkStore' +import { useAuthStore } from '../stores/authStore' +import { useChatStore } from '../stores/chatStore' +import { useToast } from '../components/Toast' + +export default function RoomPage() { + const { roomId } = useParams<{ roomId: string }>() + const navigate = useNavigate() + const { currentRoom, rooms, members, leaveRoom, setCurrentRoom, fetchMembers } = useRoomStore() + const { session, connectAddr, startHosting, joinNetwork, stopNetwork, stats, refreshStats } = useNetworkStore() + const { user } = useAuthStore() + const { messages, sendMessage, clearMessages, subscribeToChat } = useChatStore() + const { showToast } = useToast() + const [connecting, setConnecting] = useState(false) + const [copied, setCopied] = useState(false) + const [chatInput, setChatInput] = useState('') + const [showChat, setShowChat] = useState(true) + const [kickingUser, setKickingUser] = useState(null) + const chatContainerRef = useRef(null) + + const room = currentRoom ?? rooms.find((r) => r.id === roomId) + + useEffect(() => { + if (roomId) { + fetchMembers(roomId) + const membersInterval = setInterval(() => fetchMembers(roomId), 5000) + return () => clearInterval(membersInterval) + } + }, [roomId]) + + useEffect(() => { + const id = setInterval(refreshStats, 2000) + return () => clearInterval(id) + }, []) + + useEffect(() => { + let unlisten: (() => void) | null = null + subscribeToChat().then((fn) => { + unlisten = fn + }) + return () => { + unlisten?.() + clearMessages() + } + }, []) + + useEffect(() => { + const unlistenKicked = listen<{ room_id: string; reason: string }>('signaling:kicked', (event) => { + if (event.payload.room_id === roomId) { + showToast(event.payload.reason || '你已被踢出房间', 'error') + navigate('/dashboard') + } + }) + + const unlistenClosed = listen<{ room_id: string }>('signaling:room_closed', (event) => { + if (event.payload.room_id === roomId) { + showToast('房间已关闭', 'info') + navigate('/dashboard') + } + }) + + return () => { + unlistenKicked.then((fn) => fn()) + unlistenClosed.then((fn) => fn()) + } + }, [roomId]) + + useEffect(() => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight + } + }, [messages]) + + useEffect(() => { + if (!room && !currentRoom) { + navigate('/dashboard') + } + }, [room]) + + const isOwner = room?.owner_id === user?.id + + const handleConnect = async () => { + if (!roomId || !room) return + setConnecting(true) + try { + if (isOwner) { + await startHosting(roomId, room.name) + } else { + await joinNetwork(roomId, room.owner_id) + } + } catch (e: any) { + alert(`连接失败: ${e}`) + } finally { + setConnecting(false) + } + } + + const handleLeave = async () => { + if (!roomId) return + await stopNetwork() + await leaveRoom(roomId) + setCurrentRoom(null) + navigate('/dashboard') + } + + const handleCopy = () => { + const addr = connectAddr ?? '127.0.0.1:25565' + navigator.clipboard.writeText(addr) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleSendChat = async (e: React.FormEvent) => { + e.preventDefault() + if (!roomId || !chatInput.trim()) return + try { + await sendMessage(roomId, chatInput.trim()) + setChatInput('') + } catch (e: any) { + console.error('Failed to send message:', e) + } + } + + const handleKickMember = async (targetUserId: string, username: string) => { + if (!roomId) return + if (!confirm(`确定要踢出 ${username} 吗?`)) return + + setKickingUser(targetUserId) + try { + await invoke('kick_room_member', { roomId, targetUserId }) + showToast(`已踢出 ${username}`, 'success') + fetchMembers(roomId) + } catch (e: any) { + showToast(`踢出失败: ${e}`, 'error') + } finally { + setKickingUser(null) + } + } + + const handleCloseRoom = async () => { + if (!roomId) return + if (!confirm('确定要关闭房间吗?所有成员将被踢出。')) return + + try { + await invoke('close_room', { roomId }) + showToast('房间已关闭', 'success') + navigate('/dashboard') + } catch (e: any) { + showToast(`关闭失败: ${e}`, 'error') + } + } + + const sessionTypeLabel = stats?.session_type === 'p2p' ? 'P2P 直连' : stats?.session_type === 'relay' ? '中继' : '未连接' + const sessionTypeColor = stats?.session_type === 'p2p' ? 'text-accent-green' : stats?.session_type === 'relay' ? 'text-accent-orange' : 'text-text-muted' + + return ( +
+ {/* Header */} +
+
+

{room?.name ?? '房间'}

+

+ {room?.game_version} · {room?.current_players}/{room?.max_players} 人 + {isOwner && 你是房主} +

+
+ +
+ +
+ {/* Network status card */} +
+

网络连接

+ + {!session ? ( +
+

+ {isOwner + ? '点击"开始托管"让 Minecraft 服务器接受来自此房间成员的连接。' + : '点击"连接"获取本地代理地址,然后在 Minecraft 中添加该服务器。'} +

+ +
+ ) : ( +
+ {/* Stats grid */} +
+
+

连接类型

+

{sessionTypeLabel}

+
+
+

延迟

+

+ {stats?.connected ? `${stats.latency_ms} ms` : '—'} +

+
+
+

上传

+

+ {stats ? formatBytes(stats.bytes_sent) : '—'} +

+
+
+

下载

+

+ {stats ? formatBytes(stats.bytes_received) : '—'} +

+
+
+ + {/* MC connect address */} + {!isOwner && connectAddr && ( +
+

在 Minecraft 中添加此服务器地址:

+
+ + {connectAddr} + + +
+
+ )} + + {isOwner && ( +
+

+ 你的 Minecraft 服务器正在监听,房间成员可以通过 FunMC 连接到你的服务器。 +

+
+ )} +
+ )} +
+ + {/* Room members */} +
+

+ 房间成员 ({members.length}) +

+
+ {members.length === 0 ? ( +

加载中...

+ ) : ( + members.map((member) => ( +
+
+
+
+ {member.username.charAt(0).toUpperCase()} +
+ +
+
+

+ {member.username} + {member.user_id === user?.id && ( + (你) + )} +

+

+ {member.role === 'owner' ? '房主' : '成员'} +

+
+
+
+ + {member.is_online ? '在线' : '离线'} + + {isOwner && member.user_id !== user?.id && ( + + )} +
+
+ )) + )} +
+ + {isOwner && ( +
+ +
+ )} +
+ + {/* Room info */} +
+

房间信息

+
+
+ 房主 + {room?.owner_username} +
+
+ 游戏版本 + {room?.game_version} +
+
+ 人数 + {room?.current_players}/{room?.max_players} +
+
+ 状态 + {room?.status} +
+
+
+ + {/* Chat */} +
+
+

房间聊天

+ +
+ + {showChat && ( + <> +
+ {messages.length === 0 ? ( +

+ 暂无消息,发送第一条消息开始聊天 +

+ ) : ( + messages + .filter((msg) => msg.room_id === roomId) + .map((msg, i) => ( +
+ + {msg.username} + {msg.from === user?.id && ' (你)'} + + : + {msg.content} + + {new Date(msg.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + })} + +
+ )) + )} +
+ +
+ setChatInput(e.target.value)} + placeholder="输入消息..." + className="input-field flex-1" + maxLength={500} + /> + +
+ + )} +
+
+
+ ) +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}` +} diff --git a/client/ui/src/pages/ServerSetup.tsx b/client/ui/src/pages/ServerSetup.tsx new file mode 100644 index 0000000..298e925 --- /dev/null +++ b/client/ui/src/pages/ServerSetup.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useConfigStore } from '../stores/configStore' + +export default function ServerSetupPage() { + const navigate = useNavigate() + const { setCustomServer, config, loading, error } = useConfigStore() + const [serverUrl, setServerUrl] = useState('') + const [manualMode, setManualMode] = useState(false) + + const handleAutoConnect = async () => { + if (config && config.server_url) { + navigate('/login') + } + } + + const handleManualConnect = async () => { + if (!serverUrl.trim()) return + + let url = serverUrl.trim() + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'http://' + url + } + + await setCustomServer(url) + + const { config: newConfig } = useConfigStore.getState() + if (newConfig && newConfig.server_url) { + navigate('/login') + } + } + + const hasEmbeddedConfig = config && config.server_url && config.server_url !== 'http://localhost:3000' + + return ( +
+
+
+
🎮
+

FunMC

+

Minecraft 联机工具

+
+ +
+ {hasEmbeddedConfig ? ( + <> +
+
+ + 检测到预配置服务器 +
+
+ +
+

服务器名称

+

{config?.server_name || 'FunMC Server'}

+

服务器地址

+

{config?.server_url}

+
+ + + + + + ) : manualMode || !hasEmbeddedConfig ? ( + <> +

+ 连接到服务器 +

+ +
+ + setServerUrl(e.target.value)} + placeholder="例如: funmc.com:3000 或 192.168.1.100:3000" + className="w-full px-4 py-3 bg-gray-700/50 border border-gray-600 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-green-500 transition-colors" + onKeyDown={(e) => e.key === 'Enter' && handleManualConnect()} + /> +

+ 输入管理员提供的服务器地址 +

+
+ + {error && ( +
+ {error} +
+ )} + + + + {hasEmbeddedConfig && ( + + )} + + ) : null} +
+ +

+ 魔幻方开发 +

+
+
+ ) +} diff --git a/client/ui/src/pages/Settings.tsx b/client/ui/src/pages/Settings.tsx new file mode 100644 index 0000000..37765ae --- /dev/null +++ b/client/ui/src/pages/Settings.tsx @@ -0,0 +1,299 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useRelayNodeStore } from '../stores/relayNodeStore' +import { useConfigStore } from '../stores/configStore' +import { useAuthStore } from '../stores/authStore' + +export default function SettingsPage() { + const navigate = useNavigate() + const { nodes, loading, fetchNodes, addNode, removeNode, reportPing } = useRelayNodeStore() + const { config, customServerUrl, setCustomServer, clearCustomServer } = useConfigStore() + const { logout } = useAuthStore() + const [newName, setNewName] = useState('') + const [newUrl, setNewUrl] = useState('') + const [newRegion, setNewRegion] = useState('') + const [adding, setAdding] = useState(false) + const [addError, setAddError] = useState('') + const [pinging, setPinging] = useState(null) + const [showServerChange, setShowServerChange] = useState(false) + const [newServerUrl, setNewServerUrl] = useState('') + const [serverError, setServerError] = useState('') + const [changingServer, setChangingServer] = useState(false) + + useEffect(() => { + fetchNodes() + }, []) + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault() + setAddError('') + setAdding(true) + try { + await addNode(newName, newUrl, newRegion || undefined) + setNewName('') + setNewUrl('') + setNewRegion('') + } catch (e: any) { + setAddError(String(e)) + } finally { + setAdding(false) + } + } + + const handlePing = async (id: string, url: string) => { + setPinging(id) + try { + const start = performance.now() + await fetch(`${url}/ping`, { signal: AbortSignal.timeout(4000) }).catch(() => {}) + const rtt = Math.round(performance.now() - start) + await reportPing(id, rtt) + } finally { + setPinging(null) + } + } + + return ( +
+
+

设置

+

管理应用配置

+
+ +
+ {/* Server config */} +
+

服务器连接

+

当前连接的服务器信息

+ +
+
+
+

+ {config?.server_name || 'FunMC Server'} +

+

+ {config?.server_url || '未配置'} +

+
+
+ + 已连接 +
+
+ {customServerUrl && ( +

使用自定义服务器

+ )} +
+ + {showServerChange ? ( +
+ setNewServerUrl(e.target.value)} + placeholder="输入新的服务器地址" + className="input-field" + /> + {serverError && ( +

{serverError}

+ )} +
+ + +
+
+ ) : ( +
+ + {customServerUrl && ( + + )} +
+ )} +
+ + {/* Relay nodes */} +
+

中继服务器节点

+

+ 支持多节点,P2P 穿透失败时自动选择延迟最低的节点进行流量中继。 +

+ + {loading ? ( +

加载中...

+ ) : nodes.length === 0 ? ( +

暂无节点

+ ) : ( +
+ {nodes.map((node) => ( +
+
+
+ {node.name} + {node.region && node.region !== 'auto' && ( + + {node.region} + + )} + {node.priority > 0 && ( + + 主节点 + + )} +
+
+

{node.url}

+ {node.last_ping_ms != null && ( + + {node.last_ping_ms} ms + + )} +
+
+
+ + +
+
+ ))} +
+ )} + + {/* Add form */} +
+

添加节点

+
+ setNewName(e.target.value)} + required + /> + setNewUrl(e.target.value)} + type="url" + required + /> + setNewRegion(e.target.value)} + /> + +
+ {addError &&

{addError}

} +
+
+ + {/* About */} +
+

关于

+
+
+ 版本 + 0.1.0 +
+
+ 开发团队 + 魔幻方 +
+
+ 官网 + funmc.com +
+
+ 框架 + Tauri 2 + React +
+
+ 协议 + QUIC (quinn) over UDP +
+
+
+
+ + + + 魔幻方开发 +
+

© 2024 魔幻方. All rights reserved.

+
+
+
+
+ ) +} diff --git a/client/ui/src/router.tsx b/client/ui/src/router.tsx new file mode 100644 index 0000000..6456214 --- /dev/null +++ b/client/ui/src/router.tsx @@ -0,0 +1,42 @@ +import { createBrowserRouter, Navigate } from 'react-router-dom'; +import { AppLayout } from './components/AppLayout'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import DashboardPage from './pages/Dashboard'; +import RoomPage from './pages/Room'; +import FriendsPage from './pages/Friends'; +import SettingsPage from './pages/Settings'; +import { useAuthStore } from './stores/authStore'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { token } = useAuthStore(); + if (!token) return ; + return <>{children}; +} + +function PublicRoute({ children }: { children: React.ReactNode }) { + const { token } = useAuthStore(); + if (token) return ; + return <>{children}; +} + +export const router = createBrowserRouter([ + { + path: '/login', + element: , + }, + { + path: '/register', + element: , + }, + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'room/:roomId', element: }, + { path: 'friends', element: }, + { path: 'settings', element: }, + ], + }, +]); diff --git a/client/ui/src/stores/authStore.ts b/client/ui/src/stores/authStore.ts new file mode 100644 index 0000000..f05dcf5 --- /dev/null +++ b/client/ui/src/stores/authStore.ts @@ -0,0 +1,101 @@ +import { create } from 'zustand' +import { invoke } from '@tauri-apps/api/core' + +export interface User { + id: string + username: string + email: string + avatar_seed: string +} + +interface AuthResult { + user: User + token: string +} + +interface AuthState { + user: User | null + token: string | null + loading: boolean + error: string | null + initialized: boolean + login: (username: string, password: string) => Promise + register: (username: string, email: string, password: string) => Promise + logout: () => Promise + init: () => Promise + connectSignaling: () => Promise +} + +export const useAuthStore = create((set, get) => ({ + user: null, + token: localStorage.getItem('auth_token'), + loading: false, + error: null, + initialized: false, + + init: async () => { + if (get().initialized) return + try { + const result = await invoke('get_current_user') + if (result && result.user && result.token) { + localStorage.setItem('auth_token', result.token) + set({ user: result.user, token: result.token, initialized: true }) + // Auto-connect signaling after init + get().connectSignaling().catch(console.error) + } else { + localStorage.removeItem('auth_token') + set({ user: null, token: null, initialized: true }) + } + } catch (e) { + console.error('Auth init failed:', e) + localStorage.removeItem('auth_token') + set({ user: null, token: null, initialized: true }) + } + }, + + connectSignaling: async () => { + try { + await invoke('connect_signaling') + } catch (e) { + console.error('Signaling connection failed:', e) + } + }, + + login: async (username, password) => { + set({ loading: true, error: null }) + try { + const result = await invoke('login', { username, password }) + localStorage.setItem('auth_token', result.token) + set({ user: result.user, token: result.token, loading: false }) + // Connect signaling after login + get().connectSignaling().catch(console.error) + } catch (e: any) { + set({ error: String(e), loading: false }) + throw e + } + }, + + register: async (username, email, password) => { + set({ loading: true, error: null }) + try { + const result = await invoke('register', { username, email, password }) + localStorage.setItem('auth_token', result.token) + set({ user: result.user, token: result.token, loading: false }) + // Connect signaling after register + get().connectSignaling().catch(console.error) + } catch (e: any) { + set({ error: String(e), loading: false }) + throw e + } + }, + + logout: async () => { + try { + await invoke('disconnect_signaling') + await invoke('logout') + } finally { + localStorage.removeItem('auth_token') + set({ user: null, token: null }) + } + }, +})) diff --git a/client/ui/src/stores/chatStore.ts b/client/ui/src/stores/chatStore.ts new file mode 100644 index 0000000..6d0ba41 --- /dev/null +++ b/client/ui/src/stores/chatStore.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand' +import { invoke } from '@tauri-apps/api/core' +import { listen, UnlistenFn } from '@tauri-apps/api/event' + +export interface ChatMessage { + room_id: string + from: string + username: string + content: string + timestamp: number +} + +interface ChatState { + messages: ChatMessage[] + sendMessage: (roomId: string, content: string) => Promise + addMessage: (message: ChatMessage) => void + clearMessages: () => void + subscribeToChat: () => Promise +} + +export const useChatStore = create((set, get) => ({ + messages: [], + + sendMessage: async (roomId, content) => { + await invoke('send_chat_message', { roomId, content }) + }, + + addMessage: (message) => { + set((state) => ({ + messages: [...state.messages.slice(-99), message], + })) + }, + + clearMessages: () => { + set({ messages: [] }) + }, + + subscribeToChat: async () => { + const unlisten = await listen('signaling:chat_message', (event) => { + get().addMessage(event.payload) + }) + return unlisten + }, +})) diff --git a/client/ui/src/stores/configStore.ts b/client/ui/src/stores/configStore.ts new file mode 100644 index 0000000..3b712ce --- /dev/null +++ b/client/ui/src/stores/configStore.ts @@ -0,0 +1,122 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { invoke } from '@tauri-apps/api/core' + +export interface ServerConfig { + server_name: string + server_url: string + relay_url: string + version: string +} + +interface ConfigState { + config: ServerConfig | null + customServerUrl: string | null + loading: boolean + error: string | null + + initConfig: () => Promise + setCustomServer: (url: string) => Promise + clearCustomServer: () => void + getServerUrl: () => string +} + +const DEFAULT_CONFIG: ServerConfig = { + server_name: 'FunMC', + server_url: 'http://localhost:3000', + relay_url: 'localhost:7900', + version: '0.1.0', +} + +async function fetchServerConfigViaInvoke(serverUrl: string): Promise { + try { + // First set the server URL in Rust backend + await invoke('set_server_url', { url: serverUrl }) + // Then fetch the config via the backend + const config = await invoke('fetch_server_config') + return config + } catch (e) { + console.error('Failed to fetch server config:', e) + return null + } +} + +export const useConfigStore = create()( + persist( + (set, get) => ({ + config: null, + customServerUrl: null, + loading: false, + error: null, + + initConfig: async () => { + set({ loading: true, error: null }) + + try { + const { customServerUrl } = get() + + // Priority: custom server > default + if (customServerUrl) { + const serverConfig = await fetchServerConfigViaInvoke(customServerUrl) + if (serverConfig) { + set({ config: serverConfig, loading: false }) + return + } + } + + // Try default server + const defaultConfig = await fetchServerConfigViaInvoke(DEFAULT_CONFIG.server_url) + if (defaultConfig) { + set({ config: defaultConfig, loading: false }) + return + } + + // Use default config if no server is reachable + set({ config: DEFAULT_CONFIG, loading: false }) + await invoke('set_server_url', { url: DEFAULT_CONFIG.server_url }) + } catch (e) { + console.error('Config init error:', e) + set({ error: String(e), loading: false, config: DEFAULT_CONFIG }) + } + }, + + setCustomServer: async (url: string) => { + set({ loading: true, error: null }) + + try { + const normalizedUrl = url.endsWith('/') ? url.slice(0, -1) : url + const serverConfig = await fetchServerConfigViaInvoke(normalizedUrl) + + if (serverConfig) { + set({ + customServerUrl: normalizedUrl, + config: serverConfig, + loading: false + }) + } else { + set({ + error: '无法连接到服务器,请检查地址是否正确', + loading: false + }) + } + } catch (e) { + set({ error: String(e), loading: false }) + } + }, + + clearCustomServer: () => { + set({ customServerUrl: null }) + get().initConfig() + }, + + getServerUrl: () => { + const { config, customServerUrl } = get() + return customServerUrl || config?.server_url || DEFAULT_CONFIG.server_url + }, + }), + { + name: 'funmc-config', + partialize: (state) => ({ customServerUrl: state.customServerUrl }), + } + ) +) diff --git a/client/ui/src/stores/friendStore.ts b/client/ui/src/stores/friendStore.ts new file mode 100644 index 0000000..9ba2090 --- /dev/null +++ b/client/ui/src/stores/friendStore.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand' +import { invoke } from '@tauri-apps/api/core' + +export interface Friend { + id: string + username: string + avatar_seed: string + is_online: boolean + status: string +} + +export interface FriendRequest { + id: string + username: string + avatar_seed: string +} + +interface FriendState { + friends: Friend[] + requests: FriendRequest[] + loading: boolean + fetchFriends: () => Promise + fetchRequests: () => Promise + sendRequest: (username: string) => Promise + acceptRequest: (requesterId: string) => Promise + removeFriend: (friendId: string) => Promise +} + +export const useFriendStore = create((set) => ({ + friends: [], + requests: [], + loading: false, + + fetchFriends: async () => { + set({ loading: true }) + try { + const friends = await invoke('list_friends') + set({ friends, loading: false }) + } catch { + set({ loading: false }) + } + }, + + fetchRequests: async () => { + try { + const requests = await invoke('list_requests') + set({ requests }) + } catch {} + }, + + sendRequest: async (username) => { + await invoke('send_friend_request', { username }) + }, + + acceptRequest: async (requesterId) => { + await invoke('accept_friend_request', { requesterId }) + set((s) => ({ + requests: s.requests.filter((r) => r.id !== requesterId), + })) + }, + + removeFriend: async (friendId) => { + await invoke('remove_friend', { friendId }) + set((s) => ({ friends: s.friends.filter((f) => f.id !== friendId) })) + }, +})) diff --git a/client/ui/src/stores/networkStore.ts b/client/ui/src/stores/networkStore.ts new file mode 100644 index 0000000..61ef905 --- /dev/null +++ b/client/ui/src/stores/networkStore.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand' +import { invoke } from '@tauri-apps/api/core' + +export interface ConnectionStats { + session_type: string + latency_ms: number + bytes_sent: number + bytes_received: number + connected: boolean +} + +export interface NetworkSession { + room_id: string + local_port: number + session_type: string +} + +interface NetworkState { + stats: ConnectionStats | null + session: NetworkSession | null + connectAddr: string | null + startHosting: (roomId: string, roomName?: string, mcPort?: number) => Promise + joinNetwork: (roomId: string, hostUserId?: string) => Promise + stopNetwork: () => Promise + refreshStats: () => Promise +} + +export const useNetworkStore = create((set) => ({ + stats: null, + session: null, + connectAddr: null, + + startHosting: async (roomId, roomName, mcPort) => { + const session = await invoke('start_hosting', { + roomId, + roomName: roomName ?? null, + mcPort: mcPort ?? null + }) + set({ session }) + return session + }, + + joinNetwork: async (roomId, hostUserId) => { + const info = await invoke<{ connect_addr: string; local_port: number; session_type: string }>( + 'join_room_network', + { roomId, hostUserId: hostUserId ?? null } + ) + set({ + connectAddr: info.connect_addr, + session: { room_id: roomId, local_port: info.local_port, session_type: info.session_type }, + }) + return info.connect_addr + }, + + stopNetwork: async () => { + await invoke('stop_network') + set({ session: null, connectAddr: null, stats: null }) + }, + + refreshStats: async () => { + try { + const stats = await invoke('get_connection_stats') + set({ stats }) + } catch {} + }, +})) diff --git a/client/ui/src/stores/relayNodeStore.ts b/client/ui/src/stores/relayNodeStore.ts new file mode 100644 index 0000000..696268b --- /dev/null +++ b/client/ui/src/stores/relayNodeStore.ts @@ -0,0 +1,57 @@ +import { create } from 'zustand' +import { invoke } from '@tauri-apps/api/core' + +export interface RelayNode { + id: string + name: string + url: string + region: string + is_active: boolean + priority: number + last_ping_ms: number | null +} + +interface RelayNodeState { + nodes: RelayNode[] + loading: boolean + fetchNodes: () => Promise + addNode: (name: string, url: string, region?: string, priority?: number) => Promise + removeNode: (id: string) => Promise + reportPing: (id: string, pingMs: number) => Promise +} + +export const useRelayNodeStore = create((set) => ({ + nodes: [], + loading: false, + + fetchNodes: async () => { + set({ loading: true }) + try { + const nodes = await invoke('list_relay_nodes') + set({ nodes, loading: false }) + } catch { + set({ loading: false }) + } + }, + + addNode: async (name, url, region, priority) => { + const node = await invoke('add_relay_node', { name, url, region, priority }) + set((s) => ({ nodes: [...s.nodes, node] })) + }, + + removeNode: async (id) => { + await invoke('remove_relay_node', { nodeId: id }) + set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id) })) + }, + + reportPing: async (id, pingMs) => { + await invoke('report_relay_ping', { nodeId: id, pingMs }) + set((s) => ({ + nodes: s.nodes.map((n) => + n.id === id + ? { ...n, last_ping_ms: n.last_ping_ms == null ? pingMs : Math.round((n.last_ping_ms + pingMs) / 2) } + : n + ), + })) + }, +})) diff --git a/client/ui/src/stores/roomStore.ts b/client/ui/src/stores/roomStore.ts new file mode 100644 index 0000000..937ffed --- /dev/null +++ b/client/ui/src/stores/roomStore.ts @@ -0,0 +1,89 @@ +import { create } from 'zustand' +import { invoke } from '@tauri-apps/api/core' + +export interface Room { + id: string + name: string + owner_id: string + owner_username: string + max_players: number + current_players: number + is_public: boolean + has_password: boolean + game_version: string + status: string +} + +export interface RoomMember { + user_id: string + username: string + role: string + is_online: boolean +} + +interface RoomState { + rooms: Room[] + currentRoom: Room | null + members: RoomMember[] + loading: boolean + fetchRooms: () => Promise + createRoom: (params: { + name: string + maxPlayers?: number + isPublic?: boolean + password?: string + gameVersion?: string + }) => Promise + joinRoom: (roomId: string, password?: string) => Promise + leaveRoom: (roomId: string) => Promise + fetchMembers: (roomId: string) => Promise + setCurrentRoom: (room: Room | null) => void +} + +export const useRoomStore = create((set) => ({ + rooms: [], + currentRoom: null, + members: [], + loading: false, + + fetchRooms: async () => { + set({ loading: true }) + try { + const rooms = await invoke('list_rooms') + set({ rooms, loading: false }) + } catch { + set({ loading: false }) + } + }, + + createRoom: async ({ name, maxPlayers, isPublic, password, gameVersion }) => { + const roomId = await invoke('create_room', { + name, + maxPlayers, + isPublic, + password, + gameVersion, + }) + return roomId + }, + + joinRoom: async (roomId, password) => { + await invoke('join_room', { roomId, password }) + }, + + leaveRoom: async (roomId) => { + await invoke('leave_room', { roomId }) + set({ currentRoom: null, members: [] }) + }, + + fetchMembers: async (roomId) => { + try { + const members = await invoke('get_room_members', { roomId }) + set({ members }) + } catch { + set({ members: [] }) + } + }, + + setCurrentRoom: (room) => set({ currentRoom: room }), +})) diff --git a/client/ui/tailwind.config.js b/client/ui/tailwind.config.js new file mode 100644 index 0000000..74bebab --- /dev/null +++ b/client/ui/tailwind.config.js @@ -0,0 +1,49 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + 'bg-primary': '#0d1117', + 'bg-secondary': '#161b22', + 'bg-tertiary': '#21262d', + 'border': '#30363d', + 'text-primary': '#f0f6fc', + 'text-secondary': '#c9d1d9', + 'text-muted': '#8b949e', + 'accent-green': '#3fb950', + 'accent-blue': '#58a6ff', + 'accent-purple': '#a371f7', + 'accent-orange': '#d29922', + 'accent-red': '#f85149', + }, + fontFamily: { + 'sans': ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + 'mono': ['JetBrains Mono', 'Fira Code', 'Consolas', 'monospace'], + }, + animation: { + 'fade-in': 'fadeIn 0.2s ease-out', + 'slide-up': 'slideUp 0.3s ease-out', + 'pulse-green': 'pulseGreen 2s infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { opacity: '0', transform: 'translateY(10px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + pulseGreen: { + '0%, 100%': { boxShadow: '0 0 0 0 rgba(63, 185, 80, 0.4)' }, + '50%': { boxShadow: '0 0 0 8px rgba(63, 185, 80, 0)' }, + }, + }, + }, + }, + plugins: [], +} diff --git a/client/ui/tsconfig.json b/client/ui/tsconfig.json new file mode 100644 index 0000000..07b3114 --- /dev/null +++ b/client/ui/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2021", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/client/ui/tsconfig.node.json b/client/ui/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/client/ui/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/client/ui/vite.config.ts b/client/ui/vite.config.ts new file mode 100644 index 0000000..d39a1dd --- /dev/null +++ b/client/ui/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + clearScreen: false, + server: { + port: 5173, + strictPort: true, + }, + envPrefix: ['VITE_', 'TAURI_'], + build: { + target: ['es2021', 'chrome100', 'safari15'], + minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, + sourcemap: !!process.env.TAURI_DEBUG, + }, +}) diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..26ce693 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# +# FunMC 一键 Docker 部署脚本 +# +# 用法: +# curl -fsSL https://raw.githubusercontent.com/mofangfang/funmc/main/deploy.sh | bash +# 或 +# ./deploy.sh +# + +set -e + +# 颜色 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +echo -e "${CYAN}" +echo "╔═══════════════════════════════════════════════════════════╗" +echo "║ FunMC Docker 一键部署脚本 ║" +echo "║ 魔幻方开发 ║" +echo "╚═══════════════════════════════════════════════════════════╝" +echo -e "${NC}" + +# 检查 Docker +if ! command -v docker &> /dev/null; then + echo -e "${RED}错误: 未安装 Docker${NC}" + echo "请先安装 Docker: https://docs.docker.com/get-docker/" + exit 1 +fi + +if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then + echo -e "${RED}错误: 未安装 Docker Compose${NC}" + echo "请先安装 Docker Compose: https://docs.docker.com/compose/install/" + exit 1 +fi + +echo -e "${GREEN}✓ Docker 已安装${NC}" + +# 创建目录 +INSTALL_DIR="${FUNMC_DIR:-/opt/funmc}" +mkdir -p "$INSTALL_DIR" +cd "$INSTALL_DIR" + +# 下载文件 (如果不存在) +if [ ! -f "docker-compose.yml" ]; then + echo -e "${YELLOW}下载配置文件...${NC}" + curl -fsSL https://raw.githubusercontent.com/mofangfang/funmc/main/docker-compose.yml -o docker-compose.yml + curl -fsSL https://raw.githubusercontent.com/mofangfang/funmc/main/Dockerfile.server -o Dockerfile.server + curl -fsSL https://raw.githubusercontent.com/mofangfang/funmc/main/Dockerfile.relay -o Dockerfile.relay + curl -fsSL https://raw.githubusercontent.com/mofangfang/funmc/main/.env.example -o .env.example +fi + +# 生成配置 +if [ ! -f ".env" ]; then + echo -e "${YELLOW}生成配置文件...${NC}" + + # 获取服务器 IP + SERVER_IP=$(curl -s ifconfig.me || curl -s ipinfo.io/ip || hostname -I | awk '{print $1}') + + # 生成随机密码和密钥 + DB_PASSWORD=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 24) + JWT_SECRET=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 48) + ADMIN_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 12) + + cat > .env << EOF +SERVER_IP=$SERVER_IP +SERVER_NAME=FunMC Server +SERVER_DOMAIN= +DB_PASSWORD=$DB_PASSWORD +JWT_SECRET=$JWT_SECRET +ADMIN_USERNAME=admin +ADMIN_PASSWORD=$ADMIN_PASSWORD +RUST_LOG=info +EOF + + echo -e "${GREEN}✓ 配置文件已生成${NC}" +fi + +# 加载配置 +source .env + +# 启动服务 +echo -e "${YELLOW}启动服务...${NC}" +if command -v docker-compose &> /dev/null; then + docker-compose up -d --build +else + docker compose up -d --build +fi + +# 等待服务启动 +echo -e "${YELLOW}等待服务启动...${NC}" +sleep 10 + +# 显示结果 +echo "" +echo -e "${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}" +echo -e "${CYAN}║ ${GREEN}FunMC 部署完成!${CYAN} ║${NC}" +echo -e "${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${GREEN}服务器信息:${NC}" +echo -e " API 地址: http://${SERVER_IP}:3000" +echo -e " 管理面板: http://${SERVER_IP}:3000/admin" +echo -e " 下载页面: http://${SERVER_IP}:3000/download" +echo "" +echo -e "${GREEN}管理员登录:${NC}" +echo -e " 用户名: admin" +echo -e " 密码: ${ADMIN_PASSWORD}" +echo "" +echo -e "${YELLOW}重要提示:${NC}" +echo -e " 请确保以下端口已开放:" +echo -e " - 3000/tcp (API)" +echo -e " - 3001/udp (内置中继)" +echo -e " - 7900-7901/udp (中继服务器)" +echo "" +echo -e "${CYAN}常用命令:${NC}" +echo -e " 查看日志: docker logs -f funmc-server" +echo -e " 重启服务: docker-compose restart" +echo -e " 停止服务: docker-compose down" +echo -e " 更新服务: docker-compose pull && docker-compose up -d" +echo "" +echo -e "${GREEN}魔幻方开发 - 让 Minecraft 联机变得简单${NC}" diff --git a/deploy/funmc-relay.service b/deploy/funmc-relay.service new file mode 100644 index 0000000..5aff38c --- /dev/null +++ b/deploy/funmc-relay.service @@ -0,0 +1,39 @@ +[Unit] +Description=FunMC Relay Server - QUIC Relay for Minecraft Traffic +Documentation=https://github.com/mofangfang/funmc +After=network.target + +[Service] +Type=simple +User=funmc +Group=funmc +WorkingDirectory=/opt/funmc +ExecStart=/opt/funmc/relay-server +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=funmc-relay + +# 环境变量 +Environment=RUST_LOG=funmc_relay_server=info +Environment=RELAY_LISTEN_ADDR=0.0.0.0:7900 +EnvironmentFile=/opt/funmc/.env + +# 安全限制 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes + +# 网络优先 - 中继服务器需要低延迟 +Nice=-5 +IOSchedulingClass=realtime +IOSchedulingPriority=0 + +# 资源限制 +LimitNOFILE=65536 +LimitNPROC=4096 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/funmc-server.service b/deploy/funmc-server.service new file mode 100644 index 0000000..efd284f --- /dev/null +++ b/deploy/funmc-server.service @@ -0,0 +1,36 @@ +[Unit] +Description=FunMC Main Server - Minecraft Multiplayer Assistant API +Documentation=https://github.com/mofangfang/funmc +After=network.target postgresql.service +Requires=postgresql.service + +[Service] +Type=simple +User=funmc +Group=funmc +WorkingDirectory=/opt/funmc +ExecStart=/opt/funmc/server +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=funmc-server + +# 环境变量 +Environment=RUST_LOG=funmc_server=info,tower_http=info +Environment=LISTEN_ADDR=0.0.0.0:3000 +EnvironmentFile=/opt/funmc/.env + +# 安全限制 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/log/funmc +PrivateTmp=yes + +# 资源限制 +LimitNOFILE=65536 +LimitNPROC=4096 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..7a4e39e --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# FunMC 服务器安装脚本 +# 用于 Linux 服务器部署 + +set -e + +echo "================================" +echo " FunMC 服务器安装脚本" +echo " 魔幻方开发" +echo "================================" + +# 检查 root 权限 +if [ "$EUID" -ne 0 ]; then + echo "错误: 请使用 root 权限运行此脚本" + exit 1 +fi + +# 变量 +FUNMC_USER="funmc" +FUNMC_DIR="/opt/funmc" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "" +echo "[1/6] 创建系统用户..." +if ! id "$FUNMC_USER" &>/dev/null; then + useradd --system --shell /sbin/nologin --home-dir "$FUNMC_DIR" "$FUNMC_USER" + echo "已创建用户: $FUNMC_USER" +else + echo "用户已存在: $FUNMC_USER" +fi + +echo "" +echo "[2/6] 创建安装目录..." +mkdir -p "$FUNMC_DIR" +mkdir -p /var/log/funmc + +echo "" +echo "[3/6] 编译服务端..." +cd "$ROOT_DIR" +cargo build --release -p funmc-server +cargo build --release -p funmc-relay-server + +echo "" +echo "[4/6] 安装二进制文件..." +cp target/release/server "$FUNMC_DIR/server" +cp target/release/relay-server "$FUNMC_DIR/relay-server" + +# 复制迁移文件 +cp -r server/migrations "$FUNMC_DIR/" + +# 创建环境配置 +if [ ! -f "$FUNMC_DIR/.env" ]; then + cp .env.example "$FUNMC_DIR/.env" + echo "请编辑配置文件: $FUNMC_DIR/.env" +fi + +# 设置权限 +chown -R "$FUNMC_USER:$FUNMC_USER" "$FUNMC_DIR" +chown -R "$FUNMC_USER:$FUNMC_USER" /var/log/funmc +chmod 600 "$FUNMC_DIR/.env" + +echo "" +echo "[5/6] 安装 systemd 服务..." +cp deploy/funmc-server.service /etc/systemd/system/ +cp deploy/funmc-relay.service /etc/systemd/system/ +systemctl daemon-reload + +echo "" +echo "[6/6] 配置防火墙..." +if command -v ufw &>/dev/null; then + ufw allow 3000/tcp comment "FunMC API" + ufw allow 3001/udp comment "FunMC Built-in Relay" + ufw allow 7900/udp comment "FunMC Relay" + ufw allow 17900/udp comment "FunMC Relay Ping" +elif command -v firewall-cmd &>/dev/null; then + firewall-cmd --permanent --add-port=3000/tcp + firewall-cmd --permanent --add-port=3001/udp + firewall-cmd --permanent --add-port=7900/udp + firewall-cmd --permanent --add-port=17900/udp + firewall-cmd --reload +fi + +echo "" +echo "================================" +echo " 安装完成!" +echo "================================" +echo "" +echo "后续步骤:" +echo "1. 编辑配置: nano $FUNMC_DIR/.env" +echo "2. 初始化数据库并启动服务:" +echo " systemctl enable --now funmc-server" +echo " systemctl enable --now funmc-relay" +echo "3. 查看日志:" +echo " journalctl -u funmc-server -f" +echo " journalctl -u funmc-relay -f" +echo "" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c8bf05 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +version: '3.8' + +services: + # PostgreSQL 数据库 + postgres: + image: postgres:15-alpine + container_name: funmc-postgres + restart: always + environment: + POSTGRES_USER: funmc + POSTGRES_PASSWORD: ${DB_PASSWORD:-funmc_secret_password} + POSTGRES_DB: funmc + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U funmc"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - funmc-network + + # FunMC API 服务器 + server: + build: + context: . + dockerfile: Dockerfile.server + container_name: funmc-server + restart: always + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgres://funmc:${DB_PASSWORD:-funmc_secret_password}@postgres/funmc + JWT_SECRET: ${JWT_SECRET:-change_this_in_production} + LISTEN_ADDR: 0.0.0.0:3000 + RUST_LOG: info + SERVER_NAME: ${SERVER_NAME:-FunMC Server} + SERVER_IP: ${SERVER_IP:-} + SERVER_DOMAIN: ${SERVER_DOMAIN:-} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} + ADMIN_PANEL_DIR: /app/admin-panel + DOWNLOADS_DIR: /app/downloads + ports: + - "3000:3000" + - "3001:3001/udp" + volumes: + - admin_panel:/app/admin-panel + - downloads:/app/downloads + networks: + - funmc-network + + # FunMC 中继服务器 + relay: + build: + context: . + dockerfile: Dockerfile.relay + container_name: funmc-relay + restart: always + environment: + RELAY_PORT: 7900 + JWT_SECRET: ${JWT_SECRET:-change_this_in_production} + RUST_LOG: info + ports: + - "7900:7900/udp" + - "7901:7901/udp" + networks: + - funmc-network + +volumes: + postgres_data: + admin_panel: + downloads: + +networks: + funmc-network: + driver: bridge diff --git a/docker/Dockerfile.relay b/docker/Dockerfile.relay new file mode 100644 index 0000000..73f3845 --- /dev/null +++ b/docker/Dockerfile.relay @@ -0,0 +1,34 @@ +# FunMC 中继服务端 Docker 镜像 +FROM rust:1.75-slim-bookworm AS builder + +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY shared/ ./shared/ +COPY relay-server/ ./relay-server/ + +RUN cargo build --release -p funmc-relay-server + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /app/target/release/relay-server /app/relay-server + +ENV RUST_LOG=funmc_relay_server=info +ENV RELAY_LISTEN_ADDR=0.0.0.0:7900 +ENV JWT_SECRET=your-jwt-secret-change-in-production + +EXPOSE 7900/udp 17900/udp + +CMD ["./relay-server"] diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server new file mode 100644 index 0000000..80ef640 --- /dev/null +++ b/docker/Dockerfile.server @@ -0,0 +1,35 @@ +# FunMC 主服务端 Docker 镜像 +FROM rust:1.75-slim-bookworm AS builder + +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY shared/ ./shared/ +COPY server/ ./server/ + +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/* + +WORKDIR /app + +COPY --from=builder /app/target/release/server /app/server +COPY server/migrations /app/migrations + +ENV RUST_LOG=funmc_server=info +ENV LISTEN_ADDR=0.0.0.0:3000 +ENV DATABASE_URL=postgres://postgres:password@db/funmc + +EXPOSE 3000 3001 + +CMD ["./server"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..022527d --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,94 @@ +version: '3.8' + +services: + db: + image: postgres:14-alpine + container_name: funmc-db + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${DB_PASSWORD:-funmc_password} + POSTGRES_DB: funmc + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - funmc-network + + server: + build: + context: .. + dockerfile: docker/Dockerfile.server + container_name: funmc-server + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://postgres:${DB_PASSWORD:-funmc_password}@db/funmc + JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production} + LISTEN_ADDR: 0.0.0.0:3000 + RUST_LOG: funmc_server=info,tower_http=info + ports: + - "3000:3000" + - "3001:3001/udp" + networks: + - funmc-network + + relay: + build: + context: .. + dockerfile: docker/Dockerfile.relay + container_name: funmc-relay + restart: unless-stopped + environment: + RELAY_LISTEN_ADDR: 0.0.0.0:7900 + JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production} + RUST_LOG: funmc_relay_server=info + ports: + - "7900:7900/udp" + - "17900:17900/udp" + networks: + - funmc-network + + relay-backup: + build: + context: .. + dockerfile: docker/Dockerfile.relay + container_name: funmc-relay-backup + restart: unless-stopped + environment: + RELAY_LISTEN_ADDR: 0.0.0.0:7901 + JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production} + RUST_LOG: funmc_relay_server=info + ports: + - "7901:7901/udp" + - "17901:17901/udp" + networks: + - funmc-network + + nginx: + image: nginx:alpine + container_name: funmc-nginx + restart: unless-stopped + depends_on: + - server + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./ssl:/etc/nginx/ssl:ro + networks: + - funmc-network + +volumes: + postgres_data: + +networks: + funmc-network: + driver: bridge diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..6ccaf28 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,53 @@ +events { + worker_connections 1024; +} + +http { + upstream api_server { + server server:3000; + } + + server { + listen 80; + server_name funmc.com www.funmc.com; + + location / { + return 301 https://$server_name$request_uri; + } + } + + server { + listen 443 ssl http2; + server_name funmc.com www.funmc.com; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + location /api/ { + proxy_pass http://api_server; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + location /download/ { + alias /var/www/downloads/; + autoindex on; + } + } +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..1281caf --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,574 @@ +# FunMC 部署指南 + +本文档详细介绍如何部署 FunMC 服务端和客户端。 + +## 目录 + +- [系统要求](#系统要求) +- [快速部署 (Docker)](#快速部署-docker) +- [手动部署](#手动部署) +- [生产环境配置](#生产环境配置) +- [监控与维护](#监控与维护) +- [局域网部署](#局域网部署) + +--- + +## 系统要求 + +### 服务器 + +| 组件 | 最低要求 | 推荐配置 | +|------|---------|---------| +| CPU | 1 核 | 2 核+ | +| 内存 | 512 MB | 1 GB+ | +| 存储 | 1 GB | 10 GB+ | +| 系统 | Ubuntu 20.04 / Debian 11 / CentOS 8 | Ubuntu 22.04 LTS | +| 网络 | 1 Mbps | 10 Mbps+ | + +### 开放端口 + +| 端口 | 协议 | 用途 | +|------|------|------| +| 80 | TCP | HTTP (重定向到 HTTPS) | +| 443 | TCP | HTTPS (API + WebSocket) | +| 7900 | UDP | QUIC 中继服务 (主线路) | +| 7901 | UDP | QUIC 中继服务 (备用) | + +--- + +## 快速部署 (Docker) + +### 1. 安装 Docker + +```bash +# Ubuntu/Debian +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER + +# 安装 Docker Compose +sudo apt install docker-compose-plugin +``` + +### 2. 获取配置文件 + +```bash +# 克隆仓库 +git clone https://github.com/mofangfang/funmc.git +cd funmc + +# 或者只下载 Docker 配置 +mkdir funmc && cd funmc +curl -O https://raw.githubusercontent.com/mofangfang/funmc/main/docker/docker-compose.yml +curl -O https://raw.githubusercontent.com/mofangfang/funmc/main/.env.example +``` + +### 3. 配置环境变量 + +```bash +cp .env.example .env +``` + +编辑 `.env` 文件: + +```env +# 数据库配置 +POSTGRES_USER=funmc +POSTGRES_PASSWORD=your_secure_password_here +POSTGRES_DB=funmc + +# JWT 密钥 (至少 32 字符) +JWT_SECRET=change-this-to-a-very-long-random-string-at-least-32-chars + +# 服务器地址 +SERVER_DOMAIN=funmc.example.com + +# 中继服务器配置 +RELAY_PORT=7900 +RELAY_BACKUP_PORT=7901 +``` + +### 4. 启动服务 + +```bash +# 启动所有服务 +docker compose up -d + +# 查看运行状态 +docker compose ps + +# 查看日志 +docker compose logs -f + +# 停止服务 +docker compose down +``` + +### 5. 配置 SSL 证书 + +推荐使用 Certbot 获取免费 SSL 证书: + +```bash +# 安装 Certbot +sudo apt install certbot + +# 获取证书 +sudo certbot certonly --standalone -d funmc.example.com + +# 证书位置 +# /etc/letsencrypt/live/funmc.example.com/fullchain.pem +# /etc/letsencrypt/live/funmc.example.com/privkey.pem +``` + +--- + +## 手动部署 + +### 1. 安装依赖 + +#### Ubuntu/Debian + +```bash +# 更新系统 +sudo apt update && sudo apt upgrade -y + +# 安装基础工具 +sudo apt install -y build-essential pkg-config libssl-dev curl git + +# 安装 Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env + +# 安装 PostgreSQL +sudo apt install -y postgresql postgresql-contrib + +# 启动 PostgreSQL +sudo systemctl enable postgresql +sudo systemctl start postgresql +``` + +#### CentOS/RHEL + +```bash +# 更新系统 +sudo dnf update -y + +# 安装基础工具 +sudo dnf groupinstall -y "Development Tools" +sudo dnf install -y openssl-devel curl git + +# 安装 Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env + +# 安装 PostgreSQL +sudo dnf install -y postgresql-server postgresql-contrib +sudo postgresql-setup --initdb +sudo systemctl enable postgresql +sudo systemctl start postgresql +``` + +### 2. 配置数据库 + +```bash +# 切换到 postgres 用户 +sudo -u postgres psql + +# 在 psql 中执行: +CREATE DATABASE funmc; +CREATE USER funmc WITH ENCRYPTED PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE funmc TO funmc; +\q +``` + +编辑 `/etc/postgresql/14/main/pg_hba.conf` (版本号可能不同): + +``` +# 添加以下行允许本地密码认证 +local funmc funmc md5 +host funmc funmc 127.0.0.1/32 md5 +``` + +重启 PostgreSQL: + +```bash +sudo systemctl restart postgresql +``` + +### 3. 编译服务 + +```bash +# 克隆仓库 +git clone https://github.com/mofangfang/funmc.git +cd funmc + +# 编译主服务器 +cargo build --release -p funmc-server + +# 编译中继服务器 +cargo build --release -p funmc-relay-server + +# 复制二进制文件 +sudo mkdir -p /opt/funmc/bin +sudo cp target/release/funmc-server /opt/funmc/bin/ +sudo cp target/release/funmc-relay-server /opt/funmc/bin/ +``` + +### 4. 配置服务 + +创建配置目录: + +```bash +sudo mkdir -p /etc/funmc +``` + +创建主服务器配置 `/etc/funmc/server.env`: + +```env +# 数据库连接 +DATABASE_URL=postgres://funmc:your_password@localhost/funmc + +# JWT 密钥 +JWT_SECRET=your-super-secret-jwt-key-at-least-32-characters + +# 服务绑定地址 +BIND_ADDR=127.0.0.1:3000 + +# 内置 QUIC 中继端口 +QUIC_PORT=3001 + +# 日志级别 +RUST_LOG=funmc_server=info,tower_http=info +``` + +创建中继服务器配置 `/etc/funmc/relay.env`: + +```env +# 中继端口 +RELAY_PORT=7900 + +# JWT 密钥 (必须与主服务器一致) +JWT_SECRET=your-super-secret-jwt-key-at-least-32-characters + +# 日志级别 +RUST_LOG=funmc_relay_server=info +``` + +### 5. 运行数据库迁移 + +```bash +cd /path/to/funmc/server + +# 安装 sqlx-cli +cargo install sqlx-cli --no-default-features --features postgres + +# 运行迁移 +DATABASE_URL=postgres://funmc:your_password@localhost/funmc sqlx migrate run +``` + +### 6. 创建 Systemd 服务 + +主服务器 `/etc/systemd/system/funmc-server.service`: + +```ini +[Unit] +Description=FunMC API Server +After=network.target postgresql.service + +[Service] +Type=simple +User=funmc +Group=funmc +WorkingDirectory=/opt/funmc +EnvironmentFile=/etc/funmc/server.env +ExecStart=/opt/funmc/bin/funmc-server +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +中继服务器 `/etc/systemd/system/funmc-relay.service`: + +```ini +[Unit] +Description=FunMC Relay Server +After=network.target + +[Service] +Type=simple +User=funmc +Group=funmc +WorkingDirectory=/opt/funmc +EnvironmentFile=/etc/funmc/relay.env +ExecStart=/opt/funmc/bin/funmc-relay-server +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +创建服务用户并启动: + +```bash +# 创建用户 +sudo useradd -r -s /bin/false funmc + +# 设置权限 +sudo chown -R funmc:funmc /opt/funmc + +# 重载 systemd +sudo systemctl daemon-reload + +# 启动服务 +sudo systemctl enable funmc-server funmc-relay +sudo systemctl start funmc-server funmc-relay + +# 检查状态 +sudo systemctl status funmc-server funmc-relay +``` + +--- + +## 生产环境配置 + +### Nginx 反向代理 + +安装 Nginx: + +```bash +sudo apt install -y nginx +``` + +创建配置 `/etc/nginx/sites-available/funmc`: + +```nginx +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name funmc.example.com; + return 301 https://$server_name$request_uri; +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name funmc.example.com; + + # SSL 证书 + ssl_certificate /etc/letsencrypt/live/funmc.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/funmc.example.com/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + # 日志 + access_log /var/log/nginx/funmc_access.log; + error_log /var/log/nginx/funmc_error.log; + + # API 代理 + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # WebSocket 代理 + location /api/v1/ws { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; + } +} +``` + +启用配置: + +```bash +sudo ln -s /etc/nginx/sites-available/funmc /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +### 防火墙配置 + +```bash +# UFW (Ubuntu) +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw allow 7900/udp +sudo ufw allow 7901/udp +sudo ufw enable + +# firewalld (CentOS) +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --permanent --add-port=7900/udp +sudo firewall-cmd --permanent --add-port=7901/udp +sudo firewall-cmd --reload +``` + +--- + +## 监控与维护 + +### 日志查看 + +```bash +# 主服务器日志 +sudo journalctl -u funmc-server -f + +# 中继服务器日志 +sudo journalctl -u funmc-relay -f + +# Nginx 访问日志 +sudo tail -f /var/log/nginx/funmc_access.log +``` + +### 数据库备份 + +```bash +# 备份 +pg_dump -U funmc -d funmc > backup_$(date +%Y%m%d).sql + +# 恢复 +psql -U funmc -d funmc < backup_20240101.sql +``` + +### 更新服务 + +```bash +cd /path/to/funmc +git pull + +# 重新编译 +cargo build --release -p funmc-server -p funmc-relay-server + +# 更新二进制 +sudo systemctl stop funmc-server funmc-relay +sudo cp target/release/funmc-server /opt/funmc/bin/ +sudo cp target/release/funmc-relay-server /opt/funmc/bin/ +sudo systemctl start funmc-server funmc-relay +``` + +--- + +## 局域网部署 + +如果只需要在局域网内使用 FunMC,可以使用简化配置。 + +### 局域网服务器配置 + +1. **安装依赖**(同上) + +2. **简化配置** `/etc/funmc/server.env`: + +```env +DATABASE_URL=postgres://funmc:password@localhost/funmc +JWT_SECRET=local-development-secret-key-32chars +BIND_ADDR=0.0.0.0:3000 +QUIC_PORT=3001 +RUST_LOG=info +``` + +3. **启动服务**: + +```bash +# 直接运行 +./funmc-server + +# 或使用 systemd +sudo systemctl start funmc-server +``` + +### 客户端配置 + +编辑客户端配置文件,将服务器地址改为局域网 IP: + +文件位置: `client/src/config.rs` + +```rust +pub const DEFAULT_SERVER_URL: &str = "http://192.168.1.100:3000"; +pub const DEFAULT_RELAY_URL: &str = "192.168.1.100:7900"; +``` + +或者在客户端设置页面修改服务器地址。 + +### 局域网联机测试 + +1. **启动 Minecraft 服务器** + - 在一台电脑上启动 Minecraft 服务器 + - 或在单人世界中开启局域网联机 + +2. **主机操作** + - 打开 FunMC 客户端 + - 登录账号(首次需要注册) + - 创建房间 + - 点击"开始托管" + +3. **玩家操作** + - 打开 FunMC 客户端 + - 登录账号 + - 在大厅找到房间并加入 + - 点击"连接" + - 复制显示的地址(如 `127.0.0.1:25566`) + - 在 Minecraft 中添加服务器并连接 + +4. **验证连接** + - 检查 FunMC 显示的连接类型 + - 局域网内通常会显示"P2P 直连" + - 如果是"中继",检查防火墙设置 + +--- + +## 常见问题 + +### 数据库连接失败 + +``` +Error: connection refused +``` + +解决方案: +1. 检查 PostgreSQL 服务是否运行: `sudo systemctl status postgresql` +2. 检查 pg_hba.conf 配置 +3. 确认用户名密码正确 + +### QUIC 端口无法连接 + +``` +Error: connection timed out +``` + +解决方案: +1. 检查防火墙是否开放 UDP 端口 +2. 确认端口没有被其他程序占用: `sudo netstat -ulnp | grep 7900` + +### WebSocket 连接断开 + +解决方案: +1. 检查 Nginx 配置中的 `proxy_read_timeout` +2. 确认客户端心跳正常发送 + +--- + +## 支持 + +如有问题,请通过以下方式获取帮助: + +- GitHub Issues: https://github.com/mofangfang/funmc/issues +- 官方文档: https://docs.funmc.com +- 邮箱: support@funmc.com + +--- + +*魔幻方开发 © 2024* diff --git a/docs/LAN_TEST.md b/docs/LAN_TEST.md new file mode 100644 index 0000000..c63766c --- /dev/null +++ b/docs/LAN_TEST.md @@ -0,0 +1,309 @@ +# FunMC 局域网联机测试指南 + +本文档介绍如何测试 FunMC 的局域网联机功能。 + +## 测试环境准备 + +### 硬件要求 + +- 至少 2 台电脑(或使用虚拟机) +- 所有设备在同一局域网内 +- 每台设备需安装 Minecraft Java Edition + +### 软件要求 + +- PostgreSQL 14+ +- Rust 1.75+ +- Node.js 18+ +- Minecraft Java Edition 1.16+ + +--- + +## 测试步骤 + +### 第一步:部署本地服务器 + +在其中一台电脑上部署 FunMC 服务器: + +```bash +# 1. 克隆项目 +git clone https://github.com/mofangfang/funmc.git +cd funmc + +# 2. 启动数据库 +docker run -d --name funmc-db \ + -p 5432:5432 \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_DB=funmc \ + postgres:14 + +# 3. 配置环境变量 +cat > server/.env << EOF +DATABASE_URL=postgres://postgres:password@localhost/funmc +JWT_SECRET=test-jwt-secret-for-local-development +BIND_ADDR=0.0.0.0:3000 +QUIC_PORT=3001 +RUST_LOG=info +EOF + +# 4. 运行数据库迁移 +cd server +cargo sqlx database create +cargo sqlx migrate run + +# 5. 启动主服务器 +cargo run +``` + +记录服务器的局域网 IP 地址(如 `192.168.1.100`)。 + +### 第二步:配置客户端 + +在所有参与测试的电脑上: + +1. 修改客户端配置文件 `client/src/config.rs`: + +```rust +pub const DEFAULT_SERVER_URL: &str = "http://192.168.1.100:3000"; +``` + +2. 编译并运行客户端: + +```bash +cd client/ui +npm install +cd .. +cargo tauri dev +``` + +### 第三步:测试场景 + +#### 场景 1:基本局域网联机 + +**主机操作:** +1. 启动 Minecraft,创建单人世界 +2. 按 `Esc` → `对局域网开放` +3. 选择游戏模式,点击「开始局域网世界」 +4. 记下显示的端口号(如 25565) +5. 打开 FunMC 客户端 +6. 注册/登录账号 +7. 创建房间(填写房间名、游戏版本) +8. 点击「开始托管」 + +**预期结果:** +- FunMC 显示"托管中"状态 +- 显示 QUIC 端口号 + +**玩家操作:** +1. 打开 FunMC 客户端 +2. 登录账号 +3. 在大厅找到主机创建的房间 +4. 点击加入房间 +5. 在房间页面点击「连接」 +6. 等待连接建立 +7. 复制显示的地址(如 `127.0.0.1:25566`) +8. 打开 Minecraft → 多人游戏 → 添加服务器 +9. 粘贴地址并连接 + +**预期结果:** +- FunMC 显示连接类型(P2P 直连 或 中继) +- 能够成功进入 Minecraft 服务器 + +#### 场景 2:P2P 直连测试 + +在局域网环境下,两台电脑应该能够建立 P2P 直连。 + +**验证方法:** +1. 连接成功后,检查 FunMC 显示的连接类型 +2. 应显示「P2P 直连」 +3. 延迟应该很低(通常 < 10ms) + +**如果显示「中继」:** +- 检查防火墙设置 +- 确认 UDP 端口 34000-34100 未被阻止 +- 检查路由器是否启用了 UPnP + +#### 场景 3:房间聊天测试 + +1. 主机和玩家都进入同一房间 +2. 在房间页面的聊天框中发送消息 +3. 验证双方都能收到消息 + +**预期结果:** +- 消息实时显示 +- 显示发送者用户名和时间 + +#### 场景 4:多玩家测试 + +1. 让 3 人以上加入同一房间 +2. 所有人都点击「连接」 +3. 所有人都进入 Minecraft 服务器 + +**预期结果:** +- 所有玩家都能成功连接 +- 游戏内能看到所有玩家 + +#### 场景 5:断线重连测试 + +1. 建立连接并进入游戏 +2. 在 FunMC 中点击「断开连接」 +3. 重新点击「连接」 +4. 在 Minecraft 中重新连接服务器 + +**预期结果:** +- 能够重新建立连接 +- 游戏继续正常进行 + +--- + +## 测试检查清单 + +### 服务端检查 + +| 检查项 | 预期结果 | 实际结果 | +|--------|---------|---------| +| 服务器启动 | 无错误日志 | | +| 数据库连接 | 连接成功 | | +| API 响应 | 返回 200 OK | | +| WebSocket | 能够建立连接 | | + +### 客户端检查 + +| 检查项 | 预期结果 | 实际结果 | +|--------|---------|---------| +| 注册功能 | 成功创建账号 | | +| 登录功能 | 成功登录 | | +| 创建房间 | 房间出现在大厅 | | +| 加入房间 | 成功进入房间 | | +| 开始托管 | 状态显示"托管中" | | +| 连接功能 | 显示代理地址 | | +| 聊天功能 | 消息实时同步 | | + +### 网络检查 + +| 检查项 | 预期结果 | 实际结果 | +|--------|---------|---------| +| P2P 连接 | 局域网内成功 | | +| 中继连接 | 作为备用可用 | | +| 延迟 | P2P < 10ms | | +| 数据传输 | Minecraft 流量正常 | | + +--- + +## 常见问题排查 + +### 问题 1:无法连接到服务器 + +**症状:** 客户端显示"连接失败" + +**排查步骤:** +1. 确认服务器正在运行: + ```bash + curl http://192.168.1.100:3000/api/v1/rooms + ``` +2. 检查防火墙: + ```bash + # Windows + netsh advfirewall firewall add rule name="FunMC" dir=in action=allow protocol=TCP localport=3000 + + # Linux + sudo ufw allow 3000/tcp + ``` +3. 确认网络连通: + ```bash + ping 192.168.1.100 + ``` + +### 问题 2:P2P 连接失败 + +**症状:** 始终使用中继连接 + +**排查步骤:** +1. 检查 UDP 端口: + ```bash + # 确认端口 34000-34100 未被占用 + netstat -an | grep 34000 + ``` +2. 检查 NAT 类型(对称型 NAT 无法打洞) +3. 暂时禁用防火墙测试 + +### 问题 3:Minecraft 无法连接 + +**症状:** 在 Minecraft 中添加服务器后连接失败 + +**排查步骤:** +1. 确认 FunMC 显示"已连接" +2. 检查代理地址是否正确复制 +3. 确认 Minecraft 服务器正在运行 +4. 检查 Minecraft 版本兼容性 + +### 问题 4:延迟过高 + +**症状:** 游戏卡顿,延迟 > 100ms + +**排查步骤:** +1. 检查是否使用 P2P 直连 +2. 检查网络带宽 +3. 关闭其他占用带宽的程序 +4. 尝试使用有线网络代替 WiFi + +--- + +## 性能测试 + +### 延迟测试 + +使用 FunMC 内置的延迟显示功能: + +| 连接类型 | 预期延迟 | +|---------|---------| +| P2P (局域网) | < 5ms | +| P2P (公网) | 10-50ms | +| 中继 | 50-150ms | + +### 带宽测试 + +Minecraft 典型带宽需求: +- 单人连接:约 50-100 KB/s +- 多人服务器:每人约 100-200 KB/s + +--- + +## 测试报告模板 + +``` +测试日期: ____________ +测试人员: ____________ +FunMC 版本: ____________ + +测试环境: +- 服务器 IP: ____________ +- 参与电脑数量: ____________ +- Minecraft 版本: ____________ + +测试结果: +- 基本联机: [ ] 通过 [ ] 失败 +- P2P 直连: [ ] 通过 [ ] 失败 +- 房间聊天: [ ] 通过 [ ] 失败 +- 多玩家: [ ] 通过 [ ] 失败 +- 断线重连: [ ] 通过 [ ] 失败 + +问题记录: +____________________________________________ + +建议改进: +____________________________________________ +``` + +--- + +## 联系支持 + +如测试中遇到问题,请通过以下方式反馈: + +- GitHub Issues: https://github.com/mofangfang/funmc/issues +- 邮箱: support@funmc.com + +--- + +*魔幻方开发 © 2024* diff --git a/docs/NO_PUBLIC_IP.md b/docs/NO_PUBLIC_IP.md new file mode 100644 index 0000000..e02d308 --- /dev/null +++ b/docs/NO_PUBLIC_IP.md @@ -0,0 +1,218 @@ +# 无公网 IP 部署方案 + +如果你的服务器没有公网 IP(比如家庭宽带、公司内网),可以使用以下方案: + +## 方案一:使用云服务器(推荐) + +购买一台云服务器(阿里云、腾讯云、AWS 等),价格低至几十元/月: + +```bash +# 在云服务器上一键部署 +curl -fsSL https://raw.githubusercontent.com/mofangfang/funmc/main/deploy.sh | bash +``` + +优点: +- 稳定可靠,24小时在线 +- 有固定公网 IP +- 部署简单 + +## 方案二:使用内网穿透工具 + +### 使用 frp(免费开源) + +1. **在有公网 IP 的服务器上部署 frps** + +```bash +# 下载 frp +wget https://github.com/fatedier/frp/releases/download/v0.52.0/frp_0.52.0_linux_amd64.tar.gz +tar -xzf frp_0.52.0_linux_amd64.tar.gz +cd frp_0.52.0_linux_amd64 + +# 配置 frps.toml +cat > frps.toml << 'EOF' +bindPort = 7000 +auth.token = "your_secret_token" +EOF + +# 启动 +./frps -c frps.toml +``` + +2. **在内网服务器上部署 frpc + FunMC** + +```bash +# 先部署 FunMC +docker-compose up -d + +# 配置 frpc.toml +cat > frpc.toml << 'EOF' +serverAddr = "你的frps服务器IP" +serverPort = 7000 +auth.token = "your_secret_token" + +[[proxies]] +name = "funmc-api" +type = "tcp" +localIP = "127.0.0.1" +localPort = 3000 +remotePort = 3000 + +[[proxies]] +name = "funmc-quic" +type = "udp" +localIP = "127.0.0.1" +localPort = 3001 +remotePort = 3001 + +[[proxies]] +name = "funmc-relay1" +type = "udp" +localIP = "127.0.0.1" +localPort = 7900 +remotePort = 7900 + +[[proxies]] +name = "funmc-relay2" +type = "udp" +localIP = "127.0.0.1" +localPort = 7901 +remotePort = 7901 +EOF + +# 启动 frpc +./frpc -c frpc.toml +``` + +### 使用 Cloudflare Tunnel(免费) + +适合只需要 HTTP/WebSocket 的场景(不支持 UDP,中继功能需要单独处理): + +```bash +# 安装 cloudflared +curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared +chmod +x cloudflared + +# 登录 +./cloudflared tunnel login + +# 创建隧道 +./cloudflared tunnel create funmc + +# 配置 +cat > ~/.cloudflared/config.yml << 'EOF' +tunnel: funmc +credentials-file: ~/.cloudflared/.json + +ingress: + - hostname: funmc.your-domain.com + service: http://localhost:3000 + - service: http_status:404 +EOF + +# 添加 DNS 记录 +./cloudflared tunnel route dns funmc funmc.your-domain.com + +# 启动 +./cloudflared tunnel run funmc +``` + +**注意**: Cloudflare Tunnel 不支持 UDP,中继服务器需要单独使用 frp 或其他方案。 + +### 使用 Ngrok(简单但有限制) + +```bash +# 安装 +curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc +echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | sudo tee /etc/apt/sources.list.d/ngrok.list +sudo apt update && sudo apt install ngrok + +# 配置 token +ngrok config add-authtoken + +# 启动(免费版每次地址会变) +ngrok http 3000 +``` + +## 方案三:使用 ZeroTier/Tailscale 组建虚拟局域网 + +适合小团队内部使用: + +### Tailscale + +```bash +# 服务器和所有客户端都安装 Tailscale +curl -fsSL https://tailscale.com/install.sh | sh +tailscale up + +# 使用 Tailscale 分配的内网 IP 访问 +# 例如:100.x.x.x:3000 +``` + +### ZeroTier + +```bash +# 安装 +curl -s https://install.zerotier.com | sudo bash + +# 加入网络 +sudo zerotier-cli join + +# 使用 ZeroTier 分配的内网 IP +``` + +## 方案四:端口映射(如果路由器支持) + +如果你的宽带有公网 IP(可能是动态的): + +1. 登录路由器管理页面 +2. 找到 "端口映射" 或 "虚拟服务器" 或 "NAT" +3. 添加映射规则: + - 外部端口 3000 -> 内部 IP:3000 (TCP) + - 外部端口 3001 -> 内部 IP:3001 (UDP) + - 外部端口 7900 -> 内部 IP:7900 (UDP) + - 外部端口 7901 -> 内部 IP:7901 (UDP) + +4. 使用动态 DNS(DDNS)获取固定域名: + - 花生壳 + - No-IP + - DuckDNS + +## 配置客户端连接 + +无论使用哪种方案,都需要更新 FunMC 服务器配置: + +```bash +# 编辑 .env 文件 +nano /opt/funmc/.env + +# 设置为穿透后的公网地址 +SERVER_IP=frps服务器IP +# 或者设置域名 +SERVER_DOMAIN=funmc.your-domain.com +``` + +然后重启服务: + +```bash +docker-compose restart +``` + +## 客户端连接流程 + +1. 客户端启动时会显示服务器连接页面 +2. 输入穿透后的地址(如 `frps服务器IP:3000` 或 `funmc.your-domain.com`) +3. 客户端会自动获取服务器配置 +4. 之后每次启动都会自动连接 + +## 推荐方案 + +| 场景 | 推荐方案 | +|------|---------| +| 生产环境/公开服务 | 云服务器 | +| 个人/小团队 | frp + 便宜云服务器 | +| 内部测试 | Tailscale/ZeroTier | +| 临时使用 | Ngrok | + +--- + +**魔幻方开发** - 让 Minecraft 联机变得简单 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..3be0e10 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,331 @@ +# +# FunMC 一键部署脚本 (Windows) +# 用法: irm funmc.com/install.ps1 | iex +# + +$ErrorActionPreference = "Stop" + +# 配置 +$FUNMC_VERSION = "0.1.0" +$INSTALL_DIR = "$env:ProgramFiles\FunMC" +$CONFIG_DIR = "$env:ProgramData\FunMC" +$DATA_DIR = "$env:ProgramData\FunMC\data" +$LOG_DIR = "$env:ProgramData\FunMC\logs" + +# 颜色输出函数 +function Write-ColorOutput($ForegroundColor, $Message) { + $fc = $host.UI.RawUI.ForegroundColor + $host.UI.RawUI.ForegroundColor = $ForegroundColor + Write-Output $Message + $host.UI.RawUI.ForegroundColor = $fc +} + +function Write-Info($Message) { Write-ColorOutput Cyan "[INFO] $Message" } +function Write-Success($Message) { Write-ColorOutput Green "[✓] $Message" } +function Write-Warning($Message) { Write-ColorOutput Yellow "[!] $Message" } +function Write-Error($Message) { Write-ColorOutput Red "[✗] $Message" } + +# 显示标题 +Write-Host "" +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ ║" -ForegroundColor Cyan +Write-Host "║ FunMC 服务端一键部署脚本 v$FUNMC_VERSION ║" -ForegroundColor Cyan +Write-Host "║ ║" -ForegroundColor Cyan +Write-Host "║ 魔幻方开发 ║" -ForegroundColor Cyan +Write-Host "║ ║" -ForegroundColor Cyan +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +# 检查管理员权限 +$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Error "请以管理员身份运行此脚本" + Write-Host "右键点击 PowerShell,选择 '以管理员身份运行'" + exit 1 +} + +# 生成随机字符串 +function Get-RandomString($Length) { + $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + -join ((1..$Length) | ForEach-Object { $chars[(Get-Random -Maximum $chars.Length)] }) +} + +# 检查并安装 Chocolatey +function Install-Chocolatey { + Write-Info "[1/7] 检查 Chocolatey..." + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Info "安装 Chocolatey..." + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + } + Write-Success "Chocolatey 就绪" +} + +# 安装依赖 +function Install-Dependencies { + Write-Info "[2/7] 安装系统依赖..." + + # 安装 Rust + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + Write-Info "安装 Rust..." + choco install rust -y + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + + # 安装 Node.js + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Info "安装 Node.js..." + choco install nodejs-lts -y + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + + # 安装 PostgreSQL + if (-not (Get-Command psql -ErrorAction SilentlyContinue)) { + Write-Info "安装 PostgreSQL..." + choco install postgresql -y + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + + # 安装 Git + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Info "安装 Git..." + choco install git -y + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + + Write-Success "系统依赖安装完成" +} + +# 配置数据库 +function Setup-Database { + Write-Info "[3/7] 配置数据库..." + + $global:DB_PASSWORD = Get-RandomString 24 + + # 启动 PostgreSQL 服务 + Start-Service postgresql* -ErrorAction SilentlyContinue + + # 创建数据库和用户 + $pgCommands = @" +CREATE USER funmc WITH PASSWORD '$DB_PASSWORD'; +CREATE DATABASE funmc OWNER funmc; +GRANT ALL PRIVILEGES ON DATABASE funmc TO funmc; +"@ + + try { + $pgCommands | psql -U postgres 2>$null + } catch { + Write-Warning "数据库可能已存在,继续..." + } + + Write-Success "数据库配置完成" +} + +# 创建目录 +function Create-Directories { + Write-Info "[4/7] 创建目录结构..." + + @($INSTALL_DIR, $CONFIG_DIR, $DATA_DIR, $LOG_DIR) | ForEach-Object { + if (-not (Test-Path $_)) { + New-Item -ItemType Directory -Path $_ -Force | Out-Null + } + } + + Write-Success "目录创建完成" +} + +# 下载并编译 +function Build-FunMC { + Write-Info "[5/7] 编译 FunMC..." + + $srcDir = "$INSTALL_DIR\src" + + if (Test-Path $srcDir) { + Set-Location $srcDir + git pull + } else { + git clone https://github.com/mofangfang/funmc.git $srcDir + Set-Location $srcDir + } + + # 编译服务端 + cargo build --release -p funmc-server -p funmc-relay-server + + # 复制二进制文件 + Copy-Item "target\release\funmc-server.exe" $INSTALL_DIR -Force + Copy-Item "target\release\funmc-relay-server.exe" $INSTALL_DIR -Force + + # 编译管理面板 + Set-Location "$srcDir\admin-panel" + npm install + npm run build + Copy-Item "dist" "$INSTALL_DIR\admin-panel" -Recurse -Force + + Write-Success "FunMC 编译完成" +} + +# 配置服务 +function Configure-Services { + Write-Info "[6/7] 配置服务..." + + $JWT_SECRET = Get-RandomString 48 + $ADMIN_PASSWORD = Get-RandomString 12 + + # 获取服务器 IP + $SERVER_IP = (Invoke-WebRequest -Uri "https://ifconfig.me" -UseBasicParsing -TimeoutSec 5).Content.Trim() + if (-not $SERVER_IP) { + $SERVER_IP = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notmatch "Loopback" } | Select-Object -First 1).IPAddress + } + + # 创建配置文件 + $serverConfig = @" +# FunMC 服务端配置 +DATABASE_URL=postgres://funmc:$($global:DB_PASSWORD)@localhost/funmc +JWT_SECRET=$JWT_SECRET +BIND_ADDR=0.0.0.0:3000 +QUIC_PORT=3001 +RUST_LOG=info + +# 服务器信息 +SERVER_NAME=FunMC Server +SERVER_IP=$SERVER_IP +SERVER_DOMAIN= + +# 管理面板 +ADMIN_ENABLED=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=$ADMIN_PASSWORD + +# 客户端下载 +CLIENT_DOWNLOAD_ENABLED=true +CLIENT_VERSION=$FUNMC_VERSION +"@ + + $serverConfig | Out-File "$CONFIG_DIR\server.env" -Encoding UTF8 + + $relayConfig = @" +RELAY_PORT=7900 +JWT_SECRET=$JWT_SECRET +RUST_LOG=info +"@ + + $relayConfig | Out-File "$CONFIG_DIR\relay.env" -Encoding UTF8 + + # 注册 Windows 服务 + $nssm = "$env:ChocolateyInstall\bin\nssm.exe" + if (-not (Test-Path $nssm)) { + choco install nssm -y + } + + # FunMC Server 服务 + & $nssm install FunMC-Server "$INSTALL_DIR\funmc-server.exe" + & $nssm set FunMC-Server AppDirectory $INSTALL_DIR + & $nssm set FunMC-Server AppEnvironmentExtra "$(Get-Content "$CONFIG_DIR\server.env" -Raw)" + & $nssm set FunMC-Server AppStdout "$LOG_DIR\server.log" + & $nssm set FunMC-Server AppStderr "$LOG_DIR\server-error.log" + + # FunMC Relay 服务 + & $nssm install FunMC-Relay "$INSTALL_DIR\funmc-relay-server.exe" + & $nssm set FunMC-Relay AppDirectory $INSTALL_DIR + & $nssm set FunMC-Relay AppEnvironmentExtra "$(Get-Content "$CONFIG_DIR\relay.env" -Raw)" + & $nssm set FunMC-Relay AppStdout "$LOG_DIR\relay.log" + & $nssm set FunMC-Relay AppStderr "$LOG_DIR\relay-error.log" + + # 运行数据库迁移 + Set-Location "$INSTALL_DIR\src\server" + $env:DATABASE_URL = "postgres://funmc:$($global:DB_PASSWORD)@localhost/funmc" + cargo sqlx migrate run + + # 启动服务 + Start-Service FunMC-Server + Start-Service FunMC-Relay + + # 保存凭据 + $credentials = @" +====================================== +FunMC 服务端安装信息 +====================================== + +服务器 IP: $SERVER_IP +API 地址: http://${SERVER_IP}:3000 +管理面板: http://${SERVER_IP}:3000/admin + +管理员账号: admin +管理员密码: $ADMIN_PASSWORD + +数据库密码: $($global:DB_PASSWORD) +JWT 密钥: $JWT_SECRET + +====================================== +请妥善保管此文件! +====================================== +"@ + + $credentials | Out-File "$CONFIG_DIR\credentials.txt" -Encoding UTF8 + + $global:SERVER_IP = $SERVER_IP + $global:ADMIN_PASSWORD = $ADMIN_PASSWORD + + Write-Success "服务配置完成" +} + +# 配置防火墙 +function Configure-Firewall { + Write-Info "[7/7] 配置防火墙..." + + # 添加防火墙规则 + New-NetFirewallRule -DisplayName "FunMC API" -Direction Inbound -Protocol TCP -LocalPort 3000 -Action Allow -ErrorAction SilentlyContinue + New-NetFirewallRule -DisplayName "FunMC QUIC" -Direction Inbound -Protocol UDP -LocalPort 3001 -Action Allow -ErrorAction SilentlyContinue + New-NetFirewallRule -DisplayName "FunMC Relay" -Direction Inbound -Protocol UDP -LocalPort 7900-7901 -Action Allow -ErrorAction SilentlyContinue + + Write-Success "防火墙配置完成" +} + +# 显示完成信息 +function Show-Completion { + Write-Host "" + Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan + Write-Host "║ ║" -ForegroundColor Cyan + Write-Host "║ FunMC 安装完成! ║" -ForegroundColor Green + Write-Host "║ ║" -ForegroundColor Cyan + Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan + Write-Host "" + Write-Host "服务器信息:" -ForegroundColor Green + Write-Host " API 地址: http://$($global:SERVER_IP):3000" + Write-Host " 管理面板: http://$($global:SERVER_IP):3000/admin" + Write-Host "" + Write-Host "管理员登录:" -ForegroundColor Green + Write-Host " 用户名: admin" + Write-Host " 密码: $($global:ADMIN_PASSWORD)" + Write-Host "" + Write-Host "客户端下载:" -ForegroundColor Green + Write-Host " http://$($global:SERVER_IP):3000/download" + Write-Host "" + Write-Host "重要文件:" -ForegroundColor Yellow + Write-Host " 配置文件: $CONFIG_DIR\server.env" + Write-Host " 凭据信息: $CONFIG_DIR\credentials.txt" + Write-Host " 日志文件: $LOG_DIR\" + Write-Host "" + Write-Host "服务管理命令:" -ForegroundColor Cyan + Write-Host " 查看状态: Get-Service FunMC*" + Write-Host " 重启服务: Restart-Service FunMC-Server" + Write-Host " 查看日志: Get-Content $LOG_DIR\server.log -Tail 50" + Write-Host "" + Write-Host "魔幻方开发 - 让 Minecraft 联机变得简单" -ForegroundColor Green +} + +# 主流程 +try { + Install-Chocolatey + Install-Dependencies + Setup-Database + Create-Directories + Build-FunMC + Configure-Services + Configure-Firewall + Show-Completion +} catch { + Write-Error "安装过程中出错: $_" + exit 1 +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..4ce3794 --- /dev/null +++ b/install.sh @@ -0,0 +1,349 @@ +#!/bin/bash +# +# FunMC 一键部署脚本 +# 用法: curl -fsSL https://funmc.com/install.sh | bash +# + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +# 配置 +FUNMC_VERSION="0.1.0" +INSTALL_DIR="/opt/funmc" +CONFIG_DIR="/etc/funmc" +DATA_DIR="/var/lib/funmc" +LOG_DIR="/var/log/funmc" + +echo -e "${CYAN}" +echo "╔═══════════════════════════════════════════════════════════╗" +echo "║ ║" +echo "║ FunMC 服务端一键部署脚本 v${FUNMC_VERSION} ║" +echo "║ ║" +echo "║ 魔幻方开发 ║" +echo "║ ║" +echo "╚═══════════════════════════════════════════════════════════╝" +echo -e "${NC}" + +# 检查 root 权限 +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}请使用 root 权限运行此脚本${NC}" + echo "sudo bash install.sh" + exit 1 +fi + +# 检测系统 +detect_os() { + if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$ID + OS_VERSION=$VERSION_ID + else + echo -e "${RED}无法检测操作系统${NC}" + exit 1 + fi + echo -e "${GREEN}检测到系统: $OS $OS_VERSION${NC}" +} + +# 安装依赖 +install_deps() { + echo -e "${YELLOW}[1/7] 安装系统依赖...${NC}" + + case $OS in + ubuntu|debian) + apt-get update -qq + apt-get install -y -qq curl wget git build-essential pkg-config libssl-dev postgresql postgresql-contrib nginx certbot python3-certbot-nginx + ;; + centos|rhel|fedora) + dnf install -y curl wget git gcc openssl-devel postgresql-server postgresql-contrib nginx certbot python3-certbot-nginx + postgresql-setup --initdb + ;; + *) + echo -e "${RED}不支持的操作系统: $OS${NC}" + exit 1 + ;; + esac + + echo -e "${GREEN}✓ 系统依赖安装完成${NC}" +} + +# 安装 Rust +install_rust() { + echo -e "${YELLOW}[2/7] 安装 Rust...${NC}" + + if command -v cargo &> /dev/null; then + echo -e "${GREEN}✓ Rust 已安装${NC}" + else + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source $HOME/.cargo/env + echo -e "${GREEN}✓ Rust 安装完成${NC}" + fi +} + +# 安装 Node.js +install_nodejs() { + echo -e "${YELLOW}[3/7] 安装 Node.js...${NC}" + + if command -v node &> /dev/null; then + echo -e "${GREEN}✓ Node.js 已安装${NC}" + else + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs + echo -e "${GREEN}✓ Node.js 安装完成${NC}" + fi +} + +# 配置数据库 +setup_database() { + echo -e "${YELLOW}[4/7] 配置数据库...${NC}" + + systemctl enable postgresql + systemctl start postgresql + + # 生成随机密码 + DB_PASSWORD=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 24) + + # 创建数据库和用户 + sudo -u postgres psql -c "CREATE USER funmc WITH PASSWORD '$DB_PASSWORD';" 2>/dev/null || true + sudo -u postgres psql -c "CREATE DATABASE funmc OWNER funmc;" 2>/dev/null || true + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE funmc TO funmc;" + + # 配置 pg_hba.conf + PG_HBA=$(sudo -u postgres psql -t -c "SHOW hba_file;" | xargs) + if ! grep -q "funmc" "$PG_HBA"; then + echo "local funmc funmc md5" >> "$PG_HBA" + echo "host funmc funmc 127.0.0.1/32 md5" >> "$PG_HBA" + systemctl reload postgresql + fi + + echo -e "${GREEN}✓ 数据库配置完成${NC}" + echo "$DB_PASSWORD" > /tmp/funmc_db_password +} + +# 下载并编译 FunMC +build_funmc() { + echo -e "${YELLOW}[5/7] 编译 FunMC...${NC}" + + # 创建目录 + mkdir -p $INSTALL_DIR $CONFIG_DIR $DATA_DIR $LOG_DIR + + # 克隆或更新代码 + if [ -d "$INSTALL_DIR/src" ]; then + cd $INSTALL_DIR/src + git pull + else + git clone https://github.com/mofangfang/funmc.git $INSTALL_DIR/src + cd $INSTALL_DIR/src + fi + + # 编译服务端 + source $HOME/.cargo/env + cargo build --release -p funmc-server -p funmc-relay-server + + # 复制二进制文件 + cp target/release/funmc-server $INSTALL_DIR/ + cp target/release/funmc-relay-server $INSTALL_DIR/ + + # 编译管理面板前端 + cd $INSTALL_DIR/src/admin-panel + npm install + npm run build + cp -r dist $INSTALL_DIR/admin-panel + + echo -e "${GREEN}✓ FunMC 编译完成${NC}" +} + +# 配置服务 +configure_services() { + echo -e "${YELLOW}[6/7] 配置服务...${NC}" + + DB_PASSWORD=$(cat /tmp/funmc_db_password) + JWT_SECRET=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 48) + ADMIN_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 12) + + # 获取服务器 IP + SERVER_IP=$(curl -s ifconfig.me || curl -s ipinfo.io/ip || hostname -I | awk '{print $1}') + + # 创建主配置文件 + cat > $CONFIG_DIR/server.env << EOF +# FunMC 服务端配置 +DATABASE_URL=postgres://funmc:${DB_PASSWORD}@localhost/funmc +JWT_SECRET=${JWT_SECRET} +BIND_ADDR=0.0.0.0:3000 +QUIC_PORT=3001 +RUST_LOG=info + +# 服务器信息 +SERVER_NAME=FunMC Server +SERVER_IP=${SERVER_IP} +SERVER_DOMAIN= + +# 管理面板 +ADMIN_ENABLED=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=${ADMIN_PASSWORD} + +# 客户端下载 +CLIENT_DOWNLOAD_ENABLED=true +CLIENT_VERSION=${FUNMC_VERSION} +EOF + + # 创建中继配置 + cat > $CONFIG_DIR/relay.env << EOF +RELAY_PORT=7900 +JWT_SECRET=${JWT_SECRET} +RUST_LOG=info +EOF + + # 创建 systemd 服务文件 + cat > /etc/systemd/system/funmc-server.service << EOF +[Unit] +Description=FunMC API Server +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=$INSTALL_DIR +EnvironmentFile=$CONFIG_DIR/server.env +ExecStart=$INSTALL_DIR/funmc-server +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + + cat > /etc/systemd/system/funmc-relay.service << EOF +[Unit] +Description=FunMC Relay Server +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=$INSTALL_DIR +EnvironmentFile=$CONFIG_DIR/relay.env +ExecStart=$INSTALL_DIR/funmc-relay-server +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + + # 运行数据库迁移 + cd $INSTALL_DIR/src/server + DATABASE_URL="postgres://funmc:${DB_PASSWORD}@localhost/funmc" cargo sqlx migrate run + + # 启动服务 + systemctl daemon-reload + systemctl enable funmc-server funmc-relay + systemctl start funmc-server funmc-relay + + echo -e "${GREEN}✓ 服务配置完成${NC}" + + # 保存凭据 + cat > $CONFIG_DIR/credentials.txt << EOF +====================================== +FunMC 服务端安装信息 +====================================== + +服务器 IP: ${SERVER_IP} +API 地址: http://${SERVER_IP}:3000 +管理面板: http://${SERVER_IP}:3000/admin + +管理员账号: admin +管理员密码: ${ADMIN_PASSWORD} + +数据库密码: ${DB_PASSWORD} +JWT 密钥: ${JWT_SECRET} + +====================================== +请妥善保管此文件! +====================================== +EOF + chmod 600 $CONFIG_DIR/credentials.txt +} + +# 配置防火墙 +configure_firewall() { + echo -e "${YELLOW}[7/7] 配置防火墙...${NC}" + + if command -v ufw &> /dev/null; then + ufw allow 80/tcp + ufw allow 443/tcp + ufw allow 3000/tcp + ufw allow 7900/udp + ufw allow 7901/udp + echo -e "${GREEN}✓ UFW 防火墙配置完成${NC}" + elif command -v firewall-cmd &> /dev/null; then + firewall-cmd --permanent --add-port=80/tcp + firewall-cmd --permanent --add-port=443/tcp + firewall-cmd --permanent --add-port=3000/tcp + firewall-cmd --permanent --add-port=7900/udp + firewall-cmd --permanent --add-port=7901/udp + firewall-cmd --reload + echo -e "${GREEN}✓ firewalld 防火墙配置完成${NC}" + else + echo -e "${YELLOW}! 未检测到防火墙,请手动开放端口 80, 443, 3000, 7900, 7901${NC}" + fi +} + +# 显示完成信息 +show_completion() { + SERVER_IP=$(cat $CONFIG_DIR/server.env | grep SERVER_IP | cut -d= -f2) + ADMIN_PASSWORD=$(cat $CONFIG_DIR/server.env | grep ADMIN_PASSWORD | cut -d= -f2) + + echo "" + echo -e "${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}║ ║${NC}" + echo -e "${CYAN}║ ${GREEN}FunMC 安装完成!${CYAN} ║${NC}" + echo -e "${CYAN}║ ║${NC}" + echo -e "${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${GREEN}服务器信息:${NC}" + echo -e " API 地址: http://${SERVER_IP}:3000" + echo -e " 管理面板: http://${SERVER_IP}:3000/admin" + echo "" + echo -e "${GREEN}管理员登录:${NC}" + echo -e " 用户名: admin" + echo -e " 密码: ${ADMIN_PASSWORD}" + echo "" + echo -e "${GREEN}客户端下载:${NC}" + echo -e " http://${SERVER_IP}:3000/download" + echo "" + echo -e "${YELLOW}重要文件:${NC}" + echo -e " 配置文件: $CONFIG_DIR/server.env" + echo -e " 凭据信息: $CONFIG_DIR/credentials.txt" + echo -e " 日志文件: $LOG_DIR/" + echo "" + echo -e "${CYAN}服务管理命令:${NC}" + echo -e " 查看状态: systemctl status funmc-server" + echo -e " 重启服务: systemctl restart funmc-server" + echo -e " 查看日志: journalctl -u funmc-server -f" + echo "" + echo -e "${GREEN}魔幻方开发 - 让 Minecraft 联机变得简单${NC}" +} + +# 主流程 +main() { + detect_os + install_deps + install_rust + install_nodejs + setup_database + build_funmc + configure_services + configure_firewall + show_completion +} + +# 运行 +main "$@" diff --git a/relay-server/.env.example b/relay-server/.env.example new file mode 100644 index 0000000..830ba3f --- /dev/null +++ b/relay-server/.env.example @@ -0,0 +1,11 @@ +# FunMC 中继服务端环境配置 +# 复制此文件为 .env 并修改配置 + +# QUIC 监听地址 +RELAY_LISTEN_ADDR=0.0.0.0:7900 + +# JWT 密钥(必须与主服务器保持一致) +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production + +# 日志级别 +RUST_LOG=funmc_relay_server=debug diff --git a/relay-server/Cargo.toml b/relay-server/Cargo.toml new file mode 100644 index 0000000..84d9048 --- /dev/null +++ b/relay-server/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "funmc-relay-server" +version = "0.1.0" +edition = "2021" +description = "FunMC 中继服务端 - 为 Minecraft 玩家提供网络中继服务" +authors = ["魔幻方 "] + +[[bin]] +name = "relay-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 } +quinn = { workspace = true } +rustls = { workspace = true } +rcgen = { workspace = true } +tracing-subscriber = { workspace = true } +dashmap = { workspace = true } +dotenvy = "0.15" +jsonwebtoken = "9" +bytes = "1" diff --git a/relay-server/src/auth.rs b/relay-server/src/auth.rs new file mode 100644 index 0000000..cef1261 --- /dev/null +++ b/relay-server/src/auth.rs @@ -0,0 +1,27 @@ +use anyhow::{Context, Result}; +use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub exp: usize, +} + +pub fn validate_relay_token(token: &str, secret: &str) -> Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + + let token_data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .context("JWT 验证失败")?; + + let user_id = Uuid::parse_str(&token_data.claims.sub) + .context("无效的用户 ID")?; + + Ok(user_id) +} diff --git a/relay-server/src/config.rs b/relay-server/src/config.rs new file mode 100644 index 0000000..d2c17c1 --- /dev/null +++ b/relay-server/src/config.rs @@ -0,0 +1,23 @@ +use std::net::SocketAddr; + +pub struct Config { + pub listen_addr: SocketAddr, + pub jwt_secret: String, +} + +impl Config { + pub fn from_env() -> Self { + let listen_addr = std::env::var("RELAY_LISTEN_ADDR") + .unwrap_or_else(|_| "0.0.0.0:7900".into()) + .parse() + .expect("RELAY_LISTEN_ADDR 格式无效"); + + let jwt_secret = std::env::var("JWT_SECRET") + .unwrap_or_else(|_| "funmc-relay-secret-change-in-production".into()); + + Self { + listen_addr, + jwt_secret, + } + } +} diff --git a/relay-server/src/main.rs b/relay-server/src/main.rs new file mode 100644 index 0000000..23c7f28 --- /dev/null +++ b/relay-server/src/main.rs @@ -0,0 +1,275 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context, Result}; +use dashmap::DashMap; +use quinn::{Endpoint, ServerConfig, TransportConfig}; +use rcgen::{CertifiedKey, generate_simple_self_signed}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UdpSocket; +use tracing::{error, info, warn}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use uuid::Uuid; + +mod auth; +mod config; + +use auth::validate_relay_token; +use config::Config; + +type RoomPeers = DashMap; +type Rooms = DashMap; + +static TOTAL_CONNECTIONS: AtomicU64 = AtomicU64::new(0); +static ACTIVE_CONNECTIONS: AtomicU64 = AtomicU64::new(0); + +#[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_relay_server=info,quinn=warn".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let config = Config::from_env(); + + info!("╔══════════════════════════════════════════════════════════╗"); + info!("║ FunMC 中继服务端 v{} ║", env!("CARGO_PKG_VERSION")); + info!("║ 魔幻方开发 ║"); + info!("╠══════════════════════════════════════════════════════════╣"); + info!("║ 监听地址: {:43} ║", config.listen_addr); + info!("╚══════════════════════════════════════════════════════════╝"); + + let rooms: Arc = Arc::new(DashMap::new()); + + // Start UDP ping responder on same port for latency measurements + let ping_addr = config.listen_addr; + tokio::spawn(async move { + if let Err(e) = run_ping_responder(ping_addr).await { + warn!("Ping responder error: {}", e); + } + }); + + let server_config = build_server_config()?; + let endpoint = Endpoint::server(server_config, config.listen_addr) + .context("无法绑定 QUIC 端口")?; + + info!("QUIC 中继服务已启动,等待连接..."); + + loop { + match endpoint.accept().await { + Some(incoming) => { + TOTAL_CONNECTIONS.fetch_add(1, Ordering::Relaxed); + ACTIVE_CONNECTIONS.fetch_add(1, Ordering::Relaxed); + + let rooms = Arc::clone(&rooms); + let jwt_secret = config.jwt_secret.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(incoming, rooms, &jwt_secret).await { + warn!("连接处理错误: {}", e); + } + ACTIVE_CONNECTIONS.fetch_sub(1, Ordering::Relaxed); + }); + } + None => { + error!("端点已关闭"); + break; + } + } + } + + Ok(()) +} + +async fn run_ping_responder(addr: SocketAddr) -> Result<()> { + let socket = UdpSocket::bind(format!("0.0.0.0:{}", addr.port() + 10000)).await + .or_else(|_| async { UdpSocket::bind("0.0.0.0:0").await })?; + + info!("Ping responder listening on {}", socket.local_addr()?); + + let mut buf = [0u8; 64]; + loop { + match socket.recv_from(&mut buf).await { + Ok((len, src)) => { + if len >= 10 && &buf[..10] == b"FUNMC_PING" { + let response = format!("FUNMC_PONG {} {}", + ACTIVE_CONNECTIONS.load(Ordering::Relaxed), + TOTAL_CONNECTIONS.load(Ordering::Relaxed)); + let _ = socket.send_to(response.as_bytes(), src).await; + } + } + Err(e) => { + warn!("Ping recv error: {}", e); + } + } + } +} + +fn build_server_config() -> Result { + let CertifiedKey { cert, key_pair } = generate_simple_self_signed(vec!["funmc.com".into()]) + .context("生成自签名证书失败")?; + + let cert_der = CertificateDer::from(cert.der().to_vec()); + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); + + let mut server_crypto = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .context("TLS 配置失败")?; + + server_crypto.alpn_protocols = vec![b"funmc".to_vec()]; + + let mut transport = TransportConfig::default(); + transport.max_idle_timeout(Some(Duration::from_secs(60).try_into()?)); + transport.keep_alive_interval(Some(Duration::from_secs(10))); + + let mut server_config = ServerConfig::with_crypto(Arc::new(server_crypto)); + server_config.transport_config(Arc::new(transport)); + + Ok(server_config) +} + +async fn handle_connection( + incoming: quinn::Incoming, + rooms: Arc, + jwt_secret: &str, +) -> Result<()> { + let conn = incoming.await.context("接受连接失败")?; + let remote = conn.remote_address(); + info!("新连接: {}", remote); + + let (user_id, room_id) = match authenticate_peer(&conn, jwt_secret).await { + Ok(result) => result, + Err(e) => { + warn!("[{}] 认证失败: {}", remote, e); + conn.close(1u32.into(), b"auth_failed"); + return Ok(()); + } + }; + + info!("[{}] 用户 {} 加入房间 {}", remote, user_id, room_id); + + let room_peers = rooms.entry(room_id).or_insert_with(DashMap::new); + room_peers.insert(user_id, conn.clone()); + + loop { + tokio::select! { + stream = conn.accept_bi() => { + match stream { + Ok((send, recv)) => { + let peers = room_peers.clone(); + let src_user = user_id; + tokio::spawn(async move { + if let Err(e) = relay_stream(send, recv, peers, src_user).await { + warn!("流中继错误: {}", e); + } + }); + } + Err(quinn::ConnectionError::ApplicationClosed(_)) => { + info!("[{}] 用户 {} 主动断开", remote, user_id); + break; + } + Err(e) => { + warn!("[{}] 连接错误: {}", remote, e); + break; + } + } + } + _ = conn.closed() => { + info!("[{}] 连接已关闭", remote); + break; + } + } + } + + room_peers.remove(&user_id); + if room_peers.is_empty() { + rooms.remove(&room_id); + info!("房间 {} 已清空并移除", room_id); + } + + Ok(()) +} + +async fn authenticate_peer(conn: &quinn::Connection, jwt_secret: &str) -> Result<(Uuid, Uuid)> { + let mut recv = conn + .accept_uni() + .await + .context("等待认证流超时")?; + + let mut len_buf = [0u8; 4]; + recv.read_exact(&mut len_buf).await.context("读取长度失败")?; + let len = u32::from_be_bytes(len_buf) as usize; + + if len > 4096 { + anyhow::bail!("认证数据过大"); + } + + let mut buf = vec![0u8; len]; + recv.read_exact(&mut buf).await.context("读取认证数据失败")?; + + #[derive(serde::Deserialize)] + struct AuthHandshake { + token: String, + room_id: Uuid, + } + + let handshake: AuthHandshake = + serde_json::from_slice(&buf).context("解析认证数据失败")?; + + let user_id = validate_relay_token(&handshake.token, jwt_secret)?; + + Ok((user_id, handshake.room_id)) +} + +async fn relay_stream( + mut src_send: quinn::SendStream, + mut src_recv: quinn::RecvStream, + peers: DashMap, + source_user: Uuid, +) -> Result<()> { + let mut header_buf = [0u8; 17]; + src_recv.read_exact(&mut header_buf).await?; + + let is_broadcast = header_buf[0] == 0; + let dest_user = if is_broadcast { + None + } else { + Some(Uuid::from_slice(&header_buf[1..17])?) + }; + + let mut data = Vec::new(); + src_recv.read_to_end(1024 * 1024).await.map(|d| data = d.to_vec()).ok(); + src_recv.read_to_end(64 * 1024).await?; + + let full_payload = [&header_buf[..], &data].concat(); + + if let Some(target) = dest_user { + if let Some(peer_conn) = peers.get(&target) { + let (mut send, _recv) = peer_conn.open_bi().await?; + send.write_all(&full_payload).await?; + send.finish()?; + } + } else { + for entry in peers.iter() { + if *entry.key() == source_user { + continue; + } + let peer_conn = entry.value(); + if let Ok((mut send, _recv)) = peer_conn.open_bi().await { + let _ = send.write_all(&full_payload).await; + let _ = send.finish(); + } + } + } + + src_send.finish()?; + Ok(()) +} diff --git a/scripts/build-client.ps1 b/scripts/build-client.ps1 new file mode 100644 index 0000000..352f51a --- /dev/null +++ b/scripts/build-client.ps1 @@ -0,0 +1,181 @@ +# +# FunMC 客户端构建脚本 (Windows) +# 用法: .\scripts\build-client.ps1 -Platform <平台> -ServerUrl <服务器URL> +# +# 示例: +# .\scripts\build-client.ps1 -Platform all -ServerUrl "http://funmc.com:3000" +# .\scripts\build-client.ps1 -Platform windows -ServerUrl "http://192.168.1.100:3000" +# + +param( + [string]$Platform = "all", + [string]$ServerUrl = "" +) + +$ErrorActionPreference = "Stop" + +# 配置 +$Version = (Get-Content "client\Cargo.toml" | Select-String 'version = "' | Select-Object -First 1) -replace '.*"([^"]+)".*', '$1' +$OutputDir = ".\dist" + +# 颜色输出函数 +function Write-Info($Message) { Write-Host "[INFO] $Message" -ForegroundColor Cyan } +function Write-Success($Message) { Write-Host "[✓] $Message" -ForegroundColor Green } +function Write-Warning($Message) { Write-Host "[!] $Message" -ForegroundColor Yellow } +function Write-Err($Message) { Write-Host "[✗] $Message" -ForegroundColor Red } + +Write-Host "" +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ FunMC 客户端构建脚本 v$Version ║" -ForegroundColor Cyan +Write-Host "║ 魔幻方开发 ║" -ForegroundColor Cyan +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +# 检查依赖 +function Test-Dependencies { + Write-Info "检查构建依赖..." + + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + Write-Err "错误: 未找到 Rust/Cargo" + exit 1 + } + + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Err "错误: 未找到 Node.js" + exit 1 + } + + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Err "错误: 未找到 npm" + exit 1 + } + + Write-Success "依赖检查通过" +} + +# 写入服务器配置 +function Write-Config { + if ($ServerUrl) { + Write-Info "写入服务器配置: $ServerUrl" + + $config = @{ + server_url = $ServerUrl + version = $Version + } | ConvertTo-Json + + $config | Out-File -FilePath "client\ui\src\config.json" -Encoding UTF8 + + Write-Success "配置写入完成" + } +} + +# 构建前端 +function Build-Frontend { + Write-Info "构建前端..." + + Push-Location "client\ui" + npm install + npm run build + Pop-Location + + Write-Success "前端构建完成" +} + +# 构建 Windows +function Build-Windows { + Write-Info "构建 Windows (x64)..." + + Push-Location "client" + cargo tauri build + Pop-Location + + New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + + $msiBundles = Get-ChildItem "client\target\release\bundle\msi\*.msi" -ErrorAction SilentlyContinue + if ($msiBundles) { + Copy-Item $msiBundles[0].FullName "$OutputDir\FunMC-$Version-windows-x64.msi" + } + + $nsiBundles = Get-ChildItem "client\target\release\bundle\nsis\*.exe" -ErrorAction SilentlyContinue + if ($nsiBundles) { + Copy-Item $nsiBundles[0].FullName "$OutputDir\FunMC-$Version-windows-x64.exe" + } + + Write-Success "Windows 构建完成" +} + +# 构建 Android +function Build-Android { + Write-Info "构建 Android..." + + if (-not $env:ANDROID_HOME) { + Write-Warning "未设置 ANDROID_HOME,跳过 Android 构建" + return + } + + Push-Location "client" + cargo tauri android build --apk + Pop-Location + + New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + + $apkBundles = Get-ChildItem "client\gen\android\app\build\outputs\apk\universal\release\*.apk" -ErrorAction SilentlyContinue + if ($apkBundles) { + Copy-Item $apkBundles[0].FullName "$OutputDir\FunMC-$Version-android.apk" + } + + Write-Success "Android 构建完成" +} + +# 显示构建结果 +function Show-Results { + Write-Host "" + Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan + Write-Host "║ 构建完成! ║" -ForegroundColor Cyan + Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan + Write-Host "" + Write-Host "输出目录: $OutputDir" -ForegroundColor Green + Write-Host "" + + if (Test-Path $OutputDir) { + Write-Host "构建产物:" + Get-ChildItem $OutputDir | Format-Table Name, Length -AutoSize + } + + Write-Host "" + Write-Host "部署说明:" -ForegroundColor Yellow + Write-Host "1. 将 $OutputDir 目录下的文件复制到服务器的 downloads 目录" + Write-Host "2. 用户访问 http://your-server:3000/download 即可下载" + Write-Host "" + Write-Host "魔幻方开发" -ForegroundColor Green +} + +# 主流程 +try { + Test-Dependencies + Write-Config + Build-Frontend + + switch ($Platform) { + "windows" { + Build-Windows + } + "android" { + Build-Android + } + "all" { + Build-Windows + Build-Android + } + default { + Write-Err "未知平台: $Platform" + Write-Host "支持的平台: windows, android, all" + exit 1 + } + } + + Show-Results +} catch { + Write-Err "构建过程中出错: $_" + exit 1 +} diff --git a/scripts/build-client.sh b/scripts/build-client.sh new file mode 100644 index 0000000..96032a0 --- /dev/null +++ b/scripts/build-client.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# +# FunMC 客户端构建脚本 +# 用法: ./scripts/build-client.sh [平台] [服务器URL] +# +# 示例: +# ./scripts/build-client.sh all http://funmc.com:3000 +# ./scripts/build-client.sh windows http://192.168.1.100:3000 +# ./scripts/build-client.sh macos-arm64 https://mc.example.com +# + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# 默认值 +PLATFORM="${1:-all}" +SERVER_URL="${2:-}" +VERSION=$(grep '^version' client/Cargo.toml | head -1 | cut -d'"' -f2) +OUTPUT_DIR="./dist" + +echo -e "${CYAN}" +echo "╔═══════════════════════════════════════════════════════════╗" +echo "║ FunMC 客户端构建脚本 v${VERSION} ║" +echo "║ 魔幻方开发 ║" +echo "╚═══════════════════════════════════════════════════════════╝" +echo -e "${NC}" + +# 检查依赖 +check_deps() { + echo -e "${YELLOW}检查构建依赖...${NC}" + + if ! command -v cargo &> /dev/null; then + echo -e "${RED}错误: 未找到 Rust/Cargo${NC}" + exit 1 + fi + + if ! command -v node &> /dev/null; then + echo -e "${RED}错误: 未找到 Node.js${NC}" + exit 1 + fi + + if ! command -v npm &> /dev/null; then + echo -e "${RED}错误: 未找到 npm${NC}" + exit 1 + fi + + echo -e "${GREEN}✓ 依赖检查通过${NC}" +} + +# 写入服务器配置 +write_config() { + if [ -n "$SERVER_URL" ]; then + echo -e "${YELLOW}写入服务器配置: $SERVER_URL${NC}" + + cat > client/ui/src/config.json << EOF +{ + "server_url": "$SERVER_URL", + "version": "$VERSION" +} +EOF + + echo -e "${GREEN}✓ 配置写入完成${NC}" + fi +} + +# 构建前端 +build_frontend() { + echo -e "${YELLOW}构建前端...${NC}" + cd client/ui + npm install + npm run build + cd ../.. + echo -e "${GREEN}✓ 前端构建完成${NC}" +} + +# 构建 Windows +build_windows() { + echo -e "${YELLOW}构建 Windows (x64)...${NC}" + cd client + + if [[ "$OSTYPE" == "linux-gnu"* ]] || [[ "$OSTYPE" == "darwin"* ]]; then + # Cross compile + cargo tauri build --target x86_64-pc-windows-msvc + else + cargo tauri build + fi + + cd .. + + mkdir -p $OUTPUT_DIR + cp client/target/release/bundle/msi/*.msi "$OUTPUT_DIR/FunMC-${VERSION}-windows-x64.msi" 2>/dev/null || true + cp client/target/release/bundle/nsis/*.exe "$OUTPUT_DIR/FunMC-${VERSION}-windows-x64.exe" 2>/dev/null || true + + echo -e "${GREEN}✓ Windows 构建完成${NC}" +} + +# 构建 macOS +build_macos_arm64() { + echo -e "${YELLOW}构建 macOS (Apple Silicon)...${NC}" + cd client + cargo tauri build --target aarch64-apple-darwin + cd .. + + mkdir -p $OUTPUT_DIR + cp client/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg "$OUTPUT_DIR/FunMC-${VERSION}-macos-arm64.dmg" 2>/dev/null || true + + echo -e "${GREEN}✓ macOS (ARM64) 构建完成${NC}" +} + +build_macos_x64() { + echo -e "${YELLOW}构建 macOS (Intel)...${NC}" + cd client + cargo tauri build --target x86_64-apple-darwin + cd .. + + mkdir -p $OUTPUT_DIR + cp client/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg "$OUTPUT_DIR/FunMC-${VERSION}-macos-x64.dmg" 2>/dev/null || true + + echo -e "${GREEN}✓ macOS (x64) 构建完成${NC}" +} + +# 构建 Linux +build_linux() { + echo -e "${YELLOW}构建 Linux (x64)...${NC}" + cd client + cargo tauri build + cd .. + + mkdir -p $OUTPUT_DIR + cp client/target/release/bundle/appimage/*.AppImage "$OUTPUT_DIR/FunMC-${VERSION}-linux-x64.AppImage" 2>/dev/null || true + cp client/target/release/bundle/deb/*.deb "$OUTPUT_DIR/FunMC-${VERSION}-linux-x64.deb" 2>/dev/null || true + + echo -e "${GREEN}✓ Linux 构建完成${NC}" +} + +# 构建 Android +build_android() { + echo -e "${YELLOW}构建 Android...${NC}" + + if [ -z "$ANDROID_HOME" ]; then + echo -e "${RED}错误: 未设置 ANDROID_HOME${NC}" + return 1 + fi + + cd client + cargo tauri android build --apk + cd .. + + mkdir -p $OUTPUT_DIR + cp client/gen/android/app/build/outputs/apk/universal/release/*.apk "$OUTPUT_DIR/FunMC-${VERSION}-android.apk" 2>/dev/null || true + + echo -e "${GREEN}✓ Android 构建完成${NC}" +} + +# 构建 iOS +build_ios() { + echo -e "${YELLOW}构建 iOS...${NC}" + + if [[ "$OSTYPE" != "darwin"* ]]; then + echo -e "${RED}错误: iOS 只能在 macOS 上构建${NC}" + return 1 + fi + + cd client + cargo tauri ios build + cd .. + + echo -e "${GREEN}✓ iOS 构建完成 (需要在 Xcode 中归档和分发)${NC}" +} + +# 显示构建结果 +show_results() { + echo "" + echo -e "${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}║ 构建完成! ║${NC}" + echo -e "${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${GREEN}输出目录: $OUTPUT_DIR${NC}" + echo "" + + if [ -d "$OUTPUT_DIR" ]; then + echo "构建产物:" + ls -lh $OUTPUT_DIR/ + fi + + echo "" + echo -e "${YELLOW}部署说明:${NC}" + echo "1. 将 $OUTPUT_DIR 目录下的文件复制到服务器的 downloads 目录" + echo "2. 用户访问 http://your-server:3000/download 即可下载" + echo "" + echo -e "${GREEN}魔幻方开发${NC}" +} + +# 主流程 +main() { + check_deps + write_config + build_frontend + + case $PLATFORM in + windows) + build_windows + ;; + macos-arm64) + build_macos_arm64 + ;; + macos-x64) + build_macos_x64 + ;; + macos) + build_macos_arm64 + build_macos_x64 + ;; + linux) + build_linux + ;; + android) + build_android + ;; + ios) + build_ios + ;; + all) + build_windows || true + build_macos_arm64 || true + build_macos_x64 || true + build_linux || true + build_android || true + ;; + *) + echo -e "${RED}未知平台: $PLATFORM${NC}" + echo "支持的平台: windows, macos-arm64, macos-x64, macos, linux, android, ios, all" + exit 1 + ;; + esac + + show_results +} + +main "$@" diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..05571de --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,103 @@ +# FunMC 构建脚本 (Windows PowerShell) +# 用于构建所有组件 + +param( + [ValidateSet("all", "client", "server", "relay")] + [string]$Target = "all", + [switch]$Release, + [switch]$Bundle +) + +$ErrorActionPreference = "Stop" + +$RootDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) + +function Write-Step { + param([string]$Message) + Write-Host "`n==> $Message" -ForegroundColor Cyan +} + +function Build-Server { + Write-Step "构建主服务端..." + Push-Location "$RootDir/server" + + if ($Release) { + cargo build --release + } else { + cargo build + } + + Pop-Location + Write-Host "主服务端构建完成!" -ForegroundColor Green +} + +function Build-Relay { + Write-Step "构建中继服务端..." + Push-Location "$RootDir/relay-server" + + if ($Release) { + cargo build --release + } else { + cargo build + } + + Pop-Location + Write-Host "中继服务端构建完成!" -ForegroundColor Green +} + +function Build-Client { + Write-Step "构建客户端..." + Push-Location "$RootDir/client" + + # 安装前端依赖 + Write-Host "安装前端依赖..." + Push-Location "ui" + npm install + Pop-Location + + # 构建 Tauri 应用 + if ($Bundle) { + if ($Release) { + cargo tauri build + } else { + cargo tauri build --debug + } + } else { + if ($Release) { + cargo build --release + } else { + cargo build + } + } + + Pop-Location + Write-Host "客户端构建完成!" -ForegroundColor Green +} + +# 主逻辑 +Write-Host "FunMC 构建系统" -ForegroundColor Yellow +Write-Host "===============" -ForegroundColor Yellow + +switch ($Target) { + "all" { + Build-Server + Build-Relay + Build-Client + } + "client" { + Build-Client + } + "server" { + Build-Server + } + "relay" { + Build-Relay + } +} + +Write-Host "`n所有构建完成!" -ForegroundColor Green + +if ($Bundle -and $Target -in @("all", "client")) { + Write-Host "`n客户端安装包位置:" -ForegroundColor Yellow + Write-Host "$RootDir\client\target\release\bundle\" -ForegroundColor White +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..b6ca360 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# FunMC 构建脚本 (Linux/macOS) +# 用于构建所有组件 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +TARGET="all" +RELEASE=false +BUNDLE=false + +print_usage() { + echo "用法: $0 [选项]" + echo "" + echo "选项:" + echo " -t, --target 构建目标 (all, client, server, relay) [默认: all]" + echo " -r, --release 构建发布版本" + echo " -b, --bundle 打包客户端安装包" + echo " -h, --help 显示帮助信息" +} + +while [[ $# -gt 0 ]]; do + case $1 in + -t|--target) + TARGET="$2" + shift 2 + ;; + -r|--release) + RELEASE=true + shift + ;; + -b|--bundle) + BUNDLE=true + shift + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + echo "未知选项: $1" + print_usage + exit 1 + ;; + esac +done + +log_step() { + echo -e "\n\033[36m==> $1\033[0m" +} + +log_success() { + echo -e "\033[32m$1\033[0m" +} + +build_server() { + log_step "构建主服务端..." + cd "$ROOT_DIR/server" + + if $RELEASE; then + cargo build --release + else + cargo build + fi + + log_success "主服务端构建完成!" +} + +build_relay() { + log_step "构建中继服务端..." + cd "$ROOT_DIR/relay-server" + + if $RELEASE; then + cargo build --release + else + cargo build + fi + + log_success "中继服务端构建完成!" +} + +build_client() { + log_step "构建客户端..." + cd "$ROOT_DIR/client" + + # 安装前端依赖 + echo "安装前端依赖..." + cd ui + npm install + cd .. + + # 构建 Tauri 应用 + if $BUNDLE; then + if $RELEASE; then + cargo tauri build + else + cargo tauri build --debug + fi + else + if $RELEASE; then + cargo build --release + else + cargo build + fi + fi + + log_success "客户端构建完成!" +} + +echo -e "\033[33mFunMC 构建系统\033[0m" +echo "===============" + +case $TARGET in + all) + build_server + build_relay + build_client + ;; + client) + build_client + ;; + server) + build_server + ;; + relay) + build_relay + ;; + *) + echo "无效的构建目标: $TARGET" + exit 1 + ;; +esac + +echo -e "\n\033[32m所有构建完成!\033[0m" + +if $BUNDLE && [[ "$TARGET" == "all" || "$TARGET" == "client" ]]; then + echo -e "\n\033[33m客户端安装包位置:\033[0m" + echo "$ROOT_DIR/client/target/release/bundle/" +fi diff --git a/scripts/generate-icons.ps1 b/scripts/generate-icons.ps1 new file mode 100644 index 0000000..477adfa --- /dev/null +++ b/scripts/generate-icons.ps1 @@ -0,0 +1,43 @@ +# FunMC 图标生成脚本 (Windows) +# 需要安装 ImageMagick 或使用 cargo tauri icon 命令 + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent $ScriptDir +$IconsDir = "$RootDir\client\icons" +$SvgPath = "$IconsDir\icon.svg" + +Write-Host "FunMC 图标生成脚本" -ForegroundColor Yellow +Write-Host "===================" -ForegroundColor Yellow + +# 检查 SVG 源文件 +if (-not (Test-Path $SvgPath)) { + Write-Host "错误: 未找到 SVG 源文件: $SvgPath" -ForegroundColor Red + exit 1 +} + +# 方法 1: 使用 cargo tauri icon (推荐) +Write-Host "`n尝试使用 cargo tauri icon..." -ForegroundColor Cyan +Push-Location "$RootDir\client" + +try { + cargo tauri icon $SvgPath + Write-Host "图标生成成功!" -ForegroundColor Green +} +catch { + Write-Host "cargo tauri icon 失败,请手动生成图标" -ForegroundColor Yellow + Write-Host "`n手动生成步骤:" -ForegroundColor White + Write-Host "1. 安装 ImageMagick: https://imagemagick.org/script/download.php" + Write-Host "2. 或使用在线工具: https://realfavicongenerator.net/" + Write-Host "3. 或使用 Figma/Photoshop 导出以下尺寸的 PNG:" + Write-Host " - 32x32.png" + Write-Host " - 128x128.png" + Write-Host " - 128x128@2x.png (256x256)" + Write-Host " - icon.ico (Windows)" + Write-Host " - icon.icns (macOS)" +} +finally { + Pop-Location +} + +Write-Host "`n图标目录: $IconsDir" -ForegroundColor Cyan diff --git a/scripts/generate-icons.sh b/scripts/generate-icons.sh new file mode 100644 index 0000000..d3522c4 --- /dev/null +++ b/scripts/generate-icons.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# FunMC 图标生成脚本 (Linux/macOS) +# 需要安装 ImageMagick 或使用 cargo tauri icon 命令 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +ICONS_DIR="$ROOT_DIR/client/icons" +SVG_PATH="$ICONS_DIR/icon.svg" + +echo -e "\033[33mFunMC 图标生成脚本\033[0m" +echo "===================" + +# 检查 SVG 源文件 +if [ ! -f "$SVG_PATH" ]; then + echo -e "\033[31m错误: 未找到 SVG 源文件: $SVG_PATH\033[0m" + exit 1 +fi + +# 方法 1: 使用 cargo tauri icon (推荐) +echo -e "\n\033[36m尝试使用 cargo tauri icon...\033[0m" +cd "$ROOT_DIR/client" + +if cargo tauri icon "$SVG_PATH" 2>/dev/null; then + echo -e "\033[32m图标生成成功!\033[0m" +else + echo -e "\033[33mcargo tauri icon 失败,尝试使用 ImageMagick...\033[0m" + + # 方法 2: 使用 ImageMagick + if command -v convert &> /dev/null; then + echo "使用 ImageMagick 生成图标..." + + mkdir -p "$ICONS_DIR" + + # PNG 图标 + convert -background none "$SVG_PATH" -resize 32x32 "$ICONS_DIR/32x32.png" + convert -background none "$SVG_PATH" -resize 128x128 "$ICONS_DIR/128x128.png" + convert -background none "$SVG_PATH" -resize 256x256 "$ICONS_DIR/128x128@2x.png" + + # ICO (Windows) + convert -background none "$SVG_PATH" -resize 256x256 \ + -define icon:auto-resize=256,128,96,64,48,32,16 \ + "$ICONS_DIR/icon.ico" + + # ICNS (macOS) - 需要 png2icns 或 iconutil + if command -v png2icns &> /dev/null; then + convert -background none "$SVG_PATH" -resize 1024x1024 "$ICONS_DIR/icon_1024.png" + png2icns "$ICONS_DIR/icon.icns" "$ICONS_DIR/icon_1024.png" + rm "$ICONS_DIR/icon_1024.png" + elif command -v iconutil &> /dev/null; then + ICONSET_DIR="$ICONS_DIR/icon.iconset" + mkdir -p "$ICONSET_DIR" + + for size in 16 32 64 128 256 512 1024; do + convert -background none "$SVG_PATH" -resize ${size}x${size} "$ICONSET_DIR/icon_${size}x${size}.png" + done + + # 生成 @2x 版本 + convert -background none "$SVG_PATH" -resize 32x32 "$ICONSET_DIR/icon_16x16@2x.png" + convert -background none "$SVG_PATH" -resize 64x64 "$ICONSET_DIR/icon_32x32@2x.png" + convert -background none "$SVG_PATH" -resize 256x256 "$ICONSET_DIR/icon_128x128@2x.png" + convert -background none "$SVG_PATH" -resize 512x512 "$ICONSET_DIR/icon_256x256@2x.png" + convert -background none "$SVG_PATH" -resize 1024x1024 "$ICONSET_DIR/icon_512x512@2x.png" + + iconutil -c icns "$ICONSET_DIR" + rm -rf "$ICONSET_DIR" + else + echo -e "\033[33m警告: 无法生成 .icns 文件 (需要 png2icns 或 iconutil)\033[0m" + fi + + echo -e "\033[32m图标生成完成!\033[0m" + else + echo -e "\033[31m错误: 未找到 ImageMagick\033[0m" + echo "请安装 ImageMagick 后重试:" + echo " Ubuntu/Debian: sudo apt install imagemagick" + echo " macOS: brew install imagemagick" + echo " 或者使用: cargo install tauri-cli && cargo tauri icon" + exit 1 + fi +fi + +echo -e "\n图标目录: $ICONS_DIR" +ls -la "$ICONS_DIR" diff --git a/scripts/test-lan.ps1 b/scripts/test-lan.ps1 new file mode 100644 index 0000000..bb5f485 --- /dev/null +++ b/scripts/test-lan.ps1 @@ -0,0 +1,106 @@ +# FunMC 局域网联机测试脚本 (Windows PowerShell) +# 用于快速验证 FunMC 服务是否正常运行 + +Write-Host "============================================" -ForegroundColor Cyan +Write-Host " FunMC 局域网联机测试脚本" -ForegroundColor Cyan +Write-Host "============================================" -ForegroundColor Cyan +Write-Host "" + +# 检查必要的工具 +function Test-Command($cmd) { + return [bool](Get-Command $cmd -ErrorAction SilentlyContinue) +} + +# 1. 检查 Rust 环境 +Write-Host "[1/6] 检查 Rust 环境..." -ForegroundColor Yellow +if (Test-Command "cargo") { + $rustVersion = cargo --version + Write-Host " ✓ Rust 已安装: $rustVersion" -ForegroundColor Green +} else { + Write-Host " ✗ Rust 未安装,请访问 https://rustup.rs 安装" -ForegroundColor Red + exit 1 +} + +# 2. 检查 Node.js 环境 +Write-Host "[2/6] 检查 Node.js 环境..." -ForegroundColor Yellow +if (Test-Command "node") { + $nodeVersion = node --version + Write-Host " ✓ Node.js 已安装: $nodeVersion" -ForegroundColor Green +} else { + Write-Host " ✗ Node.js 未安装,请访问 https://nodejs.org 安装" -ForegroundColor Red + exit 1 +} + +# 3. 检查数据库连接 +Write-Host "[3/6] 检查数据库连接..." -ForegroundColor Yellow +$dbRunning = docker ps --filter "name=funmc-db" --format "{{.Names}}" 2>$null +if ($dbRunning -eq "funmc-db") { + Write-Host " ✓ PostgreSQL 容器运行中" -ForegroundColor Green +} else { + Write-Host " ! PostgreSQL 容器未运行,尝试启动..." -ForegroundColor Yellow + docker start funmc-db 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host " ! 创建新的 PostgreSQL 容器..." -ForegroundColor Yellow + docker run -d --name funmc-db -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_DB=funmc postgres:14 + } + Start-Sleep -Seconds 3 + Write-Host " ✓ PostgreSQL 容器已启动" -ForegroundColor Green +} + +# 4. 检查端口占用 +Write-Host "[4/6] 检查端口占用..." -ForegroundColor Yellow +$ports = @(3000, 3001, 7900, 5432) +$allFree = $true +foreach ($port in $ports) { + $used = netstat -an | Select-String ":$port\s" + if ($used) { + Write-Host " ! 端口 $port 已被占用" -ForegroundColor Yellow + $allFree = $false + } else { + Write-Host " ✓ 端口 $port 可用" -ForegroundColor Green + } +} + +# 5. 获取本机 IP +Write-Host "[5/6] 获取本机 IP..." -ForegroundColor Yellow +$localIP = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notlike "*Loopback*" -and $_.IPAddress -notlike "169.*" } | Select-Object -First 1).IPAddress +if ($localIP) { + Write-Host " ✓ 本机局域网 IP: $localIP" -ForegroundColor Green +} else { + Write-Host " ✗ 无法获取本机 IP" -ForegroundColor Red +} + +# 6. 显示测试说明 +Write-Host "[6/6] 测试说明..." -ForegroundColor Yellow +Write-Host "" +Write-Host "============================================" -ForegroundColor Cyan +Write-Host " 局域网联机测试步骤" -ForegroundColor Cyan +Write-Host "============================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "1. 启动服务端:" -ForegroundColor White +Write-Host " cd server" -ForegroundColor Gray +Write-Host " cargo run" -ForegroundColor Gray +Write-Host "" +Write-Host "2. 启动客户端 (新终端):" -ForegroundColor White +Write-Host " cd client/ui && npm install" -ForegroundColor Gray +Write-Host " cd .. && cargo tauri dev" -ForegroundColor Gray +Write-Host "" +Write-Host "3. 配置其他电脑的客户端:" -ForegroundColor White +Write-Host " 修改 client/src/config.rs:" -ForegroundColor Gray +Write-Host " DEFAULT_SERVER_URL = `"http://${localIP}:3000`"" -ForegroundColor Gray +Write-Host "" +Write-Host "4. 测试流程:" -ForegroundColor White +Write-Host " - 主机: 启动 MC 服务器 -> FunMC 创建房间 -> 开始托管" -ForegroundColor Gray +Write-Host " - 玩家: FunMC 加入房间 -> 连接 -> 复制地址到 MC" -ForegroundColor Gray +Write-Host "" +Write-Host "============================================" -ForegroundColor Cyan + +# 询问是否启动服务 +Write-Host "" +$start = Read-Host "是否立即启动服务端? (y/n)" +if ($start -eq "y") { + Write-Host "" + Write-Host "启动服务端..." -ForegroundColor Green + Set-Location server + cargo run +} diff --git a/scripts/test-lan.sh b/scripts/test-lan.sh new file mode 100644 index 0000000..16a82aa --- /dev/null +++ b/scripts/test-lan.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# FunMC 局域网联机测试脚本 (Linux/macOS) + +set -e + +echo "============================================" +echo " FunMC 局域网联机测试脚本" +echo "============================================" +echo "" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# 检查命令是否存在 +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# 1. 检查 Rust 环境 +echo -e "${YELLOW}[1/6] 检查 Rust 环境...${NC}" +if command_exists cargo; then + RUST_VERSION=$(cargo --version) + echo -e " ${GREEN}✓ Rust 已安装: $RUST_VERSION${NC}" +else + echo -e " ${RED}✗ Rust 未安装,请运行: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh${NC}" + exit 1 +fi + +# 2. 检查 Node.js 环境 +echo -e "${YELLOW}[2/6] 检查 Node.js 环境...${NC}" +if command_exists node; then + NODE_VERSION=$(node --version) + echo -e " ${GREEN}✓ Node.js 已安装: $NODE_VERSION${NC}" +else + echo -e " ${RED}✗ Node.js 未安装,请访问 https://nodejs.org 安装${NC}" + exit 1 +fi + +# 3. 检查数据库连接 +echo -e "${YELLOW}[3/6] 检查数据库连接...${NC}" +if command_exists docker; then + if docker ps --filter "name=funmc-db" --format "{{.Names}}" | grep -q "funmc-db"; then + echo -e " ${GREEN}✓ PostgreSQL 容器运行中${NC}" + else + echo -e " ${YELLOW}! PostgreSQL 容器未运行,尝试启动...${NC}" + docker start funmc-db 2>/dev/null || \ + docker run -d --name funmc-db -p 5432:5432 \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_DB=funmc \ + postgres:14 + sleep 3 + echo -e " ${GREEN}✓ PostgreSQL 容器已启动${NC}" + fi +else + echo -e " ${YELLOW}! Docker 未安装,请手动配置 PostgreSQL${NC}" +fi + +# 4. 检查端口占用 +echo -e "${YELLOW}[4/6] 检查端口占用...${NC}" +PORTS=(3000 3001 7900 5432) +for PORT in "${PORTS[@]}"; do + if lsof -i :$PORT >/dev/null 2>&1 || netstat -tuln 2>/dev/null | grep -q ":$PORT "; then + echo -e " ${YELLOW}! 端口 $PORT 已被占用${NC}" + else + echo -e " ${GREEN}✓ 端口 $PORT 可用${NC}" + fi +done + +# 5. 获取本机 IP +echo -e "${YELLOW}[5/6] 获取本机 IP...${NC}" +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + LOCAL_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "") +else + # Linux + LOCAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || ip route get 1 | awk '{print $7}' 2>/dev/null || echo "") +fi + +if [ -n "$LOCAL_IP" ]; then + echo -e " ${GREEN}✓ 本机局域网 IP: $LOCAL_IP${NC}" +else + echo -e " ${RED}✗ 无法获取本机 IP${NC}" + LOCAL_IP="YOUR_IP" +fi + +# 6. 显示测试说明 +echo -e "${YELLOW}[6/6] 测试说明...${NC}" +echo "" +echo -e "${CYAN}============================================${NC}" +echo -e "${CYAN} 局域网联机测试步骤${NC}" +echo -e "${CYAN}============================================${NC}" +echo "" +echo "1. 启动服务端:" +echo " cd server" +echo " cargo run" +echo "" +echo "2. 启动客户端 (新终端):" +echo " cd client/ui && npm install" +echo " cd .. && cargo tauri dev" +echo "" +echo "3. 配置其他电脑的客户端:" +echo " 修改 client/src/config.rs:" +echo " DEFAULT_SERVER_URL = \"http://${LOCAL_IP}:3000\"" +echo "" +echo "4. 测试流程:" +echo " - 主机: 启动 MC 服务器 -> FunMC 创建房间 -> 开始托管" +echo " - 玩家: FunMC 加入房间 -> 连接 -> 复制地址到 MC" +echo "" +echo -e "${CYAN}============================================${NC}" + +# 询问是否启动服务 +echo "" +read -p "是否立即启动服务端? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "" + echo -e "${GREEN}启动服务端...${NC}" + cd server + cargo run +fi diff --git a/server/.env.example b/server/.env.example index a636136..f34ca00 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..d123eb5 --- /dev/null +++ b/server/Cargo.toml @@ -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" diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..e614d4b --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/migrations/20240101000001_initial.sql b/server/migrations/20240101000001_initial.sql new file mode 100644 index 0000000..aaff68b --- /dev/null +++ b/server/migrations/20240101000001_initial.sql @@ -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); diff --git a/server/migrations/20240101000002_relay_nodes.sql b/server/migrations/20240101000002_relay_nodes.sql new file mode 100644 index 0000000..c698210 --- /dev/null +++ b/server/migrations/20240101000002_relay_nodes.sql @@ -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); diff --git a/server/migrations/20240101000003_add_user_ban.sql b/server/migrations/20240101000003_add_user_ban.sql new file mode 100644 index 0000000..c057a24 --- /dev/null +++ b/server/migrations/20240101000003_add_user_ban.sql @@ -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); diff --git a/server/src/api/admin.rs b/server/src/api/admin.rs new file mode 100644 index 0000000..01bda05 --- /dev/null +++ b/server/src/api/admin.rs @@ -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, +} + +#[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>, + Json(body): Json, +) -> Result, 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>) -> Result, 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>) -> Result>, StatusCode> { + let users: Vec<(Uuid, String, String, chrono::DateTime, 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 = 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>) -> Result>, StatusCode> { + let rooms: Vec<(Uuid, String, Uuid, bool, chrono::DateTime)> = 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>) -> Json { + let config = state.server_config.read().unwrap().clone(); + Json(config) +} + +pub async fn update_config( + State(state): State>, + Json(new_config): Json, +) -> StatusCode { + let mut config = state.server_config.write().unwrap(); + *config = new_config; + StatusCode::OK +} + +pub async fn get_logs() -> Json { + 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>, + Path(user_id): Path, +) -> 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>, + Path(user_id): Path, +) -> 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>, + Path(room_id): Path, +) -> 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, + } +} diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs new file mode 100644 index 0000000..d65d122 --- /dev/null +++ b/server/src/api/auth.rs @@ -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 { + 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 { + 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 { + 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 { + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::new(Algorithm::HS256), + )?; + Ok(data.claims) +} + +pub async fn register( + State(state): State>, + Json(req): Json, +) -> 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>, + Json(req): Json, +) -> 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>, + Json(req): Json, +) -> 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>, + Json(req): Json, +) -> 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 { + 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) +} diff --git a/server/src/api/download.rs b/server/src/api/download.rs new file mode 100644 index 0000000..d09f693 --- /dev/null +++ b/server/src/api/download.rs @@ -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>) -> Json { + 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>) -> Html { + 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#" + + + + + FunMC 客户端下载 - {server_name} + + + + +
+
+

FunMC

+

Minecraft 联机工具

+

{server_name}

+
+
+ +
+
+

选择你的平台

+ +
+ +
+
+
🪟
+

Windows

+

Windows 10/11

+ + 下载 .exe + +
+
+ + +
+
+
🍎
+

macOS

+

macOS 11+

+ +
+
+ + +
+
+
🐧
+

Linux

+

Ubuntu/Debian/Fedora

+ + 下载 AppImage + +
+
+
+ + +
+

移动端

+
+
+
+
🤖
+

Android

+ + 下载 APK + +
+
+
+ +
+
+
+
+ + +
+

服务器信息

+
+

服务器地址: {server_url}

+

客户端版本: v{version}

+
+

+ 下载并安装客户端后,启动程序会自动连接到此服务器,无需手动配置。 +

+
+ +
+ 魔幻方开发 · FunMC +
+
+ +"#, + server_name = config.server_name, + server_url = server_url, + version = config.client_version, + ); + + Html(html) +} + +pub async fn list_builds() -> Json> { + 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) -> Result { + 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, +} + +pub async fn trigger_build(Json(_body): Json) -> StatusCode { + StatusCode::ACCEPTED +} diff --git a/server/src/api/friends.rs b/server/src/api/friends.rs new file mode 100644 index 0000000..80341ba --- /dev/null +++ b/server/src/api/friends.rs @@ -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>, + 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>, + 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>, + auth: AuthUser, + Json(body): Json, +) -> 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>, + auth: AuthUser, + Path(requester_id): Path, +) -> 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>, + auth: AuthUser, + Path(friend_id): Path, +) -> 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() +} diff --git a/server/src/api/health.rs b/server/src/api/health.rs new file mode 100644 index 0000000..a195ef1 --- /dev/null +++ b/server/src/api/health.rs @@ -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, + pub message: Option, +} + +#[derive(Debug, Serialize)] +pub struct ServerStats { + pub online_users: usize, + pub active_rooms: usize, + pub total_connections: usize, + pub memory_usage_mb: Option, +} + +pub async fn health_check(State(state): State>) -> 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>) -> 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>) -> 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 +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs new file mode 100644 index 0000000..92983b0 --- /dev/null +++ b/server/src/api/mod.rs @@ -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) -> Router> { + 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> { + Router::new() + .route("/", get(download::download_page)) +} diff --git a/server/src/api/relay_nodes.rs b/server/src/api/relay_nodes.rs new file mode 100644 index 0000000..cc3ad75 --- /dev/null +++ b/server/src/api/relay_nodes.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +pub struct CreateNodeBody { + pub name: String, + pub url: String, + pub region: Option, + pub priority: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PingReportBody { + pub ping_ms: i64, +} + +pub async fn list_nodes( + State(state): State>, + _auth: AuthUser, +) -> impl IntoResponse { + let rows = sqlx::query_as::<_, (Uuid, String, String, String, bool, i64, Option)>( + 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>, + _auth: AuthUser, + Json(body): Json, +) -> 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>, + _auth: AuthUser, + Path(node_id): Path, +) -> 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>, + _auth: AuthUser, + Path(node_id): Path, + Json(body): Json, +) -> 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 +} diff --git a/server/src/api/rooms.rs b/server/src/api/rooms.rs new file mode 100644 index 0000000..fc558c6 --- /dev/null +++ b/server/src/api/rooms.rs @@ -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, + pub is_public: Option, + pub password: Option, + pub game_version: Option, +} + +#[derive(Debug, Deserialize)] +pub struct JoinRoomBody { + pub password: Option, +} + +pub async fn list_rooms( + State(state): State>, + _auth: AuthUser, +) -> impl IntoResponse { + let rows = sqlx::query_as::<_, (Uuid, String, Uuid, String, i64, bool, Option, 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>, + auth: AuthUser, + Json(body): Json, +) -> 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 = 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>, + _auth: AuthUser, + Path(room_id): Path, +) -> impl IntoResponse { + let row = sqlx::query_as::<_, (Uuid, String, Uuid, String, i64, bool, Option, 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>, + auth: AuthUser, + Path(room_id): Path, + Json(body): Json, +) -> impl IntoResponse { + use crate::api::auth::verify_password; + use funmc_shared::protocol::SignalingMessage; + + let room = sqlx::query_as::<_, (Uuid, Option, 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 = 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>, + auth: AuthUser, + Path(room_id): Path, +) -> 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 = 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>, + _auth: AuthUser, + Path(room_id): Path, +) -> 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 = 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>, + 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, + pub nat_type: String, +} + +pub async fn update_host_info( + State(state): State>, + auth: AuthUser, + Path(room_id): Path, + Json(body): Json, +) -> 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>, + _auth: AuthUser, + Path(room_id): Path, +) -> 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>, + 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 = 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, + pub max_players: Option, + pub is_public: Option, + pub password: Option, + pub game_version: Option, +} + +pub async fn update_room( + State(state): State>, + auth: AuthUser, + Path(room_id): Path, + Json(body): Json, +) -> 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>, + auth: AuthUser, + Path(room_id): Path, +) -> 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 = 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() + } + } +} diff --git a/server/src/api/users.rs b/server/src/api/users.rs new file mode 100644 index 0000000..7148f69 --- /dev/null +++ b/server/src/api/users.rs @@ -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>, + _auth: AuthUser, + Query(params): Query, +) -> 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>, + 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(), + } +} diff --git a/server/src/auth_middleware.rs b/server/src/auth_middleware.rs new file mode 100644 index 0000000..576bc85 --- /dev/null +++ b/server/src/auth_middleware.rs @@ -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 FromRequestParts for AuthUser +where + S: Send + Sync, + Arc: FromRef, +{ + type Rejection = (StatusCode, axum::Json); + + fn from_request_parts<'life0, 'life1, 'async_trait>( + parts: &'life0 mut axum::http::request::Parts, + state: &'life1 S, + ) -> ::core::pin::Pin> + + ::core::marker::Send + + 'async_trait, + >> + where + 'life0: 'async_trait, + 'life1: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + let state = Arc::::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"})), + )), + } + }) + } +} diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs new file mode 100644 index 0000000..596836d --- /dev/null +++ b/server/src/db/mod.rs @@ -0,0 +1 @@ +pub type DbPool = sqlx::PgPool; diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..a01374e --- /dev/null +++ b/server/src/main.rs @@ -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(()) +} diff --git a/server/src/presence/mod.rs b/server/src/presence/mod.rs new file mode 100644 index 0000000..3a80ed3 --- /dev/null +++ b/server/src/presence/mod.rs @@ -0,0 +1,34 @@ +use dashmap::DashSet; +use uuid::Uuid; + +pub struct PresenceTracker { + online: DashSet, +} + +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() + } +} diff --git a/server/src/relay/mod.rs b/server/src/relay/mod.rs new file mode 100644 index 0000000..e6837f3 --- /dev/null +++ b/server/src/relay/mod.rs @@ -0,0 +1,2 @@ +pub mod server; +pub mod quic_server; diff --git a/server/src/relay/quic_server.rs b/server/src/relay/quic_server.rs new file mode 100644 index 0000000..78d31f0 --- /dev/null +++ b/server/src/relay/quic_server.rs @@ -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 { + 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) +} diff --git a/server/src/relay/server.rs b/server/src/relay/server.rs new file mode 100644 index 0000000..38f941f --- /dev/null +++ b/server/src/relay/server.rs @@ -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>>>; + +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>, + 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(()) +} diff --git a/server/src/signaling/handler.rs b/server/src/signaling/handler.rs new file mode 100644 index 0000000..0bce9b4 --- /dev/null +++ b/server/src/signaling/handler.rs @@ -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>, + axum::extract::Query(params): axum::extract::Query>, +) -> impl IntoResponse { + // Token passed as query param: /api/v1/ws?token= + 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, 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::(); + + 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::(&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 = 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; + } + } + _ => {} + } +} diff --git a/server/src/signaling/mod.rs b/server/src/signaling/mod.rs new file mode 100644 index 0000000..148175e --- /dev/null +++ b/server/src/signaling/mod.rs @@ -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; + +pub struct SignalingHub { + sessions: DashMap, +} + +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() + } +} diff --git a/server/src/signaling/session.rs b/server/src/signaling/session.rs new file mode 100644 index 0000000..4eeaf76 --- /dev/null +++ b/server/src/signaling/session.rs @@ -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, +} diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 0000000..f099657 --- /dev/null +++ b/server/src/state.rs @@ -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, + pub signaling: Arc, + pub host_info: DashMap, + pub server_config: RwLock, + 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(), + } + } +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..595053a --- /dev/null +++ b/shared/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "funmc-shared" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..13138f1 --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1,2 @@ +pub mod models; +pub mod protocol; diff --git a/shared/src/models.rs b/shared/src/models.rs new file mode 100644 index 0000000..482bedb --- /dev/null +++ b/shared/src/models.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: Uuid, + pub username: String, + pub email: String, + pub avatar_seed: String, + pub last_seen: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum FriendshipStatus { + Pending, + Accepted, + Blocked, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Friendship { + pub requester_id: Uuid, + pub addressee_id: Uuid, + pub status: FriendshipStatus, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum RoomStatus { + Open, + InGame, + Closed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Room { + pub id: Uuid, + pub name: String, + pub owner_id: Uuid, + pub max_players: i32, + pub is_public: bool, + pub game_version: String, + pub status: RoomStatus, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum MemberRole { + Owner, + Member, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoomMember { + pub room_id: Uuid, + pub user_id: Uuid, + pub role: MemberRole, + pub joined_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum SessionType { + P2p, + Relay, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthResponse { + pub access_token: String, + pub refresh_token: String, + pub user: User, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionStats { + pub session_type: SessionType, + pub latency_ms: u32, + pub bytes_sent: u64, + pub bytes_received: u64, +} diff --git a/shared/src/protocol.rs b/shared/src/protocol.rs new file mode 100644 index 0000000..bf16f8f --- /dev/null +++ b/shared/src/protocol.rs @@ -0,0 +1,125 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Messages sent over the WebSocket signaling channel +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SignalingMessage { + /// Client registers itself with the signaling server + Register { user_id: Uuid }, + + /// ICE/NAT candidate exchange for P2P hole punching + Offer { + from: Uuid, + to: Uuid, + room_id: Uuid, + /// Serialized offer data (public IP, port, NAT type) + sdp: String, + }, + Answer { + from: Uuid, + to: Uuid, + room_id: Uuid, + sdp: String, + }, + IceCandidate { + from: Uuid, + to: Uuid, + candidate: String, + }, + + /// Presence events + UserOnline { user_id: Uuid }, + UserOffline { user_id: Uuid }, + + /// Room events pushed by server + RoomInvite { + from: Uuid, + room_id: Uuid, + room_name: String, + }, + MemberJoined { + room_id: Uuid, + user_id: Uuid, + username: String, + }, + MemberLeft { + room_id: Uuid, + user_id: Uuid, + }, + + /// Member kicked from room + Kicked { + room_id: Uuid, + reason: String, + }, + + /// Room closed by owner + RoomClosed { + room_id: Uuid, + }, + + /// Friend events + FriendRequest { from: Uuid, username: String }, + FriendAccepted { from: Uuid, username: String }, + + /// Chat messages in room + ChatMessage { + room_id: Uuid, + from: Uuid, + username: String, + content: String, + timestamp: i64, + }, + /// Send chat message to room (client -> server) + SendChat { + room_id: Uuid, + content: String, + }, + + /// P2P connection negotiation result + P2pSuccess { room_id: Uuid, peer_id: Uuid }, + P2pFailed { room_id: Uuid, peer_id: Uuid }, + + /// Relay session control + RelayJoin { + room_id: Uuid, + session_token: String, + }, + + /// Ping/pong for keepalive + Ping, + Pong, + + /// Server error response + Error { code: u16, message: String }, +} + +/// QUIC relay packet header (prefix before Minecraft TCP data) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayPacket { + pub room_id: Uuid, + pub source_peer: Uuid, + pub dest_peer: Option, // None = broadcast to room + pub payload: Vec, +} + +/// NAT type detected by STUN +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum NatType { + None, + FullCone, + RestrictedCone, + PortRestrictedCone, + Symmetric, + Unknown, +} + +/// Peer connection info exchanged during signaling +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeerInfo { + pub user_id: Uuid, + pub public_addr: String, + pub local_addrs: Vec, + pub nat_type: NatType, +}