Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/crates/services/miniapp-market-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions src/crates/services/miniapp-market-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,随后丢弃,不能下发给
Expand Down
142 changes: 140 additions & 2 deletions src/crates/services/miniapp-market-service/src/artifacts.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,50 @@
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<Semaphore>,
}

impl ArtifactStore {
pub(crate) async fn open(root: PathBuf) -> anyhow::Result<Self> {
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 {
Expand All @@ -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<PathBuf> {
let path = self.package_path(sha256);
self.put_atomic(&path, bytes).await?;
Expand Down Expand Up @@ -60,6 +99,39 @@ impl ArtifactStore {
})
}

pub(crate) async fn read_screenshot_variant(
&self,
sha256: &str,
variant: MarketImageVariant,
) -> MarketResult<Vec<u8>> {
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(());
Expand Down Expand Up @@ -95,7 +167,11 @@ impl ArtifactStore {
}

pub(crate) async fn remove_screenshot_if_exists(&self, sha256: &str) -> anyhow::Result<bool> {
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(
Expand All @@ -113,6 +189,17 @@ impl ArtifactStore {
}
}

fn render_webp_variant(bytes: Vec<u8>, max_dimension: u32) -> image::ImageResult<Vec<u8>> {
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}"))
Expand Down Expand Up @@ -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<u8> {
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());
}
}
50 changes: 49 additions & 1 deletion src/crates/services/miniapp-market-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -138,6 +139,17 @@ mod tests {
tokio::fs::write(config.web_dir.join("index.html"), "<html></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()
Expand Down Expand Up @@ -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]
Expand Down
43 changes: 41 additions & 2 deletions src/crates/services/miniapp-market-service/src/routes.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::artifacts::ArtifactStore;
use crate::artifacts::{ArtifactStore, MarketImageVariant};
use crate::auth::{
AuthService, CompletedOAuth, DesktopAuthPollRequest, RefreshTokenRequest, RequestAuth,
RequestAuthKind,
Expand Down Expand Up @@ -67,6 +67,12 @@ struct ListingQuery {
limit: Option<u32>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImageVariantQuery {
variant: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OAuthStartQuery {
Expand Down Expand Up @@ -439,11 +445,36 @@ async fn download_release(
async fn get_screenshot(
State(state): State<Arc<MarketState>>,
Path(sha256): Path<String>,
Query(query): Query<ImageVariantQuery>,
) -> MarketResult<Response> {
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()
Expand All @@ -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)
}

Expand Down
3 changes: 3 additions & 0 deletions src/crates/services/skin-market-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading