2026-02-24 20:56:36 +08:00
|
|
|
|
use axum::{
|
|
|
|
|
|
body::Body,
|
|
|
|
|
|
extract::{Path, State},
|
|
|
|
|
|
http::{header, StatusCode},
|
2026-02-25 22:00:35 +08:00
|
|
|
|
response::{Html, Response},
|
2026-02-24 20:56:36 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:22:39 +08:00
|
|
|
|
/// 列出 downloads 目录下可用的文件名(供下载页仅对存在的文件显示「下载」)
|
|
|
|
|
|
pub async fn list_download_files() -> Json<Vec<String>> {
|
|
|
|
|
|
let downloads_dir = std::env::var("DOWNLOADS_DIR").unwrap_or_else(|_| "./downloads".to_string());
|
|
|
|
|
|
let mut names = Vec::new();
|
|
|
|
|
|
if let Ok(mut rd) = tokio::fs::read_dir(&downloads_dir).await {
|
|
|
|
|
|
while let Ok(Some(entry)) = rd.next_entry().await {
|
|
|
|
|
|
if let Ok(meta) = entry.metadata().await {
|
|
|
|
|
|
if meta.is_file() {
|
|
|
|
|
|
if let Ok(name) = entry.file_name().into_string() {
|
|
|
|
|
|
names.push(name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Json(names)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 20:56:36 +08:00
|
|
|
|
pub async fn get_client_config(State(state): State<Arc<AppState>>) -> Json<ClientConfig> {
|
|
|
|
|
|
let config = state.server_config.read().unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let server_url = if !config.server_domain.is_empty() {
|
|
|
|
|
|
format!("https://{}", config.server_domain)
|
|
|
|
|
|
} else if !config.server_ip.is_empty() {
|
|
|
|
|
|
format!("http://{}:3000", config.server_ip)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"http://localhost:3000".to_string()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let relay_url = if !config.server_domain.is_empty() {
|
|
|
|
|
|
format!("{}:7900", config.server_domain)
|
|
|
|
|
|
} else if !config.server_ip.is_empty() {
|
|
|
|
|
|
format!("{}:7900", config.server_ip)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"localhost:7900".to_string()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Json(ClientConfig {
|
|
|
|
|
|
server_name: config.server_name.clone(),
|
|
|
|
|
|
server_url,
|
|
|
|
|
|
relay_url,
|
|
|
|
|
|
version: config.client_version.clone(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn download_page(State(state): State<Arc<AppState>>) -> Html<String> {
|
|
|
|
|
|
let config = state.server_config.read().unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let server_url = if !config.server_domain.is_empty() {
|
|
|
|
|
|
format!("https://{}", config.server_domain)
|
|
|
|
|
|
} else if !config.server_ip.is_empty() {
|
|
|
|
|
|
format!("http://{}:3000", config.server_ip)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"http://localhost:3000".to_string()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-25 22:00:35 +08:00
|
|
|
|
let html = format!(r##"<!DOCTYPE html>
|
2026-02-24 20:56:36 +08:00
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
|
<title>FunMC 客户端下载 - {server_name}</title>
|
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
.gradient-bg {{
|
|
|
|
|
|
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
|
|
|
|
|
}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body class="bg-gray-50 min-h-screen">
|
|
|
|
|
|
<div class="gradient-bg text-white py-16">
|
|
|
|
|
|
<div class="max-w-4xl mx-auto px-4 text-center">
|
|
|
|
|
|
<h1 class="text-4xl font-bold mb-4">FunMC</h1>
|
|
|
|
|
|
<p class="text-xl opacity-90">Minecraft 联机工具</p>
|
|
|
|
|
|
<p class="mt-2 opacity-75">{server_name}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="max-w-4xl mx-auto px-4 -mt-8">
|
|
|
|
|
|
<div class="bg-white rounded-2xl shadow-lg p-8">
|
|
|
|
|
|
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">选择你的平台</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="grid md:grid-cols-3 gap-6">
|
|
|
|
|
|
<!-- Windows -->
|
|
|
|
|
|
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
|
|
|
|
|
|
<div class="text-center">
|
|
|
|
|
|
<div class="text-5xl mb-4">🪟</div>
|
|
|
|
|
|
<h3 class="text-xl font-semibold mb-2">Windows</h3>
|
|
|
|
|
|
<p class="text-gray-500 text-sm mb-4">Windows 10/11</p>
|
2026-02-26 21:22:39 +08:00
|
|
|
|
<a href="{server_url}/api/v1/download/FunMC-{version}-windows-x64.exe" data-download-file="FunMC-{version}-windows-x64.exe"
|
|
|
|
|
|
class="dl-link inline-block w-full py-3 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
|
2026-02-24 20:56:36 +08:00
|
|
|
|
下载 .exe
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- macOS -->
|
|
|
|
|
|
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
|
|
|
|
|
|
<div class="text-center">
|
|
|
|
|
|
<div class="text-5xl mb-4">🍎</div>
|
|
|
|
|
|
<h3 class="text-xl font-semibold mb-2">macOS</h3>
|
|
|
|
|
|
<p class="text-gray-500 text-sm mb-4">macOS 11+</p>
|
|
|
|
|
|
<div class="space-y-2">
|
2026-02-26 21:22:39 +08:00
|
|
|
|
<a href="{server_url}/api/v1/download/FunMC-{version}-macos-arm64.dmg" data-download-file="FunMC-{version}-macos-arm64.dmg"
|
|
|
|
|
|
class="dl-link inline-block w-full py-3 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
|
2026-02-24 20:56:36 +08:00
|
|
|
|
Apple Silicon
|
|
|
|
|
|
</a>
|
2026-02-26 21:22:39 +08:00
|
|
|
|
<a href="{server_url}/api/v1/download/FunMC-{version}-macos-x64.dmg" data-download-file="FunMC-{version}-macos-x64.dmg"
|
|
|
|
|
|
class="dl-link inline-block w-full py-2 px-4 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm">
|
2026-02-24 20:56:36 +08:00
|
|
|
|
Intel Mac
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Linux -->
|
|
|
|
|
|
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
|
|
|
|
|
|
<div class="text-center">
|
|
|
|
|
|
<div class="text-5xl mb-4">🐧</div>
|
|
|
|
|
|
<h3 class="text-xl font-semibold mb-2">Linux</h3>
|
|
|
|
|
|
<p class="text-gray-500 text-sm mb-4">Ubuntu/Debian/Fedora</p>
|
2026-02-26 21:22:39 +08:00
|
|
|
|
<a href="{server_url}/api/v1/download/FunMC-{version}-linux-x64.AppImage" data-download-file="FunMC-{version}-linux-x64.AppImage"
|
|
|
|
|
|
class="dl-link inline-block w-full py-3 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium">
|
2026-02-24 20:56:36 +08:00
|
|
|
|
下载 AppImage
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Mobile -->
|
|
|
|
|
|
<div class="mt-8 pt-8 border-t">
|
|
|
|
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4 text-center">移动端</h3>
|
|
|
|
|
|
<div class="grid md:grid-cols-2 gap-6 max-w-2xl mx-auto">
|
|
|
|
|
|
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
|
|
|
|
|
|
<div class="text-center">
|
|
|
|
|
|
<div class="text-4xl mb-3">🤖</div>
|
|
|
|
|
|
<h4 class="font-semibold mb-2">Android</h4>
|
2026-02-26 21:22:39 +08:00
|
|
|
|
<a href="{server_url}/api/v1/download/FunMC-{version}-android.apk" data-download-file="FunMC-{version}-android.apk"
|
|
|
|
|
|
class="dl-link inline-block w-full py-2 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
2026-02-24 20:56:36 +08:00
|
|
|
|
下载 APK
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="border-2 border-gray-200 rounded-xl p-6 hover:border-green-500 transition-colors">
|
|
|
|
|
|
<div class="text-center">
|
|
|
|
|
|
<div class="text-4xl mb-3">📱</div>
|
|
|
|
|
|
<h4 class="font-semibold mb-2">iOS</h4>
|
|
|
|
|
|
<a href="#" class="inline-block w-full py-2 px-4 bg-gray-400 text-white rounded-lg cursor-not-allowed">
|
|
|
|
|
|
即将上架 App Store
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Server Info -->
|
|
|
|
|
|
<div class="mt-8 bg-white rounded-2xl shadow-lg p-8">
|
|
|
|
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">服务器信息</h3>
|
|
|
|
|
|
<div class="bg-gray-50 rounded-lg p-4 font-mono text-sm">
|
|
|
|
|
|
<p class="text-gray-600">服务器地址: <span class="text-green-600">{server_url}</span></p>
|
|
|
|
|
|
<p class="text-gray-600 mt-1">客户端版本: <span class="text-green-600">v{version}</span></p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="mt-4 text-sm text-gray-500">
|
|
|
|
|
|
下载并安装客户端后,启动程序会自动连接到此服务器,无需手动配置。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="text-center py-8 text-gray-400 text-sm">
|
|
|
|
|
|
魔幻方开发 · FunMC
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-26 21:22:39 +08:00
|
|
|
|
<script>
|
|
|
|
|
|
(function(){{
|
|
|
|
|
|
var apiBase = window.location.origin + '/api/v1';
|
|
|
|
|
|
fetch(apiBase + '/download/list').then(function(r){{ return r.json(); }}).then(function(list){{
|
|
|
|
|
|
var set = new Set(list || []);
|
|
|
|
|
|
document.querySelectorAll('a.dl-link[data-download-file]').forEach(function(a){{
|
|
|
|
|
|
var name = a.getAttribute('data-download-file');
|
|
|
|
|
|
if (!set.has(name)) {{
|
|
|
|
|
|
a.outerHTML = '<span class="inline-block w-full py-3 px-4 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed">暂无</span>';
|
|
|
|
|
|
}}
|
|
|
|
|
|
}});
|
|
|
|
|
|
}}).catch(function(){{}});
|
|
|
|
|
|
}})();
|
|
|
|
|
|
</script>
|
2026-02-24 20:56:36 +08:00
|
|
|
|
</body>
|
2026-02-25 22:00:35 +08:00
|
|
|
|
</html>"##,
|
2026-02-24 20:56:36 +08:00
|
|
|
|
server_name = config.server_name,
|
|
|
|
|
|
server_url = server_url,
|
|
|
|
|
|
version = config.client_version,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
Html(html)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn list_builds() -> Json<Vec<ClientBuild>> {
|
|
|
|
|
|
let version = env!("CARGO_PKG_VERSION");
|
|
|
|
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
|
|
|
|
|
|
|
|
|
|
let builds = vec![
|
|
|
|
|
|
ClientBuild {
|
|
|
|
|
|
platform: "windows-x64".to_string(),
|
|
|
|
|
|
arch: "x64".to_string(),
|
|
|
|
|
|
version: version.to_string(),
|
|
|
|
|
|
filename: format!("FunMC-{}-windows-x64.exe", version),
|
|
|
|
|
|
size: "45.2 MB".to_string(),
|
|
|
|
|
|
download_count: 0,
|
|
|
|
|
|
built_at: now.clone(),
|
|
|
|
|
|
status: "ready".to_string(),
|
|
|
|
|
|
},
|
|
|
|
|
|
ClientBuild {
|
|
|
|
|
|
platform: "macos-arm64".to_string(),
|
|
|
|
|
|
arch: "arm64".to_string(),
|
|
|
|
|
|
version: version.to_string(),
|
|
|
|
|
|
filename: format!("FunMC-{}-macos-arm64.dmg", version),
|
|
|
|
|
|
size: "52.1 MB".to_string(),
|
|
|
|
|
|
download_count: 0,
|
|
|
|
|
|
built_at: now.clone(),
|
|
|
|
|
|
status: "ready".to_string(),
|
|
|
|
|
|
},
|
|
|
|
|
|
ClientBuild {
|
|
|
|
|
|
platform: "macos-x64".to_string(),
|
|
|
|
|
|
arch: "x64".to_string(),
|
|
|
|
|
|
version: version.to_string(),
|
|
|
|
|
|
filename: format!("FunMC-{}-macos-x64.dmg", version),
|
|
|
|
|
|
size: "51.8 MB".to_string(),
|
|
|
|
|
|
download_count: 0,
|
|
|
|
|
|
built_at: now.clone(),
|
|
|
|
|
|
status: "ready".to_string(),
|
|
|
|
|
|
},
|
|
|
|
|
|
ClientBuild {
|
|
|
|
|
|
platform: "linux-x64".to_string(),
|
|
|
|
|
|
arch: "x64".to_string(),
|
|
|
|
|
|
version: version.to_string(),
|
|
|
|
|
|
filename: format!("FunMC-{}-linux-x64.AppImage", version),
|
|
|
|
|
|
size: "48.7 MB".to_string(),
|
|
|
|
|
|
download_count: 0,
|
|
|
|
|
|
built_at: now.clone(),
|
|
|
|
|
|
status: "ready".to_string(),
|
|
|
|
|
|
},
|
|
|
|
|
|
ClientBuild {
|
|
|
|
|
|
platform: "android-arm64".to_string(),
|
|
|
|
|
|
arch: "arm64".to_string(),
|
|
|
|
|
|
version: version.to_string(),
|
|
|
|
|
|
filename: format!("FunMC-{}-android.apk", version),
|
|
|
|
|
|
size: "38.5 MB".to_string(),
|
|
|
|
|
|
download_count: 0,
|
|
|
|
|
|
built_at: now,
|
|
|
|
|
|
status: "ready".to_string(),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
Json(builds)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:05:41 +08:00
|
|
|
|
pub async fn download_file(Path(filename): Path<String>) -> Result<Response, Response> {
|
2026-02-24 20:56:36 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-02-26 21:05:41 +08:00
|
|
|
|
let file = match File::open(&file_path).await {
|
|
|
|
|
|
Ok(f) => f,
|
|
|
|
|
|
Err(_) => {
|
|
|
|
|
|
let escaped = filename.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """);
|
|
|
|
|
|
let html = format!(r##"<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head><meta charset="UTF-8"><title>文件不存在</title></head>
|
|
|
|
|
|
<body style="font-family:sans-serif;max-width:560px;margin:80px auto;padding:20px;">
|
|
|
|
|
|
<h1>文件不存在</h1>
|
|
|
|
|
|
<p>未找到 <strong>{}</strong>。</p>
|
|
|
|
|
|
<p>可能原因:该版本尚未构建或未上传到服务器。请联系管理员,或将构建好的客户端放入服务器的 <code>downloads</code> 目录后重试。</p>
|
|
|
|
|
|
<p><a href="/download">返回下载页</a></p>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"##, escaped);
|
|
|
|
|
|
return Err(Response::builder()
|
|
|
|
|
|
.status(StatusCode::NOT_FOUND)
|
|
|
|
|
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
|
|
|
|
.body(Body::from(html))
|
|
|
|
|
|
.unwrap());
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-02-24 20:56:36 +08:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-02-25 22:07:34 +08:00
|
|
|
|
#[allow(dead_code)]
|
2026-02-24 20:56:36 +08:00
|
|
|
|
pub platforms: Vec<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn trigger_build(Json(_body): Json<TriggerBuildBody>) -> StatusCode {
|
|
|
|
|
|
StatusCode::ACCEPTED
|
|
|
|
|
|
}
|