c5afc02e94
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
- scripts/agc-template-library-publish.mjs:readProjectFiles 增加正文门禁,任意层级拒绝 .agent/.git/.svn/node_modules,根目录拒绝 dist/build/library/temp/local/.idea/.vscode - scripts/agc-template-library-publish.test.mjs:新增夹具源目录与门禁用例,覆盖 13 条拒绝路径及 game/dist/**、game/.gitignore 放行 - server-rs/crates/api-server/src/admin_templates.rs:上传归档校验与 CLI 用同一份段名单,新增 template_import_archive_rejects_identity_and_build_directories - docs/【模板规范】AGC模板包组织指南-2026-09-21.md:已知边界改为「已有机器门禁」,并说明同名目录段只在根目录受限 - docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md:CLI 与后台上传的校验口径补门禁,并追加 2026-09-21 证据(九个模板本地重打包摘要与线上清单逐条一致) - docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md:验收标准 2 补充门禁证据 - docs/project-memory/shared-memory/decision-log.md:新增 2026-09-21 正文目录门禁决策记录
1046 lines
40 KiB
Rust
1046 lines
40 KiB
Rust
use std::io::Cursor;
|
||
|
||
use axum::{
|
||
Json,
|
||
extract::{Extension, Multipart, Path, State},
|
||
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
|
||
response::{IntoResponse, Response},
|
||
};
|
||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||
use module_assets::template_library::{
|
||
MAX_TEMPLATE_IMPORT_BATCH, TemplateCover, TemplateDomainError, TemplateEdit, TemplateImport,
|
||
list_templates, prepare_template_edit, prepare_template_import,
|
||
};
|
||
use platform_oss::template_library::{TemplateLibraryStore, TemplateStoreError};
|
||
use serde_json::Value;
|
||
use sha2::{Digest, Sha256};
|
||
use shared_contracts::admin::{
|
||
AdminAgcTemplateCoverInput, AdminAgcTemplateListResponse, AdminAgcTemplatePayload,
|
||
AdminImportAgcTemplateItem, AdminImportAgcTemplateResult, AdminImportAgcTemplatesManifest,
|
||
AdminImportAgcTemplatesResponse, AdminUpdateAgcTemplateRequest,
|
||
};
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||
|
||
use crate::{
|
||
admin::AuthenticatedAdmin, api_response::json_success_body, http_error::AppError,
|
||
request_context::RequestContext, state::AppState,
|
||
};
|
||
|
||
const MAX_COVER_BYTES: usize = 5 * 1024 * 1024;
|
||
const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
|
||
/// 后台上传:单批数量、单包体积与解压后的容量上限(路由层再叠加请求体总上限)。
|
||
const MAX_IMPORT_MANIFEST_BYTES: usize = 256 * 1024;
|
||
const MAX_IMPORT_ZIP_BYTES: usize = 64 * 1024 * 1024;
|
||
const MAX_IMPORT_ARCHIVE_FILES: usize = 4096;
|
||
const MAX_IMPORT_ARCHIVE_FILE_BYTES: u64 = 256 * 1024 * 1024;
|
||
const MAX_IMPORT_ARCHIVE_TOTAL_BYTES: u64 = 512 * 1024 * 1024;
|
||
pub(crate) const AGC_TEMPLATE_IMPORT_BODY_LIMIT_BYTES: usize = 200 * 1024 * 1024;
|
||
const PUBLIC_BASE: &str = "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/";
|
||
|
||
fn fingerprint(bytes: &[u8]) -> String {
|
||
format!("{:x}", Sha256::digest(bytes))
|
||
}
|
||
|
||
fn decode_index(bytes: &[u8]) -> Result<Value, AppError> {
|
||
serde_json::from_slice(bytes).map_err(|_| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("模板库清单格式无效")
|
||
})
|
||
}
|
||
|
||
fn public_url(key: &str) -> String {
|
||
let mut url = reqwest::Url::parse(PUBLIC_BASE).expect("constant OSS URL");
|
||
url.path_segments_mut()
|
||
.expect("HTTPS path")
|
||
.extend(key.split('/'));
|
||
url.to_string()
|
||
}
|
||
|
||
fn snapshot(bytes: &[u8], writable: bool) -> Result<AdminAgcTemplateListResponse, AppError> {
|
||
let entries = list_templates(&decode_index(bytes)?).map_err(map_domain_error)?;
|
||
Ok(AdminAgcTemplateListResponse {
|
||
revision: fingerprint(bytes),
|
||
writable,
|
||
templates: entries
|
||
.into_iter()
|
||
.map(|entry| AdminAgcTemplatePayload {
|
||
id: entry.id,
|
||
title: entry.title,
|
||
summary: entry.summary,
|
||
tags: entry.tags,
|
||
runtime: entry.runtime,
|
||
engine: entry.engine,
|
||
engine_version: entry.engine_version,
|
||
template_version: entry.template_version,
|
||
enabled: entry.enabled,
|
||
cover_url: public_url(&entry.cover_key),
|
||
zip_size_bytes: entry.zip_size_bytes,
|
||
})
|
||
.collect(),
|
||
})
|
||
}
|
||
|
||
fn json_snapshot<T: serde::Serialize>(context: &RequestContext, value: T) -> Response {
|
||
let mut response = json_success_body(Some(context), value).into_response();
|
||
response
|
||
.headers_mut()
|
||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||
response
|
||
}
|
||
|
||
pub async fn admin_list_agc_templates(
|
||
State(state): State<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||
) -> Result<Response, AppError> {
|
||
let store = state.template_library_store();
|
||
let bytes = match store {
|
||
Some(store) => store.read_index().await,
|
||
None => TemplateLibraryStore::read_public_index().await,
|
||
}
|
||
.map_err(map_store_error)?;
|
||
Ok(json_snapshot(&context, snapshot(&bytes, store.is_some())?))
|
||
}
|
||
|
||
struct ValidatedCover {
|
||
bytes: Vec<u8>,
|
||
content_type: &'static str,
|
||
extension: &'static str,
|
||
width: u32,
|
||
height: u32,
|
||
}
|
||
|
||
fn validate_cover(input: AdminAgcTemplateCoverInput) -> Result<ValidatedCover, AppError> {
|
||
let invalid = |message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message);
|
||
if input.data_base64.len() > MAX_COVER_BYTES.div_ceil(3) * 4 {
|
||
return Err(invalid("封面不能超过 5 MiB"));
|
||
}
|
||
let bytes = STANDARD
|
||
.decode(&input.data_base64)
|
||
.map_err(|_| invalid("封面编码无效"))?;
|
||
validate_cover_bytes(&input.content_type, bytes)
|
||
}
|
||
|
||
/// 与编辑路径共用同一套封面上限与图片校验;上传路径直接给原始字节与声明的 content-type。
|
||
fn validate_cover_bytes(
|
||
declared_content_type: &str,
|
||
bytes: Vec<u8>,
|
||
) -> Result<ValidatedCover, AppError> {
|
||
let invalid = |message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message);
|
||
if bytes.is_empty() || bytes.len() > MAX_COVER_BYTES {
|
||
return Err(invalid("封面内容为空或超过 5 MiB"));
|
||
}
|
||
let format = image::guess_format(&bytes).map_err(|_| invalid("封面不是有效图片"))?;
|
||
let (content_type, extension) = match format {
|
||
image::ImageFormat::Png => ("image/png", "png"),
|
||
image::ImageFormat::Jpeg => ("image/jpeg", "jpg"),
|
||
image::ImageFormat::WebP => ("image/webp", "webp"),
|
||
_ => return Err(invalid("封面仅支持 PNG、JPEG 或 WebP")),
|
||
};
|
||
if declared_content_type != content_type {
|
||
return Err(invalid("封面格式与文件内容不一致"));
|
||
}
|
||
let mut reader = image::ImageReader::with_format(Cursor::new(&bytes), format);
|
||
let mut limits = image::Limits::default();
|
||
limits.max_image_width = Some(4096);
|
||
limits.max_image_height = Some(4096);
|
||
limits.max_alloc = Some(64 * 1024 * 1024);
|
||
reader.limits(limits);
|
||
let decoded = reader
|
||
.decode()
|
||
.map_err(|_| invalid("封面损坏或图片尺寸过大"))?;
|
||
let (width, height) = (decoded.width(), decoded.height());
|
||
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > 16_000_000 {
|
||
return Err(invalid("封面最多允许 1600 万像素"));
|
||
}
|
||
Ok(ValidatedCover {
|
||
bytes,
|
||
content_type,
|
||
extension,
|
||
width,
|
||
height,
|
||
})
|
||
}
|
||
|
||
fn document_bytes(value: &Value) -> Result<Vec<u8>, AppError> {
|
||
let mut bytes = serde_json::to_vec_pretty(value)
|
||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
|
||
bytes.push(b'\n');
|
||
if bytes.len() > MAX_DOCUMENT_BYTES {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("模板库元数据超过大小限制")
|
||
);
|
||
}
|
||
Ok(bytes)
|
||
}
|
||
|
||
fn check_revision(bytes: &[u8], expected: &str) -> Result<(), AppError> {
|
||
if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("模板库版本标识无效")
|
||
);
|
||
}
|
||
if fingerprint(bytes) != expected.to_ascii_lowercase() {
|
||
return Err(AppError::from_status(StatusCode::CONFLICT)
|
||
.with_code("TEMPLATE_LIBRARY_CONFLICT")
|
||
.with_message("模板库已更新,请刷新后重新编辑"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn admin_update_agc_template(
|
||
State(state): State<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||
Path(id): Path<String>,
|
||
Json(mut input): Json<AdminUpdateAgcTemplateRequest>,
|
||
) -> Result<Response, AppError> {
|
||
let store = state.template_library_store().cloned().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||
.with_message("模板管理未配置可用的存储凭据,当前仅支持查看")
|
||
})?;
|
||
let cover = match input.cover.take() {
|
||
Some(cover) => Some(
|
||
tokio::task::spawn_blocking(move || validate_cover(cover))
|
||
.await
|
||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))??,
|
||
),
|
||
None => None,
|
||
};
|
||
// 接受后的有限写入由独立任务持有,HTTP 断连不能在清单 PUT 在途时提前解锁。
|
||
let result = tokio::spawn(async move { update_template(store, id, input, cover).await })
|
||
.await
|
||
.map_err(|_| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||
.with_message("模板保存结果需要核对,请刷新列表;若发布锁仍被占用请联系运维")
|
||
})??;
|
||
Ok(json_snapshot(&context, result))
|
||
}
|
||
|
||
async fn update_template(
|
||
store: TemplateLibraryStore,
|
||
id: String,
|
||
input: AdminUpdateAgcTemplateRequest,
|
||
cover: Option<ValidatedCover>,
|
||
) -> Result<AdminAgcTemplateListResponse, AppError> {
|
||
let mut session = store
|
||
.begin_publish(uuid::Uuid::new_v4().to_string())
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
let outcome = async {
|
||
let current = session.read_index().await.map_err(map_store_error)?;
|
||
check_revision(¤t, &input.expected_revision)?;
|
||
let index = decode_index(¤t)?;
|
||
let entry = list_templates(&index)
|
||
.map_err(map_domain_error)?
|
||
.into_iter()
|
||
.find(|entry| entry.id == id)
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::NOT_FOUND).with_message("模板不存在")
|
||
})?;
|
||
let metadata_bytes = session
|
||
.read_object(&entry.metadata_key, MAX_DOCUMENT_BYTES)
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
let metadata = decode_index(&metadata_bytes)?;
|
||
let cover_reference = cover.as_ref().map(|cover| {
|
||
let sha256 = fingerprint(&cover.bytes);
|
||
TemplateCover {
|
||
key: format!(
|
||
"templates/v1/{id}/sha256/{sha256}/cover.{}",
|
||
cover.extension
|
||
),
|
||
sha256,
|
||
width: cover.width,
|
||
height: cover.height,
|
||
}
|
||
});
|
||
let updated_at = OffsetDateTime::now_utc()
|
||
.format(&Rfc3339)
|
||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
|
||
let mut prepared = prepare_template_edit(
|
||
index,
|
||
metadata,
|
||
&id,
|
||
TemplateEdit {
|
||
title: input.title,
|
||
summary: input.summary,
|
||
tags: input.tags,
|
||
enabled: input.enabled,
|
||
},
|
||
cover_reference.clone(),
|
||
&updated_at,
|
||
)
|
||
.map_err(map_domain_error)?;
|
||
let metadata_bytes = document_bytes(&prepared.metadata)?;
|
||
let metadata_key = format!(
|
||
"templates/v1/{id}/sha256/{}/template.json",
|
||
fingerprint(&metadata_bytes)
|
||
);
|
||
prepared
|
||
.set_metadata_key(&id, &metadata_key)
|
||
.map_err(map_domain_error)?;
|
||
let next_index_bytes = document_bytes(&prepared.index)?;
|
||
if let (Some(cover), Some(reference)) = (cover, cover_reference) {
|
||
session
|
||
.put_immutable(&reference.key, cover.bytes, cover.content_type)
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
}
|
||
session
|
||
.put_immutable(&metadata_key, metadata_bytes, "application/json")
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
session
|
||
.commit_index(next_index_bytes.clone())
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
snapshot(&next_index_bytes, true)
|
||
}
|
||
.await;
|
||
let released = session.finish().await.map_err(map_store_error);
|
||
match (outcome, released) {
|
||
(Ok(result), Ok(())) => Ok(result),
|
||
(Err(error), Ok(())) => Err(error),
|
||
(_, Err(error)) => Err(error),
|
||
}
|
||
}
|
||
|
||
fn import_invalid(message: impl Into<String>) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
|
||
}
|
||
|
||
/// 模板包内路径:只允许库内相对路径,拒绝绝对路径、盘符、反斜杠、`..` 与空段。
|
||
fn safe_import_archive_path(value: &str) -> Result<String, String> {
|
||
let trimmed = value.trim_end_matches('/');
|
||
if trimmed.is_empty()
|
||
|| trimmed.len() > 256
|
||
|| trimmed.starts_with(['/', '\\'])
|
||
|| trimmed.contains('\\')
|
||
|| trimmed.contains(':')
|
||
|| trimmed.contains("..")
|
||
|| trimmed
|
||
.chars()
|
||
.any(|value| value.is_control() || value.is_whitespace())
|
||
|| !trimmed
|
||
.split('/')
|
||
.all(|part| !part.is_empty() && part != ".")
|
||
{
|
||
return Err(format!("模板包条目路径无效:{value}"));
|
||
}
|
||
Ok(trimmed.to_string())
|
||
}
|
||
|
||
/// 任何层级都不允许出现的条目段:项目身份(`.agent`)与版本库 / 依赖元数据。
|
||
const IMPORT_FORBIDDEN_SEGMENTS: [&str; 4] = [".agent", ".git", ".svn", "node_modules"];
|
||
/// 只允许出现在模板根目录之外的构建产物与编辑器工作区目录。
|
||
const IMPORT_FORBIDDEN_ROOT_SEGMENTS: [&str; 7] = [
|
||
"dist", "build", "library", "temp", "local", ".idea", ".vscode",
|
||
];
|
||
|
||
/// 模板作者约定(见 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`):这些内容
|
||
/// 进了包就会跟着建到用户项目里,因此发布前失败关闭,而不是靠作者自觉。
|
||
fn import_entry_path_violation(relative: &str) -> Option<String> {
|
||
let segments = relative.split('/').collect::<Vec<_>>();
|
||
for segment in &segments {
|
||
if IMPORT_FORBIDDEN_SEGMENTS.contains(segment) {
|
||
return Some(format!("模板包不允许包含 {segment} 目录"));
|
||
}
|
||
}
|
||
if let Some(first) = segments.first() {
|
||
if IMPORT_FORBIDDEN_ROOT_SEGMENTS.contains(first) {
|
||
return Some(format!("模板包根目录不允许包含 {first} 目录"));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 只做校验、不落盘:确认上传的是合法 zip、条目安全,并包含清单声明的 entry。
|
||
fn validate_import_archive(bytes: &[u8], entry: &str) -> Result<(), String> {
|
||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
|
||
.map_err(|error| format!("模板包不是有效 zip:{error}"))?;
|
||
if archive.len() == 0 {
|
||
return Err("模板包没有文件".to_string());
|
||
}
|
||
if archive.len() > MAX_IMPORT_ARCHIVE_FILES {
|
||
return Err(format!("模板包文件数量超过 {MAX_IMPORT_ARCHIVE_FILES}"));
|
||
}
|
||
let mut total_bytes = 0_u64;
|
||
let mut has_entry = false;
|
||
for index in 0..archive.len() {
|
||
let file = archive
|
||
.by_index(index)
|
||
.map_err(|error| format!("读取模板包条目失败:{error}"))?;
|
||
if file
|
||
.unix_mode()
|
||
.is_some_and(|mode| mode & 0o170000 == 0o120000)
|
||
{
|
||
return Err("模板包不允许包含符号链接".to_string());
|
||
}
|
||
let relative = safe_import_archive_path(file.name())?;
|
||
if let Some(violation) = import_entry_path_violation(&relative) {
|
||
return Err(violation);
|
||
}
|
||
if relative == entry {
|
||
has_entry = true;
|
||
}
|
||
if file.is_dir() {
|
||
continue;
|
||
}
|
||
if file.size() > MAX_IMPORT_ARCHIVE_FILE_BYTES {
|
||
return Err(format!("模板包文件超过大小上限:{relative}"));
|
||
}
|
||
total_bytes = total_bytes.saturating_add(file.size());
|
||
if total_bytes > MAX_IMPORT_ARCHIVE_TOTAL_BYTES {
|
||
return Err("模板包解压后总大小超过上限".to_string());
|
||
}
|
||
}
|
||
if !has_entry {
|
||
return Err(format!("模板包缺少清单声明的 entry:{entry}"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 上传封面按字节嗅探格式:浏览器给的 part content-type 常是 octet-stream,不能当判据。
|
||
fn uploaded_cover_content_type(bytes: &[u8]) -> Result<&'static str, AppError> {
|
||
match image::guess_format(bytes) {
|
||
Ok(image::ImageFormat::Png) => Ok("image/png"),
|
||
Ok(image::ImageFormat::Jpeg) => Ok("image/jpeg"),
|
||
Ok(image::ImageFormat::WebP) => Ok("image/webp"),
|
||
_ => Err(import_invalid("封面仅支持 PNG、JPEG 或 WebP")),
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct PlannedImport {
|
||
import: TemplateImport,
|
||
zip_bytes: Vec<u8>,
|
||
cover_bytes: Vec<u8>,
|
||
cover_content_type: &'static str,
|
||
}
|
||
|
||
fn plan_import_item(
|
||
item: AdminImportAgcTemplateItem,
|
||
files: &BTreeMap<String, Vec<u8>>,
|
||
) -> Result<PlannedImport, AppError> {
|
||
let zip_field = item.zip_field.trim().to_string();
|
||
let cover_field = item.cover_field.trim().to_string();
|
||
if zip_field.is_empty() || cover_field.is_empty() || zip_field == cover_field {
|
||
return Err(import_invalid(format!(
|
||
"{} 必须分别提供 ZIP 与封面的文件字段名",
|
||
item.id
|
||
)));
|
||
}
|
||
let zip_bytes = files
|
||
.get(&zip_field)
|
||
.ok_or_else(|| import_invalid(format!("{} 缺少 ZIP 文件字段 {zip_field}", item.id)))?
|
||
.clone();
|
||
if zip_bytes.len() > MAX_IMPORT_ZIP_BYTES {
|
||
return Err(import_invalid(format!(
|
||
"{} 的模板包超过 {} MiB",
|
||
item.id,
|
||
MAX_IMPORT_ZIP_BYTES / (1024 * 1024)
|
||
)));
|
||
}
|
||
validate_import_archive(&zip_bytes, item.entry.trim())
|
||
.map_err(|error| import_invalid(format!("{}:{error}", item.id)))?;
|
||
let cover_bytes = files
|
||
.get(&cover_field)
|
||
.ok_or_else(|| import_invalid(format!("{} 缺少封面文件字段 {cover_field}", item.id)))?
|
||
.clone();
|
||
let cover_content_type = uploaded_cover_content_type(&cover_bytes)?;
|
||
let cover = validate_cover_bytes(cover_content_type, cover_bytes)?;
|
||
let zip_sha256 = fingerprint(&zip_bytes);
|
||
let cover_sha256 = fingerprint(&cover.bytes);
|
||
Ok(PlannedImport {
|
||
import: TemplateImport {
|
||
id: item.id.clone(),
|
||
title: item.title,
|
||
summary: item.summary,
|
||
tags: item.tags,
|
||
runtime: item.runtime,
|
||
engine: item.engine,
|
||
engine_version: item.engine_version,
|
||
template_version: item.template_version,
|
||
entry: item.entry.trim().to_string(),
|
||
zip_key: format!("templates/v1/{}/sha256/{zip_sha256}/template.zip", item.id),
|
||
zip_sha256,
|
||
zip_size_bytes: zip_bytes.len() as u64,
|
||
cover_key: format!(
|
||
"templates/v1/{}/sha256/{cover_sha256}/cover.{}",
|
||
item.id, cover.extension
|
||
),
|
||
cover_sha256,
|
||
cover_width: cover.width,
|
||
cover_height: cover.height,
|
||
},
|
||
zip_bytes,
|
||
cover_bytes: cover.bytes,
|
||
cover_content_type,
|
||
})
|
||
}
|
||
|
||
/// 校验整批上传:任一模板不合法都在任何写入之前整批拒绝。
|
||
fn plan_import(
|
||
manifest_bytes: &[u8],
|
||
files: &BTreeMap<String, Vec<u8>>,
|
||
) -> Result<(String, Vec<PlannedImport>), AppError> {
|
||
let manifest: AdminImportAgcTemplatesManifest = serde_json::from_slice(manifest_bytes)
|
||
.map_err(|_| import_invalid("导入 manifest 不是有效 JSON 或包含未知字段"))?;
|
||
if manifest.templates.is_empty() {
|
||
return Err(import_invalid("导入批次不能为空"));
|
||
}
|
||
if manifest.templates.len() > MAX_TEMPLATE_IMPORT_BATCH {
|
||
return Err(import_invalid(format!(
|
||
"单批最多导入 {MAX_TEMPLATE_IMPORT_BATCH} 个模板"
|
||
)));
|
||
}
|
||
if manifest.expected_revision.len() != 64
|
||
|| !manifest
|
||
.expected_revision
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_hexdigit())
|
||
{
|
||
return Err(import_invalid("模板库版本标识无效"));
|
||
}
|
||
let mut used_fields = BTreeSet::new();
|
||
let mut planned = Vec::with_capacity(manifest.templates.len());
|
||
for item in manifest.templates {
|
||
let fields = [item.zip_field.trim(), item.cover_field.trim()];
|
||
for field in fields {
|
||
if !field.starts_with("zip_") && !field.starts_with("cover_") {
|
||
return Err(import_invalid(format!(
|
||
"{} 的文件字段名必须以 zip_ 或 cover_ 开头",
|
||
item.id
|
||
)));
|
||
}
|
||
if !used_fields.insert(field.to_string()) {
|
||
return Err(import_invalid(format!("导入批次内文件字段名重复:{field}")));
|
||
}
|
||
}
|
||
planned.push(plan_import_item(item, files)?);
|
||
}
|
||
for name in files.keys() {
|
||
if name != "manifest" && !used_fields.contains(name) {
|
||
return Err(import_invalid(format!("请求包含未引用的文件字段:{name}")));
|
||
}
|
||
}
|
||
Ok((manifest.expected_revision, planned))
|
||
}
|
||
|
||
async fn read_import_field(
|
||
field: axum::extract::multipart::Field<'_>,
|
||
limit: usize,
|
||
name: &str,
|
||
) -> Result<Vec<u8>, AppError> {
|
||
let mut field = field;
|
||
let mut bytes = Vec::new();
|
||
while let Some(chunk) = field
|
||
.chunk()
|
||
.await
|
||
.map_err(|_| import_invalid(format!("读取 {name} 失败")))?
|
||
{
|
||
if bytes.len().saturating_add(chunk.len()) > limit {
|
||
return Err(import_invalid(format!("{name} 超过允许的大小上限")));
|
||
}
|
||
bytes.extend_from_slice(&chunk);
|
||
}
|
||
Ok(bytes)
|
||
}
|
||
|
||
pub async fn admin_import_agc_templates(
|
||
State(state): State<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||
mut multipart: Multipart,
|
||
) -> Result<Response, AppError> {
|
||
let store = state.template_library_store().cloned().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||
.with_message("模板管理未配置可用的存储凭据,当前仅支持查看")
|
||
})?;
|
||
let mut manifest_bytes: Option<Vec<u8>> = None;
|
||
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||
while let Some(field) = multipart
|
||
.next_field()
|
||
.await
|
||
.map_err(|_| import_invalid("导入请求不是有效的 multipart 表单"))?
|
||
{
|
||
let name = field.name().unwrap_or_default().trim().to_string();
|
||
let limit = match name.as_str() {
|
||
"manifest" => MAX_IMPORT_MANIFEST_BYTES,
|
||
name if name.starts_with("zip_") => MAX_IMPORT_ZIP_BYTES,
|
||
name if name.starts_with("cover_") => MAX_COVER_BYTES,
|
||
_ => return Err(import_invalid(format!("导入请求包含未知字段:{name}"))),
|
||
};
|
||
let bytes = read_import_field(field, limit, &name).await?;
|
||
if name == "manifest" {
|
||
manifest_bytes = Some(bytes);
|
||
} else if files.insert(name.clone(), bytes).is_some() {
|
||
return Err(import_invalid(format!("导入请求包含重复字段:{name}")));
|
||
}
|
||
}
|
||
let manifest_bytes =
|
||
manifest_bytes.ok_or_else(|| import_invalid("导入请求缺少 manifest 字段"))?;
|
||
let (expected_revision, planned) = plan_import(&manifest_bytes, &files)?;
|
||
// 接受后的写入由独立任务持有,HTTP 断连不能在清单 PUT 在途时提前解锁。
|
||
let result =
|
||
tokio::spawn(async move { import_templates(store, expected_revision, planned).await })
|
||
.await
|
||
.map_err(|_| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||
.with_message("模板导入结果需要核对,请刷新列表;若发布锁仍被占用请联系运维")
|
||
})??;
|
||
Ok(json_snapshot(&context, result))
|
||
}
|
||
|
||
async fn import_templates(
|
||
store: TemplateLibraryStore,
|
||
expected_revision: String,
|
||
planned: Vec<PlannedImport>,
|
||
) -> Result<AdminImportAgcTemplatesResponse, AppError> {
|
||
let mut session = store
|
||
.begin_publish(uuid::Uuid::new_v4().to_string())
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
let outcome = async {
|
||
let current = session.read_index().await.map_err(map_store_error)?;
|
||
check_revision(¤t, &expected_revision)?;
|
||
let index = decode_index(¤t)?;
|
||
let updated_at = OffsetDateTime::now_utc()
|
||
.format(&Rfc3339)
|
||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
|
||
let mut prepared = prepare_template_import(
|
||
index,
|
||
planned.iter().map(|item| item.import.clone()).collect(),
|
||
&updated_at,
|
||
)
|
||
.map_err(map_domain_error)?;
|
||
for item in &planned {
|
||
let metadata = prepared
|
||
.metadata
|
||
.iter()
|
||
.find(|(id, _)| id == &item.import.id)
|
||
.map(|(_, value)| value.clone())
|
||
.ok_or_else(|| import_invalid("导入准备结果缺少元数据"))?;
|
||
let metadata_bytes = document_bytes(&metadata)?;
|
||
let metadata_key = format!(
|
||
"templates/v1/{}/sha256/{}/template.json",
|
||
item.import.id,
|
||
fingerprint(&metadata_bytes)
|
||
);
|
||
prepared
|
||
.set_metadata_key(&item.import.id, &metadata_key)
|
||
.map_err(map_domain_error)?;
|
||
session
|
||
.put_immutable(
|
||
&item.import.zip_key,
|
||
item.zip_bytes.clone(),
|
||
"application/zip",
|
||
)
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
session
|
||
.put_immutable(
|
||
&item.import.cover_key,
|
||
item.cover_bytes.clone(),
|
||
item.cover_content_type,
|
||
)
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
session
|
||
.put_immutable(&metadata_key, metadata_bytes, "application/json")
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
}
|
||
let next_index_bytes = document_bytes(&prepared.index)?;
|
||
session
|
||
.commit_index(next_index_bytes.clone())
|
||
.await
|
||
.map_err(map_store_error)?;
|
||
let snapshot = snapshot(&next_index_bytes, true)?;
|
||
let imported = planned
|
||
.iter()
|
||
.map(|item| AdminImportAgcTemplateResult {
|
||
id: item.import.id.clone(),
|
||
template_version: item.import.template_version.clone(),
|
||
zip_size_bytes: item.import.zip_size_bytes,
|
||
zip_sha256: item.import.zip_sha256.clone(),
|
||
reused_objects: prepared.reused.contains(&item.import.id),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
Ok::<_, AppError>((snapshot, imported))
|
||
}
|
||
.await;
|
||
let released = session.finish().await.map_err(map_store_error);
|
||
match (outcome, released) {
|
||
(Ok((snapshot, imported)), Ok(())) => Ok(AdminImportAgcTemplatesResponse {
|
||
revision: snapshot.revision,
|
||
writable: snapshot.writable,
|
||
templates: snapshot.templates,
|
||
imported,
|
||
}),
|
||
(Err(error), Ok(())) => Err(error),
|
||
(_, Err(error)) => Err(error),
|
||
}
|
||
}
|
||
|
||
fn map_domain_error(error: TemplateDomainError) -> AppError {
|
||
match error {
|
||
TemplateDomainError::InvalidEdit(message) => {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
|
||
}
|
||
TemplateDomainError::NotFound => {
|
||
AppError::from_status(StatusCode::NOT_FOUND).with_message("模板不存在")
|
||
}
|
||
_ => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||
.with_message("模板库元数据无效,未进行修改"),
|
||
}
|
||
}
|
||
|
||
fn map_store_error(error: TemplateStoreError) -> AppError {
|
||
let (status, code) = match error {
|
||
TemplateStoreError::NotFound => (StatusCode::NOT_FOUND, "TEMPLATE_LIBRARY_NOT_FOUND"),
|
||
TemplateStoreError::Busy => (StatusCode::CONFLICT, "TEMPLATE_LIBRARY_BUSY"),
|
||
TemplateStoreError::Uncertain => (
|
||
StatusCode::SERVICE_UNAVAILABLE,
|
||
"TEMPLATE_LIBRARY_UNCERTAIN",
|
||
),
|
||
_ => (
|
||
StatusCode::SERVICE_UNAVAILABLE,
|
||
"TEMPLATE_LIBRARY_UNAVAILABLE",
|
||
),
|
||
};
|
||
AppError::from_status(status)
|
||
.with_code(code)
|
||
.with_message(error.to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn png_input() -> AdminAgcTemplateCoverInput {
|
||
let image = image::DynamicImage::new_rgb8(4, 3);
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("PNG");
|
||
AdminAgcTemplateCoverInput {
|
||
content_type: "image/png".to_string(),
|
||
data_base64: STANDARD.encode(bytes.into_inner()),
|
||
}
|
||
}
|
||
|
||
fn png_bytes() -> Vec<u8> {
|
||
let image = image::DynamicImage::new_rgb8(4, 3);
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("PNG");
|
||
bytes.into_inner()
|
||
}
|
||
|
||
fn archive(entries: &[(&str, &[u8], Option<u32>)]) -> Vec<u8> {
|
||
use std::io::Write;
|
||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||
for (name, bytes, mode) in entries {
|
||
let mut options = zip::write::SimpleFileOptions::default();
|
||
if let Some(mode) = mode {
|
||
options = options.unix_permissions(*mode);
|
||
}
|
||
if name.ends_with('/') {
|
||
writer.add_directory(*name, options).expect("directory");
|
||
continue;
|
||
}
|
||
writer.start_file(*name, options).expect("start file");
|
||
writer.write_all(bytes).expect("write file");
|
||
}
|
||
writer.finish().expect("finish archive").into_inner()
|
||
}
|
||
|
||
fn import_item() -> AdminImportAgcTemplateItem {
|
||
AdminImportAgcTemplateItem {
|
||
id: "smoke-import".to_string(),
|
||
title: " 冒烟模板 ".to_string(),
|
||
summary: "简介".to_string(),
|
||
tags: vec!["smoke".to_string()],
|
||
runtime: "html".to_string(),
|
||
engine: "none".to_string(),
|
||
engine_version: String::new(),
|
||
template_version: "0.1.0".to_string(),
|
||
entry: "index.html".to_string(),
|
||
zip_field: "zip_0".to_string(),
|
||
cover_field: "cover_0".to_string(),
|
||
}
|
||
}
|
||
|
||
fn import_files() -> BTreeMap<String, Vec<u8>> {
|
||
let mut files = BTreeMap::new();
|
||
files.insert(
|
||
"zip_0".to_string(),
|
||
archive(&[("index.html", b"<html></html>", None)]),
|
||
);
|
||
files.insert("cover_0".to_string(), png_bytes());
|
||
files
|
||
}
|
||
|
||
fn import_manifest(item: AdminImportAgcTemplateItem) -> Vec<u8> {
|
||
serde_json::to_vec(&AdminImportAgcTemplatesManifest {
|
||
expected_revision: "a".repeat(64),
|
||
templates: vec![item],
|
||
})
|
||
.expect("manifest json")
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_archive_validation_fails_closed() {
|
||
let ok = archive(&[
|
||
("game/index.html", b"<html></html>", None),
|
||
("game/app.js", b"console.log(1)", None),
|
||
]);
|
||
assert!(validate_import_archive(&ok, "game/index.html").is_ok());
|
||
assert!(
|
||
validate_import_archive(&ok, "game/missing.html")
|
||
.expect_err("missing entry")
|
||
.contains("缺少清单声明的 entry")
|
||
);
|
||
|
||
let traversal = archive(&[("../escape.html", b"x", None)]);
|
||
assert!(validate_import_archive(&traversal, "../escape.html").is_err());
|
||
|
||
let symlink = {
|
||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||
writer
|
||
.add_symlink(
|
||
"link.html",
|
||
"index.html",
|
||
zip::write::SimpleFileOptions::default(),
|
||
)
|
||
.expect("symlink entry");
|
||
writer.finish().expect("finish archive").into_inner()
|
||
};
|
||
assert!(
|
||
validate_import_archive(&symlink, "link.html")
|
||
.expect_err("symlink")
|
||
.contains("符号链接")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_archive_rejects_identity_and_build_directories() {
|
||
let forbidden = [
|
||
(".agent/manifest.json", "模板包不允许包含 .agent 目录"),
|
||
("game/.agent/ledger.json", "模板包不允许包含 .agent 目录"),
|
||
(".git/config", "模板包不允许包含 .git 目录"),
|
||
(".svn/entries", "模板包不允许包含 .svn 目录"),
|
||
(
|
||
"node_modules/three/package.json",
|
||
"模板包不允许包含 node_modules 目录",
|
||
),
|
||
(
|
||
"assets/node_modules/keep.txt",
|
||
"模板包不允许包含 node_modules 目录",
|
||
),
|
||
("dist/game.js", "模板包根目录不允许包含 dist 目录"),
|
||
("build/index.html", "模板包根目录不允许包含 build 目录"),
|
||
("library/import.json", "模板包根目录不允许包含 library 目录"),
|
||
("temp/asset.json", "模板包根目录不允许包含 temp 目录"),
|
||
("local/settings.json", "模板包根目录不允许包含 local 目录"),
|
||
(
|
||
".vscode/settings.json",
|
||
"模板包根目录不允许包含 .vscode 目录",
|
||
),
|
||
(".idea/misc.xml", "模板包根目录不允许包含 .idea 目录"),
|
||
];
|
||
for (name, expected) in forbidden {
|
||
let bytes = archive(&[
|
||
("game/index.html", b"<html></html>", None),
|
||
(name, b"x", None),
|
||
]);
|
||
assert_eq!(
|
||
validate_import_archive(&bytes, "game/index.html").expect_err(name),
|
||
expected,
|
||
"条目 {name} 必须被拒绝"
|
||
);
|
||
}
|
||
|
||
// 同名目录段只在根目录受限:正文内 `game/dist/**` 是模板自身内容,`.gitignore` 也不是 `.git`。
|
||
let nested = archive(&[
|
||
("game/index.html", b"<html></html>", None),
|
||
("game/dist/app.js", b"x", None),
|
||
("game/.gitignore", b"node_modules\n", None),
|
||
]);
|
||
assert!(validate_import_archive(&nested, "game/index.html").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_plan_uses_content_addressed_keys_and_sniffed_cover() {
|
||
let files = import_files();
|
||
let (revision, planned) =
|
||
plan_import(&import_manifest(import_item()), &files).expect("plan import");
|
||
|
||
assert_eq!(revision, "a".repeat(64));
|
||
let item = &planned[0];
|
||
assert!(
|
||
item.import
|
||
.zip_key
|
||
.starts_with("templates/v1/smoke-import/sha256/")
|
||
);
|
||
assert!(item.import.zip_key.ends_with("/template.zip"));
|
||
assert_eq!(item.import.zip_sha256, fingerprint(&files["zip_0"]));
|
||
assert_eq!(item.import.zip_size_bytes, files["zip_0"].len() as u64);
|
||
assert_eq!(item.cover_content_type, "image/png");
|
||
assert!(item.import.cover_key.ends_with("/cover.png"));
|
||
assert_eq!((item.import.cover_width, item.import.cover_height), (4, 3));
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_plan_rejects_unknown_and_unreferenced_fields() {
|
||
let manifest = import_manifest(import_item());
|
||
let files = import_files();
|
||
assert!(plan_import(&manifest, &files).is_ok());
|
||
|
||
let mut unreferenced = files.clone();
|
||
unreferenced.insert("zip_1".to_string(), b"stray".to_vec());
|
||
let error = plan_import(&manifest, &unreferenced).expect_err("unreferenced file field");
|
||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||
assert!(
|
||
error.message().contains("未引用的文件字段"),
|
||
"{}",
|
||
error.message()
|
||
);
|
||
|
||
let mut missing_zip = files.clone();
|
||
missing_zip.remove("zip_0");
|
||
let error = plan_import(&manifest, &missing_zip).expect_err("missing zip field");
|
||
assert!(
|
||
error.message().contains("缺少 ZIP 文件字段"),
|
||
"{}",
|
||
error.message()
|
||
);
|
||
|
||
let mut wrong_prefix = import_item();
|
||
wrong_prefix.zip_field = "payload_0".to_string();
|
||
let error = plan_import(&import_manifest(wrong_prefix), &files)
|
||
.expect_err("field prefix must be explicit");
|
||
assert!(
|
||
error.message().contains("必须以 zip_ 或 cover_ 开头"),
|
||
"{}",
|
||
error.message()
|
||
);
|
||
|
||
let unknown_field = serde_json::to_vec(&serde_json::json!({
|
||
"expectedRevision": "a".repeat(64),
|
||
"templates": [],
|
||
"extra": 1,
|
||
}))
|
||
.expect("json");
|
||
assert!(plan_import(&unknown_field, &files).is_err());
|
||
|
||
let broken_archive = {
|
||
let mut files = files.clone();
|
||
files.insert("zip_0".to_string(), b"not a zip".to_vec());
|
||
files
|
||
};
|
||
let error = plan_import(&manifest, &broken_archive).expect_err("invalid zip");
|
||
assert!(
|
||
error.message().contains("不是有效 zip"),
|
||
"{}",
|
||
error.message()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn template_cover_validates_actual_bytes_and_dimensions() {
|
||
let cover = validate_cover(png_input()).expect("valid PNG");
|
||
assert_eq!((cover.width, cover.height, cover.extension), (4, 3, "png"));
|
||
let mut mismatched = png_input();
|
||
mismatched.content_type = "image/jpeg".to_string();
|
||
assert_eq!(
|
||
validate_cover(mismatched)
|
||
.err()
|
||
.expect("mismatch")
|
||
.status_code(),
|
||
StatusCode::BAD_REQUEST
|
||
);
|
||
let svg = AdminAgcTemplateCoverInput {
|
||
content_type: "image/svg+xml".to_string(),
|
||
data_base64: STANDARD.encode(b"<svg/>"),
|
||
};
|
||
assert!(validate_cover(svg).is_err());
|
||
let mut truncated = png_input();
|
||
truncated.data_base64 = STANDARD.encode([137, 80, 78, 71, 13, 10, 26, 10]);
|
||
assert!(validate_cover(truncated).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn template_snapshot_lists_both_groups_and_uses_trusted_cover_urls() {
|
||
let mut index: Value = serde_json::from_slice(include_bytes!(concat!(
|
||
env!("CARGO_MANIFEST_DIR"),
|
||
"/../../../apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json"
|
||
))).expect("published fixture");
|
||
let entries = index["templates"].as_array_mut().expect("templates");
|
||
let count = entries.len();
|
||
let hidden = entries.remove(0);
|
||
let hidden_id = hidden["id"].as_str().expect("id").to_string();
|
||
index["inactiveTemplates"] = serde_json::json!([hidden]);
|
||
let bytes = serde_json::to_vec(&index).expect("index bytes");
|
||
let result = snapshot(&bytes, false).expect("snapshot");
|
||
assert_eq!(result.revision, fingerprint(&bytes));
|
||
assert!(!result.writable);
|
||
assert_eq!(result.templates.len(), count);
|
||
assert!(
|
||
!result
|
||
.templates
|
||
.iter()
|
||
.find(|entry| entry.id == hidden_id)
|
||
.expect("hidden")
|
||
.enabled
|
||
);
|
||
assert!(
|
||
result
|
||
.templates
|
||
.iter()
|
||
.all(|entry| entry.cover_url.starts_with(PUBLIC_BASE))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn template_cover_size_and_dimension_limits_reject_before_publication() {
|
||
let oversized = AdminAgcTemplateCoverInput {
|
||
content_type: "image/png".to_string(),
|
||
data_base64: "A".repeat(MAX_COVER_BYTES.div_ceil(3) * 4 + 4),
|
||
};
|
||
assert!(validate_cover(oversized).is_err());
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image::DynamicImage::new_rgb8(4097, 1)
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("wide PNG");
|
||
assert!(
|
||
validate_cover(AdminAgcTemplateCoverInput {
|
||
content_type: "image/png".to_string(),
|
||
data_base64: STANDARD.encode(bytes.into_inner()),
|
||
})
|
||
.is_err()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn template_edit_requires_the_exact_snapshot_revision() {
|
||
let bytes = br#"{"templates":[]}"#;
|
||
assert!(check_revision(bytes, &fingerprint(bytes)).is_ok());
|
||
assert_eq!(
|
||
check_revision(bytes, &"0".repeat(64))
|
||
.expect_err("stale")
|
||
.status_code(),
|
||
StatusCode::CONFLICT
|
||
);
|
||
assert_eq!(
|
||
check_revision(bytes, "invalid")
|
||
.expect_err("invalid")
|
||
.status_code(),
|
||
StatusCode::BAD_REQUEST
|
||
);
|
||
}
|
||
}
|