From f26098780d6746acecb2c016407140f7df442df3 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Sun, 2 Aug 2026 21:54:31 +0800 Subject: [PATCH] Add secure recipe package transfer --- .agents/logs/ai_changelog.md | 17 ++ .agents/logs/ai_lessons.md | 12 + ARCHITECTURE.md | 3 +- api/__init__.py | 4 + api/recipe_packages.py | 461 +++++++++++++++++++++++++++++++++++ web/modules/locales.js | 22 ++ web/modules/ui_recipes.js | 80 ++++++ 7 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 api/recipe_packages.py diff --git a/.agents/logs/ai_changelog.md b/.agents/logs/ai_changelog.md index d657893..b411eaa 100644 --- a/.agents/logs/ai_changelog.md +++ b/.agents/logs/ai_changelog.md @@ -77,3 +77,20 @@ - `node --check` for every JavaScript file under `web/` - `recipe_diff.js` smoke comparison passed - `git diff --check` + +## [Snapshot] 2026-08-02 — Contained recipe package transfer + +**Implemented** + +- Added versioned ZIP export with manifest checksums and explicit inclusion controls for preview snapshots, history, and model identity fields. +- Added bounded upload inspection plus single-use short-lived inspection tokens before import commit. +- Added archive path, symlink, compression, entry-count, expanded-size, JSON-depth, checksum, and WebP signature validation. +- Added staged import with generated local recipe identity, name collision rename flow, optional replace-with-history-backup support, and contained asset/history placement. +- Added Workspace Import package action and per-card Export package action with a privacy summary. + +**Validation** + +- `python_embeded/python.exe -m py_compile api/__init__.py api/models.py api/recipes.py api/recipe_packages.py` +- `node --check` for every JavaScript file under `web/` +- ZIP export → inspect smoke round trip passed with a contained WebP asset +- `git diff --check` diff --git a/.agents/logs/ai_lessons.md b/.agents/logs/ai_lessons.md index dc78c05..955fa78 100644 --- a/.agents/logs/ai_lessons.md +++ b/.agents/logs/ai_lessons.md @@ -297,3 +297,15 @@ This document serves as an architectural retrospective and UX diagnostic log for (配方历史保存的是完整序列化图,但直接展示 JSON 或可视化节点/连线差异会制造大量噪声、压垮详情面板,还可能暴露参数页刻意隐藏的敏感控件。) **The Solution (方案)**: Compare bounded semantic summaries only: prompts, pinned primitives, known model references, safe sampler/resolution fields, graph counts/fingerprint, and presentation metadata. Keep comparison pure and read-only; history restoration remains a separate destructive action with its own confirmation. (只比较有上限的语义摘要:提示词、钉选原始值、已知模型引用、安全的采样/分辨率字段、图数量/指纹和展示信息。比较器必须是纯只读逻辑,历史恢复仍是独立的破坏性动作并单独确认。) + +## 49. Inspect Before Import (导入必须先检查再提交) +**The Problem (问题)**: A recipe package contains user-controlled archive names, JSON, and image bytes. Writing entries directly into the recipe directory would make traversal, symlink, decompression-bomb, dangling-asset, and silent-overwrite failures possible before the UI can explain them. +(配方包里的归档名称、JSON 和图片字节都由用户控制。若直接写进配方目录,路径穿越、符号链接、解压炸弹、悬空资源和静默覆盖都可能在界面解释前发生。) +**The Solution (方案)**: Keep the upload in a bounded single-use inspection record, validate every entry and checksum, show a dry-run summary, then stage the normalized recipe and contained assets before atomic commit. Imports never execute code or install dependencies. +(上传包先保存在有上限、一次性的检查记录中,逐项校验路径、大小、压缩比、校验和和资源引用,展示预检摘要后再把规范化配方和资产写入临时目录并原子提交。导入永远不执行代码,也不安装依赖。) + +## 48. Semantic Diff Must Not Become a Graph Diff (语义 Diff 不能偷偷膨胀成整图 Diff) +**The Problem (问题)**: Recipe history contains complete serialized graphs, but showing a raw JSON or visual node/link diff would expose noisy internal details, overwhelm the detail panel, and risk surfacing sensitive widget values that the normal parameter view intentionally keeps opaque. +(配方历史保存的是完整序列化图,但直接展示 JSON 或可视化节点/连线差异会制造大量噪声、压垮详情面板,还可能暴露参数页刻意隐藏的敏感控件。) +**The Solution (方案)**: Compare bounded semantic summaries only: prompts, pinned primitives, known model references, safe sampler/resolution fields, graph counts/fingerprint, and presentation metadata. Keep comparison pure and read-only; history restoration remains a separate destructive action with its own confirmation. +(只比较有上限的语义摘要:提示词、钉选原始值、已知模型引用、安全的采样/分辨率字段、图数量/指纹和展示信息。比较器必须是纯只读逻辑,历史恢复仍是独立的破坏性动作并单独确认。) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 104fe08..802486b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,6 +25,7 @@ Anomalous_Model_Browser/ │ ├── models.py # Core model listing and routing (API endpoints) │ ├── notebooks.py # Notebooks management logic │ ├── recipes.py # Local Workflow Recipe CRUD and validation +│ ├── recipe_packages.py # Bounded ZIP inspection, export, staging, and import commit │ ├── scanner.py # Scanning engine for hash resolution and caching │ └── utils.py # Shared backend utilities and safe path boundary helpers ├── scraper.py # Async HTTP client logic & web scraping (Root Level) @@ -67,7 +68,7 @@ All endpoints are prefixed with `/anomalous/`. 7. **Canonical Configuration**: Runtime UI settings and newly saved API keys live in `api/config.json`. `scraper.py` reads that file first and falls back to the legacy root `config.json` only for backward compatibility. API keys must never be persisted in browser `localStorage`. 8. **Atomic Background State**: Set scan state or create the exclusive marker file before launching a worker thread/process. Folder scans use `.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep missing-model scans use `GLOBAL_SCAN_STATE`, exposed by `/anomalous/scan_missing_models_status`. 9. **Private Development Tests**: The local `tests/` directory contains backend and frontend regression tests for maintainers only. It is intentionally ignored by `.gitignore` and must not be included in the installable/plugin Git distribution. Keep these files locally for validation; do not import them from runtime code or move them into the shipped extension bundle. -10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, and use atomic replacement. A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. +10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, and use atomic replacement. A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. `recipe_packages.py` accepts only bounded ZIP packages with `manifest.json`, `recipe.json`, declared contained WebP assets, and optional bounded history. Import is inspect-then-commit, rejects traversal/symlinks/archive bombs/checksum failures, never installs code or dependencies, and stages before atomic rename. ### NON-NEGOTIABLE: Model Sidecar & Cover Lifecycle / 模型伴生文件与封面生命周期死规矩 * `.civitai_bak.*` is a persistent restore source created from a real Civitai download. Setting a custom cover MUST modify only `.preview.*`; it MUST NOT delete, overwrite, or repurpose `.civitai_bak.*`. diff --git a/api/__init__.py b/api/__init__.py index 69a5fa5..fed61d4 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -3,6 +3,7 @@ from .scanner import * from .config import * from .notebooks import * from .recipes import * +from .recipe_packages import * from .utils import * def setup_routes(app): @@ -35,6 +36,9 @@ def setup_routes(app): app.router.add_get('/anomalous/recipe_version', api_get_recipe_version) app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version) app.router.add_post('/anomalous/refresh_recipe_identity', api_refresh_recipe_identity) + app.router.add_post('/anomalous/export_recipe_package', api_export_recipe_package) + app.router.add_post('/anomalous/import_recipe_package_inspect', api_import_recipe_package_inspect) + app.router.add_post('/anomalous/import_recipe_package_commit', api_import_recipe_package_commit) app.router.add_post('/anomalous/translate', api_translate) app.router.add_get('/anomalous/base_models', api_base_models) diff --git a/api/recipe_packages.py b/api/recipe_packages.py new file mode 100644 index 0000000..33b97df --- /dev/null +++ b/api/recipe_packages.py @@ -0,0 +1,461 @@ +"""Bounded import/export for self-contained Workflow Recipe packages.""" + +import hashlib +import io +import asyncio +import json +import os +import shutil +import stat +import tempfile +import time +import uuid +import zipfile + +from aiohttp import web + +from . import recipes as recipe_store +from .recipes import get_recipes_dir +from .utils import require_filename, resolve_within + + +PACKAGE_VERSION = 1 +MAX_UPLOAD_BYTES = 32 * 1024 * 1024 +MAX_ENTRY_COUNT = 256 +MAX_ENTRY_BYTES = 16 * 1024 * 1024 +MAX_EXPANDED_BYTES = 64 * 1024 * 1024 +MAX_COMPRESSION_RATIO = 100 +MAX_HISTORY_ENTRIES = 100 +MAX_JSON_DEPTH = 64 +MAX_INSPECTIONS = 4 +INSPECTION_TTL_SECONDS = 10 * 60 +ALLOWED_COMPRESSION = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED} +_INSPECTIONS = {} + + +def _json_depth(value, depth=0): + if depth > MAX_JSON_DEPTH: + return depth + if isinstance(value, dict): + return max((_json_depth(item, depth + 1) for item in value.values()), default=depth) + if isinstance(value, list): + return max((_json_depth(item, depth + 1) for item in value), default=depth) + return depth + + +def _sha256(data): + return hashlib.sha256(data).hexdigest() + + +def _safe_archive_name(name): + if not isinstance(name, str) or not name or len(name) > 240: + raise ValueError("Invalid package entry name") + if chr(92) in name or name.startswith("/") or ":" in name: + raise ValueError("Invalid package entry name") + parts = name.split("/") + if any(not part or part in (".", "..") for part in parts): + raise ValueError("Invalid package entry name") + return name + + +def _entry_is_symlink(info): + return stat.S_ISLNK((info.external_attr >> 16) & 0xFFFF) + + +def _read_zip_entry(archive, name, maximum=MAX_ENTRY_BYTES): + info = archive.getinfo(name) + if info.file_size > maximum: + raise ValueError("Package entry is too large") + data = archive.read(info) + if len(data) != info.file_size: + raise ValueError("Package entry size mismatch") + return data + + +def _validate_zip(raw): + if not isinstance(raw, bytes) or len(raw) > MAX_UPLOAD_BYTES: + raise ValueError("Package upload is too large") + try: + archive = zipfile.ZipFile(io.BytesIO(raw), "r") + except (OSError, zipfile.BadZipFile) as error: + raise ValueError("Invalid recipe package") from error + + infos = archive.infolist() + if len(infos) > MAX_ENTRY_COUNT: + raise ValueError("Too many package entries") + names = [] + expanded = 0 + for info in infos: + name = _safe_archive_name(info.filename) + if name in names or info.is_dir() or _entry_is_symlink(info): + raise ValueError("Invalid or duplicate package entry") + if info.compress_type not in ALLOWED_COMPRESSION or info.flag_bits & 0x1: + raise ValueError("Unsupported package compression") + if info.file_size > MAX_ENTRY_BYTES: + raise ValueError("Package entry is too large") + if info.compress_size and info.file_size / info.compress_size > MAX_COMPRESSION_RATIO: + raise ValueError("Package compression ratio is unsafe") + expanded += info.file_size + if expanded > MAX_EXPANDED_BYTES: + raise ValueError("Package expands beyond the safety limit") + names.append(name) + + if "manifest.json" not in names or "recipe.json" not in names: + raise ValueError("Package is missing manifest or recipe") + return archive, names + + +def _parse_json(data, label): + try: + value = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError(f"Invalid {label}") from error + if _json_depth(value) > MAX_JSON_DEPTH: + raise ValueError(f"{label} is nested too deeply") + return value + + +def _asset_id_from_reference(reference): + asset_id = reference.get("preview", {}).get("snapshot_asset_id") if isinstance(reference, dict) else None + if not isinstance(asset_id, str) or not asset_id.endswith(".webp"): + return None + return require_filename(asset_id) + + +def _validate_manifest(manifest, archive, names): + if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION: + raise ValueError("Unsupported recipe package version") + declared = manifest.get("entries", []) + if not isinstance(declared, list) or len(declared) > MAX_ENTRY_COUNT: + raise ValueError("Invalid package manifest") + declared_map = {} + for item in declared: + if not isinstance(item, dict): + raise ValueError("Invalid package manifest entry") + name = _safe_archive_name(item.get("name")) + if name in declared_map or name not in names: + raise ValueError("Package manifest does not match archive") + declared_map[name] = item + for name in names: + if name == "manifest.json": + continue + item = declared_map.get(name) + if not item: + raise ValueError("Package contains undeclared data") + data = _read_zip_entry(archive, name) + if item.get("sha256") != _sha256(data) or item.get("size") != len(data): + raise ValueError("Package checksum mismatch") + + +def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True): + value = json.loads(json.dumps(recipe, ensure_ascii=False)) + for reference in value.get("params", {}).get("model_references", []): + if not include_snapshots: + reference.pop("preview", None) + if not include_identity: + identity = reference.get("identity") + if isinstance(identity, dict): + for key in ("sha256", "size", "provenance"): + identity.pop(key, None) + identity["status"] = "unverified" + return value + + +def _referenced_asset_ids(recipe): + result = set() + references = recipe.get("params", {}).get("model_references", []) + for reference in references if isinstance(references, list) else []: + asset_id = _asset_id_from_reference(reference) + if asset_id: + result.add(asset_id) + return result + + +def _add_zip_entry(archive, entries, name, data, media_type): + name = _safe_archive_name(name) + archive.writestr(name, data) + entries.append({"name": name, "size": len(data), "sha256": _sha256(data), "media_type": media_type}) + + +def _recipe_history_files(recipes_dir, filename): + history_dir = recipe_store._history_dir(recipes_dir, filename) + files = [] + if not os.path.isdir(history_dir): + return files + with os.scandir(history_dir) as entries: + for entry in entries: + if entry.is_file() and entry.name.endswith(".json"): + files.append(require_filename(entry.name)) + files.sort() + return files[:MAX_HISTORY_ENTRIES] + + +def _build_export(raw_recipe, recipes_dir, filename, options): + include_snapshots = options.get("include_snapshots") is True + include_history = options.get("include_history") is True + include_identity = options.get("include_identity", True) is True + recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity) + entries = [] + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: + recipe_bytes = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + _add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json") + + if include_snapshots: + assets_dir = recipe_store._recipe_assets_dir(recipes_dir, filename) + for asset_id in sorted(_referenced_asset_ids(recipe)): + asset_path = resolve_within(assets_dir, asset_id) + if not os.path.isfile(asset_path): + continue + data = open(asset_path, "rb").read(MAX_ENTRY_BYTES + 1) + if len(data) > MAX_ENTRY_BYTES or not data.startswith(b"RIFF") or data[8:12] != b"WEBP": + continue + _add_zip_entry(archive, entries, f"assets/{asset_id}", data, "image/webp") + + if include_history: + for history_name in _recipe_history_files(recipes_dir, filename): + history_path = resolve_within(recipe_store._history_dir(recipes_dir, filename), history_name) + data = open(history_path, "rb").read(MAX_ENTRY_BYTES + 1) + if len(data) > MAX_ENTRY_BYTES: + raise ValueError("Historical recipe is too large") + history_recipe = _parse_json(data, "historical recipe") + history_recipe = _sanitize_recipe_for_export(history_recipe, include_snapshots, include_identity) + data = json.dumps(history_recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + _add_zip_entry(archive, entries, f"history/{history_name}", data, "application/json") + + manifest = { + "package_version": PACKAGE_VERSION, + "recipe_schema_version": recipe.get("schema_version", 1), + "exported_at": int(time.time() * 1000), + "options": { + "include_snapshots": include_snapshots, + "include_history": include_history, + "include_identity": include_identity, + }, + "entries": entries, + } + manifest_bytes = json.dumps(manifest, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + archive.writestr("manifest.json", manifest_bytes) + return output.getvalue() + + +def _validate_recipe_assets(archive, recipe, names): + referenced = _referenced_asset_ids(recipe) + for asset_id in referenced: + name = f"assets/{asset_id}" + if name not in names: + raise ValueError("Recipe references a missing snapshot asset") + data = _read_zip_entry(archive, name) + if not data.startswith(b"RIFF") or data[8:12] != b"WEBP": + raise ValueError("Snapshot asset is not a WebP image") + for name in names: + if name.startswith("assets/") and (name[7:] not in referenced or not name.endswith(".webp")): + raise ValueError("Package contains an unreferenced asset") + + +def _inspect_package(raw): + archive, names = _validate_zip(raw) + with archive: + manifest = _parse_json(_read_zip_entry(archive, "manifest.json"), "manifest") + _validate_manifest(manifest, archive, names) + recipe = _parse_json(_read_zip_entry(archive, "recipe.json"), "recipe") + recipe_store._normalise_recipe(recipe) + _validate_recipe_assets(archive, recipe, names) + history_names = [name for name in names if name.startswith("history/")] + if len(history_names) > MAX_HISTORY_ENTRIES: + raise ValueError("Too many history entries") + for name in history_names: + history = _parse_json(_read_zip_entry(archive, name), "historical recipe") + recipe_store._normalise_recipe(history) + return { + "manifest": manifest, + "recipe": recipe, + "history_names": history_names, + "asset_names": [name for name in names if name.startswith("assets/")], + } + + +def _prune_inspections(): + now = time.time() + for token, record in list(_INSPECTIONS.items()): + if now - record["created"] > INSPECTION_TTL_SECONDS: + _INSPECTIONS.pop(token, None) + while len(_INSPECTIONS) >= MAX_INSPECTIONS: + oldest = min(_INSPECTIONS, key=lambda token: _INSPECTIONS[token]["created"]) + _INSPECTIONS.pop(oldest, None) + + +def _existing_recipe_names(recipes_dir): + return [item["data"].get("name", "") for item in recipe_store._list_recipes(recipes_dir)] + + +def _unique_name(name, existing): + base = name.strip() or "Imported Workflow Recipe" + if base not in existing: + return base + for index in range(2, 1000): + candidate = f"{base} ({index})" + if candidate not in existing: + return candidate + raise ValueError("Could not create a unique recipe name") + + +def _copy_imported_assets(archive, inspection, staging, source_recipe, target_recipe): + asset_dir = os.path.join(staging, "assets") + os.makedirs(asset_dir, exist_ok=True) + for name in inspection["asset_names"]: + asset_id = require_filename(name.split("/", 1)[1]) + data = _read_zip_entry(archive, name) + with open(resolve_within(asset_dir, asset_id), "wb") as asset_file: + asset_file.write(data) + + source_refs = { + ( + item.get("node_id"), + item.get("widget_index"), + item.get("category"), + item.get("saved_value"), + ): item + for item in source_recipe.get("params", {}).get("model_references", []) + if isinstance(item, dict) + } + for target in target_recipe.get("params", {}).get("model_references", []): + source = source_refs.get((target.get("node_id"), target.get("widget_index"), target.get("category"), target.get("saved_value"))) + asset_id = _asset_id_from_reference(source or {}) + if asset_id and os.path.isfile(resolve_within(asset_dir, asset_id)): + target["preview"] = source["preview"] + + +def _write_staged_json(path, value): + with open(path, "w", encoding="utf-8", newline="\n") as output: + json.dump(value, output, ensure_ascii=False, separators=(",", ":")) + + +def _commit_import(record, payload): + recipes_dir = get_recipes_dir() + package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False)) + collision = payload.get("collision", "rename") + name_override = payload.get("name") + if name_override is not None: + if not isinstance(name_override, str) or not name_override.strip(): + raise ValueError("Invalid imported recipe name") + package_recipe["name"] = name_override.strip() + + existing_names = _existing_recipe_names(recipes_dir) + target_filename = None + if collision == "replace": + target_filename = require_filename(payload.get("target_filename", "")) + if not target_filename.endswith(".json"): + raise ValueError("Invalid replacement target") + if not os.path.isfile(resolve_within(recipes_dir, target_filename)): + raise FileNotFoundError + elif collision != "rename": + raise ValueError("Unsupported collision choice") + else: + package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names) + + normalized = recipe_store._normalise_recipe(package_recipe) + normalized["presentation"]["imported"] = True + filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json" + normalized = recipe_store._enrich_recipe(normalized) + + staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir) + final_path = resolve_within(recipes_dir, filename) + final_assets = recipe_store._recipe_assets_dir(recipes_dir, filename) + final_history = recipe_store._history_dir(recipes_dir, filename) + backup_assets = None + try: + archive, _ = _validate_zip(record["raw"]) + with archive: + _copy_imported_assets(archive, record, staging, record["recipe"], normalized) + _write_staged_json(os.path.join(staging, "recipe.json"), normalized) + if record["history_names"]: + history_stage = os.path.join(staging, "history") + os.makedirs(history_stage, exist_ok=True) + for name in record["history_names"]: + history = _parse_json(_read_zip_entry(archive, name), "historical recipe") + _write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history) + + if collision == "replace": + existing = recipe_store._read_recipe(final_path) + recipe_store._archive_recipe(recipes_dir, filename, existing) + if os.path.isdir(final_assets): + backup_assets = os.path.join(staging, "old-assets") + shutil.move(final_assets, backup_assets) + + os.replace(os.path.join(staging, "recipe.json"), final_path) + staged_assets = os.path.join(staging, "assets") + if os.path.isdir(staged_assets): + os.replace(staged_assets, final_assets) + if record["history_names"]: + history_stage = os.path.join(staging, "history") + if os.path.isdir(final_history): + shutil.rmtree(final_history) + os.replace(history_stage, final_history) + except Exception: + if collision == "replace" and backup_assets and not os.path.isdir(final_assets) and os.path.isdir(backup_assets): + shutil.move(backup_assets, final_assets) + raise + finally: + shutil.rmtree(staging, ignore_errors=True) + return filename, normalized.get("name", "") + + +async def api_export_recipe_package(request): + try: + payload = await request.json() + filename = require_filename(payload.get("filename", "")) + if not filename.endswith(".json"): + raise ValueError("Invalid recipe filename") + recipes_dir = get_recipes_dir() + recipe = await asyncio.to_thread(recipe_store._read_recipe, resolve_within(recipes_dir, filename)) + package = await asyncio.to_thread(_build_export, recipe, recipes_dir, filename, payload) + except (AttributeError, TypeError, ValueError, json.JSONDecodeError): + return web.json_response({"status": "error", "message": "Invalid export request"}, status=400) + except FileNotFoundError: + return web.json_response({"status": "error", "message": "Recipe not found"}, status=404) + except OSError: + return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500) + return web.Response( + body=package, + content_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{os.path.splitext(filename)[0]}.anomalous-recipe.zip"'}, + ) + + +async def api_import_recipe_package_inspect(request): + try: + raw = await request.content.read(MAX_UPLOAD_BYTES + 1) + report = await asyncio.to_thread(_inspect_package, raw) + except (ValueError, OSError, zipfile.BadZipFile): + return web.json_response({"status": "error", "message": "Invalid recipe package"}, status=400) + _prune_inspections() + token = uuid.uuid4().hex + _INSPECTIONS[token] = {"created": time.time(), "raw": raw, **report} + return web.json_response({ + "status": "success", + "token": token, + "recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])}, + "asset_count": len(report["asset_names"]), + "history_count": len(report["history_names"]), + "existing_names": _existing_recipe_names(get_recipes_dir()), + }) + + +async def api_import_recipe_package_commit(request): + try: + payload = await request.json() + token = payload.get("token") + if not isinstance(token, str) or token not in _INSPECTIONS: + raise ValueError("Invalid inspection token") + record = _INSPECTIONS.pop(token) + if time.time() - record["created"] > INSPECTION_TTL_SECONDS: + raise ValueError("Inspection token expired") + filename, name = await asyncio.to_thread(_commit_import, record, payload) + except (AttributeError, TypeError, ValueError, json.JSONDecodeError): + return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400) + except FileNotFoundError: + return web.json_response({"status": "error", "message": "Replacement recipe not found"}, status=404) + except OSError: + return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500) + return web.json_response({"status": "success", "filename": filename, "name": name}) diff --git a/web/modules/locales.js b/web/modules/locales.js index b2e67fa..7f10ad5 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -107,6 +107,17 @@ export const i18n = { recipeNoTags: '暂无标签', recipeNoMatches: '没有匹配的配方。请清除搜索或标签筛选。', recipeFilterByTag: '按此标签筛选', + recipeImport: '导入配方包', + recipeExport: '导出配方包', + recipeExportError: '导出工作流配方包失败。', + recipeImportError: '导入工作流配方包失败。', + recipeExportSnapshotsConfirm: '导出时包含模型预览快照吗?\n\n快照是展示数据,不包含模型文件。', + recipeExportHistoryConfirm: '导出时包含历史版本吗?\n\n历史可能包含旧提示词和备注。', + recipeExportRedactIdentityConfirm: '是否移除模型 SHA-256、大小和来源信息?\n\n点击“确定”将移除这些身份信息。', + recipeImportSummary: '配方包检查完成。确认导入以下内容吗?', + recipeImportAssets: '预览资产', + recipeImportHistory: '历史版本', + recipeImportRenamePrompt: '配方名称已存在,请输入导入后的名称:', recipeModel: '模型', recipeLoras: 'LoRA', recipePrompt: '正向提示词', @@ -323,6 +334,17 @@ export const i18n = { recipeNoTags: 'No tags yet', recipeNoMatches: 'No recipes match. Clear the search or tag filters.', recipeFilterByTag: 'Filter by this tag', + recipeImport: 'Import package', + recipeExport: 'Export package', + recipeExportError: 'Could not export the Workflow Recipe package.', + recipeImportError: 'Could not import the Workflow Recipe package.', + recipeExportSnapshotsConfirm: 'Include model preview snapshots in the export?\n\nSnapshots are presentation data; model files are never included.', + recipeExportHistoryConfirm: 'Include historical versions in the export?\n\nHistory may contain older prompts and notes.', + recipeExportRedactIdentityConfirm: 'Remove model SHA-256, size, and provenance information?\n\nChoose OK to remove those identity fields.', + recipeImportSummary: 'The recipe package passed inspection. Import these contents?', + recipeImportAssets: 'Preview assets', + recipeImportHistory: 'Historical versions', + recipeImportRenamePrompt: 'This recipe name already exists. Enter the imported name:', recipeModel: 'Model', recipeLoras: 'LoRAs', recipePrompt: 'Positive prompt', diff --git a/web/modules/ui_recipes.js b/web/modules/ui_recipes.js index be11909..2f32072 100644 --- a/web/modules/ui_recipes.js +++ b/web/modules/ui_recipes.js @@ -38,6 +38,58 @@ function outputImageUrl(image) { return `/view?${query.toString()}`; } +async function exportRecipePackage(filename) { + const includeSnapshots = confirm(t('recipeExportSnapshotsConfirm')); + const includeHistory = confirm(t('recipeExportHistoryConfirm')); + const includeIdentity = !confirm(t('recipeExportRedactIdentityConfirm')); + const response = await fetch('/anomalous/export_recipe_package', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename, + include_snapshots: includeSnapshots, + include_history: includeHistory, + include_identity: includeIdentity, + }), + }); + if (!response.ok) throw new Error('recipe export failed'); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${filename.replace(/\.json$/i, '')}.anomalous-recipe.zip`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +async function importRecipePackage(owner, file) { + const inspectResponse = await fetch('/anomalous/import_recipe_package_inspect', { + method: 'POST', + headers: { 'Content-Type': 'application/zip' }, + body: file, + }); + const inspectPayload = await inspectResponse.json(); + if (!inspectResponse.ok || inspectPayload.status !== 'success') throw new Error('recipe import inspection failed'); + const recipeName = inspectPayload.recipe?.name || t('recipeUntitled'); + const summary = `${t('recipeImportSummary')}\n\n${recipeName}\n${t('recipeImportAssets')}: ${inspectPayload.asset_count || 0}\n${t('recipeImportHistory')}: ${inspectPayload.history_count || 0}`; + if (!confirm(summary)) return; + let name = recipeName; + if ((inspectPayload.existing_names || []).includes(name)) { + name = prompt(t('recipeImportRenamePrompt'), `${name} (Imported)`); + if (!name?.trim()) return; + } + const commitResponse = await fetch('/anomalous/import_recipe_package_commit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: inspectPayload.token, collision: 'rename', name: name.trim() }), + }); + const commitPayload = await commitResponse.json(); + if (!commitResponse.ok || commitPayload.status !== 'success') throw new Error('recipe import commit failed'); + await owner.refreshRecipes(); +} + async function captureOutputThumbnail(image) { const url = outputImageUrl(image); if (!url) return null; @@ -780,6 +832,24 @@ export async function showRecipes() { save.dataset.recipeSaveCurrent = 'true'; save.type = 'button'; save.onclick = () => this.handleSaveRecipe(); + const importButton = appendText(actionBar, 'button', t('recipeImport'), 'anomalous-btn-primary'); + importButton.type = 'button'; + importButton.onclick = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.zip,.anomalous-recipe.zip,application/zip'; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + await importRecipePackage(this, file); + } catch (error) { + console.error('Could not import Workflow Recipe package:', error); + alert(t('recipeImportError')); + } + }; + input.click(); + }; this.recipeView.appendChild(actionBar); this.recipeListContainer = document.createElement('div'); @@ -879,6 +949,16 @@ export function renderRecipeList(recipes) { const edit = appendText(actions, 'button', t('recipeEdit'), 'anomalous-btn-success'); edit.type = 'button'; edit.onclick = () => editRecipe(this, recipe?.data || {}, recipe.filename); + const exportButton = appendText(actions, 'button', t('recipeExport'), 'anomalous-btn-primary'); + exportButton.type = 'button'; + exportButton.onclick = async () => { + try { + await exportRecipePackage(recipe.filename); + } catch (error) { + console.error('Could not export Workflow Recipe package:', error); + alert(t('recipeExportError')); + } + }; const restore = appendText(actions, 'button', t('recipeOpenCanvas'), 'anomalous-btn-primary'); restore.type = 'button'; restore.onclick = async () => {