forge_core_server/routes/
images.rs1use 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, 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 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
125pub 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") .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)), )
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}