feat(upload): 支持 Agent 本地图片安全上传

This commit is contained in:
yuzhe
2026-07-23 13:26:10 +08:00
parent 69cfd0b51d
commit c067881415
12 changed files with 380 additions and 25 deletions

View File

@@ -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: