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