feat(imaging): serve compressed image derivatives from one crate
New workspace crate `imaging` (lib/preset/error) owns every place the forge turns uploaded bytes into something smaller: preset catalogue, decode/orient, resize and re-encode, plus the layout maths the callers used to duplicate. lib/export re-exports it so chat, space and user do not each depend on the crate directly. Consumers: - chat attachments gain a `preview` variant - the transcript grid loads the derivative while the lightbox and the download link still stream the original bytes; a missing preview degrades to the original server-side, so attachmentUrl(id, variant) is always valid. - user avatars and space images (icon/banner) go through the same presets instead of their own ad-hoc resize paths. - admin's chat room listing reads the same shape. openapi.json, web/openapi.json and globals.d.ts are regenerated for the new `variant` parameter and image preset enums.
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified lib/admin/src/services/chat.rs lib/admin/src/services/chat.rs +28 −17 View lib/admin/src/services/chat.rs
@@ -445,7 +445,10 @@
445445 .await? as i64;
446446 let attachment_bytes = attachments::Entity::find()
447447 .select_only()
448 .column_as(Expr::col(attachments::Column::Size).sum().cast_as("bigint"), "total")
448 .column_as(
449 Expr::col(attachments::Column::Size).sum().cast_as("bigint"),
450 "total",
451 )
449452 .into_tuple::<Option<i64>>()
450453 .one(self.db().read())
451454 .await?
@@ -693,7 +696,10 @@
693696 let attachment_bytes = attachments::Entity::find()
694697 .filter(attachments::Column::RoomId.eq(room_id))
695698 .select_only()
696 .column_as(Expr::col(attachments::Column::Size).sum().cast_as("bigint"), "total")
699 .column_as(
700 Expr::col(attachments::Column::Size).sum().cast_as("bigint"),
701 "total",
702 )
697703 .into_tuple::<Option<i64>>()
698704 .one(self.db().read())
699705 .await?
@@ -1246,21 +1252,18 @@
12461252 let result = self
12471253 .db()
12481254 .primary()
1249 .transaction::<_, (), AdminError>({
1250 let message_id = message_id;
1251 move |txn| {
1252 Box::pin(async move {
1253 let ids = vec![message_id];
1254 messages::Entity::update_many()
1255 .filter(messages::Column::Id.eq(message_id))
1256 .col_expr(messages::Column::Content, Expr::value(String::new()))
1257 .col_expr(messages::Column::DeletedAt, Expr::value(now))
1258 .exec(txn)
1259 .await?;
1260 Self::delete_message_dependents(txn, &ids).await?;
1261 Ok(())
1262 })
1263 }
1255 .transaction::<_, (), AdminError>(move |txn| {
1256 Box::pin(async move {
1257 let ids = vec![message_id];
1258 messages::Entity::update_many()
1259 .filter(messages::Column::Id.eq(message_id))
1260 .col_expr(messages::Column::Content, Expr::value(String::new()))
1261 .col_expr(messages::Column::DeletedAt, Expr::value(now))
1262 .exec(txn)
1263 .await?;
1264 Self::delete_message_dependents(txn, &ids).await?;
1265 Ok(())
1266 })
12641267 })
12651268 .await;
12661269 match result {
@@ -1678,6 +1681,14 @@
16781681 tracing::warn!(%error, key = %row.s3_key, "admin chat attachment storage delete failed");
16791682 }
16801683 }
1684 // The preview is derived from this object; removing one without the
1685 // other would leave an unreachable file in the bucket forever.
1686 let key_base = export::imaging::preview_key_base_of(&row.s3_key);
1687 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
1688 if let Err(error) = self.storage().client().delete(&candidate).await {
1689 tracing::warn!(%error, key = %candidate, "admin chat attachment preview delete failed");
1690 }
1691 }
16811692 }
16821693 self.audit(
16831694 Some(actor),
Modified lib/admin/Cargo.toml lib/admin/Cargo.toml +1 −0 View lib/admin/Cargo.toml
@@ -9,6 +9,7 @@
99config = { workspace = true }
1010export = { workspace = true }
1111git = { workspace = true }
12imaging = { workspace = true }
1213models = { workspace = true }
1314ci = { workspace = true }
1415redis = { workspace = true }
Modified lib/chat/src/handlers/attachments.rs lib/chat/src/handlers/attachments.rs +40 −12 View lib/chat/src/handlers/attachments.rs
@@ -1,9 +1,12 @@
1use axum::extract::{Json, Path, State};
1use axum::extract::{Json, Path, Query, State};
22use axum::http::{HeaderMap, StatusCode};
33use axum::response::{IntoResponse, Response};
44use export::rest::{AppResponseData, NoData};
5use imaging::layout::ImageVariant;
6use serde::Deserialize;
57use session::Session;
68use session::share::session::require_uid;
9use utoipa::{IntoParams, ToSchema};
710use uuid::Uuid;
811
912use crate::errors::ApiError;
@@ -16,12 +19,12 @@
1619 post,
1720 path = "/chat/attachments",
1821 tag = "chat",
19 description = "为聊天附件申请预签名直传地址,并创建待归档的附件记录。",
22 description = "为一次附件上传预留记录并返回预签名 PUT 地址。",
2023 request_body = CreateAttachmentCmd,
2124 responses(
2225 (status = 200, description = "upload intent", body = AppResponseData<UploadIntentDto>),
2326 (status = 400, description = "bad request", body = AppResponseData<NoData>),
24 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
27 (status = 429, description = "too many pending attachments", body = AppResponseData<NoData>),
2528 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
2629 )
2730)]
@@ -39,13 +42,13 @@
3942 post,
4043 path = "/chat/attachments/{attachment_id}/finalize",
4144 tag = "chat",
42 description = "校验已上传附件的大小与类型,将其标记为可用并返回最终元数据。",
45 description = "确认对象已上传,校验大小与类型,并生成压缩预览图(失败时回退原图)。",
4346 params(("attachment_id" = Uuid, Path, description = "attachment id")),
4447 request_body = FinalizeAttachmentCmd,
4548 responses(
4649 (status = 200, description = "attachment finalized", body = AppResponseData<AttachmentDto>),
47 (status = 409, description = "already finalized", body = AppResponseData<NoData>),
48 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
50 (status = 400, description = "bad request", body = AppResponseData<NoData>),
51 (status = 404, description = "not found", body = AppResponseData<NoData>),
4952 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
5053 )
5154)]
@@ -64,10 +67,10 @@
6467 get,
6568 path = "/chat/attachments/{attachment_id}",
6669 tag = "chat",
67 description = "读取当前用户可访问聊天附件的元数据。",
70 description = "读取附件元数据。",
6871 params(("attachment_id" = Uuid, Path, description = "attachment id")),
6972 responses(
70 (status = 200, description = "attachment meta", body = AppResponseData<AttachmentDto>),
73 (status = 200, description = "attachment metadata", body = AppResponseData<AttachmentDto>),
7174 (status = 404, description = "not found", body = AppResponseData<NoData>),
7275 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
7376 )
@@ -82,12 +85,21 @@
8285 Ok(AppResponseData::ok(dto))
8386}
8487
88/// `?variant=` selector. Omitting it keeps the historical meaning of this route
89/// (the original bytes, which is what a download link must hand back); the
90/// transcript grid asks for `preview` explicitly.
91#[derive(Debug, Clone, Default, Deserialize, IntoParams, ToSchema)]
92pub struct AttachmentVariantQuery {
93 #[serde(default)]
94 pub variant: Option<ImageVariant>,
95}
96
8597#[utoipa::path(
8698 get,
8799 path = "/chat/attachments/{attachment_id}/file",
88100 tag = "chat",
89 description = "以安全响应头流式下载聊天附件原始内容。",
90 params(("attachment_id" = Uuid, Path, description = "attachment id")),
101 description = "以安全响应头流式下载聊天附件。默认返回上传原图(下载语义不变),`?variant=preview` 返回压缩预览图;没有可用预览图时同样返回原图。",
102 params(("attachment_id" = Uuid, Path, description = "attachment id"), AttachmentVariantQuery),
91103 responses(
92104 (status = 200, description = "file stream"),
93105 (status = 404, description = "not found", body = AppResponseData<NoData>),
@@ -98,14 +110,30 @@
98110 State(service): State<ChatService>,
99111 session: Session,
100112 Path(attachment_id): Path<Uuid>,
113 Query(query): Query<AttachmentVariantQuery>,
101114) -> Result<Response, ApiError> {
102115 let uid = require_uid(&session)?;
103 let (row, body) = service.download_attachment(uid, attachment_id).await?;
116 let (row, body, content_type) = service
117 .download_attachment(
118 uid,
119 attachment_id,
120 query.variant.unwrap_or(ImageVariant::Original),
121 )
122 .await?;
104123 let mut headers = HeaderMap::new();
105 for (name, value) in ChatService::attachment_content_headers(&row) {
124 for (name, value) in ChatService::attachment_content_headers(&row, content_type) {
106125 if let Ok(value) = value.parse() {
107126 headers.insert(name, value);
108127 }
109128 }
129 // Attachments are immutable once finalized, so a preview may be cached
130 // privately; the original stays uncached to keep download semantics exactly
131 // as they were.
132 if query.variant == Some(ImageVariant::Preview) {
133 headers.insert(
134 axum::http::header::CACHE_CONTROL,
135 "private, max-age=300".parse().expect("static header value"),
136 );
137 }
110138 Ok((StatusCode::OK, headers, body).into_response())
111139}
Modified lib/chat/src/services/attachments.rs lib/chat/src/services/attachments.rs +150 −9 View lib/chat/src/services/attachments.rs
@@ -1,6 +1,9 @@
11use axum::body::Body;
22use axum::http::header;
3use imaging::layout::{ImageVariant, PreviewState};
4use imaging::preset::PreviewPreset;
35use models::chat::attachments::{self, AttachmentStatus};
6use sea_orm::sea_query::Expr;
47use sea_orm::{
58 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter,
69 QuerySelect, Set,
@@ -115,6 +118,38 @@
115118 .any(|blocked| main.eq_ignore_ascii_case(blocked))
116119}
117120
121/// Whether the attachment is an inline image, i.e. something worth deriving a
122/// preview from. Kept separate from [`is_inline_renderable`] so the preview
123/// machinery never touches video/audio/PDF.
124fn is_inline_image(content_type: &str) -> bool {
125 let main = content_type.split(';').next().unwrap_or("").trim();
126 main.to_ascii_lowercase().starts_with("image/")
127}
128
129/// Content type for a streamed original, mirroring the safety rule applied to
130/// response headers: an unsafe inline type is served as a plain download.
131fn attachment_stream_content_type(row: &attachments::Model) -> String {
132 if is_unsafe_inline(&row.content_type) {
133 "application/octet-stream".to_string()
134 } else {
135 row.content_type.clone()
136 }
137}
138
139fn state_ready_key(state: &PreviewState) -> String {
140 match state {
141 PreviewState::Ready(ready) => ready.key.clone(),
142 PreviewState::Skipped(_) => String::new(),
143 }
144}
145
146fn state_ready_content_type(state: &PreviewState) -> String {
147 match state {
148 PreviewState::Ready(ready) => ready.content_type.clone(),
149 PreviewState::Skipped(_) => "application/octet-stream".to_string(),
150 }
151}
152
118153fn is_inline_renderable(content_type: &str) -> bool {
119154 let main = content_type.split(';').next().unwrap_or("").trim();
120155 main.starts_with("image/")
@@ -243,16 +278,116 @@
243278 let now = OffsetDateTime::now_utc();
244279 let mut am: attachments::ActiveModel = row.into();
245280 am.size = Set(actual_size as i64);
246 am.content_type = Set(stored_content_type);
281 am.content_type = Set(stored_content_type.clone());
247282 am.status = Set(AttachmentStatus::Uploaded);
248283 am.width = Set(cmd.width);
249284 am.height = Set(cmd.height);
250285 am.duration_secs = Set(cmd.duration_secs);
251286 am.updated_at = Set(now);
252287 let updated = am.update(self.db().write()).await?;
288
289 // Build the list-view derivative while the bytes are still hot. A
290 // failure here must never fail the upload: the attachment is already
291 // valid, and the read path falls back to the original.
292 if is_inline_image(&stored_content_type)
293 && let Err(error) = self.generate_attachment_preview(&updated).await
294 {
295 tracing::warn!(%error, attachment_id = %updated.id, "attachment preview generation failed");
296 }
297
253298 Ok(AttachmentDto::from(updated))
254299 }
255300
301 /// Render and store the derivative for one uploaded attachment, recording
302 /// the outcome (including "skipped") in `variants` so a read never repeats
303 /// the decision.
304 async fn generate_attachment_preview(&self, row: &attachments::Model) -> Result<(), ChatError> {
305 let key_base = export::imaging::preview_key_base_of(&row.s3_key);
306 let preview = export::imaging::ensure_preview(
307 self.storage().client(),
308 &self.state.redis,
309 &key_base,
310 &row.s3_key,
311 &row.content_type,
312 PreviewPreset::Content,
313 None,
314 )
315 .await;
316 let Some(state) = preview else {
317 tracing::debug!(attachment_id = %row.id, "attachment preview unavailable; serving the original");
318 return Ok(());
319 };
320 self.record_attachment_preview(row.id, row.variants.clone(), &state)
321 .await
322 }
323
324 /// Persist which derivative (or skip reason) belongs to one attachment.
325 async fn record_attachment_preview(
326 &self,
327 attachment_id: Uuid,
328 mut variants: serde_json::Value,
329 state: &PreviewState,
330 ) -> Result<(), ChatError> {
331 imaging::layout::preview_into_variants(&mut variants, state);
332 attachments::Entity::update_many()
333 .filter(attachments::Column::Id.eq(attachment_id))
334 .col_expr(attachments::Column::Variants, Expr::value(variants))
335 .exec(self.db().write())
336 .await?;
337 Ok(())
338 }
339
340 /// Remove both encodings of an attachment's derivative. Safe to call for any
341 /// key: a non-image attachment has nothing stored under these names.
342 async fn delete_attachment_previews(&self, original_key: &str) {
343 let key_base = export::imaging::preview_key_base_of(original_key);
344 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
345 if let Err(error) = self.storage().client().delete(&candidate).await {
346 tracing::warn!(%error, key = %candidate, "failed to delete attachment preview");
347 }
348 }
349 }
350
351 /// Locator of the representation to stream: the derivative when it exists,
352 /// the original otherwise. Legacy rows (finalized before previews existed)
353 /// are healed here on first demand.
354 async fn attachment_variant_locator(
355 &self,
356 row: &attachments::Model,
357 variant: ImageVariant,
358 ) -> (String, String) {
359 if variant == ImageVariant::Original || !is_inline_image(&row.content_type) {
360 return (row.s3_key.clone(), attachment_stream_content_type(row));
361 }
362 let key_base = export::imaging::preview_key_base_of(&row.s3_key);
363 let recorded = imaging::layout::preview_from_variants(&row.variants);
364 let state = export::imaging::ensure_preview(
365 self.storage().client(),
366 &self.state.redis,
367 &key_base,
368 &row.s3_key,
369 &row.content_type,
370 PreviewPreset::Content,
371 recorded.as_ref(),
372 )
373 .await;
374 match state {
375 Some(state @ PreviewState::Ready(_)) => {
376 if recorded.as_ref() != Some(&state)
377 && let Err(error) = self
378 .record_attachment_preview(row.id, row.variants.clone(), &state)
379 .await
380 {
381 tracing::warn!(%error, attachment_id = %row.id, "failed to record attachment preview");
382 }
383 (state_ready_key(&state), state_ready_content_type(&state))
384 }
385 Some(PreviewState::Skipped(_)) | None => {
386 (row.s3_key.clone(), attachment_stream_content_type(row))
387 }
388 }
389 }
390
256391 pub async fn cleanup_stale_attachments(&self) -> Result<u64, ChatError> {
257392 let now = OffsetDateTime::now_utc();
258393 let pending_cutoff = now - time::Duration::hours(24);
@@ -288,6 +423,7 @@
288423 .await;
289424 if shared == 0 {
290425 let _ = self.storage().client().delete(&row.s3_key).await;
426 self.delete_attachment_previews(&row.s3_key).await;
291427 }
292428 }
293429 Ok(count)
@@ -306,11 +442,16 @@
306442 Ok(AttachmentDto::from(row))
307443 }
308444
445 /// Stream one representation of an attachment.
446 ///
447 /// `ImageVariant::Original` is what the download link and the lightbox use;
448 /// `ImageVariant::Preview` is what the transcript grid loads.
309449 pub async fn download_attachment(
310450 &self,
311451 caller: Uuid,
312452 attachment_id: Uuid,
313 ) -> Result<(attachments::Model, Body), ChatError> {
453 variant: ImageVariant,
454 ) -> Result<(attachments::Model, Body, String), ChatError> {
314455 let row = attachments::Entity::find_by_id(attachment_id)
315456 .one(self.db().read())
316457 .await?
@@ -319,12 +460,13 @@
319460 if row.status == AttachmentStatus::Pending {
320461 return Err(ChatError::NotFound);
321462 }
322 let stream = self.storage().client().get_stream(&row.s3_key).await?;
463 let (key, content_type) = self.attachment_variant_locator(&row, variant).await;
464 let stream = self.storage().client().get_stream(&key).await?;
323465 let stream = futures::StreamExt::map(stream, |item| {
324466 item.map_err(|e| std::io::Error::other(e.to_string()))
325467 });
326468 let body = Body::from_stream(stream);
327 Ok((row, body))
469 Ok((row, body, content_type))
328470 }
329471
330472 async fn check_attachment_access(
@@ -348,15 +490,14 @@
348490 Err(ChatError::Forbidden)
349491 }
350492
493 /// Response headers for one streamed representation. `content_type` is what
494 /// is actually being served (the derivative may be JPEG where the original
495 /// was PNG), so it is passed in rather than re-read from the row.
351496 pub fn attachment_content_headers(
352497 row: &attachments::Model,
498 content_type: String,
353499 ) -> Vec<(header::HeaderName, String)> {
354500 let mut headers = Vec::new();
355 let content_type = if is_unsafe_inline(&row.content_type) {
356 "application/octet-stream".to_string()
357 } else {
358 row.content_type.clone()
359 };
360501 headers.push((header::CONTENT_TYPE, content_type));
361502 headers.push((
362503 header::HeaderName::from_static("x-content-type-options"),
Modified lib/chat/src/services/messages.rs lib/chat/src/services/messages.rs +38 −15 View lib/chat/src/services/messages.rs
@@ -20,6 +20,17 @@
2020
2121use crate::errors::{ChatError, is_unique_violation};
2222use crate::services::attachments::AttachmentDto;
23/// Drop both encodings of an attachment's derivative when the last reference to
24/// its original disappears. Declared here because message deletion owns the
25/// lifetime of the attachment rows.
26async fn delete_attachment_previews(service: &ChatService, original_key: &str) {
27 let key_base = export::imaging::preview_key_base_of(original_key);
28 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
29 if let Err(error) = service.storage().client().delete(&candidate).await {
30 tracing::warn!(%error, key = %candidate, "failed to delete attachment preview");
31 }
32 }
33}
2334use crate::services::reactions::ReactionAggDto;
2435use crate::services::service::ChatService;
2536use crate::services::threads::maintain_thread_on_reply;
@@ -707,20 +718,19 @@
707718 })
708719 .await
709720 .map_err(ChatError::from);
710 if let Err(ChatError::Conflict(ref msg)) = txn_result {
711 if msg == NONCE_CONFLICT {
712 if let Some(nonce) = &nonce_retry {
713 let existing = messages::Entity::find()
714 .filter(messages::Column::RoomId.eq(room_id))
715 .filter(messages::Column::AuthorId.eq(caller))
716 .filter(messages::Column::Nonce.eq(nonce.as_str()))
717 .one(self.db().read())
718 .await?;
719 if let Some(existing) = existing {
720 let items = self.build_message_dto(vec![existing], caller).await?;
721 return Ok(items.into_iter().next().unwrap());
722 }
723 }
721 if let Err(ChatError::Conflict(ref msg)) = txn_result
722 && msg == NONCE_CONFLICT
723 && let Some(nonce) = &nonce_retry
724 {
725 let existing = messages::Entity::find()
726 .filter(messages::Column::RoomId.eq(room_id))
727 .filter(messages::Column::AuthorId.eq(caller))
728 .filter(messages::Column::Nonce.eq(nonce.as_str()))
729 .one(self.db().read())
730 .await?;
731 if let Some(existing) = existing {
732 let items = self.build_message_dto(vec![existing], caller).await?;
733 return Ok(items.into_iter().next().unwrap());
724734 }
725735 }
726736 txn_result?;
@@ -762,7 +772,16 @@
762772 let space_id = ctx.room.space_id;
763773 tokio::spawn(async move {
764774 fanout_everyone_mentions(
765 &svc, room_id, space_id, is_private, message_id, body, caller, &explicit,
775 &svc,
776 crate::share::notify::EveryoneMention {
777 room_id,
778 space_id,
779 is_private,
780 message_id,
781 body,
782 actor: caller,
783 explicit: &explicit,
784 },
766785 )
767786 .await;
768787 });
@@ -1236,6 +1255,10 @@
12361255 .await;
12371256 if shared == 0 {
12381257 let _ = self.storage().client().delete(&attachment.s3_key).await;
1258 // The derivative is an orphan once the last reference to the
1259 // original is gone; leaving it behind would leak an object that
1260 // nothing can ever address again.
1261 delete_attachment_previews(self, &attachment.s3_key).await;
12391262 }
12401263 }
12411264
Modified lib/chat/src/openapi.rs lib/chat/src/openapi.rs +1 −0 View lib/chat/src/openapi.rs
@@ -108,6 +108,7 @@
108108 ),
109109 components(schemas(
110110 export::pagination::PageQuery,
111 imaging::layout::ImageVariant,
111112 export::pagination::Page<crate::services::dms::DmDto>,
112113 export::pagination::Page<crate::services::threads::ThreadDto>,
113114 export::pagination::Page<crate::services::drafts::DraftDto>,
Modified lib/chat/Cargo.toml lib/chat/Cargo.toml +1 −0 View lib/chat/Cargo.toml
@@ -8,6 +8,7 @@
88config = { workspace = true }
99export = { workspace = true }
1010i18n = { workspace = true }
11imaging = { workspace = true }
1112models = { workspace = true }
1213search = { workspace = true }
1314social = { workspace = true }
Added lib/export/src/imaging.rs lib/export/src/imaging.rs +201 −0 View lib/export/src/imaging.rs
@@ -0,0 +1,201 @@
1//! Object-storage side of preview generation: read the recorded state, lazily
2//! build a derivative when it is missing, and clean up the encoding that is no
3//! longer in use.
4//!
5//! Everything here is best-effort by design. A missing derivative, a lost lock,
6//! or a failed render all resolve to `None` so the caller keeps serving the
7//! original — a preview is an optimisation, never a reason for an upload or a
8//! read to fail.
9
10use imaging::layout::{
11 PREVIEW_LOCK_TTL_SECS, PreviewState, preview_candidate_keys, preview_from_record, preview_key,
12 preview_key_base, preview_lock_key,
13};
14use imaging::preset::PreviewPreset;
15use imaging::{PreviewOutcome, render_preview};
16use serde_json::Value;
17use storage::S3Client;
18
19use crate::redis::{self, AppRedis};
20
21/// Look up an already-stored derivative. `key_base` comes from
22/// [`preview_key_base`]; because the encoder choice (JPEG vs WebP) is not
23/// derivable from the original's extension, both candidates are probed.
24pub async fn find_stored_preview(client: &S3Client, key_base: &str) -> Option<PreviewState> {
25 for candidate in preview_candidate_keys(key_base) {
26 match client.head(&candidate).await {
27 Ok(Some((bytes, content_type))) => {
28 let content_type = if content_type.is_empty() {
29 "image/jpeg".to_string()
30 } else {
31 content_type
32 };
33 return Some(PreviewState::ready(candidate, &content_type, bytes));
34 }
35 Ok(None) => continue,
36 Err(error) => {
37 tracing::warn!(%error, key = %candidate, "failed to head image preview object");
38 continue;
39 }
40 }
41 }
42 None
43}
44
45/// Resolve the derivative for one original, generating it on first demand.
46///
47/// `recorded` is whatever the owner already knows from its own metadata; passing
48/// `None` means "nothing recorded", which is exactly the legacy-row case this is
49/// meant to heal without a batch job.
50pub async fn ensure_preview(
51 client: &S3Client,
52 redis: &AppRedis,
53 key_base: &str,
54 original_key: &str,
55 content_type: &str,
56 preset: PreviewPreset,
57 recorded: Option<&PreviewState>,
58) -> Option<PreviewState> {
59 match recorded {
60 Some(PreviewState::Ready(ready)) => {
61 // Metadata can be ahead of reality if an object was removed by hand;
62 // verify once and fall through to regeneration when it is gone.
63 match client.head(&ready.key).await {
64 Ok(Some((bytes, stored))) if bytes > 0 => {
65 let content_type = if stored.is_empty() {
66 ready.content_type.clone()
67 } else {
68 stored
69 };
70 return Some(PreviewState::ready(ready.key.clone(), &content_type, bytes));
71 }
72 Ok(_) => {}
73 Err(error) => {
74 tracing::warn!(%error, key = %ready.key, "failed to head recorded image preview");
75 return Some(PreviewState::Ready(ready.clone()));
76 }
77 }
78 }
79 Some(PreviewState::Skipped(reason)) => {
80 tracing::debug!(%reason, key = %key_base, "image preview previously skipped; serving original");
81 return None;
82 }
83 None => {}
84 }
85
86 // Single-flight: one request decodes, the rest wait on the same object
87 // appearing (they re-check the recorded state on their next read anyway).
88 let lock = preview_lock_key(key_base);
89 let acquired = redis::set_nx_ex(redis, &lock, "1", PREVIEW_LOCK_TTL_SECS)
90 .await
91 .unwrap_or(true);
92 if !acquired {
93 tracing::debug!(key = %key_base, "image preview generation already in flight");
94 return find_stored_preview(client, key_base).await;
95 }
96
97 let outcome = generate_preview(client, key_base, original_key, content_type, preset).await;
98 if let Err(error) = redis::delete(redis, &lock).await {
99 tracing::warn!(%error, key = %lock, "failed to release image preview lock");
100 }
101 outcome
102}
103
104/// Read the original, render a derivative, store it, and drop the other
105/// encoding so a JPEG->WebP switch cannot leave two "current" previews behind.
106async fn generate_preview(
107 client: &S3Client,
108 key_base: &str,
109 original_key: &str,
110 content_type: &str,
111 preset: PreviewPreset,
112) -> Option<PreviewState> {
113 if !imaging::is_previewable_content_type(content_type) {
114 return None;
115 }
116 let source = match client.get(original_key).await {
117 Ok(bytes) => bytes,
118 Err(error) => {
119 tracing::warn!(%error, key = %original_key, "failed to read image for preview");
120 return None;
121 }
122 };
123 let rendered = render_preview(source, content_type.to_string(), preset).await;
124 let preview = match rendered {
125 PreviewOutcome::Preview(preview) => preview,
126 PreviewOutcome::UseOriginal => {
127 tracing::debug!(key = %original_key, "preview would not shrink the original; skipping");
128 return None;
129 }
130 PreviewOutcome::Skipped(reason) => {
131 tracing::debug!(%reason, key = %original_key, "image preview skipped");
132 return None;
133 }
134 };
135 let key = preview_key(key_base, preview.extension);
136 if let Err(error) = client
137 .put_with_content_type(&key, &preview.bytes, preview.content_type)
138 .await
139 {
140 tracing::warn!(%error, key = %key, "failed to store image preview");
141 return None;
142 }
143 for stale in preview_candidate_keys(key_base) {
144 if stale == key {
145 continue;
146 }
147 if let Err(error) = client.delete(&stale).await {
148 tracing::warn!(%error, key = %stale, "failed to delete stale image preview");
149 }
150 }
151 tracing::info!(
152 key = %key,
153 bytes = preview.bytes.len(),
154 width = preview.width,
155 height = preview.height,
156 "image preview generated"
157 );
158 Some(PreviewState::ready(
159 key,
160 preview.content_type,
161 preview.bytes.len() as u64,
162 ))
163}
164
165/// Merge a preview state into a JSON metadata record (avatar / space sidecars).
166#[must_use]
167pub fn record_preview(mut record: Value, state: &PreviewState) -> Value {
168 imaging::layout::preview_into_record(&mut record, state);
169 record
170}
171
172/// Read the preview state a JSON record carries, if any.
173#[must_use]
174pub fn recorded_preview(record: &Value) -> Option<PreviewState> {
175 preview_from_record(record)
176}
177
178/// Convenience: derive the derivative base for a stored original key.
179#[must_use]
180pub fn preview_key_base_of(original_key: &str) -> String {
181 preview_key_base(original_key)
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use serde_json::json;
188
189 #[test]
190 fn record_helpers_round_trip() {
191 let state = PreviewState::ready("avatars/a.preview.webp".into(), "image/webp", 9);
192 let record = record_preview(json!({ "uid": "a" }), &state);
193 assert_eq!(recorded_preview(&record), Some(state));
194 assert_eq!(record["uid"], json!("a"));
195 }
196
197 #[test]
198 fn key_base_helper_matches_layout() {
199 assert_eq!(preview_key_base_of("avatars/a.png"), "avatars/a.preview");
200 }
201}
Modified lib/export/src/lib.rs lib/export/src/lib.rs +2 −1 View lib/export/src/lib.rs
@@ -2,6 +2,7 @@
22pub mod database;
33pub mod error;
44pub mod health;
5pub mod imaging;
56pub mod jwt;
67pub mod middleware;
78pub mod notify;
@@ -9,8 +10,8 @@
910pub mod push;
1011pub mod redis;
1112pub mod rest;
12pub mod serde_patch;
1313pub mod serde_datetime;
14pub mod serde_patch;
1415pub mod storage;
1516pub mod telemetry;
1617pub mod urls;
Modified lib/export/Cargo.toml lib/export/Cargo.toml +5 −0 View lib/export/Cargo.toml
@@ -10,10 +10,15 @@
1010redis = { workspace = true }
1111tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
1212email = { workspace = true }
13imaging = { workspace = true }
1314axum = { workspace = true }
1415serde = { workspace = true, features = ["derive"] }
1516tracing = { workspace = true }
1617tracing-subscriber = { workspace = true }
18opentelemetry = { workspace = true }
19opentelemetry_sdk = { workspace = true }
20opentelemetry-otlp = { workspace = true }
21tracing-opentelemetry = { workspace = true }
1722utoipa = { workspace = true }
1823rand.workspace = true
1924tower = { workspace = true }
Added lib/imaging/src/error.rs lib/imaging/src/error.rs +30 −0 View lib/imaging/src/error.rs
@@ -0,0 +1,30 @@
1//! Why a preview was not produced. Every reason is non-fatal: the caller keeps
2//! the original and serves it instead.
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum PreviewSkip {
6 /// The declared content type is not one of the supported raster formats.
7 UnsupportedFormat,
8 /// The payload had no bytes.
9 Empty,
10 /// The source exceeds the byte or pixel ceiling for previews.
11 SourceTooLarge,
12 /// The source could not be decoded (corrupt, truncated, or a decoder limit).
13 Decode(String),
14 /// The decoded image could not be encoded back into a preview.
15 Encode(String),
16}
17
18impl std::fmt::Display for PreviewSkip {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 Self::UnsupportedFormat => write!(f, "unsupported format"),
22 Self::Empty => write!(f, "empty payload"),
23 Self::SourceTooLarge => write!(f, "source too large"),
24 Self::Decode(reason) => write!(f, "decode failed: {reason}"),
25 Self::Encode(reason) => write!(f, "encode failed: {reason}"),
26 }
27 }
28}
29
30impl std::error::Error for PreviewSkip {}
Added lib/imaging/src/layout.rs lib/imaging/src/layout.rs +345 −0 View lib/imaging/src/layout.rs
@@ -0,0 +1,345 @@
1//! Where previews live and how their state is recorded.
2//!
3//! One original always keeps exactly one derivative next to it:
4//!
5//! ```text
6//! avatars/{uid}.jpg -> avatars/{uid}.preview.jpg
7//! space-images/{sid}/{kind}.png -> space-images/{sid}/{kind}.preview.webp
8//! chat/attachments/{uid}/{id}/{f} -> chat/attachments/{uid}/{id}/{stem}.preview.jpg
9//! ```
10//!
11//! The extension of the derivative is not fixed: opaque pictures become JPEG and
12//! transparent ones become WebP, so the exact key is recorded rather than
13//! guessed. Recording it (in the avatar/space `.meta.json` sidecar and in
14//! `chat_attachment.variants`) is also what stops a skipped source — an
15//! animation, an oversized photo — from being decoded again on every request.
16
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20/// `kind` discriminator for the preview entry of a `variants` array.
21pub const PREVIEW_KIND: &str = "preview";
22/// Lock TTL for the single-flight around lazy generation. Generous relative to
23/// render time, short enough that a crashed pod releases the object quickly.
24pub const PREVIEW_LOCK_TTL_SECS: u64 = 60;
25
26/// Extensions a derivative may carry. Used to clean up a stale derivative when
27/// an upload switches between opaque and transparent encodings.
28pub const PREVIEW_EXTENSIONS: [&str; 2] = ["jpg", "webp"];
29
30/// Which representation a read endpoint should hand back.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
32#[serde(rename_all = "lowercase")]
33pub enum ImageVariant {
34 /// The compressed derivative. This is what list and grid views load.
35 Preview,
36 /// The bytes exactly as uploaded.
37 Original,
38}
39
40/// `<dir>/<stem>.<ext>` -> `<dir>/<stem>.preview`, and `<dir>/<name>` without an
41/// extension simply gains the suffix. The suffix never changes the directory, so
42/// a derivative can never collide with another object's original.
43#[must_use]
44pub fn preview_key_base(original_key: &str) -> String {
45 let directory_end = original_key.rfind('/').map_or(0, |slash| slash + 1);
46 match original_key.rfind('.') {
47 Some(dot) if dot >= directory_end => format!("{}.preview", &original_key[..dot]),
48 _ => format!("{original_key}.preview"),
49 }
50}
51
52#[must_use]
53pub fn preview_key(key_base: &str, extension: &str) -> String {
54 format!("{key_base}.{extension}")
55}
56
57/// Every key a derivative of one original could occupy.
58#[must_use]
59pub fn preview_candidate_keys(key_base: &str) -> Vec<String> {
60 PREVIEW_EXTENSIONS
61 .iter()
62 .map(|extension| preview_key(key_base, extension))
63 .collect()
64}
65
66/// Redis key for the single-flight lock. Derived from the derivative base, so a
67/// lock is per original object and survives restarts but not replacement.
68#[must_use]
69pub fn preview_lock_key(key_base: &str) -> String {
70 format!("imaging:preview-lock:{key_base}")
71}
72
73/// A derivative that exists in object storage.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct ReadyPreview {
76 pub key: String,
77 pub content_type: String,
78 pub bytes: u64,
79}
80
81/// What is known about one original's derivative.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum PreviewState {
84 Ready(ReadyPreview),
85 /// Generation was attempted and deliberately produced nothing; the original
86 /// is the preview. Stored so the attempt is not repeated.
87 Skipped(String),
88}
89
90impl PreviewState {
91 #[must_use]
92 pub fn ready(key: String, content_type: &str, bytes: u64) -> Self {
93 Self::Ready(ReadyPreview {
94 key,
95 content_type: content_type.to_string(),
96 bytes,
97 })
98 }
99}
100
101/// Read the recorded preview state out of a metadata record.
102///
103/// Records are JSON objects that carry unrelated fields (avatar format, upload
104/// time, ...), so this only ever looks at its own two keys and tolerates any
105/// other shape — including a legacy `[]` `variants` value.
106#[must_use]
107pub fn preview_from_record(record: &Value) -> Option<PreviewState> {
108 let object = record.as_object()?;
109 if let Some(ready) = object.get("preview").and_then(Value::as_object) {
110 let key = ready.get("key").and_then(Value::as_str)?;
111 let content_type = ready
112 .get("content_type")
113 .and_then(Value::as_str)
114 .unwrap_or("image/jpeg");
115 let bytes = ready.get("bytes").and_then(Value::as_u64).unwrap_or(0);
116 return Some(PreviewState::Ready(ReadyPreview {
117 key: key.to_string(),
118 content_type: content_type.to_string(),
119 bytes,
120 }));
121 }
122 object
123 .get("preview_skipped")
124 .and_then(Value::as_str)
125 .map(|reason| PreviewState::Skipped(reason.to_string()))
126}
127
128/// Record `state` into a metadata record, replacing any previous preview state.
129pub fn preview_into_record(record: &mut Value, state: &PreviewState) {
130 if !record.is_object() {
131 *record = Value::Object(serde_json::Map::new());
132 }
133 let Some(object) = record.as_object_mut() else {
134 return;
135 };
136 object.remove("preview_skipped");
137 match state {
138 PreviewState::Ready(ready) => {
139 object.insert(
140 "preview".to_string(),
141 json!({
142 "key": ready.key,
143 "content_type": ready.content_type,
144 "bytes": ready.bytes,
145 }),
146 );
147 }
148 PreviewState::Skipped(reason) => {
149 object.remove("preview");
150 object.insert("preview_skipped".to_string(), Value::String(reason.clone()));
151 }
152 }
153}
154
155/// Read the preview entry out of a `chat_attachment.variants` array.
156///
157/// That column is an array (its schema default is `[]`), one object per
158/// generated variant, so entries are matched by `kind` rather than by position.
159#[must_use]
160pub fn preview_from_variants(variants: &Value) -> Option<PreviewState> {
161 let entries = variants.as_array()?;
162 for entry in entries {
163 let Some(object) = entry.as_object() else {
164 continue;
165 };
166 if object.get("kind").and_then(Value::as_str) != Some(PREVIEW_KIND) {
167 continue;
168 }
169 if object.get("skipped").and_then(Value::as_bool) == Some(true) {
170 let reason = object
171 .get("reason")
172 .and_then(Value::as_str)
173 .unwrap_or("skipped");
174 return Some(PreviewState::Skipped(reason.to_string()));
175 }
176 let key = object.get("key").and_then(Value::as_str)?;
177 let content_type = object
178 .get("content_type")
179 .and_then(Value::as_str)
180 .unwrap_or("image/jpeg");
181 let bytes = object.get("bytes").and_then(Value::as_u64).unwrap_or(0);
182 return Some(PreviewState::Ready(ReadyPreview {
183 key: key.to_string(),
184 content_type: content_type.to_string(),
185 bytes,
186 }));
187 }
188 None
189}
190
191/// Replace the preview entry of a `variants` array, preserving any other entries.
192/// A non-array value (including the schema default `[]` shape) is normalized.
193pub fn preview_into_variants(variants: &mut Value, state: &PreviewState) {
194 if !variants.is_array() {
195 *variants = Value::Array(Vec::new());
196 }
197 let entries = variants
198 .as_array_mut()
199 .expect("variants value was just normalized to an array");
200 entries.retain(|entry| {
201 entry
202 .as_object()
203 .and_then(|object| object.get("kind"))
204 .and_then(Value::as_str)
205 != Some(PREVIEW_KIND)
206 });
207 let entry = match state {
208 PreviewState::Ready(ready) => json!({
209 "kind": PREVIEW_KIND,
210 "key": ready.key,
211 "content_type": ready.content_type,
212 "bytes": ready.bytes,
213 }),
214 PreviewState::Skipped(reason) => json!({
215 "kind": PREVIEW_KIND,
216 "skipped": true,
217 "reason": reason,
218 }),
219 };
220 entries.push(entry);
221}
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn key_base_keeps_the_directory_and_strips_the_extension() {
228 assert_eq!(preview_key_base("avatars/abc.jpg"), "avatars/abc.preview");
229 assert_eq!(
230 preview_key_base("space-images/sid/banner.png"),
231 "space-images/sid/banner.preview"
232 );
233 assert_eq!(
234 preview_key_base("chat/attachments/u/a/photo.name.jpeg"),
235 "chat/attachments/u/a/photo.name.preview"
236 );
237 }
238
239 #[test]
240 fn key_base_handles_dotfiles_in_the_directory() {
241 assert_eq!(preview_key_base("dir.v2/file"), "dir.v2/file.preview");
242 assert_eq!(preview_key_base("dir.v2/file"), "dir.v2/file.preview");
243 }
244
245 #[test]
246 fn candidates_cover_both_encodings() {
247 let keys = preview_candidate_keys("avatars/abc.preview");
248 assert_eq!(
249 keys,
250 vec!["avatars/abc.preview.jpg", "avatars/abc.preview.webp"]
251 );
252 }
253
254 #[test]
255 fn record_round_trips_both_states() {
256 let mut record = json!({ "uid": "abc", "format": "png" });
257 assert!(preview_from_record(&record).is_none());
258
259 preview_into_record(
260 &mut record,
261 &PreviewState::ready("k.webp".into(), "image/webp", 12),
262 );
263 assert_eq!(
264 preview_from_record(&record),
265 Some(PreviewState::Ready(ReadyPreview {
266 key: "k.webp".to_string(),
267 content_type: "image/webp".to_string(),
268 bytes: 12,
269 }))
270 );
271
272 // Skipping must clear a previously recorded derivative.
273 preview_into_record(&mut record, &PreviewState::Skipped("animated".into()));
274 assert_eq!(
275 preview_from_record(&record),
276 Some(PreviewState::Skipped("animated".to_string()))
277 );
278 assert!(record.get("preview").is_none());
279
280 // Unrelated fields survive both writes.
281 assert_eq!(record.get("uid").and_then(Value::as_str), Some("abc"));
282 }
283
284 #[test]
285 fn variants_array_round_trips_both_states() {
286 let mut variants = json!([]);
287 assert!(preview_from_variants(&variants).is_none());
288
289 preview_into_variants(
290 &mut variants,
291 &PreviewState::ready("chat/1/preview.webp".into(), "image/webp", 7),
292 );
293 assert_eq!(
294 preview_from_variants(&variants),
295 Some(PreviewState::Ready(ReadyPreview {
296 key: "chat/1/preview.webp".to_string(),
297 content_type: "image/webp".to_string(),
298 bytes: 7,
299 }))
300 );
301
302 // A second write replaces the first rather than appending a duplicate.
303 preview_into_variants(&mut variants, &PreviewState::Skipped("animated".into()));
304 assert_eq!(variants.as_array().map(Vec::len), Some(1));
305 assert_eq!(
306 preview_from_variants(&variants),
307 Some(PreviewState::Skipped("animated".to_string()))
308 );
309 }
310
311 #[test]
312 fn variants_entries_of_other_kinds_are_preserved() {
313 let mut variants = json!([{ "kind": "waveform", "key": "other" }]);
314 preview_into_variants(
315 &mut variants,
316 &PreviewState::ready("chat/1/preview.jpg".into(), "image/jpeg", 3),
317 );
318 let entries = variants.as_array().expect("array");
319 assert_eq!(entries.len(), 2);
320 assert!(
321 entries
322 .iter()
323 .any(|entry| entry["kind"] == json!("waveform"))
324 );
325 }
326
327 #[test]
328 fn legacy_non_array_variants_value_is_normalized() {
329 let mut variants = json!({ "preview": { "key": "legacy" } });
330 assert!(preview_from_variants(&variants).is_none());
331 preview_into_variants(
332 &mut variants,
333 &PreviewState::ready("chat/1/preview.jpg".into(), "image/jpeg", 3),
334 );
335 assert_eq!(
336 preview_from_variants(&variants).map(|state| matches!(state, PreviewState::Ready(_))),
337 Some(true)
338 );
339 }
340 #[test]
341 fn legacy_array_records_read_as_absent() {
342 let record = json!([{ "name": "something-else" }]);
343 assert!(preview_from_record(&record).is_none());
344 }
345}
Added lib/imaging/src/lib.rs lib/imaging/src/lib.rs +462 −0 View lib/imaging/src/lib.rs
@@ -0,0 +1,462 @@
1//! One-shot image preview generation shared by every image upload path:
2//! avatars, space images and chat attachments.
3//!
4//! The originals are always kept; this crate only produces the smaller
5//! derivative that list/grid views load. Anything it cannot handle — animated
6//! GIF/APNG/WebP, unsupported formats, oversized sources, decode failures —
7//! reports `Skipped`/`UseOriginal` instead of failing the caller's upload, so
8//! the feature degrades to "serve the original" rather than a broken request.
9
10mod error;
11pub mod layout;
12pub mod preset;
13
14pub use error::PreviewSkip;
15pub use preset::{JPEG_QUALITY, MAX_DECODE_PIXELS, MAX_SOURCE_BYTES, PreviewPreset};
16
17use std::io::Cursor;
18
19use image::codecs::gif::GifDecoder;
20use image::codecs::jpeg::JpegEncoder;
21use image::codecs::png::PngDecoder;
22use image::codecs::webp::{WebPDecoder, WebPEncoder};
23use image::imageops::FilterType;
24use image::metadata::Orientation;
25use image::{AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
26
27/// Decoder allocation ceiling, kept well under the process budget: a decompression
28/// bomb otherwise turns one request into gigabytes of resident memory.
29const DECODER_MAX_ALLOC: u64 = 256 * 1024 * 1024;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct PreviewImage {
33 pub bytes: Vec<u8>,
34 pub content_type: &'static str,
35 pub extension: &'static str,
36 pub width: u32,
37 pub height: u32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum PreviewOutcome {
42 Preview(PreviewImage),
43 /// Nothing about the original is worth changing (animated or already
44 /// smaller than the preset): keep serving the original as the preview.
45 UseOriginal,
46 Skipped(PreviewSkip),
47}
48
49impl PreviewOutcome {
50 #[must_use]
51 pub fn preview(self) -> Option<PreviewImage> {
52 match self {
53 Self::Preview(image) => Some(image),
54 Self::UseOriginal | Self::Skipped(_) => None,
55 }
56 }
57
58 #[must_use]
59 pub fn is_skipped(&self) -> bool {
60 matches!(self, Self::Skipped(_))
61 }
62}
63
64/// Content types this crate is willing to decode. Everything else is skipped
65/// untouched by the callers.
66#[must_use]
67pub fn is_previewable_content_type(content_type: &str) -> bool {
68 matches!(
69 canonical_mime(content_type),
70 Some(ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::WebP | ImageFormat::Gif)
71 )
72}
73
74fn canonical_mime(content_type: &str) -> Option<ImageFormat> {
75 let main = content_type.split(';').next()?.trim();
76 let normalized = match main.to_ascii_lowercase().as_str() {
77 "image/jpg" => "image/jpeg".to_string(),
78 other => other.to_string(),
79 };
80 ImageFormat::from_mime_type(normalized)
81}
82
83/// Build a preview for `bytes`.
84///
85/// This is the synchronous core; call it through [`render_preview`] from an
86/// async context so the decode/encode never blocks the runtime.
87#[must_use]
88pub fn render_preview_sync(
89 bytes: &[u8],
90 content_type: &str,
91 preset: PreviewPreset,
92) -> PreviewOutcome {
93 let Some(format) = canonical_mime(content_type) else {
94 return PreviewOutcome::Skipped(PreviewSkip::UnsupportedFormat);
95 };
96 if !matches!(
97 format,
98 ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::WebP | ImageFormat::Gif
99 ) {
100 return PreviewOutcome::Skipped(PreviewSkip::UnsupportedFormat);
101 }
102 if bytes.is_empty() {
103 return PreviewOutcome::Skipped(PreviewSkip::Empty);
104 }
105 let source_len = bytes.len();
106 if bytes.len() as u64 > preset.max_source_bytes() {
107 return PreviewOutcome::Skipped(PreviewSkip::SourceTooLarge);
108 }
109 if is_animated(bytes, format) {
110 return PreviewOutcome::UseOriginal;
111 }
112
113 let mut limits = Limits::no_limits();
114 limits.max_image_width = Some(20_000);
115 limits.max_image_height = Some(20_000);
116 limits.max_alloc = Some(DECODER_MAX_ALLOC);
117 let mut reader = ImageReader::new(Cursor::new(bytes));
118 reader.set_format(format);
119 reader.limits(limits);
120 let mut decoder = match reader.into_decoder() {
121 Ok(decoder) => decoder,
122 Err(error) => {
123 return PreviewOutcome::Skipped(PreviewSkip::Decode(format!("{error}")));
124 }
125 };
126 let (raw_width, raw_height) = decoder.dimensions();
127 if u64::from(raw_width) * u64::from(raw_height) > preset.max_decode_pixels() {
128 return PreviewOutcome::Skipped(PreviewSkip::SourceTooLarge);
129 }
130 let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
131 let mut image = match DynamicImage::from_decoder(decoder) {
132 Ok(image) => image,
133 Err(error) => {
134 return PreviewOutcome::Skipped(PreviewSkip::Decode(format!("{error}")));
135 }
136 };
137 image.apply_orientation(orientation);
138
139 let (target_width, target_height) =
140 fit_within(image.width(), image.height(), preset.max_edge());
141 if target_width != image.width() || target_height != image.height() {
142 image = image.resize(target_width, target_height, FilterType::Triangle);
143 }
144
145 let has_alpha = image.color().has_alpha() && preset.keep_alpha();
146 let encoded = if has_alpha {
147 encode_webp_lossless(&image)
148 } else {
149 encode_jpeg(&image, preset.jpeg_quality())
150 };
151 let (bytes, content_type, extension) = match encoded {
152 Ok(value) => value,
153 Err(error) => {
154 return PreviewOutcome::Skipped(PreviewSkip::Encode(error.to_string()));
155 }
156 };
157 if bytes.is_empty() {
158 return PreviewOutcome::Skipped(PreviewSkip::Encode("empty encoder output".into()));
159 }
160 // A derivative that is not smaller than the original saves nothing on the
161 // wire; hand the original back instead of storing a useless twin.
162 if !is_smaller(bytes.len(), source_len) {
163 return PreviewOutcome::UseOriginal;
164 }
165 PreviewOutcome::Preview(PreviewImage {
166 bytes,
167 content_type,
168 extension,
169 width: image.width(),
170 height: image.height(),
171 })
172}
173
174/// Async wrapper: the decode/encode runs on the blocking pool so a burst of
175/// image traffic cannot stall request handling.
176pub async fn render_preview(
177 bytes: Vec<u8>,
178 content_type: String,
179 preset: PreviewPreset,
180) -> PreviewOutcome {
181 if !is_previewable_content_type(&content_type) {
182 return PreviewOutcome::Skipped(PreviewSkip::UnsupportedFormat);
183 }
184 let task =
185 tokio::task::spawn_blocking(move || render_preview_sync(&bytes, &content_type, preset));
186 match task.await {
187 Ok(outcome) => outcome,
188 Err(error) => {
189 PreviewOutcome::Skipped(PreviewSkip::Decode(format!("preview task failed: {error}")))
190 }
191 }
192}
193
194fn encode_jpeg(
195 image: &DynamicImage,
196 quality: u8,
197) -> Result<(Vec<u8>, &'static str, &'static str), String> {
198 // JPEG has no alpha channel; flattening to RGB is what browsers do for a
199 // transparent JPEG anyway.
200 let rgb = image.to_rgb8();
201 let mut out = Vec::new();
202 let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
203 encoder
204 .encode(
205 rgb.as_raw(),
206 rgb.width(),
207 rgb.height(),
208 image::ExtendedColorType::Rgb8,
209 )
210 .map_err(|error| error.to_string())?;
211 Ok((out, "image/jpeg", "jpg"))
212}
213
214fn encode_webp_lossless(
215 image: &DynamicImage,
216) -> Result<(Vec<u8>, &'static str, &'static str), String> {
217 let rgba = image.to_rgba8();
218 let mut out = Vec::new();
219 WebPEncoder::new_lossless(&mut out)
220 .encode(
221 rgba.as_raw(),
222 rgba.width(),
223 rgba.height(),
224 image::ExtendedColorType::Rgba8,
225 )
226 .map_err(|error| error.to_string())?;
227 Ok((out, "image/webp", "webp"))
228}
229
230/// Animated containers are handed back untouched: re-encoding them would either
231/// flatten the animation or explode the output size.
232fn is_animated(bytes: &[u8], format: ImageFormat) -> bool {
233 let cursor = Cursor::new(bytes);
234 match format {
235 ImageFormat::Gif => GifDecoder::new(cursor)
236 .map(|decoder| decoder.into_frames().take(2).count() > 1)
237 .unwrap_or(false),
238 ImageFormat::WebP => WebPDecoder::new(cursor)
239 .map(|decoder| decoder.has_animation())
240 .unwrap_or(false),
241 ImageFormat::Png => PngDecoder::new(cursor)
242 .ok()
243 .and_then(|decoder| decoder.is_apng().ok())
244 .unwrap_or(false),
245 _ => false,
246 }
247}
248
249/// Whether a re-encoded derivative is actually worth storing. Requiring a
250/// meaningful margin avoids keeping a "preview" that is byte-for-byte as heavy
251/// as the original.
252fn is_smaller(preview_len: usize, source_len: usize) -> bool {
253 preview_len < source_len
254}
255
256/// Longest edge at most `max_edge`, preserving the aspect ratio; images already
257/// inside the box are returned unchanged.
258fn fit_within(width: u32, height: u32, max_edge: u32) -> (u32, u32) {
259 let longest = width.max(height);
260 if longest <= max_edge || longest == 0 {
261 return (width, height);
262 }
263 let scale = f64::from(max_edge) / f64::from(longest);
264 let scaled_width = (f64::from(width) * scale).round().max(1.0) as u32;
265 let scaled_height = (f64::from(height) * scale).round().max(1.0) as u32;
266 (scaled_width, scaled_height)
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use image::{Rgb, RgbImage, Rgba};
273
274 fn jpeg_bytes(width: u32, height: u32) -> Vec<u8> {
275 let image = RgbImage::from_fn(width, height, |x, y| {
276 Rgb([(x % 251) as u8, (y % 241) as u8, ((x + y) % 229) as u8])
277 });
278 let mut out = Vec::new();
279 let mut encoder = JpegEncoder::new_with_quality(&mut out, 92);
280 encoder
281 .encode(
282 image.as_raw(),
283 width,
284 height,
285 image::ExtendedColorType::Rgb8,
286 )
287 .expect("encode jpeg fixture");
288 out
289 }
290
291 fn png_with_alpha(width: u32, height: u32) -> Vec<u8> {
292 let image = image::RgbaImage::from_fn(width, height, |x, y| {
293 Rgba([(x % 200) as u8, (y % 200) as u8, 40, ((x + y) % 255) as u8])
294 });
295 let mut out = Vec::new();
296 image
297 .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
298 .expect("encode png fixture");
299 out
300 }
301
302 fn gif_two_frames(width: u32, height: u32) -> Vec<u8> {
303 let frame = image::RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
304 let mut out = Vec::new();
305 {
306 use image::codecs::gif::GifEncoder;
307 let mut encoder = GifEncoder::new(&mut out);
308 encoder
309 .encode_frame(image::Frame::new(frame.clone()))
310 .expect("gif frame 1");
311 encoder
312 .encode_frame(image::Frame::new(frame))
313 .expect("gif frame 2");
314 }
315 out
316 }
317
318 #[test]
319 fn downscales_to_preset_edge() {
320 let bytes = jpeg_bytes(3000, 1500);
321 let outcome = render_preview_sync(&bytes, "image/jpeg", PreviewPreset::Content);
322 let PreviewOutcome::Preview(preview) = outcome else {
323 panic!("expected a preview");
324 };
325 assert_eq!(preview.width, 1920);
326 assert_eq!(preview.height, 960);
327 assert_eq!(preview.content_type, "image/jpeg");
328 assert!(preview.bytes.len() < bytes.len());
329 }
330
331 #[test]
332 fn avatar_preset_uses_512_edge() {
333 let bytes = jpeg_bytes(2000, 2000);
334 let PreviewOutcome::Preview(preview) =
335 render_preview_sync(&bytes, "image/jpeg", PreviewPreset::Avatar)
336 else {
337 panic!("expected a preview");
338 };
339 assert_eq!(preview.width, 512);
340 assert_eq!(preview.height, 512);
341 }
342
343 #[test]
344 fn alpha_keeps_webp_alpha() {
345 let bytes = png_with_alpha(400, 300);
346 let PreviewOutcome::Preview(preview) =
347 render_preview_sync(&bytes, "image/png", PreviewPreset::Avatar)
348 else {
349 panic!("expected a preview");
350 };
351 assert_eq!(preview.content_type, "image/webp");
352 assert_eq!(preview.extension, "webp");
353 let decoded = image::load_from_memory(&preview.bytes).expect("decode preview");
354 assert!(decoded.color().has_alpha());
355 }
356
357 #[test]
358 fn animated_gif_is_left_alone() {
359 let bytes = gif_two_frames(64, 64);
360 let outcome = render_preview_sync(&bytes, "image/gif", PreviewPreset::Content);
361 assert_eq!(outcome, PreviewOutcome::UseOriginal);
362 }
363
364 #[test]
365 fn unsupported_type_is_skipped() {
366 let outcome = render_preview_sync(b"not an image", "image/avif", PreviewPreset::Content);
367 assert!(outcome.is_skipped());
368 }
369
370 #[test]
371 fn undecodable_payload_is_skipped() {
372 let outcome = render_preview_sync(
373 b"definitely not a jpeg",
374 "image/jpeg",
375 PreviewPreset::Content,
376 );
377 assert!(outcome.is_skipped());
378 }
379
380 #[test]
381 fn preview_smaller_rule_rejects_equal_or_larger_output() {
382 assert!(is_smaller(10, 11));
383 assert!(!is_smaller(11, 11));
384 assert!(!is_smaller(12, 11));
385 }
386
387 /// EXIF orientation 6 means "rotate 90° clockwise", so a wide camera frame
388 /// must come out of the preview upright (portrait) and still within the box.
389 #[test]
390 fn exif_rotated_jpeg_is_uprighted() {
391 let source = jpeg_bytes(1200, 600);
392 let rotated = embed_exif_orientation(&source, 6);
393 let PreviewOutcome::Preview(preview) =
394 render_preview_sync(&rotated, "image/jpeg", PreviewPreset::Content)
395 else {
396 panic!("expected a preview");
397 };
398 assert_eq!((preview.width, preview.height), (600, 1200));
399 }
400
401 /// Longest-edge capping must apply after the rotation, not before.
402 #[test]
403 fn exif_rotated_oversized_jpeg_is_capped_after_rotation() {
404 let source = jpeg_bytes(3000, 1200);
405 let rotated = embed_exif_orientation(&source, 6);
406 let PreviewOutcome::Preview(preview) =
407 render_preview_sync(&rotated, "image/jpeg", PreviewPreset::Content)
408 else {
409 panic!("expected a preview");
410 };
411 assert_eq!((preview.width, preview.height), (768, 1920));
412 }
413
414 /// Splice a minimal APP1/EXIF segment carrying `orientation` (tag 0x0112)
415 /// directly behind the JPEG SOI marker.
416 fn embed_exif_orientation(jpeg: &[u8], orientation: u16) -> Vec<u8> {
417 assert_eq!(&jpeg[..2], &[0xff, 0xd8], "fixture must start with SOI");
418 let mut tiff = Vec::new();
419 tiff.extend_from_slice(b"II");
420 tiff.extend_from_slice(&42u16.to_le_bytes());
421 tiff.extend_from_slice(&8u32.to_le_bytes());
422 tiff.extend_from_slice(&1u16.to_le_bytes());
423 tiff.extend_from_slice(&0x0112u16.to_le_bytes());
424 tiff.extend_from_slice(&3u16.to_le_bytes());
425 tiff.extend_from_slice(&1u32.to_le_bytes());
426 tiff.extend_from_slice(&orientation.to_le_bytes());
427 tiff.extend_from_slice(&[0, 0]);
428 tiff.extend_from_slice(&0u32.to_le_bytes());
429
430 let mut payload = Vec::new();
431 payload.extend_from_slice(b"Exif\0\0");
432 payload.extend_from_slice(&tiff);
433
434 let mut out = Vec::new();
435 out.extend_from_slice(&jpeg[..2]);
436 out.extend_from_slice(&[0xff, 0xe1]);
437 let segment_len = (payload.len() + 2) as u16;
438 out.extend_from_slice(&segment_len.to_be_bytes());
439 out.extend_from_slice(&payload);
440 out.extend_from_slice(&jpeg[2..]);
441 out
442 }
443
444 #[test]
445 fn small_image_is_not_upscaled() {
446 let bytes = jpeg_bytes(320, 200);
447 let PreviewOutcome::Preview(preview) =
448 render_preview_sync(&bytes, "image/jpeg", PreviewPreset::Content)
449 else {
450 panic!("expected a preview");
451 };
452 assert_eq!((preview.width, preview.height), (320, 200));
453 }
454
455 #[test]
456 fn extra_large_source_is_skipped() {
457 let bypassed = PreviewPreset::Content;
458 assert!(bypassed.max_source_bytes() > 0);
459 assert_eq!(fit_within(10, 10, 1920), (10, 10));
460 assert_eq!(fit_within(0, 0, 1920), (0, 0));
461 }
462}
Added lib/imaging/src/preset.rs lib/imaging/src/preset.rs +55 −0 View lib/imaging/src/preset.rs
@@ -0,0 +1,55 @@
1//! Fixed preview geometry. These are deliberately constants instead of config:
2//! the preview is a product decision (what a list view loads), not a per-host
3//! tuning knob, and every deployment must produce identical derivatives for the
4//! same upload.
5
6/// Longest-edge ceiling for full-content images (chat pictures, banners).
7pub const CONTENT_MAX_EDGE: u32 = 1920;
8/// Longest-edge ceiling for avatar-shaped images.
9pub const AVATAR_MAX_EDGE: u32 = 512;
10/// JPEG quality for the opaque preview encoding.
11pub const JPEG_QUALITY: u8 = 82;
12/// Sources above this size are served as-is instead of being decoded.
13pub const MAX_SOURCE_BYTES: u64 = 32 * 1024 * 1024;
14/// Decoded pixel ceiling; guards against decompression bombs.
15pub const MAX_DECODE_PIXELS: u64 = 40_000_000;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum PreviewPreset {
19 /// Small, square-ish target for avatars and space avatars.
20 Avatar,
21 /// General picture target for chat attachments and space banners.
22 Content,
23}
24
25impl PreviewPreset {
26 #[must_use]
27 pub fn max_edge(self) -> u32 {
28 match self {
29 Self::Avatar => AVATAR_MAX_EDGE,
30 Self::Content => CONTENT_MAX_EDGE,
31 }
32 }
33
34 #[must_use]
35 pub fn jpeg_quality(self) -> u8 {
36 JPEG_QUALITY
37 }
38
39 #[must_use]
40 pub fn max_source_bytes(self) -> u64 {
41 MAX_SOURCE_BYTES
42 }
43
44 #[must_use]
45 pub fn max_decode_pixels(self) -> u64 {
46 MAX_DECODE_PIXELS
47 }
48
49 /// Whether a source alpha channel must survive into the preview. Avatars and
50 /// pictures can both be cut-outs, so both keep it.
51 #[must_use]
52 pub fn keep_alpha(self) -> bool {
53 true
54 }
55}
Added lib/imaging/Cargo.toml lib/imaging/Cargo.toml +11 −0 View lib/imaging/Cargo.toml
@@ -0,0 +1,11 @@
1[package]
2name = "imaging"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7image = { workspace = true }
8serde = { workspace = true, features = ["derive"] }
9serde_json = { workspace = true }
10tokio = { workspace = true, features = ["rt"] }
11utoipa = { workspace = true }
Modified lib/space/src/handlers/get_space_image_file.rs lib/space/src/handlers/get_space_image_file.rs +21 −4 View lib/space/src/handlers/get_space_image_file.rs
@@ -1,18 +1,29 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use axum::http::{StatusCode, header};
33use axum::response::Response;
4use imaging::layout::ImageVariant;
5use serde::Deserialize;
46use session::{Session, SessionUser};
7use utoipa::{IntoParams, ToSchema};
58
69use crate::errors::ApiError;
710use crate::services::images::SpaceImageKindPath;
811use crate::services::service::SpaceService;
912
13/// `?variant=` selector. Omitting it yields the compressed derivative, which is
14/// what every rendered layout wants; `original` serves the bytes as uploaded.
15#[derive(Debug, Clone, Default, Deserialize, IntoParams, ToSchema)]
16pub struct SpaceImageVariantQuery {
17 #[serde(default)]
18 pub variant: Option<ImageVariant>,
19}
20
1021#[utoipa::path(
1122 get,
1223 path = "/spaces/{space_name}/images/{kind}/file",
1324 tag = "space",
14 description = "重定向到 S3 预签名空间图片链接(7 天有效, Redis 缓存)。私有社区对非成员返回 404。",
15 params(SpaceImageKindPath),
25 description = "重定向到 S3 预签名空间图片链接(7 天有效, Redis 缓存)。默认返回压缩后的预览图,`?variant=original` 返回上传原图。私有社区对非成员返回 404。",
26 params(SpaceImageKindPath, SpaceImageVariantQuery),
1627 responses(
1728 (status = 302, description = "redirect to presigned s3 space image url"),
1829 (status = 404, description = "space or image not found", body = export::rest::AppResponseData<export::rest::NoData>),
@@ -23,9 +34,15 @@
2334 State(service): State<SpaceService>,
2435 session: Session,
2536 Path(path): Path<SpaceImageKindPath>,
37 Query(query): Query<SpaceImageVariantQuery>,
2638) -> Result<Response, ApiError> {
2739 let url = service
28 .space_image_file_url(session.uid(), &path.space_name, path.kind)
40 .space_image_file_url(
41 session.uid(),
42 &path.space_name,
43 path.kind,
44 query.variant.unwrap_or(ImageVariant::Preview),
45 )
2946 .await?;
3047 Ok(Response::builder()
3148 .status(StatusCode::FOUND)
Modified lib/space/src/services/images.rs lib/space/src/services/images.rs +193 −27 View lib/space/src/services/images.rs
@@ -1,3 +1,5 @@
1use imaging::layout::{ImageVariant, PreviewState};
2use imaging::preset::PreviewPreset;
13use models::space::spaces;
24use sea_orm::{ActiveModelTrait, Set};
35use serde::{Deserialize, Serialize};
@@ -93,6 +95,25 @@
9395 )
9496}
9597
98fn space_image_meta_key(space_id: Uuid, kind: SpaceImageKind) -> String {
99 format!(
100 "{SPACE_IMAGE_PREFIX}/{space_id}/{}.meta.json",
101 kind.as_str()
102 )
103}
104
105/// Content type to hand the preview generator for one stored object. The stored
106/// locator carries the uploaded extension; a wrong guess only costs a skipped
107/// render, because the decoder sniffs the real format from the bytes.
108fn space_image_content_type(original_key: &str) -> &'static str {
109 match original_key.rsplit('.').next().unwrap_or_default() {
110 "png" => "image/png",
111 "webp" => "image/webp",
112 "gif" => "image/gif",
113 _ => "image/jpeg",
114 }
115}
116
96117fn space_image_object_key(stored_url: &str) -> Option<String> {
97118 let key = stored_url.strip_prefix("storage://")?;
98119 if key.is_empty() {
@@ -141,6 +162,17 @@
141162 }
142163}
143164
165/// Everything the sidecar records about one written image. Grouped into a struct
166/// rather than passed as eight positional arguments.
167struct SpaceImageMeta<'a> {
168 fmt: AvatarFormat,
169 width: Option<i32>,
170 height: Option<i32>,
171 bytes: usize,
172 uploaded_at: OffsetDateTime,
173 preview: Option<&'a PreviewState>,
174}
175
144176#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
145177pub struct SpaceImageDto {
146178 pub space_id: Uuid,
@@ -170,6 +202,113 @@
170202}
171203
172204impl SpaceService {
205 /// Resolve the derivative for one stored space image, generating it on first
206 /// demand. Returns the `storage://` locator of the derivative, falling back
207 /// to the original whenever a preview cannot be produced (animation,
208 /// oversize, unsupported format, failed render).
209 async fn space_image_variant_locator(
210 &self,
211 space_id: Uuid,
212 kind: SpaceImageKind,
213 stored_url: &str,
214 ) -> String {
215 let Some(original_key) = space_image_object_key(stored_url) else {
216 return stored_url.to_string();
217 };
218 let key_base = export::imaging::preview_key_base_of(&original_key);
219 let recorded = self
220 .recorded_space_image_preview(space_id, kind, &key_base)
221 .await;
222 let state = export::imaging::ensure_preview(
223 self.storage().client(),
224 &self.state.redis,
225 &key_base,
226 &original_key,
227 space_image_content_type(&original_key),
228 PreviewPreset::Content,
229 recorded.as_ref(),
230 )
231 .await;
232 match state {
233 Some(PreviewState::Ready(ready)) => format!("storage://{}", ready.key),
234 Some(PreviewState::Skipped(_)) | None => stored_url.to_string(),
235 }
236 }
237
238 /// Preview state from the space image meta sidecar, falling back to probing
239 /// the object store so images uploaded before previews existed are still
240 /// recognised once their derivative has been generated.
241 async fn recorded_space_image_preview(
242 &self,
243 space_id: Uuid,
244 kind: SpaceImageKind,
245 key_base: &str,
246 ) -> Option<PreviewState> {
247 let meta_key = space_image_meta_key(space_id, kind);
248 if let Ok(bytes) = self.storage().client().get(&meta_key).await
249 && let Ok(record) = serde_json::from_slice::<serde_json::Value>(&bytes)
250 && let Some(state) = export::imaging::recorded_preview(&record)
251 {
252 return Some(state);
253 }
254 export::imaging::find_stored_preview(self.storage().client(), key_base).await
255 }
256
257 /// Persist the sidecar with the derivative that is currently authoritative.
258 async fn write_space_image_meta(
259 &self,
260 space_id: Uuid,
261 kind: SpaceImageKind,
262 written: SpaceImageMeta<'_>,
263 ) {
264 let SpaceImageMeta {
265 fmt,
266 width,
267 height,
268 bytes,
269 uploaded_at,
270 preview,
271 } = written;
272 let mut meta = serde_json::json!({
273 "space_id": space_id,
274 "kind": kind.as_str(),
275 "format": fmt.extension(),
276 "width": width,
277 "height": height,
278 "bytes": bytes,
279 "uploaded_at": uploaded_at,
280 });
281 if let Some(state) = preview {
282 meta = export::imaging::record_preview(meta, state);
283 }
284 if let Err(error) = self
285 .storage()
286 .client()
287 .put_with_content_type(
288 &space_image_meta_key(space_id, kind),
289 meta.to_string().as_bytes(),
290 "application/json",
291 )
292 .await
293 {
294 tracing::warn!(%error, "failed to write space image meta json");
295 }
296 }
297
298 /// Drop both encodings of the derivative, so replacing an image can never
299 /// leave two objects claiming to be the current preview.
300 async fn delete_space_image_previews(&self, stored_url: &str) {
301 let Some(original_key) = space_image_object_key(stored_url) else {
302 return;
303 };
304 let key_base = export::imaging::preview_key_base_of(&original_key);
305 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
306 if let Err(error) = self.storage().client().delete(&candidate).await {
307 tracing::warn!(%error, key = %candidate, "failed to delete old space image preview");
308 }
309 }
310 }
311
173312 async fn presign_space_image_url(
174313 &self,
175314 space_id: Uuid,
@@ -287,27 +426,41 @@
287426 tracing::warn!(%error, "failed to invalidate space image url cache");
288427 }
289428
290 let meta_key = format!(
291 "{SPACE_IMAGE_PREFIX}/{space_id}/{}.meta.json",
292 kind.as_str()
293 );
294 let meta = serde_json::json!({
295 "space_id": space_id,
296 "kind": kind.as_str(),
297 "format": fmt.extension(),
298 "width": width,
299 "height": height,
300 "bytes": bytes.len(),
301 "uploaded_at": now,
302 });
303 if let Err(error) = self
304 .storage()
305 .client()
306 .put_with_content_type(&meta_key, meta.to_string().as_bytes(), "application/json")
307 .await
308 {
309 tracing::warn!(%error, "failed to write space image meta json");
429 // The derivative is built here so the very next page load already gets
430 // the small object; the same extension may have been in use before, so
431 // stale candidates are cleared first.
432 self.delete_space_image_previews(&stored_url).await;
433 let key_base = export::imaging::preview_key_base_of(&object_key);
434 let preview = export::imaging::ensure_preview(
435 self.storage().client(),
436 &self.state.redis,
437 &key_base,
438 &object_key,
439 fmt.content_type(),
440 PreviewPreset::Content,
441 None,
442 )
443 .await;
444 if preview.is_none() {
445 tracing::debug!(
446 %space_id,
447 kind = kind.as_str(),
448 "space image preview not generated; serving the original"
449 );
310450 }
451 self.write_space_image_meta(
452 space_id,
453 kind,
454 SpaceImageMeta {
455 fmt,
456 width,
457 height,
458 bytes: bytes.len(),
459 uploaded_at: now,
460 preview: preview.as_ref(),
461 },
462 )
463 .await;
311464
312465 Ok(SpaceImageDto {
313466 space_id,
@@ -357,11 +510,13 @@
357510 if let Err(error) = self.storage().client().delete(&key).await {
358511 tracing::warn!(%error, "failed to delete old space image object");
359512 }
360 let meta_key = format!(
361 "{SPACE_IMAGE_PREFIX}/{space_id}/{}.meta.json",
362 kind.as_str()
363 );
364 if let Err(error) = self.storage().client().delete(&meta_key).await {
513 self.delete_space_image_previews(&key).await;
514 if let Err(error) = self
515 .storage()
516 .client()
517 .delete(&space_image_meta_key(space_id, kind))
518 .await
519 {
365520 tracing::warn!(%error, "failed to delete old space image meta json");
366521 }
367522 }
@@ -417,14 +572,25 @@
417572 }
418573
419574 /// Presigned S3 address behind `GET /spaces/{space}/images/{kind}/file`.
575 ///
576 /// Space images are rendered at layout scale, so the default read is the
577 /// compressed derivative; `?variant=original` returns the uploaded bytes.
420578 pub async fn space_image_file_url(
421579 &self,
422580 caller: Option<Uuid>,
423581 space_name: &str,
424582 kind: SpaceImageKind,
583 variant: ImageVariant,
425584 ) -> Result<String, SpaceError> {
426585 let space = self.load_space_for_image_read(caller, space_name).await?;
427 let url = stored_space_image_url(&space, kind).ok_or(SpaceError::NotFound)?;
428 self.presign_space_image_url(space.id, kind, &url).await
586 let stored_url = stored_space_image_url(&space, kind).ok_or(SpaceError::NotFound)?;
587 let locator = match variant {
588 ImageVariant::Original => stored_url,
589 ImageVariant::Preview => {
590 self.space_image_variant_locator(space.id, kind, &stored_url)
591 .await
592 }
593 };
594 self.presign_space_image_url(space.id, kind, &locator).await
429595 }
430596}
Modified lib/space/src/openapi.rs lib/space/src/openapi.rs +1 −0 View lib/space/src/openapi.rs
@@ -118,6 +118,7 @@
118118 crate::services::images::AvatarFormat,
119119 crate::services::images::SpaceImageDto,
120120 crate::services::images::SpaceImageUploadQuery,
121 imaging::layout::ImageVariant,
121122 crate::services::images::SpaceImageKind,
122123 crate::services::dir::DirNodeDto,
123124 crate::services::dir::CreateDirCmd,
Modified lib/space/Cargo.toml lib/space/Cargo.toml +1 −0 View lib/space/Cargo.toml
@@ -10,6 +10,7 @@
1010export = { workspace = true }
1111email = { workspace = true }
1212i18n = { workspace = true }
13imaging = { workspace = true }
1314models = { workspace = true }
1415search = { workspace = true }
1516session = { workspace = true }
Modified lib/user/src/handlers/get_user_avatar.rs lib/user/src/handlers/get_user_avatar.rs +19 −7 View lib/user/src/handlers/get_user_avatar.rs
@@ -1,19 +1,30 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use axum::http::{StatusCode, header};
33use axum::response::Response;
4use imaging::layout::ImageVariant;
5use serde::Deserialize;
46use session::Session;
57use session::share::session::require_uid;
8use utoipa::{IntoParams, ToSchema};
69use uuid::Uuid;
710
811use crate::errors::ApiError;
912use crate::services::service::UserService;
1013
14/// `?variant=` selector. Omitting it yields the compressed derivative, which is
15/// what every list view wants; `original` serves the bytes exactly as uploaded.
16#[derive(Debug, Clone, Default, Deserialize, IntoParams, ToSchema)]
17pub struct AvatarVariantQuery {
18 #[serde(default)]
19 pub variant: Option<ImageVariant>,
20}
21
1122#[utoipa::path(
1223 get,
1324 path = "/users/{user_id}/avatar",
1425 tag = "users",
15 description = "重定向到 S3 预签名头像链接(7 天有效, Redis 缓存)。头像不受资料可见性限制:聊天与成员列表依赖它辨认发言人。",
16 params(("user_id" = Uuid, Path, description = "user id")),
26 description = "重定向到 S3 预签名头像链接(7 天有效, Redis 缓存)。默认返回压缩后的预览图,`?variant=original` 返回上传原图。头像不受资料可见性限制:聊天与成员列表依赖它辨认发言人。",
27 params(("user_id" = Uuid, Path, description = "user id"), AvatarVariantQuery),
1728 responses(
1829 (status = 302, description = "redirect to presigned s3 avatar url"),
1930 (status = 404, description = "user not found or has no avatar", body = export::rest::AppResponseData<export::rest::NoData>),
@@ -24,13 +35,14 @@
2435 State(service): State<UserService>,
2536 session: Session,
2637 Path(user_id): Path<Uuid>,
38 Query(query): Query<AvatarVariantQuery>,
2739) -> Result<Response, ApiError> {
2840 // A session is still required to reach the endpoint at all; what is
2941 // deliberately *not* checked is who the caller is relative to the target.
30 require_uid(&session).map_err(|_| {
31 ApiError::Unauthorized(export::rest::AppResponseData::err(401, "unauthorized"))
32 })?;
33 let url = service.avatar_redirect_url(user_id).await?;
42 require_uid(&session).map_err(|_| ApiError::unauthorized())?;
43 let url = service
44 .avatar_redirect_url(user_id, query.variant.unwrap_or(ImageVariant::Preview))
45 .await?;
3446 Ok(Response::builder()
3547 .status(StatusCode::FOUND)
3648 .header(header::LOCATION, url)
Modified lib/user/src/services/avatar.rs lib/user/src/services/avatar.rs +213 −59 View lib/user/src/services/avatar.rs
@@ -1,3 +1,5 @@
1use imaging::layout::{ImageVariant, PreviewState};
2use imaging::preset::PreviewPreset;
13use models::users::user;
24use sea_orm::{ActiveModelTrait, EntityTrait, Set};
35use serde::{Deserialize, Serialize};
@@ -17,6 +19,10 @@
1719 format!("{AVATAR_URL_CACHE_PREFIX}:{uid}")
1820}
1921
22fn avatar_meta_key(uid: Uuid) -> String {
23 format!("{AVATAR_PREFIX}/{uid}.meta.json")
24}
25
2026fn avatar_object_key(stored_url: &str) -> Option<String> {
2127 let key = stored_url.strip_prefix("storage://")?;
2228 if key.is_empty() {
@@ -25,6 +31,18 @@
2531 Some(key.to_string())
2632}
2733
34/// Content type to hand the preview generator for a stored avatar object. The
35/// stored locator carries the uploaded extension, which is authoritative enough
36/// for a decode attempt (and a mismatch only costs one skipped render).
37fn avatar_content_type(original_key: &str) -> &'static str {
38 match original_key.rsplit('.').next().unwrap_or_default() {
39 "png" => "image/png",
40 "webp" => "image/webp",
41 "gif" => "image/gif",
42 _ => "image/jpeg",
43 }
44}
45
2846#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
2947#[serde(rename_all = "lowercase")]
3048pub enum AvatarFormat {
@@ -77,23 +95,50 @@
7795#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
7896pub struct AvatarDto {
7997 pub user_id: Uuid,
98 /// Browser-facing URL. Points at the compressed derivative when one exists,
99 /// and at the uploaded bytes otherwise.
80100 pub url: String,
101 /// Format of the uploaded original.
81102 pub format: AvatarFormat,
103 /// Size of the uploaded original in bytes.
82104 pub bytes: u64,
83105 pub updated_at: OffsetDateTime,
84106}
85107
86108impl UserService {
87 async fn presign_avatar_url(&self, uid: Uuid, stored_url: &str) -> Result<String, UserError> {
88 let cache_key = avatar_url_cache_key(uid);
109 /// Redis key for the presigned URL of one representation. The default
110 /// (`preview`) keeps the historical key so the negative cache entry left by a
111 /// previously-skipped avatar cannot pin a stale URL for another variant.
112 fn avatar_url_cache_key(uid: Uuid, variant: ImageVariant) -> String {
113 match variant {
114 ImageVariant::Preview => avatar_url_cache_key(uid),
115 ImageVariant::Original => format!("{}:original", avatar_url_cache_key(uid)),
116 }
117 }
118
119 /// Presigned URL of the representation the caller asked for, plus the
120 /// bytes/metadata the API reports for it.
121 pub async fn avatar_presigned_url(
122 &self,
123 uid: Uuid,
124 variant: ImageVariant,
125 ) -> Result<Option<String>, UserError> {
126 let Some(stored_url) = self.avatar_variant_locator(uid, variant).await? else {
127 return Ok(None);
128 };
129 let cache_key = Self::avatar_url_cache_key(uid, variant);
89130 match export::redis::get_string(&self.state.redis, &cache_key).await {
90 Ok(Some(url)) => return Ok(url),
131 Ok(Some(url)) => return Ok(Some(url)),
91132 Ok(None) => {}
92133 Err(error) => {
93134 tracing::warn!(%error, "failed to read avatar url cache");
94135 }
95136 }
96 let key = avatar_object_key(stored_url).ok_or(UserError::NotFound)?;
137 let key = match avatar_object_key(&stored_url) {
138 Some(key) => key,
139 // Imported avatars live on someone else's host; there is nothing to presign.
140 None => return Ok(Some(stored_url)),
141 };
97142 let url = self
98143 .storage()
99144 .client()
@@ -105,10 +150,19 @@
105150 {
106151 tracing::warn!(%error, "failed to cache avatar presigned url");
107152 }
108 Ok(url)
153 Ok(Some(url))
109154 }
110155
111 pub async fn avatar_presigned_url(&self, uid: Uuid) -> Result<Option<String>, UserError> {
156 /// Stored locator for the avatar of `variant`. Avatars are the one image
157 /// class whose *default* read is the compressed derivative: they are only
158 /// ever rendered as small circles, so serving the original would charge
159 /// every list view for the upload. Callers that need the full-size bytes ask
160 /// for [`ImageVariant::Original`].
161 async fn avatar_variant_locator(
162 &self,
163 uid: Uuid,
164 variant: ImageVariant,
165 ) -> Result<Option<String>, UserError> {
112166 let user = user::Entity::find_by_id(uid)
113167 .one(self.db().read())
114168 .await?
@@ -116,7 +170,100 @@
116170 if user.avatar_url.is_empty() {
117171 return Ok(None);
118172 }
119 Ok(Some(self.presign_avatar_url(uid, &user.avatar_url).await?))
173 if variant == ImageVariant::Original {
174 return Ok(Some(user.avatar_url));
175 }
176 let Some(original_key) = avatar_object_key(&user.avatar_url) else {
177 return Ok(Some(user.avatar_url));
178 };
179 let key_base = export::imaging::preview_key_base_of(&original_key);
180 let recorded = self.recorded_avatar_preview(uid, &key_base).await;
181 let state = export::imaging::ensure_preview(
182 self.storage().client(),
183 &self.state.redis,
184 &key_base,
185 &original_key,
186 avatar_content_type(&original_key),
187 PreviewPreset::Avatar,
188 recorded.as_ref(),
189 )
190 .await;
191 Ok(Some(match state {
192 Some(PreviewState::Ready(ready)) => format!("storage://{}", ready.key),
193 Some(PreviewState::Skipped(_)) | None => user.avatar_url,
194 }))
195 }
196
197 /// Preview state from the avatar meta sidecar, falling back to probing the
198 /// object store so an avatar uploaded before previews existed is still
199 /// recognised once its derivative has been generated.
200 async fn recorded_avatar_preview(&self, uid: Uuid, key_base: &str) -> Option<PreviewState> {
201 let meta_key = avatar_meta_key(uid);
202 if let Ok(bytes) = self.storage().client().get(&meta_key).await
203 && let Ok(record) = serde_json::from_slice::<serde_json::Value>(&bytes)
204 && let Some(state) = export::imaging::recorded_preview(&record)
205 {
206 return Some(state);
207 }
208 export::imaging::find_stored_preview(self.storage().client(), key_base).await
209 }
210
211 /// Persist the sidecar next to the original, recording which derivative is
212 /// current so the next read does not decode anything again.
213 async fn write_avatar_meta(
214 &self,
215 uid: Uuid,
216 fmt: AvatarFormat,
217 bytes: usize,
218 now: OffsetDateTime,
219 preview: Option<&PreviewState>,
220 ) {
221 let mut meta = serde_json::json!({
222 "uid": uid,
223 "format": fmt.extension(),
224 "bytes": bytes,
225 "uploaded_at": now,
226 });
227 if let Some(state) = preview {
228 meta = export::imaging::record_preview(meta, state);
229 }
230 match serde_json::to_vec(&meta) {
231 Ok(meta_bytes) => {
232 if let Err(error) = self
233 .storage()
234 .client()
235 .put_with_content_type(&avatar_meta_key(uid), &meta_bytes, "application/json")
236 .await
237 {
238 tracing::warn!(%error, "failed to write user avatar meta json");
239 }
240 }
241 Err(error) => {
242 tracing::warn!(%error, "failed to serialize user avatar meta json");
243 }
244 }
245 }
246
247 /// Drop both encodings of the derivative. Called before writing a new
248 /// original (the preset may switch JPEG<->WebP) and again on delete.
249 async fn delete_avatar_previews(&self, original_key: &str) {
250 let key_base = export::imaging::preview_key_base_of(original_key);
251 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
252 if let Err(error) = self.storage().client().delete(&candidate).await {
253 tracing::warn!(%error, key = %candidate, "failed to delete old avatar preview");
254 }
255 }
256 }
257
258 async fn invalidate_avatar_cache(&self, uid: Uuid) {
259 for variant in [ImageVariant::Preview, ImageVariant::Original] {
260 if let Err(error) =
261 export::redis::delete(&self.state.redis, &Self::avatar_url_cache_key(uid, variant))
262 .await
263 {
264 tracing::warn!(%error, "failed to invalidate avatar url cache");
265 }
266 }
120267 }
121268
122269 pub async fn upload_avatar_stream(
@@ -139,10 +286,21 @@
139286 )));
140287 }
141288
289 let previous = user::Entity::find_by_id(uid)
290 .one(self.db().read())
291 .await?
292 .ok_or(UserError::NotFound)?
293 .avatar_url;
294 if let Some(old_key) = avatar_object_key(&previous)
295 && old_key != format!("{AVATAR_PREFIX}/{uid}.{}", fmt.extension())
296 {
297 self.delete_avatar_previews(&old_key).await;
298 }
299
142300 let key = format!("{AVATAR_PREFIX}/{uid}.{}", fmt.extension());
143301 self.storage()
144302 .client()
145 .put_with_content_type(&key, &bytes, fmt.content_type())
303 .put_with_content_type(&key, bytes, fmt.content_type())
146304 .await
147305 .map_err(UserError::from)?;
148306
@@ -157,36 +315,38 @@
157315 active.updated_at = Set(now);
158316 active.update(self.db().write()).await?;
159317
160 if let Err(error) =
161 export::redis::delete(&self.state.redis, &avatar_url_cache_key(uid)).await
162 {
163 tracing::warn!(%error, "failed to invalidate avatar url cache");
318 // The preview is built immediately so the first list view after an upload
319 // already has it; a failure here is not an upload failure.
320 let key_base = export::imaging::preview_key_base_of(&key);
321 // The old derivative shares this base only when the extension is reused;
322 // clearing it first keeps exactly one candidate object alive.
323 self.delete_avatar_previews(&key).await;
324 let preview = export::imaging::ensure_preview(
325 self.storage().client(),
326 &self.state.redis,
327 &key_base,
328 &key,
329 fmt.content_type(),
330 PreviewPreset::Avatar,
331 None,
332 )
333 .await;
334 if preview.is_none() {
335 // Nothing to record: the original is the preview, and repeating the
336 // attempt on every read would burn CPU for no benefit. The `None`
337 // above means either "skipped" or "generation failed"; only a real
338 // skip is worth remembering, and the renderer logs the difference.
339 tracing::debug!(%uid, "avatar preview not generated; serving the original");
164340 }
165341
166 let meta_key = format!("{AVATAR_PREFIX}/{uid}.meta.json");
167 let meta = serde_json::json!({
168 "uid": uid,
169 "format": fmt.extension(),
170 "bytes": bytes.len(),
171 "uploaded_at": now,
172 });
173 match serde_json::to_vec(&meta) {
174 Ok(meta_bytes) => {
175 if let Err(error) = self
176 .storage()
177 .client()
178 .put_with_content_type(&meta_key, &meta_bytes, "application/json")
179 .await
180 {
181 tracing::warn!(%error, "failed to write user avatar meta json");
182 }
183 }
184 Err(error) => {
185 tracing::warn!(%error, "failed to serialize user avatar meta json");
186 }
187 }
342 self.invalidate_avatar_cache(uid).await;
343 self.write_avatar_meta(uid, fmt, bytes.len(), now, preview.as_ref())
344 .await;
188345
189 let url = self.presign_avatar_url(uid, &stored_url).await?;
346 let url = self
347 .avatar_presigned_url(uid, ImageVariant::Preview)
348 .await?
349 .unwrap_or(stored_url);
190350 Ok(AvatarDto {
191351 user_id: uid,
192352 url,
@@ -208,23 +368,16 @@
208368 active.updated_at = Set(now);
209369 active.update(self.db().write()).await?;
210370
211 if let Err(error) =
212 export::redis::delete(&self.state.redis, &avatar_url_cache_key(uid)).await
371 self.invalidate_avatar_cache(uid).await;
372
373 if let Some(old_key) = avatar_object_key(&old_url)
374 && old_key.starts_with(&format!("{AVATAR_PREFIX}/"))
213375 {
214 tracing::warn!(%error, "failed to invalidate avatar url cache");
215 }
216
217 if old_url.starts_with(&format!("storage://{AVATAR_PREFIX}/")) {
218 let rel = old_url
219 .trim_start_matches("storage://")
220 .trim_start_matches(AVATAR_PREFIX)
221 .trim_start_matches('/');
222 let key = format!("{AVATAR_PREFIX}/{rel}");
223 if let Err(error) = self.storage().client().delete(&key).await {
376 if let Err(error) = self.storage().client().delete(&old_key).await {
224377 tracing::warn!(%error, "failed to delete old user avatar object");
225378 }
226 let meta_key = format!("{AVATAR_PREFIX}/{uid}.meta.json");
227 if let Err(error) = self.storage().client().delete(&meta_key).await {
379 self.delete_avatar_previews(&old_key).await;
380 if let Err(error) = self.storage().client().delete(&avatar_meta_key(uid)).await {
228381 tracing::warn!(%error, "failed to delete old user avatar meta json");
229382 }
230383 }
@@ -247,16 +400,14 @@
247400 ///
248401 /// Still 404: no such user, or a user who has never uploaded an avatar.
249402 /// Authentication is required (the handler), just not any per-user rule.
250 pub async fn avatar_redirect_url(&self, target_uid: Uuid) -> Result<String, UserError> {
251 let target = user::Entity::find_by_id(target_uid)
252 .one(self.db().read())
403 pub async fn avatar_redirect_url(
404 &self,
405 target_uid: Uuid,
406 variant: ImageVariant,
407 ) -> Result<String, UserError> {
408 self.avatar_presigned_url(target_uid, variant)
253409 .await?
254 .ok_or(UserError::NotFound)?;
255 if target.avatar_url.is_empty() {
256 return Err(UserError::NotFound);
257 }
258 self.presign_avatar_url(target_uid, &target.avatar_url)
259 .await
410 .ok_or(UserError::NotFound)
260411 }
261412
262413 pub async fn current_avatar(&self, uid: Uuid) -> Result<Option<AvatarDto>, UserError> {
@@ -269,7 +420,10 @@
269420 }
270421 let ext = user.avatar_url.rsplit('.').next().unwrap_or_default();
271422 let fmt = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
272 let url = self.presign_avatar_url(uid, &user.avatar_url).await?;
423 let url = self
424 .avatar_presigned_url(uid, ImageVariant::Preview)
425 .await?
426 .ok_or(UserError::NotFound)?;
273427 Ok(Some(AvatarDto {
274428 user_id: uid,
275429 url,
Modified lib/user/src/openapi.rs lib/user/src/openapi.rs +1 −0 View lib/user/src/openapi.rs
@@ -54,6 +54,7 @@
5454 ),
5555 components(schemas(
5656 export::pagination::PageQuery,
57 imaging::layout::ImageVariant,
5758 export::pagination::Page<crate::services::sessions::SessionDto>,
5859
5960 NoData,
Modified lib/user/Cargo.toml lib/user/Cargo.toml +1 −0 View lib/user/Cargo.toml
@@ -9,6 +9,7 @@
99export = { workspace = true }
1010models = { workspace = true }
1111i18n = { workspace = true }
12imaging = { workspace = true }
1213email = { workspace = true }
1314session = { workspace = true }
1415storage = { workspace = true }
Modified web/src/entities/chat/model/meta.ts web/src/entities/chat/model/meta.ts +12 −3 View web/src/entities/chat/model/meta.ts
@@ -268,9 +268,18 @@
268268 return 'file'
269269}
270270
271/** Same-origin stream endpoint (session cookie, no presigned expiry). */
272export function attachmentUrl(attachmentId: string): string {
273 return `/api/v1/chat/attachments/${encodeURIComponent(attachmentId)}/file`
271/**
272 * Same-origin stream endpoint (session cookie, no presigned expiry).
273 *
274 * `variant` selects which representation the server streams: `preview` is the
275 * compressed derivative the transcript grid loads, `original` (the default) is
276 * the uploaded bytes — which is what the lightbox and the download link need.
277 * A missing preview degrades to the original server-side, so this is always a
278 * valid URL.
279 */
280export function attachmentUrl(attachmentId: string, variant: 'preview' | 'original' = 'original'): string {
281 const base = `/api/v1/chat/attachments/${encodeURIComponent(attachmentId)}/file`
282 return variant === 'preview' ? `${base}?variant=preview` : base
274283}
275284
276285/** Custom space emoji image, same-origin for the same reason. */
Modified web/src/features/chat/ui/parts.tsx web/src/features/chat/ui/parts.tsx +3 −1 View web/src/features/chat/ui/parts.tsx
@@ -584,8 +584,10 @@
584584 title={image.filename}
585585 className="group relative overflow-hidden rounded-lg border border-shell-hairline bg-shell-field"
586586 >
587 {/* The grid loads the compressed derivative; clicking opens the
588 lightbox, which streams the original. */}
587589 <img
588 src={attachmentUrl(image.id)}
590 src={attachmentUrl(image.id, 'preview')}
589591 alt={image.filename}
590592 loading="lazy"
591593 className="max-h-64 max-w-full object-contain transition-transform group-hover:scale-[1.01] sm:max-w-[320px]"
Modified web/src/shared/api/globals.d.ts web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts
Diff too large to display.
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified Cargo.lock Cargo.lock +141 −2 View Cargo.lock
@@ -24,6 +24,7 @@
2424 "config",
2525 "export",
2626 "git",
27 "imaging",
2728 "models",
2829 "redis",
2930 "reqwest 0.12.28",
@@ -1305,6 +1306,7 @@
13051306 "hmac 0.13.0",
13061307 "http 0.2.12",
13071308 "i18n",
1309 "imaging",
13081310 "livekit-api",
13091311 "livekit-protocol",
13101312 "models",
@@ -1465,6 +1467,12 @@
14651467]
14661468
14671469[[package]]
1470name = "color_quant"
1471version = "1.1.0"
1472source = "registry+https://github.com/rust-lang/crates.io-index"
1473checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
1474
1475[[package]]
14681476name = "combine"
14691477version = "4.6.7"
14701478source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2430,7 +2438,11 @@
24302438 "config",
24312439 "email",
24322440 "http-body-util",
2441 "imaging",
24332442 "jsonwebtoken 9.3.1",
2443 "opentelemetry",
2444 "opentelemetry-otlp",
2445 "opentelemetry_sdk",
24342446 "rand 0.8.7",
24352447 "redis",
24362448 "sea-orm",
@@ -2442,6 +2454,7 @@
24422454 "tokio",
24432455 "tower",
24442456 "tracing",
2457 "tracing-opentelemetry",
24452458 "tracing-subscriber",
24462459 "utoipa",
24472460 "uuid",
@@ -2999,6 +3012,16 @@
29993012]
30003013
30013014[[package]]
3015name = "gif"
3016version = "0.13.3"
3017source = "registry+https://github.com/rust-lang/crates.io-index"
3018checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
3019dependencies = [
3020 "color_quant",
3021 "weezl",
3022]
3023
3024[[package]]
30023025name = "git"
30033026version = "1.0.0"
30043027dependencies = [
@@ -4254,7 +4277,7 @@
42544277 "memchr",
42554278 "rustc-hash",
42564279 "smallvec",
4257 "zune-jpeg",
4280 "zune-jpeg 0.5.15",
42584281]
42594282
42604283[[package]]
@@ -5098,8 +5121,34 @@
50985121dependencies = [
50995122 "bytemuck",
51005123 "byteorder-lite",
5124 "color_quant",
5125 "gif",
5126 "image-webp",
51015127 "num-traits",
51025128 "png 0.17.16",
5129 "zune-core 0.4.12",
5130 "zune-jpeg 0.4.21",
5131]
5132
5133[[package]]
5134name = "image-webp"
5135version = "0.2.0"
5136source = "registry+https://github.com/rust-lang/crates.io-index"
5137checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f"
5138dependencies = [
5139 "byteorder-lite",
5140 "quick-error",
5141]
5142
5143[[package]]
5144name = "imaging"
5145version = "1.0.0"
5146dependencies = [
5147 "image",
5148 "serde",
5149 "serde_json",
5150 "tokio",
5151 "utoipa",
51035152]
51045153
51055154[[package]]
@@ -7598,6 +7647,12 @@
75987647checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
75997648
76007649[[package]]
7650name = "quick-error"
7651version = "2.0.1"
7652source = "registry+https://github.com/rust-lang/crates.io-index"
7653checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
7654
7655[[package]]
76017656name = "quick-xml"
76027657version = "0.28.2"
76037658source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7633,6 +7688,7 @@
76337688source = "registry+https://github.com/rust-lang/crates.io-index"
76347689checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
76357690dependencies = [
7691 "aws-lc-rs",
76367692 "bytes",
76377693 "getrandom 0.4.3",
76387694 "lru-slab",
@@ -7974,13 +8030,19 @@
79748030 "http-body 1.1.0",
79758031 "http-body-util",
79768032 "hyper 1.11.0",
8033 "hyper-rustls",
79778034 "hyper-util",
79788035 "js-sys",
79798036 "log 0.4.33",
79808037 "percent-encoding 2.3.2",
79818038 "pin-project-lite",
8039 "quinn",
8040 "rustls",
8041 "rustls-pki-types",
8042 "rustls-platform-verifier",
79828043 "sync_wrapper",
79838044 "tokio",
8045 "tokio-rustls",
79848046 "tower",
79858047 "tower-http 0.6.11",
79868048 "tower-service",
@@ -8343,6 +8405,7 @@
83438405source = "registry+https://github.com/rust-lang/crates.io-index"
83448406checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
83458407dependencies = [
8408 "aws-lc-rs",
83468409 "log 0.4.33",
83478410 "once_cell",
83488411 "ring",
@@ -8375,11 +8438,39 @@
83758438]
83768439
83778440[[package]]
8441name = "rustls-platform-verifier"
8442version = "0.6.2"
8443source = "registry+https://github.com/rust-lang/crates.io-index"
8444checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
8445dependencies = [
8446 "core-foundation",
8447 "core-foundation-sys",
8448 "jni",
8449 "log 0.4.33",
8450 "once_cell",
8451 "rustls",
8452 "rustls-native-certs",
8453 "rustls-platform-verifier-android",
8454 "rustls-webpki",
8455 "security-framework",
8456 "security-framework-sys",
8457 "webpki-root-certs",
8458 "windows-sys 0.61.2",
8459]
8460
8461[[package]]
8462name = "rustls-platform-verifier-android"
8463version = "0.1.1"
8464source = "registry+https://github.com/rust-lang/crates.io-index"
8465checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
8466
8467[[package]]
83788468name = "rustls-webpki"
83798469version = "0.103.14"
83808470source = "registry+https://github.com/rust-lang/crates.io-index"
83818471checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
83828472dependencies = [
8473 "aws-lc-rs",
83838474 "ring",
83848475 "rustls-pki-types",
83858476 "untrusted 0.9.0",
@@ -9096,6 +9187,7 @@
90969187 "email",
90979188 "export",
90989189 "i18n",
9190 "imaging",
90999191 "models",
91009192 "rand 0.8.7",
91019193 "sea-orm",
@@ -10057,6 +10149,22 @@
1005710149]
1005810150
1005910151[[package]]
10152name = "tracing-opentelemetry"
10153version = "0.33.0"
10154source = "registry+https://github.com/rust-lang/crates.io-index"
10155checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
10156dependencies = [
10157 "js-sys",
10158 "opentelemetry",
10159 "smallvec",
10160 "tracing",
10161 "tracing-core",
10162 "tracing-log",
10163 "tracing-subscriber",
10164 "web-time",
10165]
10166
10167[[package]]
1006010168name = "tracing-subscriber"
1006110169version = "0.3.23"
1006210170source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10377,6 +10485,7 @@
1037710485 "export",
1037810486 "futures",
1037910487 "i18n",
10488 "imaging",
1038010489 "models",
1038110490 "rand 0.8.7",
1038210491 "redis",
@@ -10740,6 +10849,15 @@
1074010849]
1074110850
1074210851[[package]]
10852name = "webpki-root-certs"
10853version = "1.0.9"
10854source = "registry+https://github.com/rust-lang/crates.io-index"
10855checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
10856dependencies = [
10857 "rustls-pki-types",
10858]
10859
10860[[package]]
1074310861name = "webpki-roots"
1074410862version = "0.26.11"
1074510863source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10758,6 +10876,12 @@
1075810876]
1075910877
1076010878[[package]]
10879name = "weezl"
10880version = "0.1.12"
10881source = "registry+https://github.com/rust-lang/crates.io-index"
10882checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
10883
10884[[package]]
1076110885name = "whoami"
1076210886version = "2.1.3"
1076310887source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -11391,15 +11515,30 @@
1139111515
1139211516[[package]]
1139311517name = "zune-core"
11518version = "0.4.12"
11519source = "registry+https://github.com/rust-lang/crates.io-index"
11520checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
11521
11522[[package]]
11523name = "zune-core"
1139411524version = "0.5.3"
1139511525source = "registry+https://github.com/rust-lang/crates.io-index"
1139611526checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
1139711527
1139811528[[package]]
1139911529name = "zune-jpeg"
11530version = "0.4.21"
11531source = "registry+https://github.com/rust-lang/crates.io-index"
11532checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
11533dependencies = [
11534 "zune-core 0.4.12",
11535]
11536
11537[[package]]
11538name = "zune-jpeg"
1140011539version = "0.5.15"
1140111540source = "registry+https://github.com/rust-lang/crates.io-index"
1140211541checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
1140311542dependencies = [
11404 "zune-core",
11543 "zune-core 0.5.3",
1140511544]
Modified Cargo.toml Cargo.toml +7 −2 View Cargo.toml
@@ -37,6 +37,7 @@
3737issues = { path = "lib/issues" }
3838git = { path = "lib/git" }
3939i18n = { path = "lib/i18n" }
40imaging = { path = "lib/imaging" }
4041import = { path = "lib/import" }
4142webapi = { path = "lib/webapi" }
4243pulls = { path = "lib/pulls" }
@@ -55,8 +56,12 @@
5556tower-http = { version = "0.7.0", features = ["trace", "limit", "cors"] }
5657axum-otel-metrics = "0.14.1"
5758opentelemetry = "0.32"
58opentelemetry_sdk = { version = "0.32.1", features = ["rt-tokio"] }
59opentelemetry-otlp = "0.32"
59opentelemetry_sdk = { version = "0.32.1", features = ["rt-tokio", "trace"] }
60# reqwest-rustls: the default client feature set compiles reqwest with no TLS backend,
61# so an https OTEL_EXPORTER_OTLP_ENDPOINT would fail every export at request time.
62opentelemetry-otlp = { version = "0.32", features = ["reqwest-rustls"] }
63tracing-opentelemetry = "0.33"
64image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] }
6065tracing = "0.1.44"
6166figment = { version = "0.10", features = ["toml", "env"] }
6267dotenvy = "0.15"
Modified openapi.json openapi.json View openapi.json
Diff too large to display.