Skip to main content

construct/gateway/
api_artifact_body.rs

1//! Serves the raw bytes of a Kumiho artifact's underlying local file.
2//!
3//! The `/api/artifact-body?location=<path>` endpoint reads a file from the
4//! local filesystem (the artifact's `location` as stored in Kumiho) and
5//! streams it back with a best-effort Content-Type. Required so the web
6//! Asset Browser and Workflow Runs viewers can render text / images /
7//! video artifacts without each viewer re-implementing file IO.
8
9use 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; // 256 MiB
21
22#[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}