feat(upload): 支持 Agent 本地图片安全上传
This commit is contained in:
@@ -16,8 +16,9 @@ Use a two-phase plan/apply workflow. Optimize for correct placement, not speed.
|
||||
- Never create a new round until the operator confirms the resolved work and current round.
|
||||
- Never retry a timed-out create request. First inspect current server state; otherwise a retry can create a duplicate round.
|
||||
- Require `externalId` for every agent-created work. Reuse the same value for safe retries.
|
||||
- Accept 1-30 public `http`/`https` image URLs. Preserve their order; the first image is the cover.
|
||||
- Accept either 1-30 public `http`/`https` image URLs or 1-30 local image files. Never mix both modes in one plan. Preserve order; the first image is the cover.
|
||||
- Require an active Tencent COS configuration. URLs already using its public or CDN origin are reused; other public images are downloaded and stored in that COS by the API.
|
||||
- Upload local files as `multipart/form-data` from the bundled script. Never read image bytes into the conversation, print Base64, or put binary data in the plan.
|
||||
- Put API keys only in `DELIVERY_DESK_API_KEY`. Do not paste keys into chat, plans, source files, or command history.
|
||||
- Prefer a project-scoped API key. A platform key has a wider blast radius and always requires explicit group verification.
|
||||
- Stop on any mismatch, ambiguity, changed project/work state, or missing input. Ask the operator instead of guessing.
|
||||
@@ -40,8 +41,9 @@ For both operations, require:
|
||||
- Delivery Desk base URL. Default to `DELIVERY_DESK_BASE_URL` or `http://127.0.0.1:3010` only for local development.
|
||||
- A valid API key in `DELIVERY_DESK_API_KEY`.
|
||||
- Exact target project, resolved to group ID/name and project ID/name/slug.
|
||||
- Ordered public image URLs.
|
||||
- Confirmation that the source URLs are reachable until the API finishes importing them. After a cross-origin import succeeds, Delivery Desk uses the resulting COS URL.
|
||||
- Ordered public image URLs or ordered local image paths.
|
||||
- For URL mode, confirmation that source URLs remain reachable until import finishes.
|
||||
- For file mode, confirmation that files remain unchanged until apply. The plan records absolute path, byte size, SHA-256, and content type; it never records image bytes.
|
||||
|
||||
For `create_work`, also require:
|
||||
|
||||
@@ -102,6 +104,20 @@ python $skillScript plan-round `
|
||||
--output tmp/delivery-plan.json
|
||||
```
|
||||
|
||||
Use local files instead of URLs:
|
||||
|
||||
```powershell
|
||||
python $skillScript plan-work `
|
||||
--project-id 12 `
|
||||
--external-id client-2026-002 `
|
||||
--title "本地生成作品" `
|
||||
--image-file "D:\generated\01.png" `
|
||||
--image-file "D:\generated\02.png" `
|
||||
--output tmp/delivery-plan.json
|
||||
```
|
||||
|
||||
Use repeated `--image-url` or repeated `--image-file`, never both. Local files must be JPEG, PNG, GIF, WebP, or AVIF, each no larger than 20 MB.
|
||||
|
||||
Optional `plan-round` content flags:
|
||||
|
||||
- `--title`, `--description`, and repeated `--tag` replace current values.
|
||||
@@ -120,7 +136,7 @@ Show the plan summary exactly, including:
|
||||
- Work ID and current/next round for `create_round`.
|
||||
- `externalId` for `create_work`.
|
||||
- Title, complete description, complete tag list.
|
||||
- Ordered image URLs with `1` marked as the cover.
|
||||
- Ordered image URLs, or local file path/name/size/SHA-256, with `1` marked as the cover.
|
||||
- Confirmation code.
|
||||
|
||||
Ask: `确认按以上目标和内容执行吗?请回复“确认 <confirmation_code>”。`
|
||||
@@ -137,7 +153,7 @@ python $skillScript apply `
|
||||
--confirm ABCD1234EF56
|
||||
```
|
||||
|
||||
The script re-fetches the project/work, checks for state drift, performs one POST, and reads the created resource back. Treat only a successful verification result as complete.
|
||||
The script re-fetches the project/work, checks for state drift, revalidates local file hashes when applicable, performs one POST, and reads the created resource back. Local files are streamed by the script as multipart and never enter model context. Treat only a successful verification result as complete.
|
||||
|
||||
Report group, project, work ID, `externalId`, created round number, title, image count, and whether the server returned an existing idempotent work.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Returns current work content, project identity, images, and rounds. Use `?round=
|
||||
|
||||
### `POST /api/projects/:projectId/works`
|
||||
|
||||
JSON body:
|
||||
URL mode uses a JSON body:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -72,6 +72,15 @@ Constraints:
|
||||
- Cross-origin images must be supported image responses no larger than 20 MB. Local, private, reserved, and non-standard-port targets are rejected.
|
||||
- Repeating the same `projectId + externalId` returns the existing work with `idempotent: true`.
|
||||
|
||||
Local-file mode uses `multipart/form-data` with text fields `externalId`, `title`, `description`, `tags` and repeated file field `images`.
|
||||
|
||||
- Send `tags` as a JSON array string.
|
||||
- Send 1-30 JPEG, PNG, GIF, WebP, or AVIF files, each no larger than 20 MB.
|
||||
- File order is display order; file 1 is the cover.
|
||||
- The server validates actual image content instead of trusting only the filename or declared MIME.
|
||||
- With active Tencent COS configuration, accepted files are streamed from server temporary storage into COS.
|
||||
- Do not send Base64 in JSON. The agent script reads local files directly into the multipart request; binary bytes never belong in the plan or conversation.
|
||||
|
||||
### `POST /api/works/:workId/rounds`
|
||||
|
||||
JSON body:
|
||||
@@ -87,6 +96,7 @@ JSON body:
|
||||
|
||||
This endpoint is not idempotent. One successful call creates exactly one new round. Never blindly retry after a timeout.
|
||||
It applies the same COS reuse/import rules as work creation.
|
||||
It also accepts the same local-file multipart fields, except `externalId` is omitted.
|
||||
|
||||
## State guards
|
||||
|
||||
|
||||
@@ -5,16 +5,30 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
MAX_LOCAL_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
LOCAL_IMAGE_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jfif": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".avif": "image/avif",
|
||||
}
|
||||
|
||||
|
||||
class UploadError(RuntimeError):
|
||||
def __init__(self, message: str, *, action: str = "ask_operator", next_step: str = "停止操作,将错误和目标信息告知调用者并等待处理") -> None:
|
||||
@@ -37,7 +51,7 @@ def retry(message: str, next_step: str) -> UploadError:
|
||||
def http_failure(method: str, path: str, status: int, detail: str) -> UploadError:
|
||||
message = f"{method} {path} 返回 {status}: {detail}"
|
||||
if status in {400, 413, 422}:
|
||||
return revise(message, "根据错误修改输入或图片 URL,重新生成计划,并取得新的确认码后再执行")
|
||||
return revise(message, "根据错误修改输入、图片 URL 或本地文件,重新生成计划,并取得新的确认码后再执行")
|
||||
if status == 401:
|
||||
return UploadError(message, next_step="停止操作,请调用者配置或更换有效的 DELIVERY_DESK_API_KEY")
|
||||
if status == 403:
|
||||
@@ -91,6 +105,75 @@ def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, A
|
||||
raise retry(f"无法连接 {base}: {error.reason}", "确认服务地址正确且服务可达后,原样重试当前只读命令") from error
|
||||
|
||||
|
||||
def request_multipart(base: str, path: str, *, fields: dict[str, str], files: list[dict[str, Any]]) -> tuple[int, Any]:
|
||||
boundary = f"delivery-desk-{uuid.uuid4().hex}"
|
||||
parsed = urlsplit(base)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.query or parsed.fragment:
|
||||
raise revise("Delivery Desk base URL 格式无效", "改用有效的 HTTP/HTTPS 服务地址后重新生成计划")
|
||||
target = f"{parsed.path.rstrip('/')}{path}" or "/"
|
||||
|
||||
def write_text(stream: Any, value: str) -> None:
|
||||
stream.write(value.encode("utf-8"))
|
||||
|
||||
with tempfile.TemporaryFile() as body:
|
||||
for name, value in fields.items():
|
||||
write_text(body, f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n")
|
||||
for item in files:
|
||||
filename = str(item["name"]).replace("\\", "_").replace('"', "_")
|
||||
write_text(
|
||||
body,
|
||||
f"--{boundary}\r\nContent-Disposition: form-data; name=\"images\"; filename=\"{filename}\"\r\n"
|
||||
f"Content-Type: {item['content_type']}\r\n\r\n",
|
||||
)
|
||||
digest = hashlib.sha256()
|
||||
copied = 0
|
||||
with Path(str(item["path"])).open("rb") as image:
|
||||
while chunk := image.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
copied += len(chunk)
|
||||
body.write(chunk)
|
||||
if copied != int(item["size"]) or digest.hexdigest() != item["sha256"]:
|
||||
raise revise("本地图片在确认后发生变化,旧计划已失效", "重新生成并展示计划,取得新的确认码后再执行")
|
||||
write_text(body, "\r\n")
|
||||
write_text(body, f"--{boundary}--\r\n")
|
||||
length = body.tell()
|
||||
body.seek(0)
|
||||
|
||||
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
||||
connection = connection_type(parsed.hostname, parsed.port, timeout=120)
|
||||
try:
|
||||
connection.request(
|
||||
"POST",
|
||||
target,
|
||||
body=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key()}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Content-Length": str(length),
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
raw = response.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(raw) if raw else None
|
||||
except json.JSONDecodeError:
|
||||
payload = raw
|
||||
if response.status >= 400:
|
||||
detail = payload.get("error", raw) if isinstance(payload, dict) else raw
|
||||
raise http_failure("POST", path, response.status, str(detail))
|
||||
return response.status, payload
|
||||
except UploadError:
|
||||
raise
|
||||
except (OSError, TimeoutError, http.client.HTTPException) as error:
|
||||
raise UploadError(
|
||||
f"state_unknown: POST {path} 的结果未知,禁止自动重试:{error}",
|
||||
next_step="使用 works --external-id 或 inspect-work 只读核对服务器状态;若仍无法确认,询问调用者后再决定",
|
||||
) from error
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def print_json(value: Any) -> None:
|
||||
print(json.dumps(value, ensure_ascii=False, indent=2))
|
||||
|
||||
@@ -131,6 +214,55 @@ def validate_images(values: list[str]) -> list[str]:
|
||||
return cleaned
|
||||
|
||||
|
||||
def describe_image_files(values: list[str]) -> list[dict[str, Any]]:
|
||||
if not 1 <= len(values) <= 30:
|
||||
raise revise("必须提供 1-30 个本地图片文件", "调整图片数量和顺序,重新生成计划并取得新的确认码")
|
||||
result: list[dict[str, Any]] = []
|
||||
for value in values:
|
||||
try:
|
||||
file_path = Path(value).expanduser().resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise revise(f"本地图片不存在或不可读取: {value}", "修正文件路径后重新生成计划") from error
|
||||
if not file_path.is_file():
|
||||
raise revise(f"本地图片不是普通文件: {file_path}", "改用有效图片文件后重新生成计划")
|
||||
size = file_path.stat().st_size
|
||||
if size < 1 or size > MAX_LOCAL_IMAGE_BYTES:
|
||||
raise revise(f"本地图片大小必须在 1 字节到 20 MB 之间: {file_path}", "压缩或替换图片后重新生成计划")
|
||||
content_type = LOCAL_IMAGE_TYPES.get(file_path.suffix.lower(), "")
|
||||
if not content_type:
|
||||
raise revise(f"不支持的本地图片类型: {file_path.name}", "改用 JPEG、PNG、GIF、WebP 或 AVIF 图片后重新生成计划")
|
||||
digest = hashlib.sha256()
|
||||
with file_path.open("rb") as source:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
result.append({
|
||||
"path": str(file_path),
|
||||
"name": file_path.name,
|
||||
"size": size,
|
||||
"sha256": digest.hexdigest(),
|
||||
"content_type": content_type,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def verify_planned_files(images: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for item in images:
|
||||
try:
|
||||
file_path = Path(str(item.get("path", ""))).resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise revise("本地图片在确认后不存在或不可读取,旧计划已失效", "修正路径并重新生成计划") from error
|
||||
content_type = LOCAL_IMAGE_TYPES.get(file_path.suffix.lower(), "")
|
||||
if (
|
||||
not file_path.is_file()
|
||||
or str(file_path) != item.get("path")
|
||||
or file_path.name != item.get("name")
|
||||
or file_path.stat().st_size != item.get("size")
|
||||
or content_type != item.get("content_type")
|
||||
):
|
||||
raise revise("本地图片在确认后发生变化,旧计划已失效", "重新生成并展示计划,取得新的确认码后再执行")
|
||||
return images
|
||||
|
||||
|
||||
def confirmation_code(plan: dict[str, Any]) -> str:
|
||||
material = {key: value for key, value in plan.items() if key != "confirmation_code"}
|
||||
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
@@ -169,11 +301,18 @@ def content_from_args(args: argparse.Namespace, current: dict[str, Any] | None =
|
||||
tags = [] if args.clear_tags else args.tags if args.tags is not None else current.get("tags", [])
|
||||
if not str(title or "").strip():
|
||||
raise revise("标题不能为空", "补充非空标题后重新生成计划并取得新的确认码")
|
||||
if args.image_urls:
|
||||
image_source = "url"
|
||||
images: list[Any] = validate_images(args.image_urls)
|
||||
else:
|
||||
image_source = "file"
|
||||
images = describe_image_files(args.image_files or [])
|
||||
return {
|
||||
"title": str(title).strip(),
|
||||
"description": str(description or ""),
|
||||
"tags": [str(tag).strip() for tag in tags if str(tag).strip()],
|
||||
"images": validate_images(args.image_urls),
|
||||
"image_source": image_source,
|
||||
"images": images,
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +345,7 @@ def cmd_plan_work(args: argparse.Namespace) -> None:
|
||||
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", args.external_id):
|
||||
raise revise("externalId 格式无效", "改用符合 [A-Za-z0-9._:-]{1,128} 的稳定 externalId 后重新生成计划")
|
||||
plan = {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"operation": "create_work",
|
||||
"base_url": base,
|
||||
"target": project_identity(project),
|
||||
@@ -226,7 +365,7 @@ def cmd_plan_round(args: argparse.Namespace) -> None:
|
||||
rounds = work.get("rounds") or []
|
||||
current_round = max((int(item.get("round_number", 0)) for item in rounds), default=0)
|
||||
plan = {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"operation": "create_round",
|
||||
"base_url": base,
|
||||
"target": project_identity(project),
|
||||
@@ -256,12 +395,33 @@ def same_content(detail: dict[str, Any], expected: dict[str, Any]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def upload_content(base: str, path: str, content: dict[str, Any], extra_fields: dict[str, str] | None = None) -> tuple[int, Any]:
|
||||
image_source = str(content.get("image_source") or "url")
|
||||
fields = {
|
||||
"title": str(content["title"]),
|
||||
"description": str(content.get("description", "")),
|
||||
"tags": json.dumps(content.get("tags", []), ensure_ascii=False),
|
||||
**(extra_fields or {}),
|
||||
}
|
||||
if image_source == "file":
|
||||
files = verify_planned_files(content["images"])
|
||||
return request_multipart(base, path, fields=fields, files=files)
|
||||
body = {
|
||||
"title": content["title"],
|
||||
"description": content.get("description", ""),
|
||||
"tags": content.get("tags", []),
|
||||
"images": content["images"],
|
||||
**(extra_fields or {}),
|
||||
}
|
||||
return request_json(base, path, method="POST", body=body)
|
||||
|
||||
|
||||
def cmd_apply(args: argparse.Namespace) -> None:
|
||||
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
||||
expected_code = confirmation_code(plan)
|
||||
if args.confirm != expected_code or plan.get("confirmation_code") != expected_code:
|
||||
raise revise("确认码不匹配;计划可能已改变,禁止执行", "重新展示当前计划并取得与当前计划一致的新确认码")
|
||||
if plan.get("schema_version") != 1 or plan.get("operation") not in {"create_work", "create_round"}:
|
||||
if plan.get("schema_version") not in {1, 2} or plan.get("operation") not in {"create_work", "create_round"}:
|
||||
raise UploadError("不支持的计划格式")
|
||||
base = base_url(plan.get("base_url"))
|
||||
target = plan["target"]
|
||||
@@ -277,7 +437,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
|
||||
if existing:
|
||||
print_json({"success": True, "idempotent": True, "message": "externalId 已存在,未发送创建请求", "target": target, "work": existing[0]})
|
||||
return
|
||||
_, created = request_json(base, f"/api/projects/{target['project_id']}/works", method="POST", body={**content, "externalId": external_id})
|
||||
_, created = upload_content(base, f"/api/projects/{target['project_id']}/works", content, {"externalId": external_id})
|
||||
work_id = int(created["id"])
|
||||
verified = get_work(base, work_id)
|
||||
assert_work_project(verified, int(target["project_id"]))
|
||||
@@ -291,7 +451,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
|
||||
assert_work_project(before, int(target["project_id"]))
|
||||
if int(before.get("version_number", 0)) != int(work_plan["expected_version_number"]):
|
||||
raise UploadError("作品当前版本已变化,旧计划失效;请重新生成计划并确认")
|
||||
_, created = request_json(base, f"/api/works/{work_plan['work_id']}/rounds", method="POST", body=content)
|
||||
_, created = upload_content(base, f"/api/works/{work_plan['work_id']}/rounds", content)
|
||||
verified = get_work(base, int(work_plan["work_id"]))
|
||||
if int(verified.get("version_number", 0)) != int(work_plan["expected_version_number"]) + 1 or not same_content(verified, content):
|
||||
raise UploadError("新轮次请求返回成功,但回读轮次或内容不一致")
|
||||
@@ -310,7 +470,9 @@ def add_content(command: argparse.ArgumentParser, *, title_required: bool) -> No
|
||||
command.add_argument("--clear-description", action="store_true")
|
||||
command.add_argument("--tag", action="append", dest="tags", default=None)
|
||||
command.add_argument("--clear-tags", action="store_true")
|
||||
command.add_argument("--image-url", action="append", dest="image_urls", required=True)
|
||||
image_input = command.add_mutually_exclusive_group(required=True)
|
||||
image_input.add_argument("--image-url", action="append", dest="image_urls")
|
||||
image_input.add_argument("--image-file", action="append", dest="image_files")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
@@ -88,7 +88,16 @@ app.use((err: Error, _req: Request, res: Response, _next: unknown) => {
|
||||
void _next;
|
||||
// multer 文件类型/大小错误
|
||||
const message = err.message || '服务器内部错误';
|
||||
const errorCode = (err as Error & { code?: string }).code;
|
||||
const statusCode = Number((err as Error & { statusCode?: number }).statusCode);
|
||||
if (errorCode === 'LIMIT_FILE_SIZE') {
|
||||
res.status(413).json({ success: false, error: '单张图片不能超过 20 MB' });
|
||||
return;
|
||||
}
|
||||
if (errorCode === 'LIMIT_FILE_COUNT' || errorCode === 'LIMIT_UNEXPECTED_FILE') {
|
||||
res.status(400).json({ success: false, error: '每次最多上传 30 张图片' });
|
||||
return;
|
||||
}
|
||||
if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
|
||||
res.status(statusCode).json({ success: false, error: message });
|
||||
return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { notesRepository } from '../repositories/notesRepository.js';
|
||||
import { imagesRepository } from '../repositories/imagesRepository.js';
|
||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||
import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js';
|
||||
import { storeExternalImageUrl, storeUploadedFile } from '../storage.js';
|
||||
import { StorageImportError, storeExternalImageUrl, storeUploadedFile } from '../storage.js';
|
||||
import { recalculateCollectionStatus } from './collectionsService.js';
|
||||
import { ensureProjectCompatibilityCollection, recalculateProjectReviewStatus } from './projectsService.js';
|
||||
|
||||
@@ -15,13 +15,34 @@ export interface UrlRound { title: string; description: string; tags: string[];
|
||||
type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string };
|
||||
type PreparedRound = { title: string; description: string; tags: string[]; images: StoredImage[] };
|
||||
|
||||
async function readImageSize(filePath: string): Promise<{ width: number; height: number }> {
|
||||
try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; }
|
||||
catch { return { width: 0, height: 0 }; }
|
||||
}
|
||||
|
||||
async function prepareFiles(files: UploadedFile[]): Promise<StoredImage[]> {
|
||||
return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) })));
|
||||
const supported = new Map([
|
||||
['jpeg', { extension: '.jpg', contentType: 'image/jpeg' }],
|
||||
['png', { extension: '.png', contentType: 'image/png' }],
|
||||
['gif', { extension: '.gif', contentType: 'image/gif' }],
|
||||
['webp', { extension: '.webp', contentType: 'image/webp' }],
|
||||
['avif', { extension: '.avif', contentType: 'image/avif' }],
|
||||
]);
|
||||
const inspected = await Promise.all(files.map(async (file) => {
|
||||
try {
|
||||
const metadata = await sharp(file.path).metadata();
|
||||
const detected = metadata.format ? supported.get(metadata.format) : undefined;
|
||||
if (!detected || !metadata.width || !metadata.height) throw new Error('invalid image');
|
||||
return { file, width: metadata.width, height: metadata.height, ...detected };
|
||||
} catch {
|
||||
throw new StorageImportError(422, `上传文件不是有效或受支持的图片: ${file.originalname || file.filename}`);
|
||||
}
|
||||
}));
|
||||
const prepared: StoredImage[] = [];
|
||||
for (const item of inspected) {
|
||||
const stored = await storeUploadedFile({
|
||||
...item.file,
|
||||
originalname: `upload${item.extension}`,
|
||||
mimetype: item.contentType,
|
||||
});
|
||||
prepared.push({ width: item.width, height: item.height, ...stored });
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
async function prepareExternalImages(images: string[]): Promise<StoredImage[]> {
|
||||
|
||||
@@ -79,3 +79,5 @@ flowchart LR
|
||||
平台管理员可在管理页保存和测试 COS 配置。SecretId/SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密,读取接口不返回明文。连接测试会上传、读取并删除临时对象。
|
||||
|
||||
外部 API 图片统一归一到当前活动 COS:所有 URL 会先拒绝本机、私有网段、局域网和保留地址;与配置的 COS 公开域名或 CDN 域名同源时直接保存,其他公开 URL 经 DNS SSRF 防护、图片类型和 20 MB 大小校验后下载,并按内容哈希转存到 COS。没有活动 COS 配置时拒绝 URL 导入;全部图片准备成功后才创建作品或新验收轮次。该规则不自动追溯迁移历史图片内容。
|
||||
|
||||
外部 Agent 只有本地图片时使用现有 multipart 通道,不使用 Base64。Skill 计划只保存文件路径、大小和 SHA-256,执行时重新校验后流式上传;服务端以 Sharp 验证真实图片内容,再从临时文件写入活动 COS。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- 批注回复线程、只能撤回本人反馈并保留撤回记录
|
||||
- 客户项目链接、密码、姓名、访问期限和验收决定
|
||||
- API Key、审计日志、COS 前端配置、连接测试、私有地址拦截及外部 URL 安全转存
|
||||
- 内置 Agent 安全上传 Skill、双阶段确认脚本、回归测试和可分发 ZIP
|
||||
- 内置 Agent 安全上传 Skill、URL/本地文件双输入、双阶段确认脚本、回归测试和可分发 ZIP
|
||||
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
|
||||
- 桌面端与移动端响应式页面
|
||||
|
||||
|
||||
@@ -71,6 +71,22 @@ curl -X POST http://localhost:3010/api/projects/1/works \
|
||||
|
||||
相同 `projectId + externalId` 的重试不会重复创建,响应包含 `idempotent: true`。异源图片成功转存后不再依赖原地址长期可用;任意图片校验或转存失败时不会创建作品记录。
|
||||
|
||||
### 本地文件上传
|
||||
|
||||
外部 Agent 只有本地图片时,不需要转换为 Base64。相同接口支持 `multipart/form-data`,重复使用 `images` 文件字段,并通过 Bearer API Key 鉴权:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3010/api/projects/1/works \
|
||||
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
|
||||
-F "externalId=client-2026-002" \
|
||||
-F "title=本地生成作品" \
|
||||
-F 'tags=["#本地文件"]' \
|
||||
-F "images=@01.png" \
|
||||
-F "images=@02.png"
|
||||
```
|
||||
|
||||
URL 与本地文件不能在同一次请求中混用。文件顺序即展示顺序,第一张为封面;单张不超过 20 MB,服务端会校验真实图片内容并上传到活动 COS。
|
||||
|
||||
## 创建新验收轮次
|
||||
|
||||
每轮只能提交一个方案。标题、正文、标签和图片会形成不可修改的轮次快照;新轮次自动锁定上一轮。
|
||||
|
||||
@@ -57,6 +57,8 @@ JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使
|
||||
|
||||
Agent 通过 API 新建作品或提交验收轮次时,使用 `.agents/skills/upload-delivery-desk-work`。API Key 只通过 `DELIVERY_DESK_API_KEY` 环境变量注入,不写入计划文件、文档或 Git。
|
||||
|
||||
Agent 可用 `--image-url` 提交公网图片,也可用 `--image-file` 将生成在运营电脑上的本地图片直接 multipart 上传;两种模式不混用,不把图片转换为 Base64。
|
||||
|
||||
```powershell
|
||||
python tests/test_upload_skill.py
|
||||
powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1
|
||||
|
||||
@@ -83,6 +83,11 @@ try {
|
||||
const blockedPrivateStorage=await request('/api/management/storage-configs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({region:'ap-guangzhou',bucket:'blocked-private-1234567890',public_base_url:'http://127.0.0.1:8080',cdn_domain:'',path_prefix:'delivery-desk',secret_id:'test-secret-id',secret_key:'test-secret-key'})},adminCookie);
|
||||
expectStatus(blockedPrivateStorage.response.status,400,'拒绝私有地址作为对象存储访问域名',blockedPrivateStorage.body);
|
||||
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 invalidMultipart=new FormData();
|
||||
invalidMultipart.append('title','伪造图片内容');
|
||||
invalidMultipart.append('images',new Blob(['not-a-real-image'],{type:'image/png'}),'spoofed.png');
|
||||
const invalidUpload=await request(`/api/projects/${projectId}/works`,{method:'POST',body:invalidMultipart},adminCookie);
|
||||
expectStatus(invalidUpload.response.status,422,'拒绝仅伪造 MIME 的上传文件',invalidUpload.body);
|
||||
const privateImageUrls=['http://localhost/private.jpg','http://127.0.0.1/private.jpg','http://10.0.0.1/private.jpg','http://100.64.0.1/private.jpg','http://169.254.0.1/private.jpg','http://172.16.0.1/private.jpg','http://192.168.1.1/private.jpg','http://[::1]/private.jpg','http://[fd00::1]/private.jpg'];
|
||||
for(const [index,imageUrl] of privateImageUrls.entries()){
|
||||
const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:`禁止私有图片 ${index+1}`,images:[imageUrl]})},adminCookie);
|
||||
@@ -107,7 +112,25 @@ try {
|
||||
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;
|
||||
cosPrototype.putObject=(async(options:unknown)=>{
|
||||
const item=options as {Key:string;Body?:NodeJS.ReadableStream};
|
||||
importedStorageKey=String(item.Key);
|
||||
if(item.Body&&typeof item.Body.on==='function'){
|
||||
await new Promise<void>((resolve,reject)=>{item.Body!.on('data',()=>undefined);item.Body!.once('end',resolve);item.Body!.once('error',reject)});
|
||||
}
|
||||
return{statusCode:200};
|
||||
}) as typeof cosPrototype.putObject;
|
||||
const localUpload=new FormData();
|
||||
localUpload.append('externalId','runtime-local-file');
|
||||
localUpload.append('title','本地文件上传作品');
|
||||
localUpload.append('tags',JSON.stringify(['#multipart']));
|
||||
const validPng=Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='),(character)=>character.charCodeAt(0));
|
||||
localUpload.append('images',new Blob([validPng],{type:'image/png'}),'generated.png');
|
||||
const localWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',body:localUpload},adminCookie);
|
||||
expectStatus(localWork.response.status,201,'multipart 本地图片上传 COS',localWork.body);
|
||||
const localDetail=await request(`/api/works/${Number((localWork.body as {id:number}).id)}`,{},adminCookie);
|
||||
const localImage=(localDetail.body as {images:Array<{url:string;width:number;height:number;storage_provider:string;storage_key:string}>}).images[0];
|
||||
if(!importedStorageKey.startsWith('delivery-desk/originals/')||localImage.url!==`https://cdn.example.com/${importedStorageKey}`||localImage.storage_provider!=='tencent_cos'||localImage.width!==1||localImage.height!==1)throw new Error('multipart 本地图片没有校验并写入当前 COS');
|
||||
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);
|
||||
@@ -285,7 +308,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 幂等、COS 同源复用、异源转存、SSRF 拦截与单方案轮次\n');
|
||||
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、multipart 本地上传、COS 归一化、SSRF 拦截与单方案轮次\n');
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
await closeDatabase();
|
||||
|
||||
Binary file not shown.
@@ -8,8 +8,11 @@ import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -36,6 +39,19 @@ def make_plan(operation: str) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
def make_file_plan(images: list[dict]) -> dict:
|
||||
value = {
|
||||
"schema_version": 2,
|
||||
"operation": "create_work",
|
||||
"base_url": "http://example.invalid",
|
||||
"target": IDENTITY,
|
||||
"external_id": "agent-file-001",
|
||||
"content": {**CONTENT, "image_source": "file", "images": images},
|
||||
}
|
||||
value["confirmation_code"] = MODULE.confirmation_code(value)
|
||||
return value
|
||||
|
||||
|
||||
def write_plan(folder: Path, value: dict) -> Path:
|
||||
path = folder / "plan.json"
|
||||
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
|
||||
@@ -56,6 +72,7 @@ def apply_silently(path: Path, code: str) -> dict:
|
||||
def main() -> None:
|
||||
original_exact_project = MODULE.exact_project
|
||||
original_request_json = MODULE.request_json
|
||||
original_request_multipart = MODULE.request_multipart
|
||||
original_get_work = MODULE.get_work
|
||||
MODULE.exact_project = lambda _base, _project_id: PROJECT
|
||||
try:
|
||||
@@ -88,6 +105,82 @@ def main() -> None:
|
||||
assert result["work_id"] == 88 and result["image_count"] == 1
|
||||
assert [method for method, _path in create_calls].count("POST") == 1
|
||||
|
||||
local_image = folder / "generated.png"
|
||||
local_image.write_bytes(b"local-image-content")
|
||||
descriptors = MODULE.describe_image_files([str(local_image)])
|
||||
captured: dict = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
captured["authorization"] = self.headers.get("Authorization")
|
||||
captured["content_type"] = self.headers.get("Content-Type")
|
||||
captured["body"] = self.rfile.read(int(self.headers["Content-Length"]))
|
||||
response = json.dumps({"id": 88}).encode()
|
||||
self.send_response(201)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(response)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response)
|
||||
|
||||
def log_message(self, _format, *_args):
|
||||
return
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
previous_key = os.environ.get("DELIVERY_DESK_API_KEY")
|
||||
os.environ["DELIVERY_DESK_API_KEY"] = "dd_test"
|
||||
thread.start()
|
||||
try:
|
||||
status, payload = MODULE.request_multipart(
|
||||
f"http://127.0.0.1:{server.server_port}",
|
||||
"/api/projects/12/works",
|
||||
fields={"title": "作品标题", "description": "正文", "tags": "[]"},
|
||||
files=descriptors,
|
||||
)
|
||||
local_image.write_bytes(b"x" * len(b"local-image-content"))
|
||||
try:
|
||||
MODULE.request_multipart(
|
||||
f"http://127.0.0.1:{server.server_port}",
|
||||
"/api/projects/12/works",
|
||||
fields={"title": "作品标题", "description": "正文", "tags": "[]"},
|
||||
files=descriptors,
|
||||
)
|
||||
raise AssertionError("同尺寸但哈希变化的文件未被阻止")
|
||||
except MODULE.UploadError as error:
|
||||
assert error.action == "revise" and "发生变化" in str(error)
|
||||
local_image.write_bytes(b"local-image-content")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
if previous_key is None:
|
||||
os.environ.pop("DELIVERY_DESK_API_KEY", None)
|
||||
else:
|
||||
os.environ["DELIVERY_DESK_API_KEY"] = previous_key
|
||||
assert status == 201 and payload["id"] == 88
|
||||
assert captured["authorization"] == "Bearer dd_test"
|
||||
assert "multipart/form-data" in captured["content_type"]
|
||||
assert b"local-image-content" in captured["body"] and b'name="images"' in captured["body"]
|
||||
|
||||
file_plan = make_file_plan(descriptors)
|
||||
file_path = write_plan(folder, file_plan)
|
||||
MODULE.request_json = lambda *_args, **_kwargs: (200, [])
|
||||
multipart_calls: list[tuple] = []
|
||||
MODULE.request_multipart = lambda *_args, **_kwargs: (multipart_calls.append((_args, _kwargs)) or (201, {"id": 88}))
|
||||
MODULE.get_work = lambda *_args, **_kwargs: {**detail(1, image_url="https://cos.example.com/originals/generated.png"), "external_id": "agent-file-001"}
|
||||
result = apply_silently(file_path, file_plan["confirmation_code"])
|
||||
assert result["work_id"] == 88 and len(multipart_calls) == 1
|
||||
assert multipart_calls[0][1]["files"][0]["sha256"] == descriptors[0]["sha256"]
|
||||
|
||||
local_image.write_bytes(b"changed-after-confirmation")
|
||||
multipart_calls.clear()
|
||||
try:
|
||||
apply_silently(file_path, file_plan["confirmation_code"])
|
||||
raise AssertionError("确认后变化的文件未被阻止")
|
||||
except MODULE.UploadError as error:
|
||||
assert error.action == "revise" and "发生变化" in str(error)
|
||||
assert not multipart_calls
|
||||
|
||||
round_plan = make_plan("create_round")
|
||||
round_path = write_plan(folder, round_plan)
|
||||
MODULE.get_work = lambda *_args, **_kwargs: detail(3, work_id=34)
|
||||
@@ -126,6 +219,7 @@ def main() -> None:
|
||||
finally:
|
||||
MODULE.exact_project = original_exact_project
|
||||
MODULE.request_json = original_request_json
|
||||
MODULE.request_multipart = original_request_multipart
|
||||
MODULE.get_work = original_get_work
|
||||
print("Upload Skill regression tests passed")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user