Skip to main content

ograf_core/handlers/
graphics.rs

1use axum::{
2    extract::{Path, Query, State},
3    http::{HeaderMap, StatusCode},
4    response::{IntoResponse, Response},
5    Json,
6};
7use serde::Deserialize;
8use serde_json::{json, Value};
9
10use crate::{
11    error::Result,
12    handlers::api_key_from,
13    models::Graphic,
14    store::graphics::{is_valid_graphic_id, safe_join, GraphicStore},
15    AppState,
16};
17
18// Optional access control via `filter_graphics` — the default
19// `AllowAllAccessControl` returns all graphics (maintaining the original
20// "unscoped by design" behavior), but implementations can restrict visibility
21// based on zones, roles, or other policies.
22
23pub async fn list_graphics(
24    State(state): State<AppState>,
25    headers: HeaderMap,
26) -> Result<Json<Value>> {
27    let api_key = api_key_from(&headers);
28    let all_graphics = GraphicStore::new(&state.config.graphics_storage)
29        .list()
30        .await?;
31    let visible = state.access.filter_graphics(&api_key, all_graphics).await;
32    let list: Vec<Value> = visible.iter().map(Graphic::list_info).collect();
33    Ok(Json(json!({ "graphics": list })))
34}
35
36pub async fn get_graphic(
37    State(state): State<AppState>,
38    Path(graphic_id): Path<String>,
39) -> Result<Json<Value>> {
40    let graphic = GraphicStore::new(&state.config.graphics_storage)
41        .get(&graphic_id)
42        .await?;
43    Ok(Json(json!({
44        "graphic": graphic.manifest,
45        "metadata": {
46            "createdAt": graphic.uploaded_at,
47        }
48    })))
49}
50
51#[derive(Deserialize)]
52pub struct ThumbnailQuery {
53    pub file: String,
54}
55
56pub async fn get_thumbnail(
57    State(state): State<AppState>,
58    Path(graphic_id): Path<String>,
59    Query(query): Query<ThumbnailQuery>,
60) -> Response {
61    if !is_valid_graphic_id(&graphic_id) {
62        return StatusCode::NOT_FOUND.into_response();
63    }
64
65    let mime = mime_guess::from_path(&query.file).first_or_octet_stream();
66    let is_supported = matches!(
67        mime.essence_str(),
68        "image/png" | "image/jpeg" | "image/gif" | "image/webp"
69    );
70    if !is_supported {
71        return (
72            StatusCode::BAD_REQUEST,
73            Json(json!({ "error": "unsupported thumbnail file type" })),
74        )
75            .into_response();
76    }
77
78    let storage_path = GraphicStore::new(&state.config.graphics_storage).path_for(&graphic_id);
79    let Some(file_path) = safe_join(&storage_path, &query.file) else {
80        return (
81            StatusCode::BAD_REQUEST,
82            Json(json!({ "error": "invalid thumbnail file reference" })),
83        )
84            .into_response();
85    };
86
87    match tokio::fs::read(&file_path).await {
88        Ok(bytes) => (
89            StatusCode::OK,
90            [(axum::http::header::CONTENT_TYPE, mime.to_string())],
91            bytes,
92        )
93            .into_response(),
94        Err(_) => StatusCode::NOT_FOUND.into_response(),
95    }
96}
97
98pub async fn serve_graphic_asset(
99    State(state): State<AppState>,
100    Path((graphic_id, asset_path)): Path<(String, String)>,
101) -> Response {
102    let storage_path = match GraphicStore::new(&state.config.graphics_storage)
103        .get(&graphic_id)
104        .await
105    {
106        Ok(graphic) => graphic.storage_path,
107        Err(_) => return StatusCode::NOT_FOUND.into_response(),
108    };
109    let Some(file_path) = safe_join(std::path::Path::new(&storage_path), &asset_path) else {
110        return StatusCode::NOT_FOUND.into_response();
111    };
112
113    match tokio::fs::read(&file_path).await {
114        Ok(bytes) => {
115            let mime = mime_guess::from_path(&asset_path)
116                .first_or_octet_stream()
117                .to_string();
118            (
119                StatusCode::OK,
120                [(axum::http::header::CONTENT_TYPE, mime)],
121                bytes,
122            )
123                .into_response()
124        }
125        Err(_) => StatusCode::NOT_FOUND.into_response(),
126    }
127}