Skip to main content

jax_daemon/http_server/api/v0/bucket/
cat.rs

1use axum::extract::{Json, Query, State};
2use axum::response::{IntoResponse, Response};
3use base64::Engine;
4use reqwest::{Client, RequestBuilder, Url};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use common::prelude::MountError;
9
10use crate::http_server::api::client::ApiRequest;
11use crate::ServiceState;
12
13#[derive(Debug, Clone, Serialize, Deserialize, clap::Args)]
14pub struct CatRequest {
15    /// Bucket ID to read from
16    #[arg(long)]
17    pub bucket_id: Uuid,
18
19    /// Path in bucket to read
20    #[arg(long)]
21    pub path: String,
22
23    /// Optional: specific version hash to read from
24    #[arg(long)]
25    #[serde(default)]
26    pub at: Option<String>,
27
28    /// Optional: force download (attachment) instead of inline display
29    #[arg(long)]
30    #[serde(default)]
31    pub download: Option<bool>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CatResponse {
36    pub path: String,
37    /// Base64-encoded file content
38    pub content: String,
39    pub size: usize,
40    pub mime_type: String,
41}
42
43// JSON POST handler (original)
44pub async fn handler(
45    State(state): State<ServiceState>,
46    Json(req): Json<CatRequest>,
47) -> Result<impl IntoResponse, CatError> {
48    let response = handle_cat_request(state, req).await?;
49    Ok((http::StatusCode::OK, Json(response)).into_response())
50}
51
52// Query GET handler (for viewing/downloading)
53pub async fn handler_get(
54    State(state): State<ServiceState>,
55    Query(req): Query<CatRequest>,
56) -> Result<Response, CatError> {
57    let is_download = req.download.unwrap_or(false);
58    let cat_response = handle_cat_request(state, req).await?;
59
60    // Decode base64 content back to bytes
61    let content_bytes = base64::engine::general_purpose::STANDARD
62        .decode(&cat_response.content)
63        .map_err(|e| CatError::InvalidPath(format!("Failed to decode content: {}", e)))?;
64
65    // Determine Content-Disposition header (inline for viewing, attachment for download)
66    let disposition = if is_download {
67        format!(
68            "attachment; filename=\"{}\"",
69            std::path::Path::new(&cat_response.path)
70                .file_name()
71                .and_then(|n| n.to_str())
72                .unwrap_or("download")
73        )
74    } else {
75        format!(
76            "inline; filename=\"{}\"",
77            std::path::Path::new(&cat_response.path)
78                .file_name()
79                .and_then(|n| n.to_str())
80                .unwrap_or("file")
81        )
82    };
83
84    // Return as binary with appropriate headers
85    Ok((
86        http::StatusCode::OK,
87        [
88            (
89                axum::http::header::CONTENT_TYPE,
90                cat_response.mime_type.as_str(),
91            ),
92            (
93                axum::http::header::CONTENT_DISPOSITION,
94                disposition.as_str(),
95            ),
96        ],
97        content_bytes,
98    )
99        .into_response())
100}
101
102async fn handle_cat_request(state: ServiceState, req: CatRequest) -> Result<CatResponse, CatError> {
103    // Load mount - either from specific link or role-based
104    let mount = if let Some(hash_str) = &req.at {
105        // Parse the hash string and create a Link
106        match hash_str.parse::<common::linked_data::Hash>() {
107            Ok(hash) => {
108                let link = common::linked_data::Link::new(common::linked_data::LD_RAW_CODEC, hash);
109                match common::mount::Mount::load(&link, state.peer().secret(), state.peer().blobs())
110                    .await
111                {
112                    Ok(mount) => mount,
113                    Err(e) => {
114                        tracing::error!("Failed to load mount from link: {}", e);
115                        return Err(CatError::Mount(e));
116                    }
117                }
118            }
119            Err(e) => {
120                return Err(CatError::InvalidPath(format!("Invalid hash format: {}", e)));
121            }
122        }
123    } else {
124        // Load mount based on role (owners see HEAD, mirrors see latest_published)
125        state.peer().mount_for_read(req.bucket_id).await?
126    };
127
128    let path_buf = std::path::PathBuf::from(&req.path);
129    if !path_buf.is_absolute() {
130        return Err(CatError::InvalidPath("Path must be absolute".into()));
131    }
132
133    // Get file data
134    let data = mount.cat(&path_buf).await?;
135
136    // Get node link to extract MIME type
137    let node_link = mount.get(&path_buf).await?;
138    let mime_type = node_link
139        .data()
140        .and_then(|data| data.mime())
141        .map(|mime| mime.to_string())
142        .unwrap_or_else(|| "application/octet-stream".to_string());
143
144    // Encode as base64 for JSON transport
145    let content = base64::engine::general_purpose::STANDARD.encode(&data);
146    let size = data.len();
147
148    Ok(CatResponse {
149        path: req.path,
150        content,
151        size,
152        mime_type,
153    })
154}
155
156#[derive(Debug, thiserror::Error)]
157pub enum CatError {
158    #[error("Invalid path: {0}")]
159    InvalidPath(String),
160    #[error("Mount error: {0}")]
161    Mount(#[from] MountError),
162}
163
164impl IntoResponse for CatError {
165    fn into_response(self) -> Response {
166        match self {
167            CatError::InvalidPath(msg) => (
168                http::StatusCode::BAD_REQUEST,
169                format!("Invalid path: {}", msg),
170            )
171                .into_response(),
172            CatError::Mount(_) => (
173                http::StatusCode::INTERNAL_SERVER_ERROR,
174                "Unexpected error".to_string(),
175            )
176                .into_response(),
177        }
178    }
179}
180
181// Client implementation - builds request for this operation
182impl ApiRequest for CatRequest {
183    type Response = CatResponse;
184
185    fn build_request(self, base_url: &Url, client: &Client) -> RequestBuilder {
186        let full_url = base_url.join("/api/v0/bucket/cat").unwrap();
187        client.post(full_url).json(&self)
188    }
189}