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				orig_variant_id: None, // Will be set by generate_image_variants
110				file_id: None,
111				parent_id: None,
112				owner_tag: None,
113				creator_tag: Some(auth.id_tag.as_ref().into()),
114				content_type: content_type.into(),
115				file_name: format!("{}-profile-pic.jpg", auth.id_tag).into(),
116				file_tp: Some("BLOB".into()),
117				created_at: None,
118				tags: Some(vec!["profile".into()]),
119				x: Some(json!({ "dim": dim })),
120				visibility: Some('P'), // Profile pics are always public
121				status: None,
122			},
123		)
124		.await?;
125
126	// Extract numeric f_id
127	let f_id = match f_id {
128		meta_adapter::FileId::FId(fid) => fid,
129		meta_adapter::FileId::FileId(fid) => {
130			// Already has a file_id (duplicate), use it directly
131			app.meta_adapter
132				.update_tenant(
133					auth.tn_id,
134					&meta_adapter::UpdateTenantData {
135						profile_pic: Patch::Value(fid.to_string()),
136						..Default::default()
137					},
138				)
139				.await?;
140			info!("User {} uploaded profile image (existing): {}", auth.id_tag, fid);
141			return Ok((
142				StatusCode::OK,
143				Json(json!({
144					"fileId": fid,
145					"type": "profile-pic"
146				})),
147			));
148		}
149	};
150
151	// Generate image variants using the helper function
152	let result =
153		image::generate_image_variants(&app, auth.tn_id, f_id, &image_data, &preset).await?;
154
155	// Schedule TenantImageUpdaterTask to update tenant profile_pic after file_id is generated
156	app.scheduler
157		.task(TenantImageUpdaterTask::new(auth.tn_id, f_id, TenantImageType::ProfilePic))
158		.depend_on(vec![result.file_id_task])
159		.schedule()
160		.await?;
161
162	// Return pending file_id (prefixed with @)
163	let pending_file_id = format!("@{}", f_id);
164
165	info!("User {} uploaded profile image: {}", auth.id_tag, pending_file_id);
166
167	Ok((
168		StatusCode::OK,
169		Json(json!({
170			"fileId": pending_file_id,
171			"type": "profile-pic"
172		})),
173	))
174}
175
176/// PUT /me/cover - Upload cover image
177pub async fn put_cover_image(
178	State(app): State<App>,
179	Auth(auth): Auth,
180	body: Bytes,
181) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
182	// Get image data directly from body
183	let image_data = body.to_vec();
184
185	if image_data.is_empty() {
186		return Err(Error::ValidationError("No image data provided".into()));
187	}
188
189	// Detect content type from image data
190	let content_type = image::detect_image_type(&image_data)
191		.ok_or_else(|| Error::ValidationError("Invalid or unsupported image format".into()))?;
192
193	// Get image dimensions
194	let dim = image::get_image_dimensions(&image_data).await?;
195	info!("Cover image dimensions: {}x{}", dim.0, dim.1);
196
197	// Get preset for cover images
198	let preset = preset::presets::cover();
199
200	// Create file metadata
201	let f_id = app
202		.meta_adapter
203		.create_file(
204			auth.tn_id,
205			meta_adapter::CreateFile {
206				preset: Some("cover".into()),
207				orig_variant_id: None, // Will be set by generate_image_variants
208				file_id: None,
209				parent_id: None,
210				owner_tag: None,
211				creator_tag: Some(auth.id_tag.as_ref().into()),
212				content_type: content_type.into(),
213				file_name: format!("{}-cover.jpg", auth.id_tag).into(),
214				file_tp: Some("BLOB".into()),
215				created_at: None,
216				tags: Some(vec!["cover".into()]),
217				x: Some(json!({ "dim": dim })),
218				visibility: Some('P'), // Cover images are always public
219				status: None,
220			},
221		)
222		.await?;
223
224	// Extract numeric f_id
225	let f_id = match f_id {
226		meta_adapter::FileId::FId(fid) => fid,
227		meta_adapter::FileId::FileId(fid) => {
228			// Already has a file_id (duplicate), use it directly
229			app.meta_adapter
230				.update_tenant(
231					auth.tn_id,
232					&meta_adapter::UpdateTenantData {
233						cover_pic: Patch::Value(fid.to_string()),
234						..Default::default()
235					},
236				)
237				.await?;
238			info!("User {} uploaded cover image (existing): {}", auth.id_tag, fid);
239			return Ok((
240				StatusCode::OK,
241				Json(json!({
242					"fileId": fid,
243					"type": "cover"
244				})),
245			));
246		}
247	};
248
249	// Generate image variants using the helper function
250	let result =
251		image::generate_image_variants(&app, auth.tn_id, f_id, &image_data, &preset).await?;
252
253	// Schedule TenantImageUpdaterTask to update tenant cover_pic after file_id is generated
254	app.scheduler
255		.task(TenantImageUpdaterTask::new(auth.tn_id, f_id, TenantImageType::CoverPic))
256		.depend_on(vec![result.file_id_task])
257		.schedule()
258		.await?;
259
260	// Return pending file_id (prefixed with @)
261	let pending_file_id = format!("@{}", f_id);
262
263	info!("User {} uploaded cover image: {}", auth.id_tag, pending_file_id);
264
265	Ok((
266		StatusCode::OK,
267		Json(json!({
268			"fileId": pending_file_id,
269			"type": "cover"
270		})),
271	))
272}
273
274// vim: ts=4