Skip to main content

cloudillo_profile/
media.rs

1//! Profile image/media handlers
2
3use async_trait::async_trait;
4use axum::{body::Bytes, extract::State, http::StatusCode, Json};
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use std::sync::Arc;
8
9use crate::prelude::*;
10use cloudillo_core::extract::Auth;
11use cloudillo_core::scheduler::{Task, TaskId};
12use cloudillo_file::{image, preset};
13use cloudillo_types::meta_adapter;
14
15/// Image type for tenant profile updates
16#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
17pub enum TenantImageType {
18	ProfilePic,
19	CoverPic,
20}
21
22/// Task to update tenant profile/cover image after file ID is generated
23#[derive(Debug, Serialize, Deserialize)]
24pub struct TenantImageUpdaterTask {
25	tn_id: TnId,
26	f_id: u64,
27	image_type: TenantImageType,
28}
29
30impl TenantImageUpdaterTask {
31	pub fn new(tn_id: TnId, f_id: u64, image_type: TenantImageType) -> Arc<Self> {
32		Arc::new(Self { tn_id, f_id, image_type })
33	}
34}
35
36#[async_trait]
37impl Task<App> for TenantImageUpdaterTask {
38	fn kind() -> &'static str {
39		"tenant.image-update"
40	}
41	fn kind_of(&self) -> &'static str {
42		Self::kind()
43	}
44
45	fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
46		let task: TenantImageUpdaterTask = serde_json::from_str(ctx)
47			.map_err(|_| Error::Internal("invalid TenantImageUpdaterTask context".into()))?;
48		Ok(Arc::new(task))
49	}
50
51	fn serialize(&self) -> String {
52		serde_json::to_string(self).unwrap_or_default()
53	}
54
55	async fn run(&self, app: &App) -> ClResult<()> {
56		// Get the generated file_id
57		let file_id = app.meta_adapter.get_file_id(self.tn_id, self.f_id).await?;
58
59		// Update tenant with the final file_id
60		let update = match self.image_type {
61			TenantImageType::ProfilePic => meta_adapter::UpdateTenantData {
62				profile_pic: Patch::Value(file_id.to_string()),
63				..Default::default()
64			},
65			TenantImageType::CoverPic => meta_adapter::UpdateTenantData {
66				cover_pic: Patch::Value(file_id.to_string()),
67				..Default::default()
68			},
69		};
70
71		app.meta_adapter.update_tenant(self.tn_id, &update).await?;
72
73		info!("Updated tenant {} {:?} to {}", self.tn_id, self.image_type, file_id);
74		Ok(())
75	}
76}
77
78/// PUT /me/image - Upload profile picture
79pub async fn put_profile_image(
80	State(app): State<App>,
81	Auth(auth): Auth,
82	body: Bytes,
83) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
84	// Get image data directly from body
85	let image_data = body.to_vec();
86
87	if image_data.is_empty() {
88		return Err(Error::ValidationError("No image data provided".into()));
89	}
90
91	// Detect content type from image data
92	let content_type = image::detect_image_type(&image_data)
93		.ok_or_else(|| Error::ValidationError("Invalid or unsupported image format".into()))?;
94
95	// Get image dimensions
96	let dim = image::get_image_dimensions(&image_data).await?;
97	info!("Profile image dimensions: {}x{}", dim.0, dim.1);
98
99	// Get preset for profile pictures
100	let preset = preset::presets::profile_picture();
101
102	// Create file metadata
103	let f_id = app
104		.meta_adapter
105		.create_file(
106			auth.tn_id,
107			meta_adapter::CreateFile {
108				preset: Some("profile-picture".into()),
109				creator_tag: Some(auth.id_tag.as_ref().into()),
110				content_type: content_type.into(),
111				file_name: format!("{}-profile-pic.jpg", auth.id_tag).into(),
112				file_tp: Some("BLOB".into()),
113				tags: Some(vec!["profile".into()]),
114				x: Some(json!({ "dim": dim })),
115				visibility: Some('P'), // Profile pics are always public
116				..Default::default()
117			},
118		)
119		.await?;
120
121	// Extract numeric f_id
122	let f_id = match f_id {
123		meta_adapter::FileId::FId(fid) => fid,
124		meta_adapter::FileId::FileId(fid) => {
125			// Already has a file_id (duplicate), use it directly
126			app.meta_adapter
127				.update_tenant(
128					auth.tn_id,
129					&meta_adapter::UpdateTenantData {
130						profile_pic: Patch::Value(fid.to_string()),
131						..Default::default()
132					},
133				)
134				.await?;
135			info!("User {} uploaded profile image (existing): {}", auth.id_tag, fid);
136			return Ok((
137				StatusCode::OK,
138				Json(json!({
139					"fileId": fid,
140					"type": "profile-pic"
141				})),
142			));
143		}
144	};
145
146	// Generate image variants using the helper function
147	let result =
148		image::generate_image_variants(&app, auth.tn_id, f_id, &image_data, &preset).await?;
149
150	// Schedule TenantImageUpdaterTask to update tenant profile_pic after file_id is generated
151	app.scheduler
152		.task(TenantImageUpdaterTask::new(auth.tn_id, f_id, TenantImageType::ProfilePic))
153		.depend_on(vec![result.file_id_task])
154		.schedule()
155		.await?;
156
157	// Return pending file_id (prefixed with @)
158	let pending_file_id = format!("@{}", f_id);
159
160	info!("User {} uploaded profile image: {}", auth.id_tag, pending_file_id);
161
162	Ok((
163		StatusCode::OK,
164		Json(json!({
165			"fileId": pending_file_id,
166			"type": "profile-pic"
167		})),
168	))
169}
170
171/// PUT /me/cover - Upload cover image
172pub async fn put_cover_image(
173	State(app): State<App>,
174	Auth(auth): Auth,
175	body: Bytes,
176) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
177	// Get image data directly from body
178	let image_data = body.to_vec();
179
180	if image_data.is_empty() {
181		return Err(Error::ValidationError("No image data provided".into()));
182	}
183
184	// Detect content type from image data
185	let content_type = image::detect_image_type(&image_data)
186		.ok_or_else(|| Error::ValidationError("Invalid or unsupported image format".into()))?;
187
188	// Get image dimensions
189	let dim = image::get_image_dimensions(&image_data).await?;
190	info!("Cover image dimensions: {}x{}", dim.0, dim.1);
191
192	// Get preset for cover images
193	let preset = preset::presets::cover();
194
195	// Create file metadata
196	let f_id = app
197		.meta_adapter
198		.create_file(
199			auth.tn_id,
200			meta_adapter::CreateFile {
201				preset: Some("cover".into()),
202				creator_tag: Some(auth.id_tag.as_ref().into()),
203				content_type: content_type.into(),
204				file_name: format!("{}-cover.jpg", auth.id_tag).into(),
205				file_tp: Some("BLOB".into()),
206				tags: Some(vec!["cover".into()]),
207				x: Some(json!({ "dim": dim })),
208				visibility: Some('P'), // Cover images are always public
209				..Default::default()
210			},
211		)
212		.await?;
213
214	// Extract numeric f_id
215	let f_id = match f_id {
216		meta_adapter::FileId::FId(fid) => fid,
217		meta_adapter::FileId::FileId(fid) => {
218			// Already has a file_id (duplicate), use it directly
219			app.meta_adapter
220				.update_tenant(
221					auth.tn_id,
222					&meta_adapter::UpdateTenantData {
223						cover_pic: Patch::Value(fid.to_string()),
224						..Default::default()
225					},
226				)
227				.await?;
228			info!("User {} uploaded cover image (existing): {}", auth.id_tag, fid);
229			return Ok((
230				StatusCode::OK,
231				Json(json!({
232					"fileId": fid,
233					"type": "cover"
234				})),
235			));
236		}
237	};
238
239	// Generate image variants using the helper function
240	let result =
241		image::generate_image_variants(&app, auth.tn_id, f_id, &image_data, &preset).await?;
242
243	// Schedule TenantImageUpdaterTask to update tenant cover_pic after file_id is generated
244	app.scheduler
245		.task(TenantImageUpdaterTask::new(auth.tn_id, f_id, TenantImageType::CoverPic))
246		.depend_on(vec![result.file_id_task])
247		.schedule()
248		.await?;
249
250	// Return pending file_id (prefixed with @)
251	let pending_file_id = format!("@{}", f_id);
252
253	info!("User {} uploaded cover image: {}", auth.id_tag, pending_file_id);
254
255	Ok((
256		StatusCode::OK,
257		Json(json!({
258			"fileId": pending_file_id,
259			"type": "cover"
260		})),
261	))
262}
263
264// vim: ts=4