fix(wcd): verify imported stickers and remap production assets
This commit is contained in:
+10
-10
@@ -438,7 +438,7 @@ async def _create_wcd_job(wcd_file: UploadFile, user_params: dict) -> JobCreateR
|
||||
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
|
||||
real_id = str(meta["asset_id"])
|
||||
remap[pkg_id] = real_id
|
||||
file_map[pkg_id] = {
|
||||
file_map[real_id] = {
|
||||
"path": str(_asset_dir(real_id) / f"asset{_asset_extension_for_mime(mime)}"),
|
||||
"mime": mime,
|
||||
}
|
||||
@@ -1323,15 +1323,15 @@ def _asset_meta_items() -> list[dict]:
|
||||
|
||||
|
||||
def _find_asset_by_sha256(digest: str) -> dict | None:
|
||||
for meta in _asset_meta_items():
|
||||
if meta.get("sha256") == digest:
|
||||
return meta
|
||||
for meta in _asset_meta_items():
|
||||
try:
|
||||
ext = _asset_extension_for_mime(str(meta.get("mime_type") or ""))
|
||||
path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}"
|
||||
if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == digest:
|
||||
meta["sha256"] = digest
|
||||
if not path.exists():
|
||||
continue
|
||||
actual_digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if actual_digest == digest:
|
||||
meta["sha256"] = actual_digest
|
||||
_write_asset_meta(_asset_dir(str(meta["asset_id"])), meta)
|
||||
return meta
|
||||
except Exception:
|
||||
@@ -1528,7 +1528,7 @@ async def update_design_template(
|
||||
return DesignTemplate(**data)
|
||||
|
||||
|
||||
@app.delete("/api/design-templates/{template_id}", status_code=204)
|
||||
@app.delete("/api/design-templates/{template_id}", status_code=204, response_model=None)
|
||||
def delete_design_template(template_id: str) -> None:
|
||||
template_dir = _design_template_dir(template_id)
|
||||
if not (template_dir / "template.json").exists():
|
||||
@@ -1790,7 +1790,7 @@ def analyze_asset_line_spacing(
|
||||
return LineSpacingAnalysisSummary(**result.as_dict())
|
||||
|
||||
|
||||
@app.delete("/api/assets/{asset_id}", status_code=204)
|
||||
@app.delete("/api/assets/{asset_id}", status_code=204, response_model=None)
|
||||
def delete_asset(asset_id: str) -> None:
|
||||
d = _asset_dir(asset_id)
|
||||
meta = _read_asset_meta(d)
|
||||
@@ -1916,7 +1916,7 @@ async def update_project(
|
||||
return Project(**data)
|
||||
|
||||
|
||||
@app.delete("/api/projects/{project_id}", status_code=204)
|
||||
@app.delete("/api/projects/{project_id}", status_code=204, response_model=None)
|
||||
def delete_project(project_id: str) -> None:
|
||||
pdir = _project_dir(project_id)
|
||||
path = pdir / "project.json"
|
||||
@@ -2011,7 +2011,7 @@ async def upload_font(
|
||||
return Font(**meta)
|
||||
|
||||
|
||||
@app.delete("/api/fonts/{font_id}", status_code=204)
|
||||
@app.delete("/api/fonts/{font_id}", status_code=204, response_model=None)
|
||||
def delete_font(font_id: str) -> None:
|
||||
"""删除已上传字体(内置默认字体不可删除)。"""
|
||||
if font_id == "__default__":
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import sys
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import UploadFile
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from service import app # noqa: E402
|
||||
|
||||
|
||||
SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10">'
|
||||
'<rect width="20" height="10" fill="#3366ff"/></svg>'
|
||||
)
|
||||
|
||||
|
||||
def png_bytes(color=(51, 102, 255)):
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (1, 1), color).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_asset_store(tmp_path, monkeypatch):
|
||||
assets_dir = tmp_path / "service_assets"
|
||||
assets_dir.mkdir()
|
||||
monkeypatch.setattr(app, "ASSETS_DIR", assets_dir)
|
||||
design_templates_dir = tmp_path / "service_design_templates"
|
||||
design_templates_dir.mkdir()
|
||||
monkeypatch.setattr(app, "DESIGN_TEMPLATES_DIR", design_templates_dir)
|
||||
return assets_dir
|
||||
|
||||
|
||||
def asset_dirs(assets_dir):
|
||||
return sorted(path for path in assets_dir.glob("*/*") if path.is_dir())
|
||||
|
||||
|
||||
def test_duplicate_content_is_reused(isolated_asset_store):
|
||||
first = app._register_import_asset("first", SVG.encode(), "image/svg+xml")
|
||||
second = app._register_import_asset("second", SVG.encode(), "image/svg+xml")
|
||||
|
||||
assert first["asset_id"] == second["asset_id"]
|
||||
assert second["name"] == "first"
|
||||
assert len(asset_dirs(isolated_asset_store)) == 1
|
||||
|
||||
|
||||
def test_same_name_different_content_gets_unique_assets(isolated_asset_store):
|
||||
first = app._register_import_asset("same-name", SVG.encode(), "image/svg+xml")
|
||||
second = app._register_import_asset(
|
||||
"same-name", SVG.replace("#3366ff", "#ff3366").encode(), "image/svg+xml"
|
||||
)
|
||||
|
||||
assert first["asset_id"] != second["asset_id"]
|
||||
assert len(asset_dirs(isolated_asset_store)) == 2
|
||||
assert first["sha256"] != second["sha256"]
|
||||
|
||||
|
||||
def test_stale_sha256_meta_does_not_reuse_mismatched_file(isolated_asset_store):
|
||||
content = SVG.encode()
|
||||
existing_id = "asset_stale00000000000000000000000000"
|
||||
existing_dir = isolated_asset_store / existing_id[:2] / existing_id
|
||||
existing_dir.mkdir(parents=True)
|
||||
(existing_dir / "asset.svg").write_text('<svg xmlns="x" width="1" height="1"/>')
|
||||
(existing_dir / "meta.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"asset_id": existing_id,
|
||||
"name": "old",
|
||||
"type": "sticker",
|
||||
"mime_type": "image/svg+xml",
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
imported = app._register_import_asset("new", content, "image/svg+xml")
|
||||
|
||||
assert imported["asset_id"] != existing_id
|
||||
assert len(asset_dirs(isolated_asset_store)) == 2
|
||||
|
||||
|
||||
def test_wcd_production_uses_real_asset_ids(tmp_path, isolated_asset_store, monkeypatch):
|
||||
manifest = {
|
||||
"format": "wordcloud-canvas",
|
||||
"version": 1,
|
||||
"name": "test-design",
|
||||
"assets": [{"id": "pkg-1", "name": "sticker", "type": "svg", "mimeType": "image/svg+xml"}],
|
||||
}
|
||||
document_json = {
|
||||
"width": 40,
|
||||
"height": 20,
|
||||
"background": "#ffffff",
|
||||
"layers": [],
|
||||
"layerFolders": [],
|
||||
"elements": [
|
||||
{
|
||||
"id": "element-1",
|
||||
"type": "sticker",
|
||||
"name": "sticker",
|
||||
"assetId": "pkg-1",
|
||||
"x": 5,
|
||||
"y": 5,
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"rotation": 0,
|
||||
"opacity": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
package = BytesIO()
|
||||
with zipfile.ZipFile(package, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr("document.json", json.dumps(document_json))
|
||||
archive.writestr("assets/pkg-1.png", png_bytes())
|
||||
|
||||
class Paths:
|
||||
input_dir = tmp_path / "input"
|
||||
output_dir = tmp_path / "output"
|
||||
|
||||
def prepare_job_dirs(job_id):
|
||||
Paths.input_dir.mkdir(parents=True)
|
||||
Paths.output_dir.mkdir(parents=True)
|
||||
return Paths
|
||||
|
||||
manager = SimpleNamespace(
|
||||
create_job=lambda: "job-test",
|
||||
set_artifacts=lambda *args, **kwargs: None,
|
||||
set_status=lambda *args, **kwargs: None,
|
||||
)
|
||||
original_compose = app._compose_design_png
|
||||
captured = {}
|
||||
|
||||
def compose(document, file_map, output_path):
|
||||
captured["document"] = document
|
||||
captured["file_map"] = dict(file_map)
|
||||
original_compose(document, file_map, output_path)
|
||||
|
||||
monkeypatch.setattr(app, "manager", manager)
|
||||
monkeypatch.setattr(app, "storage", SimpleNamespace(prepare_job_dirs=prepare_job_dirs))
|
||||
monkeypatch.setattr(app, "_write_order", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(app, "_compose_design_png", compose)
|
||||
monkeypatch.setattr(app.threading, "Thread", lambda target, **kwargs: SimpleNamespace(start=target))
|
||||
|
||||
upload = UploadFile(filename="test.wcd", file=BytesIO(package.getvalue()))
|
||||
asyncio.run(app._create_wcd_job(upload, {}))
|
||||
|
||||
document = captured["document"]
|
||||
file_map = captured["file_map"]
|
||||
real_ids = [element["assetId"] for element in document["elements"]]
|
||||
assert real_ids
|
||||
assert all(asset_id in file_map for asset_id in real_ids)
|
||||
assert all(Path(asset_id).is_absolute() is False for asset_id in real_ids)
|
||||
|
||||
png_path = isolated_asset_store.parent / "composed.png"
|
||||
original_compose(document, file_map, png_path)
|
||||
|
||||
with Image.open(png_path) as image:
|
||||
assert image.getpixel((10, 10)) == (51, 102, 255)
|
||||
Reference in New Issue
Block a user