Skip to main content

agentmux_srv/server/
attachments.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP side of image attachments
5//! (docs/specs/SPEC_AGENT_PANE_IMAGE_ATTACHMENTS_2026_09_26.md §6.2, §6.5):
6//!
7//! - `GET  /api/v1/attachments/:id/:kind` — serve `original`, `thumb` or `send`.
8//! - `POST /api/v1/attachments/upload?name=` — the paste fallback: the raw
9//!   body is one image, streamed to disk and hashed as it arrives.
10//!
11//! Both sit in `authed_routes`, so they need `X-AuthKey`.
12
13use axum::{
14    body::Body,
15    extract::{Path, Query, State},
16    http::{header, HeaderMap, StatusCode},
17    response::{IntoResponse, Json, Response},
18};
19use futures_util::StreamExt;
20use serde_json::json;
21use sha2::{Digest, Sha256};
22use tokio::io::AsyncWriteExt;
23
24use super::AppState;
25use crate::backend::attachments::{self, Kind};
26
27fn service(state: &AppState) -> std::sync::Arc<attachments::Service> {
28    attachments::init(state.broker.clone(), state.config_watcher.clone())
29}
30
31fn error(status: StatusCode, msg: impl Into<String>) -> Response {
32    (status, Json(json!({ "error": msg.into() }))).into_response()
33}
34
35/// `GET /api/v1/attachments/:id/:kind`. The original is immutable by hash;
36/// thumb and send-copy change with the transform fingerprint, so everything
37/// is `no-cache` with an `ETag` of `<id>-<fp>` and a 304 on a match.
38pub(super) async fn handle_attachment_file(
39    State(state): State<AppState>,
40    Path((id, kind)): Path<(String, String)>,
41    headers: HeaderMap,
42) -> Response {
43    if !attachments::is_valid_id(&id) {
44        return error(StatusCode::BAD_REQUEST, "invalid attachment id");
45    }
46    let Some(kind) = Kind::parse(&kind) else {
47        return error(
48            StatusCode::BAD_REQUEST,
49            "kind must be original, thumb or send",
50        );
51    };
52    let svc = service(&state);
53    let edge = svc.limits().send_max_edge;
54    let fp = attachments::store::fingerprint(edge);
55    let found = if kind == Kind::Original {
56        // The original is served straight from blobs/: it must not depend on
57        // the derive pipeline succeeding.
58        svc.store().original(&id)
59    } else {
60        // Re-derives (under the service's limits) when the fingerprint
61        // changed since the attachment was processed.
62        match svc.ensure_derived(&id).await {
63            Some(_) => svc.store().file(&id, &fp, kind),
64            None => None,
65        }
66    };
67    let Some((path, mime)) = found else {
68        return error(StatusCode::NOT_FOUND, "attachment not found");
69    };
70    // The original never changes for an id; derived files change with the
71    // fingerprint.
72    let etag = if kind == Kind::Original {
73        format!("\"{id}\"")
74    } else {
75        format!("\"{id}-{}\"", attachments::store::fingerprint(edge))
76    };
77    if headers
78        .get(header::IF_NONE_MATCH)
79        .and_then(|v| v.to_str().ok())
80        .is_some_and(|v| v == etag)
81    {
82        return StatusCode::NOT_MODIFIED.into_response();
83    }
84    let file = match tokio::fs::File::open(&path).await {
85        Ok(f) => f,
86        Err(_) => return error(StatusCode::NOT_FOUND, "attachment not found"),
87    };
88    let len = file.metadata().await.map(|m| m.len()).unwrap_or(0);
89    Response::builder()
90        .status(StatusCode::OK)
91        .header(header::CONTENT_TYPE, mime)
92        .header(header::CONTENT_LENGTH, len.to_string())
93        .header(header::CACHE_CONTROL, "no-cache")
94        .header(header::ETAG, etag)
95        .body(Body::from_stream(tokio_util::io::ReaderStream::new(file)))
96        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
97}
98
99#[derive(serde::Deserialize)]
100pub(super) struct UploadQuery {
101    /// Display name for the returned info ("Pasted image …png").
102    name: Option<String>,
103}
104
105/// `POST /api/v1/attachments/upload?name=` — body is one image file.
106/// → 200 `AttachmentInfo` · 400 empty · 413 over the per-file cap ·
107///   415 not a supported image · 500 I/O.
108///
109/// The body is read as a stream and counted as it is written: axum's
110/// `DefaultBodyLimit` only governs buffering extractors, so it would not
111/// stop a streamed body.
112pub(super) async fn handle_attachment_upload(
113    State(state): State<AppState>,
114    Query(q): Query<UploadQuery>,
115    body: Body,
116) -> Response {
117    let svc = service(&state);
118    let cap = svc.limits().max_total_bytes;
119    let name = q
120        .name
121        .filter(|n| !n.trim().is_empty())
122        .unwrap_or_else(|| "Pasted image".to_string());
123
124    let tmp = svc.store().new_incoming_path();
125    let written = async {
126        let mut out = tokio::fs::File::create(&tmp)
127            .await
128            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
129        let mut hasher = Sha256::new();
130        let mut total: u64 = 0;
131        let mut stream = body.into_data_stream();
132        while let Some(chunk) = stream.next().await {
133            let chunk =
134                chunk.map_err(|e| (StatusCode::BAD_REQUEST, format!("upload interrupted: {e}")))?;
135            total += chunk.len() as u64;
136            if total > cap {
137                return Err((
138                    StatusCode::PAYLOAD_TOO_LARGE,
139                    format!(
140                        "The image is larger than the {} MB limit.",
141                        cap / (1024 * 1024)
142                    ),
143                ));
144            }
145            hasher.update(&chunk);
146            out.write_all(&chunk)
147                .await
148                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
149        }
150        out.flush()
151            .await
152            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
153        drop(out);
154        if total == 0 {
155            return Err((StatusCode::BAD_REQUEST, "empty body".to_string()));
156        }
157        Ok(hex::encode(hasher.finalize()))
158    }
159    .await;
160
161    let id = match written {
162        Ok(id) => id,
163        Err((status, msg)) => {
164            let _ = tokio::fs::remove_file(&tmp).await;
165            return error(status, msg);
166        }
167    };
168
169    match svc.commit_upload(tmp, id, name).await {
170        Ok(info) => Json(info).into_response(),
171        Err(e) => {
172            let status = match e.code {
173                "unsupported" | "heic" => StatusCode::UNSUPPORTED_MEDIA_TYPE,
174                "too_large_dimensions" => StatusCode::PAYLOAD_TOO_LARGE,
175                "io" => StatusCode::INTERNAL_SERVER_ERROR,
176                _ => StatusCode::UNPROCESSABLE_ENTITY,
177            };
178            (status, Json(json!({ "error": e.message, "code": e.code }))).into_response()
179        }
180    }
181}