mod accounts; mod content; mod index; mod ssh; use anyhow::{Context, Result, ensure}; use axum::{ Json, Router, body::Body, extract::{DefaultBodyLimit, Path, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, routing::{get, patch, post, put}, }; use base64::Engine; use content::{Snapshot, Status}; use serde_json::{Value, json}; use std::{ path::PathBuf, sync::{ Arc, atomic::{AtomicU64, Ordering}, }, time::Duration, }; use tokio::sync::{Mutex, RwLock, Semaphore}; use tracing::{info, warn}; type ApiResult = std::result::Result; struct ApiError(StatusCode, String); impl IntoResponse for ApiError { fn into_response(self) -> Response { (self.0, Json(json!({"error": self.1}))).into_response() } } fn bad(error: impl std::fmt::Display) -> ApiError { ApiError(StatusCode::BAD_REQUEST, error.to_string()) } fn internal(error: impl std::fmt::Display) -> ApiError { tracing::error!(error = %error, "operation failed"); ApiError( StatusCode::INTERNAL_SERVER_ERROR, "服务器操作失败,请联系管理员".into(), ) } struct App { data: PathBuf, ssh_config: PathBuf, origin: String, secure: bool, registration: bool, accounts: Mutex, hash_slots: Arc, snapshot: RwLock, indexed: RwLock>, views: AtomicU64, } fn origin(app: &App, headers: &HeaderMap) -> ApiResult<()> { if headers.get("origin").and_then(|h| h.to_str().ok()) != Some(app.origin.as_str()) { return Err(ApiError(StatusCode::FORBIDDEN, "Origin 校验失败".into())); } Ok(()) } async fn posts(State(app): State>) -> Json { let snapshot = app.snapshot.read().await; let mut posts: Vec<_> = snapshot .posts .values() .filter(|p| p.meta.status == Status::Published) .map(|p| &p.meta) .collect(); posts.sort_by(|a, b| b.date.cmp(&a.date).then(a.slug.cmp(&b.slug))); Json(json!({"commit": snapshot.commit, "posts": posts})) } async fn public_post( State(app): State>, Path(key): Path, ) -> ApiResult> { let snapshot = app.snapshot.read().await; let post = snapshot .posts .get(&key) .filter(|p| p.meta.status == Status::Published) .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "文章不存在".into()))?; // Relaxed is sufficient: this independent counter does not synchronize other state. app.views.fetch_add(1, Ordering::Relaxed); Ok(Json( json!({"meta": post.meta, "markdown": post.markdown, "assets": post.assets.iter().map(|a| json!({"name": a.name, "url": format!("/api/assets/{key}/{}", a.name)})).collect::>()}), )) } async fn asset( State(app): State>, Path((key, name)): Path<(String, String)>, ) -> ApiResult { let snapshot = app.snapshot.read().await; let asset = snapshot .posts .get(&key) .filter(|p| p.meta.status == Status::Published) .and_then(|p| p.assets.iter().find(|a| a.name == name)) .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "资源不存在".into()))?; let bytes = base64::engine::general_purpose::STANDARD .decode(&asset.data) .map_err(internal)?; let mime = match name .rsplit('.') .next() .unwrap_or("") .to_ascii_lowercase() .as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "webp" => "image/webp", _ => return Err(internal("invalid stored image")), }; Ok(([(header::CONTENT_TYPE, mime)], Body::from(bytes)).into_response()) } async fn stats(State(app): State>) -> Json { let snapshot = app.snapshot.read().await; let posts: Vec<_> = snapshot .posts .values() .filter(|p| p.meta.status == Status::Published) .collect(); let tags: std::collections::BTreeSet<_> = posts.iter().flat_map(|p| &p.meta.tags).collect(); Json( json!({"published_posts": posts.len(), "tags": tags.len(), "markdown_bytes": posts.iter().map(|p| p.markdown.len()).sum::(), "article_requests_since_start": app.views.load(Ordering::Relaxed)}), ) } async fn admin_status(State(app): State>, headers: HeaderMap) -> ApiResult> { accounts::authorize(&app, &headers, false, true).await?; let snapshot = app.snapshot.read().await; Ok(Json( json!({"commit": snapshot.commit, "total_posts": snapshot.posts.len(), "drafts": snapshot.posts.values().filter(|p| p.meta.status == Status::Draft).count(), "indexed_commit": *app.indexed.read().await}), )) } async fn sync_index(app: &App, snapshot: &Snapshot) { match tokio::time::timeout(Duration::from_secs(30), index::rebuild(snapshot)).await { Ok(Ok(())) => { *app.indexed.write().await = Some(snapshot.commit.clone()); info!(action = "index_synced", commit = %snapshot.commit); } Ok(Err(error)) => warn!(action = "index_pending", error = %error), Err(_) => warn!(action = "index_pending", "rebuild timeout"), } } fn router(app: Arc) -> Router { Router::new() .route( "/api/health", get(|| async { Json(json!({"status": "ok"})) }), ) .route("/api/posts", get(posts)) .route("/api/posts/{slug}", get(public_post)) .route("/api/assets/{slug}/{name}", get(asset)) .route("/api/stats", get(stats)) .route("/api/auth/register", post(accounts::register)) .route("/api/auth/login", post(accounts::login)) .route("/api/auth/logout", post(accounts::logout)) .route("/api/account", get(accounts::me)) .route("/api/account/ssh-key", put(accounts::set_key)) .route("/api/account/password", put(accounts::change_password)) .route("/api/admin/users", get(accounts::users)) .route("/api/admin/users/{username}", patch(accounts::grant)) .route("/api/admin/status", get(admin_status)) .layer(DefaultBodyLimit::max(8 * 1024)) .layer(axum::middleware::from_fn( |request: axum::extract::Request, next: axum::middleware::Next| async move { let mut response = next.run(request).await; for (key, value) in [ (header::CACHE_CONTROL, "no-store"), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), ( header::CONTENT_SECURITY_POLICY, "default-src 'none'; frame-ancestors 'none'", ), (header::REFERRER_POLICY, "no-referrer"), ] { response .headers_mut() .insert(key, HeaderValue::from_static(value)); } response }, )) .with_state(app) } async fn refresh(app: &App, repo: &std::path::Path, source: &std::path::Path) -> Result<()> { // Recover a previously committed ref even if snapshot publication was interrupted. let mut current = app.snapshot.read().await.clone(); let head = String::from_utf8(content::git(repo, &["rev-parse", "refs/heads/main"], None).await?)?; if current.commit != head.trim() { current = content::scan(repo).await?; } let snapshot = ssh::import(repo, source, ¤t).await?; if snapshot.commit != app.snapshot.read().await.commit { content::persist(&app.data, &snapshot).await?; info!(action = "published", commit = %snapshot.commit); *app.snapshot.write().await = snapshot; } Ok(()) } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let command = std::env::args().nth(1); match command.as_deref() { Some("ssh-gate") => { return ssh::gate(&std::env::args().nth(2).context("缺少发布者身份")?).await; } Some("pre-receive") => return ssh::pre_receive().await, Some("authorized-keys") => return ssh::authorized_keys(), Some("provision-db") => return index::provision().await, Some("hash-password") => { use std::io::Read; let mut value = String::new(); std::io::stdin().take(1025).read_to_string(&mut value)?; println!( "{}", accounts::hash_password(value.trim_end_matches(['\r', '\n']).into()).await? ); return Ok(()); } None | Some("rebuild-index") => {} Some(_) => anyhow::bail!("未知命令"), } let data = PathBuf::from(std::env::var("DATA_DIR").unwrap_or_else(|_| "data".into())); tokio::fs::create_dir_all(&data).await?; let data = tokio::fs::canonicalize(data).await?; let lock_file = std::fs::OpenOptions::new() .create(true) .truncate(false) .write(true) .open(data.join("writer.lock"))?; fs2::FileExt::try_lock_exclusive(&lock_file).context("内容目录已被另一个 API 进程使用")?; let repo = content::init(&data).await?; let snapshot = content::scan(&repo).await?; content::persist(&data, &snapshot).await?; if command.as_deref() == Some("rebuild-index") { index::rebuild(&snapshot).await?; return Ok(()); } let origin = std::env::var("PUBLIC_ORIGIN").context("必须设置 PUBLIC_ORIGIN")?; let parsed: axum::http::Uri = origin.parse().context("PUBLIC_ORIGIN 无效")?; let secure = parsed.scheme_str() == Some("https"); ensure!( secure || (parsed.scheme_str() == Some("http") && matches!(parsed.host(), Some("localhost" | "127.0.0.1"))), "生产环境必须使用 HTTPS" ); ensure!( !origin.ends_with('/') && parsed.path() == "/" && parsed.query().is_none() && parsed .authority() .is_some_and(|a| !a.as_str().contains('@')), "PUBLIC_ORIGIN 必须为不带末尾斜杠的站点 origin" ); let source = PathBuf::from( std::env::var("INGRESS_REPO").unwrap_or_else(|_| "/srv/ingress/content.git".into()), ); ensure!(source.is_absolute(), "INGRESS_REPO 必须是绝对路径"); let ssh_config = PathBuf::from( std::env::var("CHUBLOG_SSH_CONFIG") .unwrap_or_else(|_| data.join("publishers.json").to_string_lossy().into_owned()), ); let accounts = accounts::load(&data, &ssh_config, source.clone()).await?; ensure!( accounts.grants.repository == source, "授权配置仓库与 INGRESS_REPO 不一致" ); let app = Arc::new(App { data, ssh_config, origin, secure, registration: std::env::var("ALLOW_REGISTRATION").as_deref() == Ok("true"), accounts: Mutex::new(accounts), hash_slots: Arc::new(Semaphore::new(2)), snapshot: RwLock::new(snapshot), indexed: RwLock::new(None), views: AtomicU64::new(0), }); let worker_app = app.clone(); let worker = tokio::spawn(async move { let mut ticks = 0; loop { if let Err(error) = refresh(&worker_app, &repo, &source).await { warn!(action = "import_pending", error = %error); } if ticks % 30 == 0 { let snapshot = worker_app.snapshot.read().await.clone(); if worker_app.indexed.read().await.as_deref() != Some(snapshot.commit.as_str()) { sync_index(&worker_app, &snapshot).await; } else if !matches!( tokio::time::timeout(Duration::from_secs(10), index::consistent(&snapshot)) .await, Ok(Ok(true)) ) { *worker_app.indexed.write().await = None; sync_index(&worker_app, &snapshot).await; } } ticks += 1; tokio::time::sleep(Duration::from_secs(2)).await; } }); let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3001".into()); let listener = tokio::net::TcpListener::bind(&bind).await?; info!(%bind, "ChuBlog API ready"); let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; axum::serve(listener, router(app)) .with_graceful_shutdown(async move { tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} } }) .await?; worker.abort(); let _ = worker.await; drop(lock_file); Ok(()) } #[cfg(test)] mod tests;