use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use recall_wire::{
AdminStats, ClaudeCliStatus, Health, MergeError, MergeStatus, PushRequest, PushResponse,
SyncResponse,
};
use super::respond::{error, internal, json};
use super::AppState;
use crate::now;
const ADMIN_HTML: &str = include_str!("../../assets/admin.html");
const ADMIN_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'";
const REQUIRED_FIELDS_MSG: &str =
"project_key, file_path, and content (string) are required, unless deleted is true";
pub(super) async fn handle_push(State(state): State<Arc<AppState>>, body: Bytes) -> Response {
let req: PushRequest = match serde_json::from_slice(&body) {
Ok(req) => req,
Err(_) => {
let missing_field = serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.as_object()
.map(|o| !o.contains_key("project_key") || !o.contains_key("file_path"))
})
.unwrap_or(false);
return error(
StatusCode::BAD_REQUEST,
if missing_field {
REQUIRED_FIELDS_MSG
} else {
"invalid json body"
},
);
}
};
if req.project_key.is_empty() || req.file_path.is_empty() {
return error(StatusCode::BAD_REQUEST, REQUIRED_FIELDS_MSG);
}
if recall_wire::validate_file_path(&req.file_path).is_err() {
return error(
StatusCode::BAD_REQUEST,
"file_path must be relative, no traversal",
);
}
let updated_at = now();
if req.deleted {
if let Err(e) = state.store.tombstone(
&req.project_key,
&req.file_path,
&req.source_env,
&updated_at,
) {
return internal(e);
}
return json(
StatusCode::OK,
&PushResponse {
ok: true,
project_key: req.project_key,
file_path: req.file_path,
deleted: true,
merged: false,
updated_at,
},
);
}
let existing = match state.store.get(&req.project_key, &req.file_path) {
Ok(e) => e,
Err(e) => return internal(e),
};
let Some(incoming) = req.content.clone() else {
return error(StatusCode::BAD_REQUEST, REQUIRED_FIELDS_MSG);
};
let mut content = incoming.clone();
let mut merged = false;
if let Some(stored) = should_merge(&state, existing.as_ref(), &incoming) {
match state.merger.merge(stored, &incoming).await {
Ok(out) => {
content = out;
merged = true;
let mut rt = state.write();
rt.last_merge_at = now();
rt.last_merge_error = None;
}
Err(e) => {
eprintln!(
"merge failed for {}/{}, falling back to last-write-wins: {e}",
req.project_key, req.file_path
);
state.write().last_merge_error = Some(MergeError {
message: e.to_string(),
at: now(),
});
}
}
}
if let Err(e) = state.store.upsert(
&req.project_key,
&req.file_path,
&content,
&req.source_env,
&updated_at,
) {
return internal(e);
}
json(
StatusCode::OK,
&PushResponse {
ok: true,
project_key: req.project_key,
file_path: req.file_path,
deleted: false,
merged,
updated_at,
},
)
}
fn should_merge<'a>(
state: &AppState,
existing: Option<&'a crate::store::Existing>,
incoming: &str,
) -> Option<&'a str> {
if !state.cfg.merge_enabled {
return None;
}
let stored = existing.filter(|e| !e.deleted && e.content != incoming)?;
state
.read()
.claude_status
.logged_in
.then_some(stored.content.as_str())
}
pub(super) async fn handle_pull(
State(state): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let Some(project_key) = params.get("project_key").filter(|k| !k.is_empty()) else {
return error(
StatusCode::BAD_REQUEST,
"project_key query param is required",
);
};
match state.store.list(project_key) {
Ok(files) => json(
StatusCode::OK,
&SyncResponse {
project_key: project_key.clone(),
files,
},
),
Err(e) => internal(e),
}
}
fn last_offbox_at(backup_dir: &str) -> String {
if backup_dir.is_empty() {
return String::new();
}
std::fs::read_to_string(std::path::Path::new(backup_dir).join(".last-offbox"))
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
pub(super) async fn handle_health(State(state): State<Arc<AppState>>) -> Response {
let last_sync_at = match state.store.last_sync_at() {
Ok(v) => v,
Err(e) => return internal(e),
};
let rt = state.read();
let claude_cli = if rt.claude_status.checked_at.is_empty() {
ClaudeCliStatus::default()
} else {
ClaudeCliStatus {
checked_at: rt.claude_status.checked_at.clone(),
available: Some(rt.claude_status.available),
logged_in: Some(rt.claude_status.logged_in),
error: rt.claude_status.error.clone(),
}
};
let body = Health {
status: "ok".to_string(),
git_commit: state.cfg.git_commit.clone(),
started_at: state.started_at.clone(),
last_sync_at,
last_backup_at: rt.last_backup_at.clone(),
last_offbox_at: last_offbox_at(&state.cfg.backup_dir),
merge: MergeStatus {
enabled: state.cfg.merge_enabled,
claude_cli,
last_merge_at: rt.last_merge_at.clone(),
last_merge_error: rt.last_merge_error.clone(),
},
};
drop(rt);
json(StatusCode::OK, &body)
}
pub(super) async fn handle_admin_page() -> Response {
(
StatusCode::OK,
[
("content-type", "text/html; charset=utf-8"),
("x-content-type-options", "nosniff"),
("content-security-policy", ADMIN_CSP),
],
ADMIN_HTML,
)
.into_response()
}
pub(super) async fn handle_admin_stats(State(state): State<Arc<AppState>>) -> Response {
let (projects, totals) = match state.store.admin_stats() {
Ok(v) => v,
Err(e) => return internal(e),
};
let last_backup_at = state.read().last_backup_at.clone();
json(
StatusCode::OK,
&AdminStats {
projects,
totals,
git_commit: state.cfg.git_commit.clone(),
last_backup_at,
},
)
}
pub(super) async fn not_found() -> Response {
error(StatusCode::NOT_FOUND, "not found")
}