Compare commits

..

2 Commits

Author SHA1 Message Date
kdletters 3478854385 补齐画布合并后2MiB校验
在生成完成态合并与元数据迁移后重新校验最终布局大小

新增合并结果超过2MiB时拒绝保存的边界测试
2026-07-19 19:21:51 +08:00
kdletters d0278f0370 热修图片画布布局上限至2MiB
将 API 与 SpacetimeDB 的画布布局校验上限统一提高到 2 MiB
为站内与外部画布保存接口增加 4 MiB 请求体上限
补充 2 MiB 边界通过和超限拒绝测试
2026-07-19 17:54:09 +08:00
4 changed files with 111 additions and 14 deletions
@@ -96,7 +96,8 @@ pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "proj-";
pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
const EDITOR_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
pub(crate) const EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024;
const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project";
@@ -8314,6 +8315,24 @@ mod tests {
);
}
#[test]
fn editor_layout_serialization_accepts_payload_up_to_two_mib() {
let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 2));
let serialized = serialize_editor_layers(payload).expect("two MiB layout should save");
assert_eq!(serialized.len(), EDITOR_LAYOUT_MAX_BYTES);
}
#[test]
fn editor_layout_serialization_rejects_payload_over_two_mib() {
let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 1));
let error = serialize_editor_layers(payload).expect_err("oversized layout should fail");
assert!(error.body_text().contains("图层布局过大"));
}
#[test]
fn editor_asset_metadata_serialization_rejects_nested_inline_media() {
let error = serialize_editor_asset_metadata(Some(json!({
@@ -1,6 +1,7 @@
use axum::{
Router,
extract::DefaultBodyLimit,
handler::Handler,
middleware,
routing::{get, patch, post},
};
@@ -13,12 +14,12 @@ use crate::{
stream_editor_agent_message,
},
editor_project::{
create_editor_asset, create_editor_asset_folder, create_editor_project,
create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder,
delete_editor_project, edit_editor_image, extract_editor_ui_design_assets,
generate_editor_icon_spritesheet, generate_editor_image, get_editor_asset_library,
get_editor_generation_pricing, get_editor_project, list_editor_projects,
list_public_editor_project_resources, load_recent_editor_project,
EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, create_editor_asset, create_editor_asset_folder,
create_editor_project, create_editor_project_resource, delete_editor_asset,
delete_editor_asset_folder, delete_editor_project, edit_editor_image,
extract_editor_ui_design_assets, generate_editor_icon_spritesheet, generate_editor_image,
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
split_editor_icon_spritesheet, submit_editor_asset_showcase,
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
@@ -58,7 +59,10 @@ pub fn router(state: AppState) -> Router<AppState> {
.route(
"/api/editor/projects/{project_id}",
get(get_editor_project)
.patch(save_editor_project_layout)
.patch(
save_editor_project_layout
.layer(DefaultBodyLimit::max(EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES)),
)
.delete(delete_editor_project)
.route_layer(middleware::from_fn_with_state(
state.clone(),
@@ -1,9 +1,12 @@
use axum::{
Router, middleware,
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, patch, post},
};
use crate::{
editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
external_api_auth::require_external_api_key,
external_assets_api::{
confirm_external_asset_object, create_external_direct_upload_ticket,
@@ -84,10 +87,12 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/external/v1/editor/projects/{project_id}/canvas",
patch(save_external_editor_canvas).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
patch(save_external_editor_canvas)
.layer(DefaultBodyLimit::max(EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/{project_id}/resources",
@@ -7,7 +7,7 @@ const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project";
const EDITOR_ASSET_DEFAULT_FOLDER_LABEL: &str = "项目素材";
const EDITOR_PROJECT_MAX_TITLE_CHARS: usize = 80;
const EDITOR_ASSET_MAX_LABEL_CHARS: usize = 80;
const EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES: usize = 256 * 1024;
const EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES: usize = 2 * 1024 * 1024;
const EDITOR_PROJECT_SOURCE_TYPES: [&str; 3] = ["uploaded", "generated", "mock_generated"];
const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending";
const EDITOR_SHOWCASE_STATUS_APPROVED: &str = "approved";
@@ -1549,6 +1549,7 @@ fn persist_editor_project_layout(
input_layers_json,
now,
)?;
let layers_json = normalize_layout_json(layers_json)?;
ctx.db.editor_canvas().canvas_id().delete(&canvas.canvas_id);
ctx.db.editor_canvas().insert(EditorCanvas {
@@ -4997,6 +4998,74 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn editor_layout_accepts_json_up_to_two_mib() {
let payload = format!(
"\"{}\"",
"x".repeat(EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES - 2)
);
let normalized = normalize_layout_json(payload).expect("two MiB layout should save");
assert_eq!(normalized.len(), EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES);
}
#[test]
fn editor_layout_rejects_json_over_two_mib() {
let payload = format!(
"\"{}\"",
"x".repeat(EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES - 1)
);
let error = normalize_layout_json(payload).expect_err("oversized layout should fail");
assert_eq!(error, "图片画布图层布局过大");
}
#[test]
fn editor_layout_rejects_merged_result_over_two_mib() {
let incoming = json!([{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-1",
"resourceId": "generation-dialog:dialog-1",
"padding": "x".repeat(EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES - 200),
"dialog": {
"id": "dialog-1",
"mode": "generate",
"status": "generating"
}
}]);
let current = json!([
{
"layerId": "layer-generated",
"resourceId": "resource-generated",
"title": "生成结果"
},
{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-1",
"resourceId": "generation-dialog:dialog-1",
"dialog": {
"id": "dialog-1",
"mode": "generate",
"status": "idle",
"generatedLayerId": "layer-generated"
}
}
]);
let merged = merge_completed_canvas_generation_items(
current.to_string().as_str(),
incoming.to_string(),
)
.expect("merge itself should succeed");
assert!(merged.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES);
assert_eq!(
normalize_layout_json(merged).expect_err("final merged layout should fail"),
"图片画布图层布局过大"
);
}
fn public_showcase_asset() -> EditorShowcaseAsset {
let now = Timestamp::from_micros_since_unix_epoch(1_000_000);
EditorShowcaseAsset {