The import dialog said "with 1 picture" for a package with a cover and one model thumbnail: the cover lives inside recipe.json as a small image, not as an asset file. Inspect now returns has_cover and model_pictures, and the dialog says "a cover, N model thumbnails". The save dialog's cover preview stretched the chosen image to the dialog's width; it now keeps its proportions (object-fit: contain). Checked live: a recipe with an output-image cover and a LoRA thumbnail, exported and imported with the output image moved away, shows both on the new card and its detail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
502 lines
22 KiB
Python
502 lines
22 KiB
Python
"""Recipe packages (配方包): one Workflow Recipe as a .zip to give to someone else — the recipe,
|
|
its cover, its models' thumbnails and, when asked, its versions — and taking one in as a new
|
|
recipe card. Bounded: entry names, count, sizes, compression ratio and checksums are checked
|
|
before anything is written; an import never replaces a recipe (a taken name gets " (2)")."""
|
|
|
|
import hashlib
|
|
import io
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import tempfile
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
import zipfile
|
|
|
|
from aiohttp import web
|
|
|
|
from . import recipe_images, recipe_schema, recipe_store
|
|
from .recipe_store 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 _cover_asset_id(recipe):
|
|
presentation = recipe.get("presentation", {}) if isinstance(recipe, dict) else {}
|
|
asset_id = presentation.get("cover_asset_id") if isinstance(presentation, dict) else None
|
|
if not isinstance(asset_id, str) or not asset_id.startswith("cover-") or not asset_id.endswith(".webp"):
|
|
return None
|
|
return require_filename(asset_id)
|
|
|
|
|
|
def _picture_summary(recipe):
|
|
"""What a package shows the receiver: (has a cover, how many model thumbnails). The cover
|
|
is usually the small image inside recipe.json (`thumbnail`), not an asset file."""
|
|
thumbnail = recipe.get("thumbnail")
|
|
has_cover = bool(_cover_asset_id(recipe)) or (
|
|
isinstance(thumbnail, str) and thumbnail.lower().startswith(recipe_schema.SAFE_THUMBNAIL_PREFIXES))
|
|
references = recipe.get("params", {}).get("model_references", [])
|
|
models = {_asset_id_from_reference(item) for item in references if isinstance(item, dict)} if isinstance(references, list) else set()
|
|
models.discard(None)
|
|
return has_cover, len(models)
|
|
|
|
|
|
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, include_model_notes=True):
|
|
value = json.loads(json.dumps(recipe, ensure_ascii=False))
|
|
# A source_image points into the exporting machine's output directory and
|
|
# is not portable package data. The bounded thumbnail remains the cover.
|
|
value["source_image"] = None
|
|
for reference in value.get("params", {}).get("model_references", []):
|
|
if not include_model_notes:
|
|
reference.pop("user_note", None)
|
|
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, include_model_previews=True):
|
|
result = set()
|
|
cover_asset = _cover_asset_id(recipe)
|
|
if cover_asset:
|
|
result.add(cover_asset)
|
|
if include_model_previews:
|
|
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", True) is True
|
|
include_history = options.get("include_history") is True
|
|
include_identity = options.get("include_identity", True) is True
|
|
include_model_notes = options.get("include_model_notes", True) is True
|
|
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity, include_model_notes)
|
|
history_recipes = []
|
|
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)
|
|
with open(history_path, "rb") as history_file:
|
|
data = history_file.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_recipes.append((history_name, _sanitize_recipe_for_export(
|
|
history_recipe,
|
|
include_snapshots,
|
|
include_identity,
|
|
include_model_notes,
|
|
)))
|
|
asset_ids = _referenced_asset_ids(recipe, include_snapshots)
|
|
for _, history_recipe in history_recipes:
|
|
asset_ids.update(_referenced_asset_ids(history_recipe, include_snapshots))
|
|
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 asset_ids:
|
|
assets_dir = recipe_images._recipe_assets_dir(recipes_dir, filename)
|
|
for asset_id in sorted(asset_ids):
|
|
asset_path = resolve_within(assets_dir, asset_id)
|
|
if not os.path.isfile(asset_path):
|
|
continue
|
|
with open(asset_path, "rb") as asset_file:
|
|
data = asset_file.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, history_recipe in history_recipes:
|
|
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,
|
|
"include_model_notes": include_model_notes,
|
|
},
|
|
"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, recipes, names):
|
|
referenced = set()
|
|
for recipe in recipes:
|
|
referenced.update(_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_schema._normalise_recipe(recipe)
|
|
history_names = [name for name in names if name.startswith("history/")]
|
|
if len(history_names) > MAX_HISTORY_ENTRIES:
|
|
raise ValueError("Too many history entries")
|
|
history_recipes = []
|
|
for name in history_names:
|
|
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
|
|
recipe_schema._normalise_recipe(history)
|
|
history_recipes.append(history)
|
|
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
|
|
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):
|
|
"""Writes the inspected package as a new recipe (never over one): (filename, name)."""
|
|
recipes_dir = get_recipes_dir()
|
|
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
|
|
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()
|
|
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), _existing_recipe_names(recipes_dir))
|
|
|
|
normalized = recipe_schema._normalise_recipe(package_recipe)
|
|
normalized["presentation"]["imported"] = True
|
|
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
|
normalized = recipe_schema._enrich_recipe(normalized)
|
|
|
|
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
|
|
final_path = resolve_within(recipes_dir, filename)
|
|
final_assets = recipe_images._recipe_assets_dir(recipes_dir, filename)
|
|
final_history = recipe_store._history_dir(recipes_dir, filename)
|
|
installed = []
|
|
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)
|
|
# The images first, the recipe last: a recipe card never shows without its pictures.
|
|
staged_assets = os.path.join(staging, "assets")
|
|
if os.listdir(staged_assets):
|
|
os.makedirs(os.path.dirname(final_assets), exist_ok=True)
|
|
os.replace(staged_assets, final_assets)
|
|
installed.append(final_assets)
|
|
if record["history_names"]:
|
|
os.makedirs(os.path.dirname(final_history), exist_ok=True)
|
|
os.replace(os.path.join(staging, "history"), final_history)
|
|
installed.append(final_history)
|
|
os.replace(os.path.join(staging, "recipe.json"), final_path)
|
|
except Exception:
|
|
for path in installed: # this import's own new folders; nothing of the user's
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
raise
|
|
finally:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
return filename, normalized.get("name", "")
|
|
|
|
|
|
def _download_name(recipe, filename):
|
|
"""The .zip's name for the browser: the recipe's name, safe on every system."""
|
|
name = "".join("_" if char in '<>:"/\\|?*' or ord(char) < 32 else char for char in str(recipe.get("name") or "")).strip(" .")
|
|
return f"{name or os.path.splitext(filename)[0]}.anomalous-recipe.zip"
|
|
|
|
|
|
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)
|
|
name = _download_name(recipe, filename)
|
|
return web.Response(body=package, content_type="application/zip", headers={
|
|
"Content-Disposition": f"attachment; filename=\"recipe.anomalous-recipe.zip\"; filename*=UTF-8''{urllib.parse.quote(name)}",
|
|
})
|
|
|
|
|
|
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"]),
|
|
"has_cover": _picture_summary(report["recipe"])[0],
|
|
"model_pictures": _picture_summary(report["recipe"])[1],
|
|
"history_count": len(report["history_names"]),
|
|
"existing_names": _existing_recipe_names(get_recipes_dir()),
|
|
"imported_name": _unique_name(report["recipe"].get("name", ""), _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 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})
|