feat(api): 添加安全上传 Skill 和 COS 图片归一化
This commit is contained in:
44
scripts/package-upload-skill.ps1
Normal file
44
scripts/package-upload-skill.ps1
Normal file
@@ -0,0 +1,44 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||
$skillName = 'upload-delivery-desk-work'
|
||||
$sourcePath = Join-Path $repositoryRoot ".agents\skills\$skillName"
|
||||
$packageDirectory = Join-Path $repositoryRoot 'skill-packages'
|
||||
$packagePath = Join-Path $packageDirectory "$skillName.zip"
|
||||
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $sourcePath 'SKILL.md'))) {
|
||||
throw "Skill not found: $sourcePath"
|
||||
}
|
||||
|
||||
$previousNoBytecode = $env:PYTHONDONTWRITEBYTECODE
|
||||
try {
|
||||
$env:PYTHONDONTWRITEBYTECODE = '1'
|
||||
python (Join-Path $repositoryRoot 'tests\test_upload_skill.py')
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Upload Skill regression tests failed.'
|
||||
}
|
||||
} finally {
|
||||
$env:PYTHONDONTWRITEBYTECODE = $previousNoBytecode
|
||||
}
|
||||
|
||||
$runtimeFiles = Get-ChildItem -LiteralPath $sourcePath -Recurse -Force | Where-Object {
|
||||
$_.Name -eq '__pycache__' -or $_.Extension -in @('.pyc', '.pyo')
|
||||
}
|
||||
if ($runtimeFiles) {
|
||||
throw 'Skill contains Python runtime cache. Remove it before packaging.'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $packageDirectory | Out-Null
|
||||
if (Test-Path -LiteralPath $packagePath) {
|
||||
Remove-Item -LiteralPath $packagePath -Force
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::CreateFromDirectory(
|
||||
$sourcePath,
|
||||
$packagePath,
|
||||
[System.IO.Compression.CompressionLevel]::Optimal,
|
||||
$true
|
||||
)
|
||||
|
||||
Write-Output $packagePath
|
||||
@@ -5,6 +5,7 @@ process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-ch
|
||||
|
||||
const { default: app } = await import('../api/app.js');
|
||||
const { database, closeDatabase } = await import('../api/database.js');
|
||||
const { encryptSecret } = await import('../api/configCrypto.js');
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||
const address = server.address();
|
||||
@@ -28,7 +29,6 @@ try {
|
||||
expectStatus(login.response.status, 200, '平台管理员登录');
|
||||
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
|
||||
if (!adminCookie) throw new Error('登录未返回会话 Cookie');
|
||||
|
||||
const group = await request('/api/management/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '测试运营组', username: 'test_manager', display_name: '测试管理员', password: 'Manager123!' }) }, adminCookie);
|
||||
expectStatus(group.response.status, 201, '创建运营组');
|
||||
const groupId = Number((group.body as { id: number }).id);
|
||||
@@ -76,12 +76,42 @@ try {
|
||||
expectStatus(project.response.status, 201, '创建项目', project.body);
|
||||
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
|
||||
const projectId = Number((project.body as { id: number }).id);
|
||||
const missingStorage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'缺少 COS 配置',images:['https://cdn.example.com/missing.jpg']})},adminCookie);
|
||||
expectStatus(missingStorage.response.status,409,'没有活动 COS 时拒绝 URL 导入',missingStorage.body);
|
||||
const worksWithoutStorage=await request(`/api/projects/${projectId}/works`,{},adminCookie);
|
||||
if((worksWithoutStorage.body as unknown[]).length!==0)throw new Error('URL 导入失败后仍创建了作品记录');
|
||||
await database.insertId("INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,status,test_status,created_by) VALUES (?,?,?,?,?,?,?,?,?,?)", ['ap-guangzhou','runtime-test-1234567890','https://runtime-test-1234567890.cos.ap-guangzhou.myqcloud.com','https://cdn.example.com','delivery-desk',encryptSecret('test-secret-id'),encryptSecret('test-secret-key'),'active','passed',1]);
|
||||
const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'禁止内网图片',images:['http://127.0.0.1/private.jpg']})},adminCookie);
|
||||
expectStatus(blockedPrivateImage.response.status,400,'拒绝内网图片转存',blockedPrivateImage.body);
|
||||
const blockedPrivateIpv6=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'禁止 IPv6 本机图片',images:['http://[::1]/private.jpg']})},adminCookie);
|
||||
expectStatus(blockedPrivateIpv6.response.status,400,'拒绝 IPv6 本机图片转存',blockedPrivateIpv6.body);
|
||||
const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie);
|
||||
expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body);
|
||||
const otherProjectId=Number((otherProject.body as {id:number}).id);
|
||||
const otherWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'其他项目作品',images:['https://cdn.example.com/isolated.jpg']})},adminCookie);
|
||||
expectStatus(otherWork.response.status,201,'创建其他项目作品',otherWork.body);
|
||||
const otherWorkId=Number((otherWork.body as {id:number}).id);
|
||||
const nativeFetch=globalThis.fetch;
|
||||
const {default:COS}=await import('cos-nodejs-sdk-v5');
|
||||
const cosPrototype=COS.prototype as unknown as {putObject:(...args:unknown[])=>unknown};
|
||||
const nativePutObject=cosPrototype.putObject;
|
||||
let importedStorageKey='';
|
||||
try{
|
||||
globalThis.fetch=(async(input:RequestInfo|URL,init?:RequestInit)=>{
|
||||
const target=input instanceof Request?input.url:String(input);
|
||||
if(target==='https://93.184.216.34/source.png')return new Response(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64'),{status:200,headers:{'Content-Type':'image/png'}});
|
||||
return nativeFetch(input,init);
|
||||
}) as typeof fetch;
|
||||
cosPrototype.putObject=(async(options:unknown)=>{importedStorageKey=String((options as {Key:string}).Key);return{statusCode:200}}) as typeof cosPrototype.putObject;
|
||||
const importedWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'异源转存作品',images:['https://93.184.216.34/source.png']})},adminCookie);
|
||||
expectStatus(importedWork.response.status,201,'异源图片转存 COS',importedWork.body);
|
||||
const importedDetail=await request(`/api/works/${Number((importedWork.body as {id:number}).id)}`,{},adminCookie);
|
||||
const importedImage=(importedDetail.body as {images:Array<{url:string;storage_provider:string;storage_key:string}>}).images[0];
|
||||
if(!importedStorageKey.startsWith('delivery-desk/imports/')||importedImage.url!==`https://cdn.example.com/${importedStorageKey}`||importedImage.storage_provider!=='tencent_cos')throw new Error('异源图片没有归一到当前 COS');
|
||||
}finally{
|
||||
globalThis.fetch=nativeFetch;
|
||||
cosPrototype.putObject=nativePutObject;
|
||||
}
|
||||
|
||||
const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie);
|
||||
expectStatus(access.response.status, 200, '配置客户访问');
|
||||
@@ -129,7 +159,7 @@ try {
|
||||
const firstRound=(workDetail.body as {rounds:Array<{version_number:number;round_status:string;completion_reason:string}>}).rounds.find((item)=>item.version_number===1);
|
||||
if(firstRound?.round_status!=='completed'||firstRound.completion_reason!=='superseded')throw new Error('新验收轮次未自动收口旧轮次');
|
||||
const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images;
|
||||
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存');
|
||||
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='tencent_cos')throw new Error('同源 COS 图片没有直接复用');
|
||||
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
|
||||
expectStatus(reviewLogin.response.status, 200, '客户登录');
|
||||
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
|
||||
@@ -250,7 +280,7 @@ try {
|
||||
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
|
||||
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
|
||||
|
||||
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、JSON URL 作品与单方案轮次\n');
|
||||
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、COS 同源复用、异源转存、SSRF 拦截与单方案轮次\n');
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
await closeDatabase();
|
||||
|
||||
Reference in New Issue
Block a user