init: 本地静态页面托管服务
- 初始化
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.idea
|
||||||
Generated
+1009
File diff suppressed because it is too large
Load Diff
+18
@@ -0,0 +1,18 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"stash-core",
|
||||||
|
"stash-cli",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1.38", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "stash-cli"
|
||||||
|
version = { workspace = true }
|
||||||
|
edition = { workspace = true }
|
||||||
|
license = { workspace = true }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "stash"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
stash-core = { path = "../stash-core",version = "0.1.0" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
colored = "3.1.1"
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use clap::Parser;
|
||||||
|
use colored::*;
|
||||||
|
use stash_core::{Config, start};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "stash",
|
||||||
|
author = "stash developers",
|
||||||
|
version,
|
||||||
|
about = "🚀 本地静态页面托管与多项目管理工具",
|
||||||
|
long_about = "支持本地静态页面按目录隔离托管,支持前端直接调用同源接口,提供清爽的项目导航列表。"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// 静态页面托管根目录(支持相对路径和绝对路径)
|
||||||
|
#[arg(short, long, default_value = "./public", value_name = "DIR")]
|
||||||
|
root: PathBuf,
|
||||||
|
|
||||||
|
/// 监听端口
|
||||||
|
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// 绑定监听地址(默认 127.0.0.1,如需局域网访问可传 0.0.0.0)
|
||||||
|
#[arg(long, default_value = "127.0.0.1", value_name = "HOST")]
|
||||||
|
host: String,
|
||||||
|
|
||||||
|
/// 关闭根目录项目导航列表功能(仅托管静态文件)
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
no_listing: bool,
|
||||||
|
|
||||||
|
/// 关闭 CORS 跨域标头注入
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
no_cors: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// 1. 初始化 tracing 日志
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// 2. 解析命令行输入
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// 检查目录是否存在,不存在则友好提示并自动创建默认 public
|
||||||
|
if !cli.root.exists() {
|
||||||
|
if cli.root == PathBuf::from("./public") {
|
||||||
|
println!(
|
||||||
|
"{} 默认托管目录 {} 不存在,正在自动创建...",
|
||||||
|
"[INFO]".cyan(),
|
||||||
|
"./public".yellow()
|
||||||
|
);
|
||||||
|
std::fs::create_dir_all(&cli.root)?;
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{} 指定的托管目录不存在: {}",
|
||||||
|
"[ERROR]".red().bold(),
|
||||||
|
cli.root.display().to_string().yellow()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let abs_root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone());
|
||||||
|
|
||||||
|
// 3. 构造 stash-core 配置并启动
|
||||||
|
let config = Config {
|
||||||
|
root: abs_root.clone(),
|
||||||
|
port: cli.port,
|
||||||
|
host: cli.host,
|
||||||
|
directory_listing: !cli.no_listing,
|
||||||
|
enable_cors: !cli.no_cors,
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle = match start(config).await {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{} 启动失败: {}", "[FATAL]".red().bold(), err);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 打印启动成功 Banner
|
||||||
|
println!();
|
||||||
|
println!("{}", "==================================================".bright_blue());
|
||||||
|
println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed());
|
||||||
|
println!("{}", "==================================================".bright_blue());
|
||||||
|
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
|
||||||
|
println!(" ➜ 托管路径: {}", abs_root.display().to_string().white());
|
||||||
|
println!(" ➜ 目录导航页: {}", if !cli.no_listing { "开启".green() } else { "关闭".red() });
|
||||||
|
println!(" ➜ 接口列表: {}", format!("http://{}/api/pages", handle.local_addr).dimmed());
|
||||||
|
println!("{}", "--------------------------------------------------".bright_blue());
|
||||||
|
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 5. 跨平台捕获退出信号
|
||||||
|
wait_for_shutdown_signal().await;
|
||||||
|
|
||||||
|
println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow());
|
||||||
|
handle.stop();
|
||||||
|
|
||||||
|
// 留出时间让 tokio 优雅关闭 socket 句柄
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
println!("{} 服务已安全退出。\n", "[DONE]".green().bold());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 监听停机信号(支持 Windows Ctrl+C 以及 Linux/macOS SIGINT/SIGTERM)
|
||||||
|
async fn wait_for_shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("无法注册 Ctrl+C 信号处理器");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("无法注册 SIGTERM 信号处理器")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "stash-core"
|
||||||
|
version = { workspace = true }
|
||||||
|
edition = { workspace = true }
|
||||||
|
license = { workspace = true }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
|
||||||
|
# Web 核心与静态托管
|
||||||
|
axum = "0.8.9"
|
||||||
|
tower-http = { version = "0.7.1", features = ["fs", "cors", "trace"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
walkdir = "2.4"
|
||||||
|
mime_guess = "2.0"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>404 - 资源未找到</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f6f9;
|
||||||
|
--container-bg: #ffffff;
|
||||||
|
--primary: #ff5c38;
|
||||||
|
--title-color: #2b2f36;
|
||||||
|
--desc-color: #8a92a3;
|
||||||
|
--border-color: #edf2f7;
|
||||||
|
--shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--title-color);
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--container-bg);
|
||||||
|
padding: 44px 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: var(--desc-color);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--title-color);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<div class="code">404</div>
|
||||||
|
<h2>资源或页面不存在</h2>
|
||||||
|
<p>请求的路径 <code>__PATH__</code> 不存在或缺少 index.html</p>
|
||||||
|
<a href="/" class="btn">返回应用大厅</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Stash 本地应用控制台</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
/* suxing.me 经典轻量博客/导航设计配色体系 */
|
||||||
|
--bg: #f4f6f9;
|
||||||
|
--container-bg: #ffffff;
|
||||||
|
--primary: #ff5c38;
|
||||||
|
--primary-light: rgba(255, 92, 56, 0.08);
|
||||||
|
--title-color: #2b2f36;
|
||||||
|
--desc-color: #8a92a3;
|
||||||
|
--border-color: #edf2f7;
|
||||||
|
--card-hover-bg: #f8fafc;
|
||||||
|
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--title-color);
|
||||||
|
padding: 48px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
max-width: 1080px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主容器卡片化,呈现图中的圆角纯白面板 */
|
||||||
|
.panel {
|
||||||
|
background: var(--container-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 28px 32px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部标题区(参考图中的左侧圆环图标+加粗标题) */
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
color: var(--title-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--title-color);
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #b1b9c7;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4列自适应网格,复刻参考图结构 */
|
||||||
|
.site-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.site-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.site-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 每一个站点项目的水平卡片 */
|
||||||
|
.site-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-item:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-item:hover .site-name {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 圆形小头像/图标 */
|
||||||
|
.site-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
box-shadow: 0 2px 6px rgba(255, 92, 56, 0.12);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--title-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--desc-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态占位 */
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: var(--desc-color);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="header-title-group">
|
||||||
|
<span class="header-icon">
|
||||||
|
<!-- 类似参考图中的网球/球体圆环图标 -->
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
|
||||||
|
<path d="M2 12h20"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<h2>本地前端应用</h2>
|
||||||
|
</div>
|
||||||
|
<div class="panel-action">•••</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="site-grid">
|
||||||
|
__CARDS__
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
/// 静态页面托管根目录(存放一个或多个前端项目)
|
||||||
|
pub root: PathBuf,
|
||||||
|
/// 监听端口
|
||||||
|
pub port: u16,
|
||||||
|
/// 监听地址,默认 127.0.0.1
|
||||||
|
pub host: String,
|
||||||
|
/// 是否在根路径没有 index.html 时展示项目仪表盘
|
||||||
|
pub directory_listing: bool,
|
||||||
|
/// 是否注入 CORS 标头
|
||||||
|
pub enable_cors: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
root: PathBuf::from("./public"),
|
||||||
|
port: 8080,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
directory_listing: true,
|
||||||
|
enable_cors: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum StashError {
|
||||||
|
#[error("IO 错误: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("目录不存在或非法: {0}")]
|
||||||
|
InvalidDirectory(String),
|
||||||
|
|
||||||
|
#[error("网络地址解析失败: {0}")]
|
||||||
|
AddrParse(#[from] std::net::AddrParseError),
|
||||||
|
|
||||||
|
#[error("端口绑定失败: {0}")]
|
||||||
|
ServerBind(String),
|
||||||
|
|
||||||
|
#[error("未找到资源: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for StashError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, err_msg) = match self {
|
||||||
|
StashError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||||
|
StashError::InvalidDirectory(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = Json(json!({
|
||||||
|
"error": err_msg,
|
||||||
|
"status": status.as_u16(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod listing;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
pub use config::Config;
|
||||||
|
pub use error::StashError;
|
||||||
|
pub use listing::{scan_projects, AppProject};
|
||||||
|
pub use server::{start, ServerHandle};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const INDEX_TEMPLATE: &str = include_str!("../assets/index.html");
|
||||||
|
const NOT_FOUND_TEMPLATE: &str = include_str!("../assets/404.html");
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
pub struct AppProject {
|
||||||
|
pub name: String,
|
||||||
|
pub route_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
||||||
|
let mut projects = Vec::new();
|
||||||
|
|
||||||
|
let Ok(read_dir) = std::fs::read_dir(root) else {
|
||||||
|
return projects;
|
||||||
|
};
|
||||||
|
|
||||||
|
for item in read_dir.flatten() {
|
||||||
|
let path = item.path();
|
||||||
|
let name = item.file_name().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
let has_index = path.join("index.html").exists() || path.join("index.htm").exists();
|
||||||
|
if has_index {
|
||||||
|
let route_path = format!("/{}/", name);
|
||||||
|
projects.push(AppProject { name, route_path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
projects
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_dashboard(projects: &[AppProject]) -> String {
|
||||||
|
let mut cards_html = String::new();
|
||||||
|
|
||||||
|
for proj in projects {
|
||||||
|
// 提取首字符作为圆形头像字母
|
||||||
|
let initial = proj.name.chars().next().unwrap_or('A').to_uppercase().to_string();
|
||||||
|
|
||||||
|
cards_html.push_str(&format!(
|
||||||
|
r#"
|
||||||
|
<a class="site-item" href="{url}" title="{name}">
|
||||||
|
<div class="site-avatar">{initial}</div>
|
||||||
|
<div class="site-info">
|
||||||
|
<span class="site-name">{name}</span>
|
||||||
|
<span class="site-desc">本地托管应用 • {url}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
"#,
|
||||||
|
url = proj.route_path,
|
||||||
|
name = proj.name,
|
||||||
|
initial = initial
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_cards = if cards_html.is_empty() {
|
||||||
|
r#"<div class="empty-state">当前目录下暂无含有 index.html 的前端项目</div>"#.to_string()
|
||||||
|
} else {
|
||||||
|
cards_html
|
||||||
|
};
|
||||||
|
|
||||||
|
INDEX_TEMPLATE.replace("__CARDS__", &final_cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_not_found(path: &str) -> String {
|
||||||
|
NOT_FOUND_TEMPLATE.replace("__PATH__", path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use axum::{
|
||||||
|
extract::{OriginalUri, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use tower_http::{
|
||||||
|
cors::{Any, CorsLayer},
|
||||||
|
services::ServeDir,
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::StashError;
|
||||||
|
use crate::listing::{render_dashboard, render_not_found, scan_projects};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerHandle {
|
||||||
|
shutdown_tx: oneshot::Sender<()>,
|
||||||
|
pub local_addr: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerHandle {
|
||||||
|
pub fn stop(self) {
|
||||||
|
let _ = self.shutdown_tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根路径处理器
|
||||||
|
async fn root_handler(State(state): State<AppState>) -> Result<Response, StashError> {
|
||||||
|
let index_file = state.config.root.join("index.html");
|
||||||
|
|
||||||
|
// 1. 如果托管目录本身就是一个完整的前端项目(含 index.html),优先直接展示
|
||||||
|
if index_file.exists() {
|
||||||
|
let content = tokio::fs::read_to_string(&index_file).await?;
|
||||||
|
return Ok(([(header::CONTENT_TYPE, "text/html; charset=utf-8")], Html(content)).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 否则展示筛选后的可用前端应用大厅
|
||||||
|
let projects = scan_projects(&state.config.root);
|
||||||
|
let html = render_dashboard(&projects);
|
||||||
|
Ok(Html(html).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局 404 自定义页面处理器
|
||||||
|
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> impl IntoResponse {
|
||||||
|
let html = render_not_found(&uri.path());
|
||||||
|
(StatusCode::NOT_FOUND, Html(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API: 健康检查
|
||||||
|
async fn health_check() -> impl IntoResponse {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"service": "stash"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API: 项目列表
|
||||||
|
async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let projects = scan_projects(&state.config.root);
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"projects": projects
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||||
|
if !config.root.exists() {
|
||||||
|
return Err(StashError::InvalidDirectory(
|
||||||
|
config.root.display().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared_state = AppState {
|
||||||
|
config: Arc::new(config.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 静态文件服务:找不到静态资源时 fallback 到自定义 not_found_handler
|
||||||
|
let serve_dir = ServeDir::new(&config.root)
|
||||||
|
.append_index_html_on_directories(true)
|
||||||
|
.fallback(axum::handler::HandlerWithoutStateExt::into_service(not_found_handler));
|
||||||
|
|
||||||
|
let mut app = Router::new()
|
||||||
|
.route("/api/health", get(health_check))
|
||||||
|
.route("/api/projects", get(list_projects_api))
|
||||||
|
.route("/", get(root_handler))
|
||||||
|
.fallback_service(serve_dir)
|
||||||
|
.with_state(shared_state)
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
if config.enable_cors {
|
||||||
|
app = app.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StashError::ServerBind(format!("端口 {} 绑定失败: {}", config.port, e)))?;
|
||||||
|
|
||||||
|
let local_addr = listener.local_addr()?;
|
||||||
|
info!("Stash 服务运行于: http://{}", local_addr);
|
||||||
|
|
||||||
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let server = axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
info!("Stash 优雅停机...");
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(err) = server.await {
|
||||||
|
warn!("服务异常退出: {}", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ServerHandle {
|
||||||
|
shutdown_tx,
|
||||||
|
local_addr,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user