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