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)
|
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
|
||||||
real_id = str(meta["asset_id"])
|
real_id = str(meta["asset_id"])
|
||||||
remap[pkg_id] = real_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)}"),
|
"path": str(_asset_dir(real_id) / f"asset{_asset_extension_for_mime(mime)}"),
|
||||||
"mime": mime,
|
"mime": mime,
|
||||||
}
|
}
|
||||||
@@ -1323,15 +1323,15 @@ def _asset_meta_items() -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _find_asset_by_sha256(digest: str) -> dict | None:
|
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():
|
for meta in _asset_meta_items():
|
||||||
try:
|
try:
|
||||||
ext = _asset_extension_for_mime(str(meta.get("mime_type") or ""))
|
ext = _asset_extension_for_mime(str(meta.get("mime_type") or ""))
|
||||||
path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}"
|
path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}"
|
||||||
if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == digest:
|
if not path.exists():
|
||||||
meta["sha256"] = digest
|
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)
|
_write_asset_meta(_asset_dir(str(meta["asset_id"])), meta)
|
||||||
return meta
|
return meta
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1528,7 +1528,7 @@ async def update_design_template(
|
|||||||
return DesignTemplate(**data)
|
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:
|
def delete_design_template(template_id: str) -> None:
|
||||||
template_dir = _design_template_dir(template_id)
|
template_dir = _design_template_dir(template_id)
|
||||||
if not (template_dir / "template.json").exists():
|
if not (template_dir / "template.json").exists():
|
||||||
@@ -1790,7 +1790,7 @@ def analyze_asset_line_spacing(
|
|||||||
return LineSpacingAnalysisSummary(**result.as_dict())
|
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:
|
def delete_asset(asset_id: str) -> None:
|
||||||
d = _asset_dir(asset_id)
|
d = _asset_dir(asset_id)
|
||||||
meta = _read_asset_meta(d)
|
meta = _read_asset_meta(d)
|
||||||
@@ -1916,7 +1916,7 @@ async def update_project(
|
|||||||
return Project(**data)
|
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:
|
def delete_project(project_id: str) -> None:
|
||||||
pdir = _project_dir(project_id)
|
pdir = _project_dir(project_id)
|
||||||
path = pdir / "project.json"
|
path = pdir / "project.json"
|
||||||
@@ -2011,7 +2011,7 @@ async def upload_font(
|
|||||||
return Font(**meta)
|
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:
|
def delete_font(font_id: str) -> None:
|
||||||
"""删除已上传字体(内置默认字体不可删除)。"""
|
"""删除已上传字体(内置默认字体不可删除)。"""
|
||||||
if font_id == "__default__":
|
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