Skip to main content

mj_controller/server/api/
files.rs

1use super::*;
2
3/// A unified diff of everything the session changed.
4pub(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/// One file from the session's workspace, as bytes.
14///
15/// The path is checked here as well as on the target: a caller that spells an
16/// absolute or escaping path has made a mistake worth naming, and there is no
17/// reason to spend a round trip to the target discovering it.
18#[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
112/// Get the session's work out, in whichever form the caller asked for.
113pub(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            // The filename reaches a header, so keep it to characters that
138            // cannot end the quoted string or split the response.
139            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}