diff --git a/src/crates/services/miniapp-market-service/Cargo.toml b/src/crates/services/miniapp-market-service/Cargo.toml index e432d6b90..4ea25d7d0 100644 --- a/src/crates/services/miniapp-market-service/Cargo.toml +++ b/src/crates/services/miniapp-market-service/Cargo.toml @@ -25,7 +25,7 @@ sha2 = { workspace = true } similar = { workspace = true } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] } thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "rt", "time"] } +tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tower-http = { version = "0.6.11", features = ["fs", "set-header", "trace"] } tracing = { workspace = true } url = { workspace = true } diff --git a/src/crates/services/miniapp-market-service/README.md b/src/crates/services/miniapp-market-service/README.md index 95567b92e..798a8e6d6 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -46,6 +46,9 @@ - Release 不可变,新版本审核期间继续提供旧的已批准版本。 - 批准必须原子绑定 package hash、截图 hash、规范化 metadata 和 `review_bundle_hash`。 +- 截图 URL 无 query 时保持规范化原图兼容;只允许 `compact-v1`(最大边 640px) + 和 `large-v1`(最大边 1280px)两个有界变体。变体按需生成到原图旁,不进入 + 审核 hash,删除原图时必须同步删除变体。 - 市场包只能包含协议白名单文件,必须拒绝 Node、npm、非空 ESM、zip-slip、 link、重复/大小写冲突路径和超限解压。 - GitHub token 只用于读取公开 `{id,login,avatar_url}`,随后丢弃,不能下发给 diff --git a/src/crates/services/miniapp-market-service/src/artifacts.rs b/src/crates/services/miniapp-market-service/src/artifacts.rs index 8a27b743f..418ee0d37 100644 --- a/src/crates/services/miniapp-market-service/src/artifacts.rs +++ b/src/crates/services/miniapp-market-service/src/artifacts.rs @@ -1,11 +1,39 @@ use crate::error::{MarketError, MarketResult}; +use std::io::Cursor; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::SystemTime; +use tokio::sync::Semaphore; use uuid::Uuid; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MarketImageVariant { + CompactV1, + LargeV1, +} + +impl MarketImageVariant { + const ALL: [Self; 2] = [Self::CompactV1, Self::LargeV1]; + + pub(crate) const fn cache_key(self) -> &'static str { + match self { + Self::CompactV1 => "compact-v1", + Self::LargeV1 => "large-v1", + } + } + + const fn max_dimension(self) -> u32 { + match self { + Self::CompactV1 => 640, + Self::LargeV1 => 1_280, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct ArtifactStore { root: PathBuf, + variant_generation_permits: Arc, } impl ArtifactStore { @@ -13,7 +41,10 @@ impl ArtifactStore { tokio::fs::create_dir_all(root.join("packages")).await?; tokio::fs::create_dir_all(root.join("screenshots")).await?; tokio::fs::create_dir_all(root.join(".tmp")).await?; - Ok(Self { root }) + Ok(Self { + root, + variant_generation_permits: Arc::new(Semaphore::new(4)), + }) } pub(crate) fn package_path(&self, sha256: &str) -> PathBuf { @@ -24,6 +55,14 @@ impl ArtifactStore { content_path(&self.root.join("screenshots"), sha256, "webp") } + fn screenshot_variant_path(&self, sha256: &str, variant: MarketImageVariant) -> PathBuf { + content_path( + &self.root.join("screenshots"), + sha256, + &format!("{}.webp", variant.cache_key()), + ) + } + pub(crate) async fn put_package(&self, sha256: &str, bytes: &[u8]) -> MarketResult { let path = self.package_path(sha256); self.put_atomic(&path, bytes).await?; @@ -60,6 +99,39 @@ impl ArtifactStore { }) } + pub(crate) async fn read_screenshot_variant( + &self, + sha256: &str, + variant: MarketImageVariant, + ) -> MarketResult> { + let variant_path = self.screenshot_variant_path(sha256, variant); + match tokio::fs::read(&variant_path).await { + Ok(bytes) => return Ok(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(MarketError::internal(error)), + } + + let _permit = self + .variant_generation_permits + .acquire() + .await + .map_err(MarketError::internal)?; + match tokio::fs::read(&variant_path).await { + Ok(bytes) => return Ok(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(MarketError::internal(error)), + } + + let source = self.read_screenshot(sha256).await?; + let max_dimension = variant.max_dimension(); + let bytes = tokio::task::spawn_blocking(move || render_webp_variant(source, max_dimension)) + .await + .map_err(MarketError::internal)? + .map_err(MarketError::internal)?; + self.put_atomic(&variant_path, &bytes).await?; + Ok(bytes) + } + async fn put_atomic(&self, path: &Path, bytes: &[u8]) -> MarketResult<()> { if path.exists() { return Ok(()); @@ -95,7 +167,11 @@ impl ArtifactStore { } pub(crate) async fn remove_screenshot_if_exists(&self, sha256: &str) -> anyhow::Result { - remove_if_exists(&self.screenshot_path(sha256)).await + let mut removed = remove_if_exists(&self.screenshot_path(sha256)).await?; + for variant in MarketImageVariant::ALL { + removed |= remove_if_exists(&self.screenshot_variant_path(sha256, variant)).await?; + } + Ok(removed) } pub(crate) async fn package_hashes_older_than( @@ -113,6 +189,17 @@ impl ArtifactStore { } } +fn render_webp_variant(bytes: Vec, max_dimension: u32) -> image::ImageResult> { + let decoded = image::load_from_memory_with_format(&bytes, image::ImageFormat::WebP)?; + if decoded.width() <= max_dimension && decoded.height() <= max_dimension { + return Ok(bytes); + } + let resized = decoded.thumbnail(max_dimension, max_dimension); + let mut cursor = Cursor::new(Vec::new()); + resized.write_to(&mut cursor, image::ImageFormat::WebP)?; + Ok(cursor.into_inner()) +} + fn content_path(root: &Path, sha256: &str, extension: &str) -> PathBuf { let prefix = sha256.get(..2).unwrap_or("00"); root.join(prefix).join(format!("{sha256}.{extension}")) @@ -164,3 +251,54 @@ async fn content_hashes_older_than( } Ok(hashes) } + +#[cfg(test)] +mod tests { + use super::*; + use image::{DynamicImage, ImageBuffer, Rgba}; + + fn test_webp(width: u32, height: u32) -> Vec { + let image = DynamicImage::ImageRgba8(ImageBuffer::from_fn(width, height, |x, y| { + Rgba([(x % 255) as u8, (y % 255) as u8, 120, 255]) + })); + let mut output = Cursor::new(Vec::new()); + image + .write_to(&mut output, image::ImageFormat::WebP) + .unwrap(); + output.into_inner() + } + + #[tokio::test] + async fn caches_and_removes_resized_screenshot_variants() { + let temporary = tempfile::tempdir().unwrap(); + let store = ArtifactStore::open(temporary.path().to_path_buf()) + .await + .unwrap(); + let sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + store + .put_screenshot(sha256, &test_webp(800, 400)) + .await + .unwrap(); + + let compact = store + .read_screenshot_variant(sha256, MarketImageVariant::CompactV1) + .await + .unwrap(); + let decoded = + image::load_from_memory_with_format(&compact, image::ImageFormat::WebP).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (640, 320)); + let variant_path = store.screenshot_variant_path(sha256, MarketImageVariant::CompactV1); + assert!(variant_path.exists()); + assert_eq!( + store + .read_screenshot_variant(sha256, MarketImageVariant::CompactV1) + .await + .unwrap(), + compact + ); + + assert!(store.remove_screenshot_if_exists(sha256).await.unwrap()); + assert!(!store.screenshot_path(sha256).exists()); + assert!(!variant_path.exists()); + } +} diff --git a/src/crates/services/miniapp-market-service/src/lib.rs b/src/crates/services/miniapp-market-service/src/lib.rs index 9df559c43..73105de31 100644 --- a/src/crates/services/miniapp-market-service/src/lib.rs +++ b/src/crates/services/miniapp-market-service/src/lib.rs @@ -114,8 +114,9 @@ async fn security_headers(request: Request, next: Next) -> Response { mod tests { use super::*; use axum::body::{Body, Bytes}; - use axum::http::{Request, StatusCode}; + use axum::http::{header, Request, StatusCode}; use axum::routing::post; + use std::io::Cursor; use tower::ServiceExt; #[tokio::test] @@ -138,6 +139,17 @@ mod tests { tokio::fs::write(config.web_dir.join("index.html"), "") .await .unwrap(); + let screenshot_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let mut screenshot = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(800, 400) + .write_to(&mut screenshot, image::ImageFormat::WebP) + .unwrap(); + ArtifactStore::open(config.artifact_dir.clone()) + .await + .unwrap() + .put_screenshot(screenshot_hash, &screenshot.into_inner()) + .await + .unwrap(); let app = build_market_router(config).await.unwrap(); let response = app .clone() @@ -177,6 +189,42 @@ mod tests { .unwrap(); let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(body["error"]["code"], "not_found"); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/miniapp/api/v1/screenshots/{screenshot_hash}?variant=compact-v1" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers()[header::ETAG] + .to_str() + .unwrap() + .ends_with("-compact-v1\"")); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let decoded = image::load_from_memory_with_format(&body, image::ImageFormat::WebP).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (640, 320)); + + let response = app + .oneshot( + Request::builder() + .uri(format!( + "/miniapp/api/v1/screenshots/{screenshot_hash}?variant=unbounded" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] diff --git a/src/crates/services/miniapp-market-service/src/routes.rs b/src/crates/services/miniapp-market-service/src/routes.rs index bfd468671..9bf870d33 100644 --- a/src/crates/services/miniapp-market-service/src/routes.rs +++ b/src/crates/services/miniapp-market-service/src/routes.rs @@ -1,4 +1,4 @@ -use crate::artifacts::ArtifactStore; +use crate::artifacts::{ArtifactStore, MarketImageVariant}; use crate::auth::{ AuthService, CompletedOAuth, DesktopAuthPollRequest, RefreshTokenRequest, RequestAuth, RequestAuthKind, @@ -67,6 +67,12 @@ struct ListingQuery { limit: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImageVariantQuery { + variant: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct OAuthStartQuery { @@ -439,11 +445,36 @@ async fn download_release( async fn get_screenshot( State(state): State>, Path(sha256): Path, + Query(query): Query, ) -> MarketResult { if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(MarketError::not_found("Screenshot was not found.")); } - let bytes = state.artifacts.read_screenshot(&sha256).await?; + let variant = match query.variant.as_deref() { + None => None, + Some("compact-v1") => Some(MarketImageVariant::CompactV1), + Some("large-v1") => Some(MarketImageVariant::LargeV1), + Some(_) => { + return Err(MarketError::bad_request( + "invalid_image_variant", + "Image variant must be compact-v1 or large-v1.", + )) + } + }; + let bytes = match variant { + Some(variant) => { + state + .artifacts + .read_screenshot_variant(&sha256, variant) + .await? + } + None => state.artifacts.read_screenshot(&sha256).await?, + }; + let content_length = bytes.len(); + let etag = match variant { + Some(variant) => format!("\"{}-{}\"", sha256, variant.cache_key()), + None => format!("\"{sha256}\""), + }; let mut response = Response::new(Body::from(bytes)); response .headers_mut() @@ -452,6 +483,14 @@ async fn get_screenshot( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=31536000, immutable"), ); + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&etag).map_err(MarketError::internal)?, + ); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&content_length.to_string()).map_err(MarketError::internal)?, + ); Ok(response) } diff --git a/src/crates/services/skin-market-service/README.md b/src/crates/services/skin-market-service/README.md index 233032243..e555cc00b 100644 --- a/src/crates/services/skin-market-service/README.md +++ b/src/crates/services/skin-market-service/README.md @@ -22,6 +22,9 @@ Key invariants: review bundle hash; - only declared package-local raster/video assets are accepted; preview output is normalized to same-origin WebP; +- the no-query preview URL remains the normalized original; only the bounded + `compact-v1` (640px) and `large-v1` (1280px) query variants are accepted, + generated lazily beside the original and removed with it; - listing slugs and package IDs cannot be transferred between owners through an update submission; - upload size, expansion, entry count, media dimensions and MIME are bounded diff --git a/src/crates/services/skin-market-service/src/artifacts.rs b/src/crates/services/skin-market-service/src/artifacts.rs index 0727d6af1..04082c4b4 100644 --- a/src/crates/services/skin-market-service/src/artifacts.rs +++ b/src/crates/services/skin-market-service/src/artifacts.rs @@ -1,14 +1,40 @@ use crate::error::{SkinMarketError, SkinMarketResult}; +use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::SystemTime; -use tokio::sync::{Mutex, OwnedMutexGuard}; +use tokio::sync::{Mutex, OwnedMutexGuard, Semaphore}; use uuid::Uuid; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MarketImageVariant { + CompactV1, + LargeV1, +} + +impl MarketImageVariant { + const ALL: [Self; 2] = [Self::CompactV1, Self::LargeV1]; + + pub(crate) const fn cache_key(self) -> &'static str { + match self { + Self::CompactV1 => "compact-v1", + Self::LargeV1 => "large-v1", + } + } + + const fn max_dimension(self) -> u32 { + match self { + Self::CompactV1 => 640, + Self::LargeV1 => 1_280, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct ArtifactStore { root: PathBuf, mutation_lock: Arc>, + variant_generation_permits: Arc, } pub(crate) struct ArtifactMutationGuard { @@ -23,6 +49,7 @@ impl ArtifactStore { Ok(Self { root, mutation_lock: Arc::new(Mutex::new(())), + variant_generation_permits: Arc::new(Semaphore::new(4)), }) } @@ -40,6 +67,14 @@ impl ArtifactStore { content_path(&self.root.join("previews"), sha256, "webp") } + fn preview_variant_path(&self, sha256: &str, variant: MarketImageVariant) -> PathBuf { + content_path( + &self.root.join("previews"), + sha256, + &format!("{}.webp", variant.cache_key()), + ) + } + pub(crate) async fn put_package( &self, _guard: &ArtifactMutationGuard, @@ -86,6 +121,39 @@ impl ArtifactStore { .await } + pub(crate) async fn read_preview_variant( + &self, + sha256: &str, + variant: MarketImageVariant, + ) -> SkinMarketResult> { + let variant_path = self.preview_variant_path(sha256, variant); + match tokio::fs::read(&variant_path).await { + Ok(bytes) => return Ok(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(SkinMarketError::internal(error)), + } + + let _permit = self + .variant_generation_permits + .acquire() + .await + .map_err(SkinMarketError::internal)?; + match tokio::fs::read(&variant_path).await { + Ok(bytes) => return Ok(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(SkinMarketError::internal(error)), + } + + let source = self.read_preview(sha256).await?; + let max_dimension = variant.max_dimension(); + let bytes = tokio::task::spawn_blocking(move || render_webp_variant(source, max_dimension)) + .await + .map_err(SkinMarketError::internal)? + .map_err(SkinMarketError::internal)?; + self.put_atomic(&variant_path, &bytes).await?; + Ok(bytes) + } + async fn put_atomic(&self, target: &Path, bytes: &[u8]) -> SkinMarketResult<()> { if target.exists() { return Ok(()); @@ -129,7 +197,11 @@ impl ArtifactStore { _guard: &ArtifactMutationGuard, sha256: &str, ) -> anyhow::Result { - remove_if_exists(&self.preview_path(sha256)).await + let mut removed = remove_if_exists(&self.preview_path(sha256)).await?; + for variant in MarketImageVariant::ALL { + removed |= remove_if_exists(&self.preview_variant_path(sha256, variant)).await?; + } + Ok(removed) } pub(crate) async fn package_hashes_older_than( @@ -170,6 +242,17 @@ impl ArtifactStore { } } +fn render_webp_variant(bytes: Vec, max_dimension: u32) -> image::ImageResult> { + let decoded = image::load_from_memory_with_format(&bytes, image::ImageFormat::WebP)?; + if decoded.width() <= max_dimension && decoded.height() <= max_dimension { + return Ok(bytes); + } + let resized = decoded.thumbnail(max_dimension, max_dimension); + let mut cursor = Cursor::new(Vec::new()); + resized.write_to(&mut cursor, image::ImageFormat::WebP)?; + Ok(cursor.into_inner()) +} + fn content_path(root: &Path, sha256: &str, extension: &str) -> PathBuf { root.join(sha256.get(..2).unwrap_or("00")) .join(format!("{sha256}.{extension}")) @@ -227,3 +310,57 @@ async fn hashes_older_than( } Ok(hashes) } + +#[cfg(test)] +mod tests { + use super::*; + use image::{DynamicImage, ImageBuffer, Rgba}; + + fn test_webp(width: u32, height: u32) -> Vec { + let image = DynamicImage::ImageRgba8(ImageBuffer::from_fn(width, height, |x, y| { + Rgba([(x % 255) as u8, (y % 255) as u8, 120, 255]) + })); + let mut output = Cursor::new(Vec::new()); + image + .write_to(&mut output, image::ImageFormat::WebP) + .unwrap(); + output.into_inner() + } + + #[tokio::test] + async fn caches_and_removes_resized_preview_variants() { + let temporary = tempfile::tempdir().unwrap(); + let store = ArtifactStore::open(temporary.path().to_path_buf()) + .await + .unwrap(); + let sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let guard = store.lock_mutations().await; + store + .put_preview(&guard, sha256, &test_webp(800, 400)) + .await + .unwrap(); + drop(guard); + + let compact = store + .read_preview_variant(sha256, MarketImageVariant::CompactV1) + .await + .unwrap(); + let decoded = + image::load_from_memory_with_format(&compact, image::ImageFormat::WebP).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (640, 320)); + let variant_path = store.preview_variant_path(sha256, MarketImageVariant::CompactV1); + assert!(variant_path.exists()); + assert_eq!( + store + .read_preview_variant(sha256, MarketImageVariant::CompactV1) + .await + .unwrap(), + compact + ); + + let guard = store.lock_mutations().await; + assert!(store.remove_preview(&guard, sha256).await.unwrap()); + assert!(!store.preview_path(sha256).exists()); + assert!(!variant_path.exists()); + } +} diff --git a/src/crates/services/skin-market-service/src/lib.rs b/src/crates/services/skin-market-service/src/lib.rs index 3b0efa361..7ef4cb819 100644 --- a/src/crates/services/skin-market-service/src/lib.rs +++ b/src/crates/services/skin-market-service/src/lib.rs @@ -468,6 +468,34 @@ mod tests { "public, max-age=31536000, immutable" ); + let compact_preview = app + .clone() + .oneshot(request( + "GET", + &format!("{private_preview_path}?variant=compact-v1"), + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(compact_preview.status(), StatusCode::OK); + assert!(compact_preview.headers()[header::ETAG] + .to_str() + .unwrap() + .ends_with("-compact-v1\"")); + + let invalid_preview_variant = app + .clone() + .oneshot(request( + "GET", + &format!("{private_preview_path}?variant=unbounded"), + None, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(invalid_preview_variant.status(), StatusCode::BAD_REQUEST); + let response = app .clone() .oneshot(request( diff --git a/src/crates/services/skin-market-service/src/routes.rs b/src/crates/services/skin-market-service/src/routes.rs index 1385d7831..241b30916 100644 --- a/src/crates/services/skin-market-service/src/routes.rs +++ b/src/crates/services/skin-market-service/src/routes.rs @@ -1,4 +1,4 @@ -use crate::artifacts::ArtifactStore; +use crate::artifacts::{ArtifactStore, MarketImageVariant}; use crate::auth::{AuthenticatedIdentity, IdentityVerifier}; use crate::config::SkinMarketConfig; use crate::db::Database; @@ -54,6 +54,12 @@ struct ListingQuery { limit: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImageVariantQuery { + variant: Option, +} + #[derive(Debug, Deserialize)] struct SubmissionQuery { status: Option, @@ -354,6 +360,7 @@ async fn get_preview( State(state): State>, headers: HeaderMap, Path(sha256): Path, + Query(query): Query, ) -> SkinMarketResult { validate_sha256(&sha256)?; let public_references: i64 = sqlx::query_scalar( @@ -390,8 +397,31 @@ async fn get_preview( )); } } - let bytes = state.artifacts.read_preview(&sha256).await?; + let variant = match query.variant.as_deref() { + None => None, + Some("compact-v1") => Some(MarketImageVariant::CompactV1), + Some("large-v1") => Some(MarketImageVariant::LargeV1), + Some(_) => { + return Err(SkinMarketError::bad_request( + "invalid_image_variant", + "Image variant must be compact-v1 or large-v1.", + )) + } + }; + let bytes = match variant { + Some(variant) => { + state + .artifacts + .read_preview_variant(&sha256, variant) + .await? + } + None => state.artifacts.read_preview(&sha256).await?, + }; let content_length = bytes.len(); + let etag = match variant { + Some(variant) => format!("\"{}-{}\"", sha256, variant.cache_key()), + None => format!("\"{sha256}\""), + }; let mut response = Response::new(Body::from(bytes)); response .headers_mut() @@ -406,7 +436,7 @@ async fn get_preview( ); response.headers_mut().insert( header::ETAG, - HeaderValue::from_str(&format!("\"{sha256}\"")).map_err(SkinMarketError::internal)?, + HeaderValue::from_str(&etag).map_err(SkinMarketError::internal)?, ); response.headers_mut().insert( header::CONTENT_LENGTH, diff --git a/src/miniapp-market-web/README.md b/src/miniapp-market-web/README.md index 21848437d..139f1c50b 100644 --- a/src/miniapp-market-web/README.md +++ b/src/miniapp-market-web/README.md @@ -85,6 +85,12 @@ BitFun 桌面端内嵌的原生市场 Scene 不在这里。它位于 的 `validate_screenshot`:1–5 张,PNG/JPEG/WebP,单张 ≤ 5 MiB,单边 ≤ 16384px 且总像素 ≤ 40MP。它**不校验宽高比**——16:9 是显示契约,不是上传门槛。 +市场图片端点保留无 query 的规范化原图,同时支持两个有版本号的 WebP 变体: +`?variant=compact-v1`(最大边 640px)和 `?variant=large-v1`(最大边 1280px)。 +列表卡片应使用 compact,详情页通过 `srcset` 在两者间选择;变体首次访问时由后端 +生成并落盘,之后按 immutable 内容缓存。不要去掉版本号或给端点传任意尺寸,否则 +会破坏缓存边界并放大服务端图片处理成本。 + ## 本地开发 首次开发先在仓库根目录安装依赖: diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index 17ef754a8..4021fb1fc 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -34,6 +34,7 @@ import { downloadUrl, loginUrl, marketApi, MarketApiError } from './api'; import { formatCompactNumber, formatMarketDate } from './format'; import { useLocale, type Locale, type MessageKey } from './i18n'; import { MiniAppIcon } from './MiniAppIcon'; +import { marketImageSrcSet, marketImageUrl, retryOriginalMarketImage } from './marketImages'; import { useTheme, type Theme } from './theme'; import type { AdminSubmissionDetail, @@ -596,7 +597,13 @@ function AppCard({ >
{app.screenshotUrls[0] ? ( - {localized.name} + {localized.name} retryOriginalMarketImage(event.currentTarget, app.screenshotUrls[0])} + /> ) : ( @@ -808,8 +815,13 @@ function DetailPage({ {app.screenshotUrls.map((url, index) => ( {`${localized.name} retryOriginalMarketImage(event.currentTarget, url)} /> ))}
@@ -1330,7 +1342,15 @@ function AdminPage({
{selected.submission.screenshotUrls.map((url, index) => (
- {`${t('submissionScreenshot')} + {`${t('submissionScreenshot')} retryOriginalMarketImage(event.currentTarget, url)} + />
#{index + 1} {selected.screenshotHashes[index]} diff --git a/src/miniapp-market-web/src/marketImages.test.ts b/src/miniapp-market-web/src/marketImages.test.ts new file mode 100644 index 000000000..4e3128a45 --- /dev/null +++ b/src/miniapp-market-web/src/marketImages.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { marketImageSrcSet, marketImageUrl, retryOriginalMarketImage } from './marketImages'; + +describe('market image URLs', () => { + it('adds a versioned image variant without losing existing URL state', () => { + expect(marketImageUrl('/miniapp/api/v1/screenshots/hash?token=one#focus', 'compact-v1')) + .toBe('/miniapp/api/v1/screenshots/hash?token=one&variant=compact-v1#focus'); + }); + + it('replaces an existing variant and exposes responsive candidates', () => { + const source = 'https://market.openbitfun.com/image?variant=old'; + expect(marketImageUrl(source, 'large-v1')) + .toBe('https://market.openbitfun.com/image?variant=large-v1'); + expect(marketImageSrcSet(source)).toContain('variant=compact-v1 640w'); + expect(marketImageSrcSet(source)).toContain('variant=large-v1 1280w'); + }); + + it('retries the original URL only once after an optimized image fails', () => { + const image = { + dataset: {} as Record, + removeAttribute: vi.fn(), + src: 'optimized', + } as unknown as HTMLImageElement; + expect(retryOriginalMarketImage(image, '/original.webp')).toBe(true); + expect(image.src).toBe('/original.webp'); + expect(retryOriginalMarketImage(image, '/original.webp')).toBe(false); + }); +}); diff --git a/src/miniapp-market-web/src/marketImages.ts b/src/miniapp-market-web/src/marketImages.ts new file mode 100644 index 000000000..af538b331 --- /dev/null +++ b/src/miniapp-market-web/src/marketImages.ts @@ -0,0 +1,30 @@ +export type MarketImageVariant = 'compact-v1' | 'large-v1'; + +export function marketImageUrl(source: string, variant: MarketImageVariant): string { + if (!source) return source; + + const hashIndex = source.indexOf('#'); + const hash = hashIndex >= 0 ? source.slice(hashIndex) : ''; + const withoutHash = hashIndex >= 0 ? source.slice(0, hashIndex) : source; + const queryIndex = withoutHash.indexOf('?'); + const path = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash; + const params = new URLSearchParams(queryIndex >= 0 ? withoutHash.slice(queryIndex + 1) : ''); + params.set('variant', variant); + return `${path}?${params.toString()}${hash}`; +} + +export function marketImageSrcSet(source: string): string { + return [ + `${marketImageUrl(source, 'compact-v1')} 640w`, + `${marketImageUrl(source, 'large-v1')} 1280w`, + ].join(', '); +} + +export function retryOriginalMarketImage(image: HTMLImageElement, source: string): boolean { + if (!source || image.dataset.marketImageOriginal === source) return false; + image.dataset.marketImageOriginal = source; + image.removeAttribute('srcset'); + image.removeAttribute('sizes'); + image.src = source; + return true; +} diff --git a/src/skin-market-web/README.md b/src/skin-market-web/README.md index 4b6470dc9..753600f06 100644 --- a/src/skin-market-web/README.md +++ b/src/skin-market-web/README.md @@ -15,6 +15,13 @@ broker. Skin writes use the `/skin`-scoped CSRF alias issued by that broker. Local development proxies `/miniapp/api` to `127.0.0.1:9710`; set `MINIAPP_MARKET_DEV_API` when the broker runs elsewhere. +Preview endpoints retain the normalized original when no query is present and +offer bounded, versioned WebP variants: `?variant=compact-v1` (640px maximum +edge) and `?variant=large-v1` (1280px maximum edge). Catalog and embedded-client +cards use compact previews; larger web/detail surfaces use responsive candidates. +The server generates variants lazily, stores them beside the content-addressed +original, and serves public variants with immutable cache headers. + ```bash pnpm --dir src/skin-market-web dev pnpm --dir src/skin-market-web type-check diff --git a/src/skin-market-web/src/AdminPage.tsx b/src/skin-market-web/src/AdminPage.tsx index 1da115297..396448fb8 100644 --- a/src/skin-market-web/src/AdminPage.tsx +++ b/src/skin-market-web/src/AdminPage.tsx @@ -4,6 +4,7 @@ import { sharedMarketLoginUrl } from './account'; import { skinMarketApi } from './api'; import { formatMarketDate } from './format'; import type { Locale, Translate } from './i18n'; +import { marketImageUrl, retryOriginalMarketImage } from './marketImages'; import type { AppearanceAdminSubmissionDetail, AppearanceSubmission, @@ -161,7 +162,15 @@ export function AdminPage({ account, accountResolved, locale, t }: AdminPageProp <>

{detail.submission.name || detail.submission.slug}

{detail.submission.description}

- {detail.submission.previewUrl && } + {detail.submission.previewUrl && ( + retryOriginalMarketImage(event.currentTarget, detail.submission.previewUrl!)} + /> + )}
{t('packageIdentity')}
{detail.submission.packageId || t('notDeclared')}
diff --git a/src/skin-market-web/src/CatalogPage.tsx b/src/skin-market-web/src/CatalogPage.tsx index be9676fac..beec22a37 100644 --- a/src/skin-market-web/src/CatalogPage.tsx +++ b/src/skin-market-web/src/CatalogPage.tsx @@ -284,6 +284,7 @@ function AppearanceRow({ eager, item, locale, onNavigate, t }: AppearanceRowProp alt={t('previewAlt', { name: item.name })} name={item.name} eager={eager} + sizes="(max-width: 800px) calc(100vw - 32px), (max-width: 1180px) calc(100vw - 64px), 720px" t={t} /> diff --git a/src/skin-market-web/src/DetailPage.tsx b/src/skin-market-web/src/DetailPage.tsx index 203f4b95b..45be32356 100644 --- a/src/skin-market-web/src/DetailPage.tsx +++ b/src/skin-market-web/src/DetailPage.tsx @@ -165,6 +165,8 @@ export function DetailPage({ catalogSearch, isAdmin, locale, onNavigate, slug, t alt={t('previewAlt', { name: detail.name })} name={detail.name} eager + sizes="(max-width: 800px) calc(100vw - 32px), (max-width: 1180px) calc(100vw - 64px), 720px" + variant="large-v1" t={t} />
diff --git a/src/skin-market-web/src/PosterImage.tsx b/src/skin-market-web/src/PosterImage.tsx index 3b2bc72d5..c596b3481 100644 --- a/src/skin-market-web/src/PosterImage.tsx +++ b/src/skin-market-web/src/PosterImage.tsx @@ -1,6 +1,12 @@ import { useEffect, useState } from 'react'; import { ImageBroken } from '@phosphor-icons/react'; import type { Translate } from './i18n'; +import { + marketImageSrcSet, + marketImageUrl, + retryOriginalMarketImage, + type MarketImageVariant, +} from './marketImages'; interface PosterImageProps { alt: string; @@ -8,9 +14,19 @@ interface PosterImageProps { src: string; t: Translate; eager?: boolean; + sizes?: string; + variant?: MarketImageVariant; } -export function PosterImage({ alt, name, src, t, eager = false }: PosterImageProps) { +export function PosterImage({ + alt, + name, + src, + t, + eager = false, + sizes, + variant = 'compact-v1', +}: PosterImageProps) { const [failed, setFailed] = useState(false); useEffect(() => setFailed(false), [src]); @@ -32,12 +48,16 @@ export function PosterImage({ alt, name, src, t, eager = false }: PosterImagePro return ( {alt} setFailed(true)} + onError={(event) => { + if (!retryOriginalMarketImage(event.currentTarget, src)) setFailed(true); + }} /> ); } diff --git a/src/skin-market-web/src/SubmissionsPage.tsx b/src/skin-market-web/src/SubmissionsPage.tsx index ffc8d5c02..d5c562774 100644 --- a/src/skin-market-web/src/SubmissionsPage.tsx +++ b/src/skin-market-web/src/SubmissionsPage.tsx @@ -4,6 +4,7 @@ import { sharedMarketLoginUrl } from './account'; import { skinMarketApi } from './api'; import { formatMarketDate } from './format'; import type { Locale, Translate } from './i18n'; +import { marketImageUrl, retryOriginalMarketImage } from './marketImages'; import type { AppearancePublicationStatus, AppearanceSubmission, @@ -137,7 +138,15 @@ export function SubmissionsPage({ account, accountResolved, locale, t }: Submiss
{submission.previewUrl - ? + ? ( + retryOriginalMarketImage(event.currentTarget, submission.previewUrl!)} + /> + ) : }
diff --git a/src/skin-market-web/src/marketImages.test.ts b/src/skin-market-web/src/marketImages.test.ts new file mode 100644 index 000000000..831962f28 --- /dev/null +++ b/src/skin-market-web/src/marketImages.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { marketImageSrcSet, marketImageUrl, retryOriginalMarketImage } from './marketImages'; + +describe('market image URLs', () => { + it('adds a versioned image variant without losing existing URL state', () => { + expect(marketImageUrl('/skin/api/v1/artifacts/previews/hash?token=one#focus', 'compact-v1')) + .toBe('/skin/api/v1/artifacts/previews/hash?token=one&variant=compact-v1#focus'); + }); + + it('replaces an existing variant and exposes responsive candidates', () => { + const source = 'https://market.openbitfun.com/image?variant=old'; + expect(marketImageUrl(source, 'large-v1')) + .toBe('https://market.openbitfun.com/image?variant=large-v1'); + expect(marketImageSrcSet(source)).toContain('variant=compact-v1 640w'); + expect(marketImageSrcSet(source)).toContain('variant=large-v1 1280w'); + }); + + it('retries the original URL only once after an optimized image fails', () => { + const image = { + dataset: {} as Record, + removeAttribute: vi.fn(), + src: 'optimized', + } as unknown as HTMLImageElement; + expect(retryOriginalMarketImage(image, '/original.webp')).toBe(true); + expect(image.src).toBe('/original.webp'); + expect(retryOriginalMarketImage(image, '/original.webp')).toBe(false); + }); +}); diff --git a/src/skin-market-web/src/marketImages.ts b/src/skin-market-web/src/marketImages.ts new file mode 100644 index 000000000..af538b331 --- /dev/null +++ b/src/skin-market-web/src/marketImages.ts @@ -0,0 +1,30 @@ +export type MarketImageVariant = 'compact-v1' | 'large-v1'; + +export function marketImageUrl(source: string, variant: MarketImageVariant): string { + if (!source) return source; + + const hashIndex = source.indexOf('#'); + const hash = hashIndex >= 0 ? source.slice(hashIndex) : ''; + const withoutHash = hashIndex >= 0 ? source.slice(0, hashIndex) : source; + const queryIndex = withoutHash.indexOf('?'); + const path = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash; + const params = new URLSearchParams(queryIndex >= 0 ? withoutHash.slice(queryIndex + 1) : ''); + params.set('variant', variant); + return `${path}?${params.toString()}${hash}`; +} + +export function marketImageSrcSet(source: string): string { + return [ + `${marketImageUrl(source, 'compact-v1')} 640w`, + `${marketImageUrl(source, 'large-v1')} 1280w`, + ].join(', '); +} + +export function retryOriginalMarketImage(image: HTMLImageElement, source: string): boolean { + if (!source || image.dataset.marketImageOriginal === source) return false; + image.dataset.marketImageOriginal = source; + image.removeAttribute('srcset'); + image.removeAttribute('sizes'); + image.src = source; + return true; +} diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx index 1b7b53210..5e4db25da 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx @@ -45,6 +45,7 @@ import { type MarketListingSummary, type MarketSort, } from '@/infrastructure/api/service-api/MiniAppMarketAPI'; +import { marketImageUrl, retryOriginalMarketImage } from '@/infrastructure/api/service-api/MarketImage'; import { MarketAccountControls } from '@/features/market-account'; import { useMarketAccount } from '@/infrastructure/market-account'; import { createLogger } from '@/shared/utils/logger'; @@ -360,7 +361,13 @@ const MiniAppMarketView: React.FC = () => { >
{item.screenshotUrls[0] ? ( - + retryOriginalMarketImage(event.currentTarget, item.screenshotUrls[0]!)} + /> ) : ( { > {detail.screenshotUrls.length ? (
- {detail.screenshotUrls.map((url) => )} + {detail.screenshotUrls.map((url, index) => ( + retryOriginalMarketImage(event.currentTarget, url)} + /> + ))}
) : null} {workspaceUnsupported ? ( diff --git a/src/web-ui/src/infrastructure/api/service-api/MarketImage.test.ts b/src/web-ui/src/infrastructure/api/service-api/MarketImage.test.ts new file mode 100644 index 000000000..2a82e7d3f --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/MarketImage.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { marketImageSrcSet, marketImageUrl, retryOriginalMarketImage } from './MarketImage'; + +describe('market image URLs', () => { + it('adds a versioned image variant without losing existing URL state', () => { + expect(marketImageUrl('/market/image?token=one#focus', 'compact-v1')) + .toBe('/market/image?token=one&variant=compact-v1#focus'); + }); + + it('replaces an existing variant and exposes responsive candidates', () => { + const source = 'https://market.openbitfun.com/image?variant=old'; + expect(marketImageUrl(source, 'large-v1')) + .toBe('https://market.openbitfun.com/image?variant=large-v1'); + expect(marketImageSrcSet(source)).toContain('variant=compact-v1 640w'); + expect(marketImageSrcSet(source)).toContain('variant=large-v1 1280w'); + }); + + it('retries the original URL only once after an optimized image fails', () => { + const image = { + dataset: {} as Record, + removeAttribute: vi.fn(), + src: 'optimized', + } as unknown as HTMLImageElement; + expect(retryOriginalMarketImage(image, '/original.webp')).toBe(true); + expect(image.src).toBe('/original.webp'); + expect(retryOriginalMarketImage(image, '/original.webp')).toBe(false); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/MarketImage.ts b/src/web-ui/src/infrastructure/api/service-api/MarketImage.ts new file mode 100644 index 000000000..af538b331 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/MarketImage.ts @@ -0,0 +1,30 @@ +export type MarketImageVariant = 'compact-v1' | 'large-v1'; + +export function marketImageUrl(source: string, variant: MarketImageVariant): string { + if (!source) return source; + + const hashIndex = source.indexOf('#'); + const hash = hashIndex >= 0 ? source.slice(hashIndex) : ''; + const withoutHash = hashIndex >= 0 ? source.slice(0, hashIndex) : source; + const queryIndex = withoutHash.indexOf('?'); + const path = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash; + const params = new URLSearchParams(queryIndex >= 0 ? withoutHash.slice(queryIndex + 1) : ''); + params.set('variant', variant); + return `${path}?${params.toString()}${hash}`; +} + +export function marketImageSrcSet(source: string): string { + return [ + `${marketImageUrl(source, 'compact-v1')} 640w`, + `${marketImageUrl(source, 'large-v1')} 1280w`, + ].join(', '); +} + +export function retryOriginalMarketImage(image: HTMLImageElement, source: string): boolean { + if (!source || image.dataset.marketImageOriginal === source) return false; + image.dataset.marketImageOriginal = source; + image.removeAttribute('srcset'); + image.removeAttribute('sizes'); + image.src = source; + return true; +} diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx index c9a7337ba..8141960ce 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx @@ -21,6 +21,11 @@ import { type AppearanceMarketRelease, type AppearanceMarketSort, } from '@/infrastructure/api/service-api/AppearanceMarketAPI'; +import { + marketImageSrcSet, + marketImageUrl, + retryOriginalMarketImage, +} from '@/infrastructure/api/service-api/MarketImage'; import { getAppearancePackageValidationError, useAppearance, @@ -288,7 +293,16 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial data-bf-part="marketDetailPreview" > {detail.previewUrl - ? {detail.name} + ? ( + {detail.name} retryOriginalMarketImage(event.currentTarget, detail.previewUrl)} + /> + ) : }
{item.previewUrl - ? + ? ( + retryOriginalMarketImage(event.currentTarget, item.previewUrl)} + /> + ) : } {t(`package.market.mode.${item.mode}`)}
diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx index 2a535c779..dbd33fcdb 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx @@ -6,6 +6,7 @@ import { type AppearanceAdminSubmissionDetail, type AppearanceMarketSubmission, } from '@/infrastructure/api/service-api/AppearanceMarketAPI'; +import { marketImageUrl, retryOriginalMarketImage } from '@/infrastructure/api/service-api/MarketImage'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { isTauriRuntime } from '@/infrastructure/runtime'; import { notificationService } from '@/shared/notification-system'; @@ -411,7 +412,15 @@ export function AppearanceMarketWorkflows({ workflow }: AppearanceMarketWorkflow >
{submission.previewUrl - ? + ? ( + retryOriginalMarketImage(event.currentTarget, submission.previewUrl!)} + /> + ) : }
@@ -515,7 +524,13 @@ export function AppearanceMarketWorkflows({ workflow }: AppearanceMarketWorkflow

{reviewDetail.submission.description}

{reviewDetail.submission.previewUrl && ( - + retryOriginalMarketImage(event.currentTarget, reviewDetail.submission.previewUrl!)} + /> )}