feat(upload): 支持 Agent 本地图片安全上传
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user