3D 产物 content type 只认字节或槽位白名单,去掉兜底
- 删除 fallback_extension 与「未知声明原样放行 + 扩展名兜底」的归一逻辑 - 槽位白名单收敛为唯一映射表(content type ↔ 对象键扩展名),跨槽位类型返回 None - resolve_artifact_content_type 改为返回 Result:字节认不出且声明不在白名单时按 502 失败, 不再把 text/plain 之类的声明连同拼出来的 .glb 扩展名落进 OSS 与 asset_object - 错误体只回显截断到 64 字符的声明值,避免把任意长度的 provider 文本带进响应 - 重写 storage 测试:新增白名单外声明、跨槽位类型与字节认不出三类用例 - 同步技术方案:说明白名单范围与「不做任何兜底」的口径
This commit is contained in:
@@ -52,13 +52,36 @@ impl Model3dArtifactSlot {
|
||||
}
|
||||
}
|
||||
|
||||
/// provider 未给出可用 content type 时的兜底扩展名。
|
||||
const fn fallback_extension(self) -> &'static str {
|
||||
/// 本槽位接受的全部 content type。表外的取值一律拒绝:不做归一兜底,也不替
|
||||
/// provider 猜一个类型,因为猜错的类型会连同扩展名一起写进 OSS metadata 与
|
||||
/// `asset_object.content_type`,比失败更难发现。
|
||||
const fn declared_content_types(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Model => "glb",
|
||||
Self::Preview => "webp",
|
||||
Self::Model => &[
|
||||
"model/gltf-binary",
|
||||
"model/gltf+json",
|
||||
"model/fbx",
|
||||
"application/x-fbx",
|
||||
],
|
||||
Self::Preview => &["image/png", "image/jpeg", "image/jpg", "image/webp"],
|
||||
}
|
||||
}
|
||||
|
||||
/// 已知 content type 对应的对象键扩展名;表外返回 `None`。
|
||||
fn artifact_extension(self, content_type: &str) -> Option<&'static str> {
|
||||
if !self.declared_content_types().contains(&content_type) {
|
||||
return None;
|
||||
}
|
||||
Some(match content_type {
|
||||
"model/gltf-binary" => "glb",
|
||||
"model/gltf+json" => "gltf",
|
||||
"model/fbx" | "application/x-fbx" => "fbx",
|
||||
"image/png" => "png",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 已落地的产物:对象键与对象元数据来自 HEAD 复核,不是 PUT 的入参原样回填。
|
||||
@@ -89,13 +112,12 @@ pub(crate) async fn store_model3d_artifact(
|
||||
.oss_client()
|
||||
.ok_or_else(|| oss_unavailable("OSS 未完成环境变量配置,无法写入 3D 产物。"))?;
|
||||
let http_client = state.editor_oss_http_client();
|
||||
let content_type = resolve_artifact_content_type(content_type, slot, bytes.as_slice());
|
||||
let content_type = resolve_artifact_content_type(content_type, slot, bytes.as_slice())?;
|
||||
let sha256 = sha256_hex(bytes.as_slice());
|
||||
let file_name = format!(
|
||||
"{}.{}",
|
||||
slot.file_stem(),
|
||||
artifact_extension(content_type.as_str(), slot)
|
||||
);
|
||||
let extension = slot
|
||||
.artifact_extension(content_type.as_str())
|
||||
.ok_or_else(|| unsupported_artifact_content_type(content_type.as_str(), slot))?;
|
||||
let file_name = format!("{}.{}", slot.file_stem(), extension);
|
||||
let put_result = oss_client
|
||||
.put_object(
|
||||
http_client,
|
||||
@@ -167,20 +189,19 @@ pub(crate) async fn store_model3d_artifact(
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> String {
|
||||
/// provider 声明的 content type 去参数、去空白、转小写后与槽位白名单比对;
|
||||
/// 不在表里(含空值、`application/octet-stream`)返回 `None`。
|
||||
fn declared_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> Option<&'static str> {
|
||||
let normalized = raw
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if normalized.is_empty() || normalized == "application/octet-stream" {
|
||||
return match slot {
|
||||
Model3dArtifactSlot::Model => "model/gltf-binary".to_string(),
|
||||
Model3dArtifactSlot::Preview => "image/webp".to_string(),
|
||||
};
|
||||
}
|
||||
normalized
|
||||
slot.declared_content_types()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|known| *known == normalized)
|
||||
}
|
||||
|
||||
/// glb 的容器魔数(小端 `glTF`);`model/gltf+json` 的判据是首个非空白字节为 `{`。
|
||||
@@ -225,43 +246,52 @@ fn sniff_preview_content_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 产物 content type:两个槽位都先嗅探字节,嗅不出来才信 provider 声明。
|
||||
/// 产物 content type:两个槽位都先嗅探字节,嗅不出来才信 provider 声明;两者都不成立就失败。
|
||||
///
|
||||
/// provider 对二进制产物常给 `application/octet-stream`,归一后会被写成
|
||||
/// `model/gltf-binary` + `.glb`;客户端按这份 content type 判定能不能预览,说错就是
|
||||
/// 拿 GLTFLoader 去解 FBX。预览图同理:provider / CDN 常把 PNG 渲染图标成
|
||||
/// `application/octet-stream` 或干脆不写 `Content-Type`,照抄声明就会把 `image/webp`
|
||||
/// 写进 OSS metadata 与 `asset_object.content_type`,素材库下载命名与后台等非浏览器消费方
|
||||
/// 都会拿到错的类型。浏览器 `<img>` 自己能按魔数嗅探,但那不是让记录值继续说谎的理由。
|
||||
/// provider 对二进制产物常给 `application/octet-stream`,因此「字节优先」是这条链路的前提:
|
||||
/// 客户端按这份 content type 判定能不能预览,说错就是拿 GLTFLoader 去解 FBX。预览图同理:
|
||||
/// provider / CDN 常把 PNG 渲染图标成 `application/octet-stream` 或干脆不写 `Content-Type`,
|
||||
/// 照抄声明就会把 `image/webp` 写进 OSS metadata 与 `asset_object.content_type`,素材库下载
|
||||
/// 命名与后台等非浏览器消费方都会拿到错的类型。浏览器 `<img>` 自己能按魔数嗅探,
|
||||
/// 但那不是让记录值继续说谎的理由。
|
||||
///
|
||||
/// 声明值只在槽位白名单内才被采信:白名单外的取值会让类型与扩展名互相矛盾
|
||||
/// (provider 说 `text/plain`、对象键却只能拼出 `.glb`),并且把 provider 的任意文本
|
||||
/// 原样写进 OSS metadata 与 `asset_object.content_type`。字节认不出、声明也不在白名单
|
||||
/// 时直接失败,让一次生成失败退款,而不是落一条自相矛盾的元数据。
|
||||
///
|
||||
/// 这里不把 worker 的 `preview_dimensions` 猜出的格式传下来:那一步是「必须能解码」的正交校验,
|
||||
/// 与写库时的类型归一各管一件事,重复的只是读文件头这一下,换来判定点只有一个。
|
||||
fn resolve_artifact_content_type(raw: &str, slot: Model3dArtifactSlot, bytes: &[u8]) -> String {
|
||||
match slot {
|
||||
Model3dArtifactSlot::Model => {
|
||||
if let Some(sniffed) = sniff_model_content_type(bytes) {
|
||||
return sniffed.to_string();
|
||||
}
|
||||
}
|
||||
Model3dArtifactSlot::Preview => {
|
||||
if let Some(sniffed) = sniff_preview_content_type(bytes) {
|
||||
return sniffed.to_string();
|
||||
}
|
||||
}
|
||||
fn resolve_artifact_content_type(
|
||||
raw: &str,
|
||||
slot: Model3dArtifactSlot,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, AppError> {
|
||||
let sniffed = match slot {
|
||||
Model3dArtifactSlot::Model => sniff_model_content_type(bytes),
|
||||
Model3dArtifactSlot::Preview => sniff_preview_content_type(bytes),
|
||||
};
|
||||
if let Some(sniffed) = sniffed {
|
||||
return Ok(sniffed.to_string());
|
||||
}
|
||||
normalize_artifact_content_type(raw, slot)
|
||||
declared_artifact_content_type(raw, slot)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| unsupported_artifact_content_type(raw, slot))
|
||||
}
|
||||
|
||||
fn artifact_extension(content_type: &str, slot: Model3dArtifactSlot) -> &'static str {
|
||||
match content_type {
|
||||
"model/gltf-binary" => "glb",
|
||||
"model/gltf+json" => "gltf",
|
||||
"model/fbx" | "application/x-fbx" => "fbx",
|
||||
"image/png" => "png",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
_ => slot.fallback_extension(),
|
||||
}
|
||||
/// 声明类型不在槽位白名单里:不落库,也不猜一个类型继续走。
|
||||
fn unsupported_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> AppError {
|
||||
// 声明值是不可信输入,只回显截断后的一段,避免把任意长度的 provider 文本带进错误体。
|
||||
let declared: String = raw.trim().chars().take(64).collect();
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": TRIPO_PROVIDER,
|
||||
"reason": "model3d-artifact-content-type-unsupported",
|
||||
"message": format!(
|
||||
"provider 返回的 {} 产物类型不可识别,且字节特征与任何受支持格式都不匹配。",
|
||||
slot.file_stem()
|
||||
),
|
||||
"declaredContentType": declared,
|
||||
}))
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
@@ -282,19 +312,35 @@ mod tests {
|
||||
const JPEG_BYTES: &[u8] = b"\xFF\xD8\xFF\xE0rest-of-jpeg";
|
||||
const WEBP_BYTES: &[u8] = b"RIFF\x24\x00\x00\x00WEBPVP8 rest-of-webp";
|
||||
|
||||
/// 期望可归一的用例:失败即 panic,并把真实错误带出来。
|
||||
fn resolve(declared: &str, slot: Model3dArtifactSlot, bytes: &[u8]) -> String {
|
||||
resolve_artifact_content_type(declared, slot, bytes)
|
||||
.unwrap_or_else(|error| panic!("声明 {declared} 的产物应当可归一:{error}"))
|
||||
}
|
||||
|
||||
/// 期望被拒的用例:断言按「上游内容不合法」失败。
|
||||
fn resolve_rejected(declared: &str, slot: Model3dArtifactSlot, bytes: &[u8]) {
|
||||
let error = resolve_artifact_content_type(declared, slot, bytes)
|
||||
.expect_err(&format!("声明 {declared} 的产物必须被拒绝"));
|
||||
assert_eq!(
|
||||
error.status_code(),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"声明 {declared} 的产物未通过识别时应按上游内容不合法失败"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_content_type_follows_bytes_over_declaration() {
|
||||
// provider 对二进制产物常给 application/octet-stream。归一后是 model/gltf-binary,
|
||||
// 一个 FBX 结果就会以 .glb 落库,客户端按它判定预览必然解析失败。
|
||||
let content_type = resolve_artifact_content_type(
|
||||
// provider 对二进制产物常给 application/octet-stream,照抄声明会让 FBX 以 .glb 落库。
|
||||
let content_type = resolve(
|
||||
"application/octet-stream",
|
||||
Model3dArtifactSlot::Model,
|
||||
FBX_BYTES,
|
||||
);
|
||||
assert_eq!(content_type, "model/fbx");
|
||||
assert_eq!(
|
||||
artifact_extension(content_type.as_str(), Model3dArtifactSlot::Model),
|
||||
"fbx"
|
||||
Model3dArtifactSlot::Model.artifact_extension(content_type.as_str()),
|
||||
Some("fbx")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -303,11 +349,7 @@ mod tests {
|
||||
// 声明说 glb、字节是 fbx 时以字节为准:这份 content type 会进 asset_object,
|
||||
// 前端拿它决定禁用 3D 预览与否,不能让它说错。
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(
|
||||
"model/gltf-binary",
|
||||
Model3dArtifactSlot::Model,
|
||||
FBX_BYTES,
|
||||
),
|
||||
resolve("model/gltf-binary", Model3dArtifactSlot::Model, FBX_BYTES),
|
||||
"model/fbx"
|
||||
);
|
||||
}
|
||||
@@ -315,49 +357,51 @@ mod tests {
|
||||
#[test]
|
||||
fn model_content_type_recognizes_each_supported_container() {
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(
|
||||
resolve(
|
||||
"application/octet-stream",
|
||||
Model3dArtifactSlot::Model,
|
||||
GLB_BYTES,
|
||||
GLB_BYTES
|
||||
),
|
||||
"model/gltf-binary"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(
|
||||
resolve(
|
||||
"application/octet-stream",
|
||||
Model3dArtifactSlot::Model,
|
||||
GLTF_JSON_BYTES,
|
||||
GLTF_JSON_BYTES
|
||||
),
|
||||
"model/gltf+json"
|
||||
);
|
||||
assert_eq!(
|
||||
artifact_extension("model/gltf+json", Model3dArtifactSlot::Model),
|
||||
"gltf"
|
||||
Model3dArtifactSlot::Model.artifact_extension("model/gltf+json"),
|
||||
Some("gltf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_content_type_keeps_declaration_when_bytes_are_unknown() {
|
||||
// 嗅不出来时保持既有行为:声明可用就信声明,否则回落 glb。
|
||||
fn model_content_type_rejects_declarations_outside_the_allowlist() {
|
||||
// 字节认不出、声明又不在白名单时不再兜底成 .glb:放行 text/html 这类类型
|
||||
// 会落成「错的 content type + 拼出来的扩展名」,比直接失败更难发现。
|
||||
for declared in [
|
||||
"application/octet-stream",
|
||||
"text/html; charset=utf-8",
|
||||
"text/plain",
|
||||
"",
|
||||
] {
|
||||
resolve_rejected(declared, Model3dArtifactSlot::Model, UNKNOWN_BYTES);
|
||||
}
|
||||
// 白名单内的声明在字节认不出时仍被采信,且扩展名与它一致。
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(
|
||||
"application/octet-stream",
|
||||
Model3dArtifactSlot::Model,
|
||||
UNKNOWN_BYTES,
|
||||
),
|
||||
"model/gltf-binary"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(
|
||||
resolve(
|
||||
"model/gltf-binary",
|
||||
Model3dArtifactSlot::Model,
|
||||
UNKNOWN_BYTES,
|
||||
UNKNOWN_BYTES
|
||||
),
|
||||
"model/gltf-binary"
|
||||
);
|
||||
assert_eq!(
|
||||
artifact_extension("binary/octet-stream", Model3dArtifactSlot::Model),
|
||||
"glb"
|
||||
Model3dArtifactSlot::Model.artifact_extension("binary/octet-stream"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
@@ -373,57 +417,59 @@ mod tests {
|
||||
(WEBP_BYTES, "application/octet-stream", "image/webp"),
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(declared, Model3dArtifactSlot::Preview, bytes),
|
||||
resolve(declared, Model3dArtifactSlot::Preview, bytes),
|
||||
expected,
|
||||
"声明 {declared} 的预览图必须按字节判定"
|
||||
);
|
||||
}
|
||||
// 类型与对象键扩展名同源于这一次判定:PNG 预览落库是 preview.png。
|
||||
assert_eq!(
|
||||
artifact_extension("image/png", Model3dArtifactSlot::Preview),
|
||||
"png"
|
||||
Model3dArtifactSlot::Preview.artifact_extension("image/png"),
|
||||
Some("png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_content_type_keeps_declaration_when_bytes_are_unknown() {
|
||||
// 嗅不出图片格式时保持既有行为:声明可用就信声明,否则回落 webp。
|
||||
fn preview_content_type_keeps_only_allowlisted_declarations() {
|
||||
// 带参数的声明先去掉参数再比对。
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(
|
||||
resolve(
|
||||
"image/jpeg; charset=binary",
|
||||
Model3dArtifactSlot::Preview,
|
||||
UNKNOWN_BYTES,
|
||||
UNKNOWN_BYTES
|
||||
),
|
||||
"image/jpeg"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(
|
||||
"application/octet-stream",
|
||||
Model3dArtifactSlot::Preview,
|
||||
UNKNOWN_BYTES,
|
||||
),
|
||||
"image/webp"
|
||||
// octet-stream、模型字节与白名单外的图片格式都不构成可用声明:
|
||||
// 预览槽位同样不做「猜一个 webp」的兜底。
|
||||
resolve_rejected(
|
||||
"application/octet-stream",
|
||||
Model3dArtifactSlot::Preview,
|
||||
UNKNOWN_BYTES,
|
||||
);
|
||||
// 模型字节不是预览图格式,不参与预览判定,仍走图片归一。
|
||||
assert_eq!(
|
||||
resolve_artifact_content_type(
|
||||
"application/octet-stream",
|
||||
Model3dArtifactSlot::Preview,
|
||||
GLB_BYTES,
|
||||
),
|
||||
"image/webp"
|
||||
resolve_rejected(
|
||||
"application/octet-stream",
|
||||
Model3dArtifactSlot::Preview,
|
||||
GLB_BYTES,
|
||||
);
|
||||
resolve_rejected("image/avif", Model3dArtifactSlot::Preview, UNKNOWN_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_extension_maps_known_fbx_spellings() {
|
||||
fn artifact_extension_rejects_cross_slot_types() {
|
||||
assert_eq!(
|
||||
artifact_extension("application/x-fbx", Model3dArtifactSlot::Model),
|
||||
"fbx"
|
||||
Model3dArtifactSlot::Model.artifact_extension("application/x-fbx"),
|
||||
Some("fbx")
|
||||
);
|
||||
assert_eq!(
|
||||
artifact_extension("image/png", Model3dArtifactSlot::Preview),
|
||||
"png"
|
||||
Model3dArtifactSlot::Model.artifact_extension("image/png"),
|
||||
None,
|
||||
"预览图类型不能落进模型槽位"
|
||||
);
|
||||
assert_eq!(
|
||||
Model3dArtifactSlot::Preview.artifact_extension("model/fbx"),
|
||||
None,
|
||||
"模型类型不能落进预览槽位"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::model3d::common::Model3dGenerationSource;
|
||||
/// image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。
|
||||
///
|
||||
/// 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` /
|
||||
/// `assetFolderId` / `assetLabel`,二选一,不用 tagged enum。
|
||||
/// `assetFolderId` / `assetLabel`,至少给一个,不用 tagged enum。
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[derive(ts_rs::TS)]
|
||||
@@ -16,7 +16,7 @@ use crate::model3d::common::Model3dGenerationSource;
|
||||
pub struct Model3dImageToModelRequest {
|
||||
pub source: Model3dGenerationSource,
|
||||
pub generation: Model3dImageToModelParams,
|
||||
/// 项目资源落点;与 `assetFolderId` 二选一。
|
||||
/// 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub project_id: Option<String>,
|
||||
@@ -24,7 +24,7 @@ pub struct Model3dImageToModelRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
/// 素材库落点;与 `projectId` 二选一。
|
||||
/// 素材库落点;与 `projectId` 至少给一个,两个都给合法。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub asset_folder_id: Option<String>,
|
||||
|
||||
@@ -11,14 +11,15 @@ use crate::editor_canvas::EditorCanvasGenerationCompletionPayload;
|
||||
///
|
||||
/// 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` /
|
||||
/// `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」
|
||||
/// 多拼一层判别结构。服务端按「二选一」校验:两个都给或都不给都拒绝。
|
||||
/// 多拼一层判别结构。服务端按「至少一个落点」校验:两个都不给拒绝,两个都给合法(两条落点都落);
|
||||
/// 没有 `projectId` 时结果只落素材库,与其它画布生成工具一致。
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[derive(ts_rs::TS)]
|
||||
#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)]
|
||||
pub struct Model3dTextToModelRequest {
|
||||
pub generation: Model3dTextToModelParams,
|
||||
/// 项目资源落点;与 `assetFolderId` 二选一。
|
||||
/// 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub project_id: Option<String>,
|
||||
@@ -26,7 +27,7 @@ pub struct Model3dTextToModelRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
/// 素材库落点;与 `projectId` 二选一。
|
||||
/// 素材库落点;与 `projectId` 至少给一个,两个都给合法。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional = nullable)]
|
||||
pub asset_folder_id: Option<String>,
|
||||
|
||||
Reference in New Issue
Block a user