construct/gateway/
api_artifact_body.rs1use super::AppState;
10use super::api::require_auth;
11use axum::{
12 Json,
13 extract::{Query, State},
14 http::{HeaderMap, StatusCode, header},
15 response::{IntoResponse, Response},
16};
17use serde::Deserialize;
18use std::path::{Path, PathBuf};
19
20const MAX_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024; #[derive(Deserialize)]
23pub struct ArtifactBodyQuery {
24 pub location: String,
25}
26
27pub async fn handle_artifact_body(
28 State(state): State<AppState>,
29 headers: HeaderMap,
30 Query(q): Query<ArtifactBodyQuery>,
31) -> Response {
32 if let Err(e) = require_auth(&state, &headers) {
33 return e.into_response();
34 }
35
36 let path = match resolve_location(&q.location) {
37 Ok(p) => p,
38 Err(msg) => {
39 return (
40 StatusCode::BAD_REQUEST,
41 Json(serde_json::json!({ "error": msg })),
42 )
43 .into_response();
44 }
45 };
46
47 let meta = match tokio::fs::metadata(&path).await {
48 Ok(m) => m,
49 Err(e) => {
50 return (
51 StatusCode::NOT_FOUND,
52 Json(serde_json::json!({
53 "error": format!("artifact not found on disk: {e}"),
54 "path": path.display().to_string(),
55 })),
56 )
57 .into_response();
58 }
59 };
60
61 if !meta.is_file() {
62 return (
63 StatusCode::BAD_REQUEST,
64 Json(serde_json::json!({
65 "error": "artifact location is not a regular file",
66 "path": path.display().to_string(),
67 })),
68 )
69 .into_response();
70 }
71
72 if meta.len() > MAX_ARTIFACT_BYTES {
73 return (
74 StatusCode::PAYLOAD_TOO_LARGE,
75 Json(serde_json::json!({
76 "error": format!(
77 "artifact exceeds {} MiB preview limit",
78 MAX_ARTIFACT_BYTES / (1024 * 1024)
79 ),
80 "size": meta.len(),
81 })),
82 )
83 .into_response();
84 }
85
86 let bytes = match tokio::fs::read(&path).await {
87 Ok(b) => b,
88 Err(e) => {
89 return (
90 StatusCode::INTERNAL_SERVER_ERROR,
91 Json(serde_json::json!({ "error": format!("read failed: {e}") })),
92 )
93 .into_response();
94 }
95 };
96
97 let mime = mime_guess::from_path(&path)
98 .first_or_octet_stream()
99 .to_string();
100
101 let filename = path
102 .file_name()
103 .and_then(|n| n.to_str())
104 .unwrap_or("artifact");
105
106 (
107 StatusCode::OK,
108 [
109 (header::CONTENT_TYPE, mime),
110 (header::CACHE_CONTROL, "private, max-age=60".to_string()),
111 (
112 header::CONTENT_DISPOSITION,
113 format!("inline; filename=\"{filename}\""),
114 ),
115 ],
116 bytes,
117 )
118 .into_response()
119}
120
121fn resolve_location(raw: &str) -> Result<PathBuf, String> {
122 let trimmed = raw.trim();
123 if trimmed.is_empty() {
124 return Err("location is empty".to_string());
125 }
126
127 let stripped = trimmed
128 .strip_prefix("file://")
129 .unwrap_or(trimmed)
130 .to_string();
131
132 let expanded = if let Some(rest) = stripped.strip_prefix("~/") {
133 match directories::UserDirs::new() {
134 Some(dirs) => dirs.home_dir().join(rest),
135 None => return Err("cannot resolve '~': no home directory".to_string()),
136 }
137 } else if stripped == "~" {
138 match directories::UserDirs::new() {
139 Some(dirs) => dirs.home_dir().to_path_buf(),
140 None => return Err("cannot resolve '~': no home directory".to_string()),
141 }
142 } else {
143 PathBuf::from(stripped)
144 };
145
146 if !Path::new(&expanded).is_absolute() {
147 return Err("location must be an absolute path".to_string());
148 }
149
150 Ok(expanded)
151}