Gitea、webhook 与 API Key 以受保护引用替代常态明文输出;旧值先备份并校验后可恢复迁移,MCP 仅提供项目只读状态。 Rules-Applied: R01,R05,R06,R07,R10 Feature-Confirmed: true
381 lines
12 KiB
Rust
381 lines
12 KiB
Rust
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<String>,
|
||
}
|
||
|
||
/// 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::<Sha256>::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<Vec<PushCommit>, 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<String> {
|
||
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<String>,
|
||
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<String, String> {
|
||
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::<Sha256>::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>, 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);
|
||
}
|
||
}
|