Add secure recipe package transfer
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
(只比较有上限的语义摘要:提示词、钉选原始值、已知模型引用、安全的采样/分辨率字段、图数量/指纹和展示信息。比较器必须是纯只读逻辑,历史恢复仍是独立的破坏性动作并单独确认。)
|
||||
|
||||
+2
-1
@@ -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.*`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user