forge_core_server/routes/
images.rs

1use axum::{
2    Router,
3    body::Body,
4    extract::{DefaultBodyLimit, Multipart, Path, State},
5    http::{StatusCode, header},
6    response::{Json as ResponseJson, Response},
7    routing::{delete, get, post},
8};
9use chrono::{DateTime, Utc};
10use forge_core_db::models::{
11    image::{Image, TaskImage},
12    task::Task,
13};
14use forge_core_deployment::Deployment;
15use forge_core_services::services::image::ImageError;
16use forge_core_utils::response::ApiResponse;
17use serde::{Deserialize, Serialize};
18use sqlx::Error as SqlxError;
19use tokio::fs::File;
20use tokio_util::io::ReaderStream;
21use ts_rs_forge::TS;
22use uuid::Uuid;
23
24use crate::{DeploymentImpl, error::ApiError};
25
26#[derive(Debug, Clone, Serialize, Deserialize, TS)]
27pub struct ImageResponse {
28    pub id: Uuid,
29    pub file_path: String, // relative path to display in markdown
30    pub original_name: String,
31    pub mime_type: Option<String>,
32    pub size_bytes: i64,
33    pub hash: String,
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36}
37
38impl ImageResponse {
39    pub fn from_image(image: Image) -> Self {
40        // special relative path for images
41        let markdown_path = format!(
42            "{}/{}",
43            forge_core_utils::path::FORGE_IMAGES_DIR,
44            image.file_path
45        );
46        Self {
47            id: image.id,
48            file_path: markdown_path,
49            original_name: image.original_name,
50            mime_type: image.mime_type,
51            size_bytes: image.size_bytes,
52            hash: image.hash,
53            created_at: image.created_at,
54            updated_at: image.updated_at,
55        }
56    }
57}
58
59pub async fn upload_image(
60    State(deployment): State<DeploymentImpl>,
61    multipart: Multipart,
62) -> Result<ResponseJson<ApiResponse<ImageResponse>>, ApiError> {
63    let image_response = process_image_upload(&deployment, multipart, None).await?;
64    Ok(ResponseJson(ApiResponse::success(image_response)))
65}
66
67pub(crate) async fn process_image_upload(
68    deployment: &DeploymentImpl,
69    mut multipart: Multipart,
70    link_task_id: Option<Uuid>,
71) -> Result<ImageResponse, ApiError> {
72    let image_service = deployment.image();
73
74    while let Some(field) = multipart.next_field().await? {
75        if field.name() == Some("image") {
76            let filename = field
77                .file_name()
78                .map(|s| s.to_string())
79                .unwrap_or_else(|| "image.png".to_string());
80
81            let data = field.bytes().await?;
82            let image = image_service.store_image(&data, &filename).await?;
83
84            if let Some(task_id) = link_task_id {
85                TaskImage::associate_many_dedup(
86                    &deployment.db().pool,
87                    task_id,
88                    std::slice::from_ref(&image.id),
89                )
90                .await?;
91            }
92
93            deployment
94                .track_if_analytics_allowed(
95                    "image_uploaded",
96                    serde_json::json!({
97                        "image_id": image.id.to_string(),
98                        "size_bytes": image.size_bytes,
99                        "mime_type": image.mime_type,
100                        "task_id": link_task_id.map(|id| id.to_string()),
101                    }),
102                )
103                .await;
104
105            return Ok(ImageResponse::from_image(image));
106        }
107    }
108
109    Err(ApiError::Image(ImageError::NotFound))
110}
111
112pub async fn upload_task_image(
113    Path(task_id): Path<Uuid>,
114    State(deployment): State<DeploymentImpl>,
115    multipart: Multipart,
116) -> Result<ResponseJson<ApiResponse<ImageResponse>>, ApiError> {
117    Task::find_by_id(&deployment.db().pool, task_id)
118        .await?
119        .ok_or(ApiError::Database(SqlxError::RowNotFound))?;
120
121    let image_response = process_image_upload(&deployment, multipart, Some(task_id)).await?;
122    Ok(ResponseJson(ApiResponse::success(image_response)))
123}
124
125/// Serve an image file by ID
126pub async fn serve_image(
127    Path(image_id): Path<Uuid>,
128    State(deployment): State<DeploymentImpl>,
129) -> Result<Response, ApiError> {
130    let image_service = deployment.image();
131    let image = image_service
132        .get_image(image_id)
133        .await?
134        .ok_or_else(|| ApiError::Image(ImageError::NotFound))?;
135    let file_path = image_service.get_absolute_path(&image);
136
137    let file = File::open(&file_path).await?;
138    let metadata = file.metadata().await?;
139
140    let stream = ReaderStream::new(file);
141    let body = Body::from_stream(stream);
142
143    let content_type = image
144        .mime_type
145        .as_deref()
146        .unwrap_or("application/octet-stream");
147
148    let response = Response::builder()
149        .status(StatusCode::OK)
150        .header(header::CONTENT_TYPE, content_type)
151        .header(header::CONTENT_LENGTH, metadata.len())
152        .header(header::CACHE_CONTROL, "public, max-age=31536000") // Cache for 1 year
153        .body(body)
154        .map_err(|e| ApiError::Image(ImageError::ResponseBuildError(e.to_string())))?;
155
156    Ok(response)
157}
158
159pub async fn delete_image(
160    Path(image_id): Path<Uuid>,
161    State(deployment): State<DeploymentImpl>,
162) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {
163    let image_service = deployment.image();
164    image_service.delete_image(image_id).await?;
165    Ok(ResponseJson(ApiResponse::success(())))
166}
167
168pub async fn get_task_images(
169    Path(task_id): Path<Uuid>,
170    State(deployment): State<DeploymentImpl>,
171) -> Result<ResponseJson<ApiResponse<Vec<ImageResponse>>>, ApiError> {
172    let images = Image::find_by_task_id(&deployment.db().pool, task_id).await?;
173    let image_responses = images.into_iter().map(ImageResponse::from_image).collect();
174    Ok(ResponseJson(ApiResponse::success(image_responses)))
175}
176
177pub fn routes() -> Router<DeploymentImpl> {
178    Router::new()
179        .route(
180            "/upload",
181            post(upload_image).layer(DefaultBodyLimit::max(20 * 1024 * 1024)), // 20MB limit
182        )
183        .route("/{id}/file", get(serve_image))
184        .route("/{id}", delete(delete_image))
185        .route("/task/{task_id}", get(get_task_images))
186        .route(
187            "/task/{task_id}/upload",
188            post(upload_task_image).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
189        )
190}