agentmux_srv/server/
attachments.rs1use 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
35pub(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 svc.store().original(&id)
59 } else {
60 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 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 name: Option<String>,
103}
104
105pub(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}