use axum::{ body::Bytes, extract::Path, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, Json, }; use serde_json::json; use crate::{db, db::log_event}; /// push payload 中的一条 commit #[derive(Debug, PartialEq)] pub struct PushCommit { pub sha: String, pub message: String, pub timestamp: Option, } /// HMAC-SHA256 验签(X-Gitea-Signature 头) pub fn verify_hmac_sha256(secret: &str, body: &[u8], sig_hex: &str) -> bool { use hmac::{Hmac, Mac}; use sha2::Sha256; let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { Ok(m) => m, Err(_) => return false, }; mac.update(body); let expected: String = mac .finalize() .into_bytes() .iter() .map(|b| format!("{b:02x}")) .collect(); expected.eq_ignore_ascii_case(sig_hex.trim()) } /// 解析 Gitea push payload 的 commits 数组 pub fn parse_push_payload(body: &[u8]) -> Result, String> { let json: serde_json::Value = serde_json::from_slice(body).map_err(|e| format!("payload 非法 JSON: {e}"))?; let commits = json["commits"] .as_array() .ok_or("payload 缺少 commits 数组")?; Ok(commits .iter() .filter_map(|c| { let sha = c["id"].as_str()?.to_string(); let message = c["message"].as_str().unwrap_or("").to_string(); let timestamp = c["timestamp"].as_str().map(|s| s.to_string()); Some(PushCommit { sha, message, timestamp, }) }) .collect()) } /// 从请求头提取签名:优先 X-Gitea-Signature,兼容 X-Hub-Signature-256 fn extract_signature(headers: &HeaderMap) -> Option { if let Some(v) = headers .get("x-gitea-signature") .and_then(|v| v.to_str().ok()) { return Some(v.to_string()); } headers .get("x-hub-signature-256") .and_then(|v| v.to_str().ok()) .map(|v| v.trim_start_matches("sha256=").to_string()) } pub async fn handle_gitea_webhook( Path(project_id): Path, headers: HeaderMap, body: Bytes, ) -> Response { let conn = match db::pool().get() { Ok(conn) => conn, Err(_) => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": "webhook 状态 unavailable"})), ) .into_response() } }; let secret = match webhook_secret_for_conn( &conn, &crate::credential_store::WindowsCredentialStore, &project_id, ) { Ok(secret) => secret, Err(error) if error.contains("not_configured") || error.contains("不存在") => { return ( StatusCode::NOT_FOUND, Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"})), ) .into_response() } Err(_) => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": "webhook 状态 needs_repair"})), ) .into_response() } }; let sig = extract_signature(&headers); let verified = sig .as_deref() .map(|s| verify_hmac_sha256(&secret, &body, s)) .unwrap_or(false); if !verified { log_event( "gitea_webhook", "warn", &format!( "项目 {project_id} webhook 验签失败(签名{})", if sig.is_some() { "不匹配" } else { "缺失" } ), None, ); return ( StatusCode::UNAUTHORIZED, Json(json!({"error": "签名验证失败"})), ) .into_response(); } let event = headers .get("x-gitea-event") .and_then(|v| v.to_str().ok()) .unwrap_or(""); match event { "push" => handle_push(&project_id, &body).await, "pull_request" => handle_pull_request(&project_id, &body).await, other => Json(json!({"ignored": other})).into_response(), } } fn webhook_secret_for_conn( conn: &rusqlite::Connection, store: &impl crate::credential_store::CredentialStore, project_id: &str, ) -> Result { let bytes = crate::credential_store::get_or_migrate_legacy( conn, store, crate::credential_store::LegacyCredentialKind::GiteaWebhookSecret, project_id, )?; String::from_utf8(bytes).map_err(|_| "webhook 状态 needs_repair:凭证编码无效".to_string()) } async fn handle_push(project_id: &str, body: &Bytes) -> Response { let commits = match parse_push_payload(body) { Ok(c) => c, Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(), }; let conn = match db::pool().get() { Ok(c) => c, Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()})), ) .into_response() } }; // U3:按 project_id 找到本地路径加载规则 ID 集合,剔除 trailer 里的未知 ID let valid_rules = crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id); let mut received = 0; for c in &commits { if crate::commands::commit_metrics::record_commit_validated( &conn, project_id, &c.sha, &c.message, c.timestamp.as_deref(), valid_rules.as_ref(), ) .is_ok() { received += 1; } } if received > 0 { db::touch_sync_state(&conn, "flywheel"); } log_event( "gitea_webhook", "info", &format!( "项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit", commits.len() ), None, ); Json(json!({"received": received})).into_response() } async fn handle_pull_request(project_id: &str, body: &Bytes) -> Response { let payload: serde_json::Value = match serde_json::from_slice(body) { Ok(v) => v, Err(e) => { return ( StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()})), ) .into_response() } }; let raw_action = payload["action"].as_str().unwrap_or(""); let pr = &payload["pull_request"]; let pr_number = pr["number"].as_i64().unwrap_or(0); let action = match raw_action { "opened" | "reopened" => "opened", "closed" => { if pr["merged"].as_bool().unwrap_or(false) { "merged" } else { "closed" } } _ => return Json(json!({"ignored": raw_action})).into_response(), }; let title = pr["title"].as_str().unwrap_or("").to_string(); let head_branch = pr["head"]["label"].as_str().unwrap_or("").to_string(); let base_branch = pr["base"]["label"].as_str().unwrap_or("").to_string(); let author = pr["user"]["login"].as_str().unwrap_or("").to_string(); let id = uuid::Uuid::new_v4().to_string(); let conn = match db::pool().get() { Ok(c) => c, Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()})), ) .into_response() } }; let result = conn.execute( "INSERT INTO pr_events (id, project_id, pr_number, title, action, head_branch, base_branch, author) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![id, project_id, pr_number, title, action, head_branch, base_branch, author], ); match result { Ok(_) => { db::touch_sync_state(&conn, "flywheel"); log_event( "gitea_webhook", "info", &format!("项目 {project_id} PR #{pr_number} {action}"), None, ); Json(json!({"recorded": action, "pr_number": pr_number})).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()})), ) .into_response(), } } // ── 测试 ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; fn sign(secret: &str, body: &[u8]) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(body); mac.finalize() .into_bytes() .iter() .map(|b| format!("{b:02x}")) .collect() } #[test] fn hmac_verify_accepts_valid_signature() { let body = br#"{"commits":[]}"#; let sig = sign("my-secret", body); assert!(verify_hmac_sha256("my-secret", body, &sig)); assert!(verify_hmac_sha256("my-secret", body, &sig.to_uppercase())); } #[test] fn hmac_verify_rejects_wrong_secret_or_tampered_body() { let body = br#"{"commits":[]}"#; let sig = sign("my-secret", body); assert!(!verify_hmac_sha256("other-secret", body, &sig)); assert!(!verify_hmac_sha256( "my-secret", br#"{"commits":[1]}"#, &sig )); assert!(!verify_hmac_sha256("my-secret", body, "deadbeef")); } #[test] fn legacy_webhook_migration_keeps_signature_valid_and_failure_recoverable() { use crate::credential_store::MemoryCredentialStore; let conn = crate::db::conn_with_schema(); conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://example.invalid', 'synthetic-token')", []).unwrap(); conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('r', 'i', 'p', 'owner', 'repo', 'synthetic-webhook-secret')", []).unwrap(); let failing = MemoryCredentialStore::failing(); let error = webhook_secret_for_conn(&conn, &failing, "p").unwrap_err(); assert!(error.contains("needs_repair")); assert!(!error.contains("synthetic-webhook-secret")); let legacy: String = conn .query_row( "SELECT webhook_secret FROM gitea_repos WHERE project_id='p'", [], |r| r.get(0), ) .unwrap(); assert_eq!(legacy, "synthetic-webhook-secret"); let store = MemoryCredentialStore::new(); let secret = webhook_secret_for_conn(&conn, &store, "p").unwrap(); let body = br#"{"commits":[]}"#; assert!(verify_hmac_sha256( &secret, body, &sign("synthetic-webhook-secret", body) )); let (legacy, reference): (Option, String) = conn .query_row( "SELECT webhook_secret, webhook_secret_ref FROM gitea_repos WHERE project_id='p'", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert!(legacy.is_none()); assert!(!reference.is_empty()); assert_eq!(webhook_secret_for_conn(&conn, &store, "p").unwrap(), secret); } #[test] fn parse_push_payload_extracts_commits() { let body = br#"{ "ref": "refs/heads/master", "commits": [ {"id": "abc123", "message": "feat(core): add thing", "timestamp": "2026-07-02T10:00:00+08:00"}, {"id": "def456", "message": "fix(ui): repair", "timestamp": null} ] }"#; let commits = parse_push_payload(body).unwrap(); assert_eq!(commits.len(), 2); assert_eq!(commits[0].sha, "abc123"); assert_eq!(commits[0].message, "feat(core): add thing"); assert_eq!( commits[0].timestamp.as_deref(), Some("2026-07-02T10:00:00+08:00") ); assert_eq!(commits[1].timestamp, None); } #[test] fn parse_push_payload_rejects_invalid() { assert!(parse_push_payload(b"not json").is_err()); assert!(parse_push_payload(br#"{"no_commits": true}"#).is_err()); assert_eq!(parse_push_payload(br#"{"commits":[]}"#).unwrap().len(), 0); } }