Files
Genarrative/server-rs/crates/api-server/src/admin_templates.rs
T
kdletters 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
模板正文目录门禁:CLI 打包与后台上传统一拒绝身份/版本库/依赖/构建目录
- 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 正文目录门禁决策记录
2026-09-21 16:25:35 +08:00

1046 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&current, &input.expected_revision)?;
let index = decode_index(&current)?;
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(&current, &expected_revision)?;
let index = decode_index(&current)?;
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
);
}
}