mj_controller/server/api/
files.rs1use super::*;
2
3pub(super) async fn diff(
5 State(state): State<ServerState>,
6 Path(session_id): Path<String>,
7 Query(options): Query<DiffOptions>,
8) -> Result<Response, ApiFailure> {
9 let backend = backend(&state)?.clone();
10 let json = options.json;
11 let diff = backend.diff(session_id, options).await?;
12 if json {
13 let details: mj_checkpoint::archive::SessionDiff =
14 serde_json::from_str(&diff).context("decode session diff metadata")?;
15 return Ok(Json(details).into_response());
16 }
17 Ok(([(CONTENT_TYPE, "text/x-diff; charset=utf-8")], diff).into_response())
18}
19
20fn validate_session_file_path(path: &std::path::Path) -> Result<(), ApiFailure> {
29 if path.as_os_str().is_empty()
30 || path.is_absolute()
31 || path
32 .components()
33 .any(|component| matches!(component, Component::Prefix(_) | Component::RootDir))
34 {
35 return Err(ApiFailure::bad_request(
36 "path must be relative to the directory the session's agent runs in",
37 ));
38 }
39 Ok(())
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct WriteFileQuery {
49 pub path: PathBuf,
50 #[serde(default)]
51 pub overwrite: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct WriteFileResponse {
56 pub path: PathBuf,
57 pub bytes: usize,
58}
59
60pub(super) async fn write_file(
61 State(state): State<ServerState>,
62 Path(session_id): Path<String>,
63 Query(query): Query<WriteFileQuery>,
64 bytes: axum::body::Bytes,
65) -> Result<Json<WriteFileResponse>, ApiFailure> {
66 validate_session_file_path(&query.path)?;
67 {
68 let snapshot = state.snapshot_rx.borrow();
69 let session = require_session_record(&snapshot, &session_id)?;
70 if !session.is_idle || session.lifecycle != ViewerLifecycleCategory::Live {
71 return Err(ApiFailure::conflict(
72 "session must be live and idle for file injection",
73 ));
74 }
75 }
76 let count = bytes.len();
77 backend(&state)?
78 .write_file(
79 session_id,
80 query.path.clone(),
81 bytes.to_vec(),
82 query.overwrite,
83 )
84 .await?;
85 Ok(Json(WriteFileResponse {
86 path: query.path,
87 bytes: count,
88 }))
89}
90
91pub(super) async fn elicitations(
92 State(state): State<ServerState>,
93 Path(session_id): Path<String>,
94) -> Result<Json<Vec<mj_core::elicitation::ElicitationRequest>>, ApiFailure> {
95 let snapshot = state.snapshot_rx.borrow();
96 Ok(Json(
97 require_session_record(&snapshot, &session_id)?
98 .pending_elicitations
99 .clone(),
100 ))
101}
102
103pub(super) async fn respond_elicitation(
104 State(state): State<ServerState>,
105 Path((session_id, elicitation_id)): Path<(String, String)>,
106 Json(response): Json<mj_core::elicitation::ElicitationResponse>,
107) -> Result<StatusCode, ApiFailure> {
108 send_action(
109 &state,
110 ControllerAction::RespondElicitation {
111 session_id,
112 elicitation_id,
113 response,
114 },
115 )
116 .await
117}
118
119pub(super) async fn read_file(
120 State(state): State<ServerState>,
121 Path(session_id): Path<String>,
122 Query(query): Query<FileQuery>,
123) -> Result<Response, ApiFailure> {
124 let backend = backend(&state)?.clone();
125 let path = PathBuf::from(query.path.trim());
126 validate_session_file_path(&path)?;
127 let bytes = backend.read_file(session_id, path).await?;
128 Ok(([(CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
129}
130
131pub(super) async fn export(
133 State(state): State<ServerState>,
134 Path(session_id): Path<String>,
135 Json(request): Json<ExportRequest>,
136) -> Result<Response, ApiFailure> {
137 let backend = backend(&state)?.clone();
138 match request.kind {
139 ExportKind::Patch => {
140 let diff = backend.diff(session_id, DiffOptions::default()).await?;
141 Ok(([(CONTENT_TYPE, "text/x-diff; charset=utf-8")], diff).into_response())
142 }
143 ExportKind::Branch => {
144 let branch = request
145 .branch
146 .as_deref()
147 .map(str::trim)
148 .filter(|branch| !branch.is_empty())
149 .ok_or_else(|| ApiFailure::bad_request("a branch export needs a branch name"))?
150 .to_owned();
151 let pushed = backend.push_branch(session_id, branch).await?;
152 Ok(Json(pushed).into_response())
153 }
154 ExportKind::Bundle => {
155 let bundle = backend.bundle(session_id.clone()).await?;
156 let filename: String = format!("{session_id}-{}.bundle", bundle.repository)
159 .chars()
160 .map(|character| match character {
161 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => character,
162 _ => '-',
163 })
164 .collect();
165 Ok((
166 [
167 (CONTENT_TYPE, "application/octet-stream".to_owned()),
168 (
169 CONTENT_DISPOSITION,
170 format!("attachment; filename=\"{filename}\""),
171 ),
172 ],
173 bundle.bytes,
174 )
175 .into_response())
176 }
177 }
178}