fix: extensive ui improvements

This commit is contained in:
Jet 2026-03-26 01:32:59 -07:00
parent 691394445a
commit 6ba64d29a9
No known key found for this signature in database
17 changed files with 684 additions and 142 deletions

View file

@ -19,6 +19,12 @@ pub struct Question {
answered_at: String,
}
#[derive(Serialize)]
pub struct QuestionStats {
asked: i64,
answered: i64,
}
pub async fn get_questions(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Question>>, StatusCode> {
@ -51,6 +57,29 @@ pub async fn get_questions(
Ok(Json(questions))
}
pub async fn get_question_stats(
State(state): State<Arc<AppState>>,
) -> Result<Json<QuestionStats>, StatusCode> {
let db = state
.db
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let asked: i64 = db
.query_row("SELECT COUNT(*) FROM questions", [], |row| row.get(0))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let answered: i64 = db
.query_row(
"SELECT COUNT(*) FROM questions WHERE answer IS NOT NULL",
[],
|row| row.get(0),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(QuestionStats { asked, answered }))
}
#[derive(Deserialize)]
pub struct SubmitQuestion {
question: String,
@ -191,10 +220,12 @@ pub struct MtaHookResponse {
}
fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
let expected_secret = expected_secret.trim();
let header_secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
.unwrap_or("")
.trim();
if header_secret == expected_secret {
return true;
}
@ -224,7 +255,29 @@ fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
None => return false,
};
password == expected_secret
password.trim() == expected_secret
}
fn webhook_secret_debug(headers: &HeaderMap) -> String {
let header_secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let auth = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let decoded = auth
.strip_prefix("Basic ")
.and_then(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_default();
format!(
"x-webhook-secret={header_secret:?}; authorization={auth:?}; basic-decoded={decoded:?}"
)
}
fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(i64, String)> {
@ -304,6 +357,21 @@ fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option<String> {
})
}
fn subject_from_headers_value(value: Option<&Value>) -> Option<String> {
let headers = value?.as_array()?;
headers.iter().find_map(|header| {
let parts = header.as_array()?;
let name = parts.first()?.as_str()?.trim();
if !name.eq_ignore_ascii_case("Subject") {
return None;
}
parts
.get(1)?
.as_str()
.map(|s| s.trim().to_string())
})
}
fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option<(i64, String)> {
if let Some(messages) = payload.get("messages").and_then(Value::as_array) {
for message in messages {
@ -318,6 +386,7 @@ fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option
&["headers", "subject"],
],
)
.or_else(|| subject_from_headers_value(value_at_path(message, &["message", "headers"])))
.as_deref(),
&string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(),
@ -338,6 +407,7 @@ fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option
&["headers", "subject"],
],
)
.or_else(|| subject_from_headers_value(value_at_path(payload, &["message", "headers"])))
.as_deref(),
&string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(),
@ -380,7 +450,11 @@ pub async fn webhook(
body: String,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
if !webhook_secret_matches(&headers, &state.webhook_secret) {
eprintln!("Rejected webhook: invalid secret");
eprintln!(
"Rejected webhook: invalid secret; expected_len={}; {}",
state.webhook_secret.len(),
webhook_secret_debug(&headers)
);
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
}
@ -515,4 +589,28 @@ mod tests {
Some((9, "Answer body".to_string()))
);
}
#[test]
fn extracts_reply_from_value_with_header_pairs() {
let payload: Value = serde_json::from_str(
r#"{
"envelope": {
"to": [{"address":"qa@extremist.software"}]
},
"message": {
"headers": [
["From", " jet@extremist.software\r\n"],
["Subject", " Re: 11 - hi\r\n"]
],
"contents": "Answer from header pairs"
}
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply_from_value(&payload, "extremist.software"),
Some((11, "Answer from header pairs".to_string()))
);
}
}

View file

@ -52,6 +52,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
"/api/questions",
get(handlers::get_questions).post(handlers::post_question),
)
.route("/api/questions/stats", get(handlers::get_question_stats))
.route("/api/webhook", post(handlers::webhook))
.layer(CorsLayer::permissive())
.with_state(state);