Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion backend/app/app_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import asyncio
import json
import logging
import subprocess
import tempfile
from dataclasses import dataclass
Expand All @@ -18,7 +19,7 @@

from sqlalchemy.orm import Session

from app import app_git, models, timeutil
from app import app_git, icon_assets, icon_ownership, models, timeutil
from app.app_capabilities import (
contract_from_app_state,
local_manifest_runtime_fields,
Expand All @@ -31,6 +32,7 @@
unlink_app_bundle,
)
from app.manifest_contract import (
ICON_MAX_BYTES,
MANIFEST_MAX_BYTES,
ManifestContractError,
validate_manifest_contract,
Expand All @@ -46,6 +48,9 @@ def __init__(self, code: str, message: str, *, status_code: int = 422):
self.status_code = status_code


log = logging.getLogger("mobius.app_apply")


@dataclass(frozen=True)
class ApplyResult:
app: models.App
Expand Down Expand Up @@ -130,6 +135,70 @@ def _entry_source(snapshot_dir: Path, manifest: dict) -> str:
return source


def _normalize_manifest_icon(relative: str, raw: bytes) -> bytes:
if len(raw) > ICON_MAX_BYTES:
raise AppApplyError(
"icon_too_large",
f"Manifest icon {relative!r} exceeds the {ICON_MAX_BYTES}-byte limit.",
)
try:
return icon_assets.normalize_icon(raw)
except icon_assets.InvalidIcon as exc:
raise AppApplyError("icon_invalid", str(exc)) from exc


def _manifest_icon(snapshot_dir: Path, manifest: dict) -> bytes | None:
"""Normalize the icon declared by this exact accepted source snapshot."""
relative = manifest.get("icon")
if not relative:
return None
path = snapshot_dir / relative
try:
raw = path.read_bytes()
except FileNotFoundError as exc:
raise AppApplyError(
"icon_missing", f"Manifest icon {relative!r} does not exist.",
) from exc
except OSError as exc:
raise AppApplyError(
"icon_unreadable", f"Could not read manifest icon {relative!r}: {exc}",
) from exc
return _normalize_manifest_icon(relative, raw)


def reconcile_manifest_icons(db: Session) -> tuple[list[int], list[str]]:
"""Split legacy icon ownership from each app's accepted Git revision.

The transition is per-row and idempotent. Missing or invalid immutable source
never risks old effective artwork: those bytes become an explicit override,
while a later accepted revision remains free to populate package artwork.
"""
repaired: list[int] = []
warnings: list[str] = []
apps = (
db.query(models.App)
.filter(
models.App.deleted_at.is_(None),
models.App.icon_ownership_split.is_(False),
)
.order_by(models.App.id)
.all()
)
for app in apps:
try:
transition = icon_ownership.split_legacy_icon_ownership(app)
db.commit()
db.refresh(app)
if transition.changed:
repaired.append(app.id)
if transition.warning:
warnings.append(f"app {app.id}: {transition.warning}")
except Exception as exc:
db.rollback()
warnings.append(f"app {app.id}: {exc}")
return repaired, warnings


def _validate_local_identity(source_dir: Path, manifest: dict) -> None:
if manifest["id"] != source_dir.name:
raise AppApplyError(
Expand Down Expand Up @@ -206,6 +275,11 @@ async def apply_source_revision(
if app is None or app.manifest_url is None:
_validate_local_identity(source_path, manifest)
source = _entry_source(snapshot_dir, manifest)
package_icon = (
_manifest_icon(snapshot_dir, manifest)
if app is None or app.manifest_url is None
else None
)

if created:
app = models.App(
Expand All @@ -232,7 +306,16 @@ async def apply_source_revision(
app.jsx_source,
app.compiled_path,
app.source_commit,
app.icon_png,
app.icon_override_png,
app.icon_ownership_split,
)
transition = icon_ownership.split_legacy_icon_ownership(app)
if transition.warning:
log.warning(
"legacy icon ownership for app %s: %s",
app.id, transition.warning,
)

staged = _compiled_dir() / f"app-{app.id}.js.staging"
await compile_jsx(
Expand All @@ -257,6 +340,7 @@ async def apply_source_revision(
runtime_fields = local_manifest_runtime_fields(manifest)
app.name = manifest["name"]
app.description = manifest["description"]
app.icon_png = package_icon
if "offline_capable" in runtime_fields:
app.offline_capable = runtime_fields["offline_capable"]
app.capability_contract = contract_from_app_state(
Expand Down Expand Up @@ -292,6 +376,9 @@ async def apply_source_revision(
source,
str(published),
app.source_commit,
app.icon_png,
app.icon_override_png,
app.icon_ownership_split,
)
)
if not changed:
Expand Down
9 changes: 5 additions & 4 deletions backend/app/app_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -2017,8 +2017,9 @@ def abort_in_progress_merge(source_dir: str | Path) -> bool:
)


def _blob_at(repo: Path, ref: str, rel: str) -> bytes | None:
def read_blob(source_dir: str | Path, ref: str, rel: str) -> bytes | None:
"""Raw bytes of `rel` at `ref`, or None if the path is absent there."""
repo = Path(source_dir)
proc = subprocess.run(
["git", "-C", str(repo), "cat-file", "-p", f"{ref}:{rel}"],
capture_output=True, timeout=_GIT_TIMEOUT, check=False, env=_git_env(repo),
Expand Down Expand Up @@ -2175,9 +2176,9 @@ def resolve_version_only_conflict(
return None
resolved: dict[str, bytes] = {}
for rel in merge_conflicts:
ours = _blob_at(repo, LOCAL_BRANCH, rel)
theirs = _blob_at(repo, UPSTREAM_BRANCH, rel)
base_blob = _blob_at(repo, base_ref, rel)
ours = read_blob(repo, LOCAL_BRANCH, rel)
theirs = read_blob(repo, UPSTREAM_BRANCH, rel)
base_blob = read_blob(repo, base_ref, rel)
# An add/add or delete conflict (a side missing the file) is not the
# version-bump shape; leave it to the owner.
if ours is None or theirs is None or base_blob is None:
Expand Down
17 changes: 17 additions & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,23 @@ def _slugify_for_source_dir(name: str) -> str:
with eng.connect() as conn:
conn.execute(text("ALTER TABLE apps ADD COLUMN icon_png BLOB NULL"))
conn.commit()
if "icon_override_png" not in apps_cols:
with eng.connect() as conn:
conn.execute(text(
"ALTER TABLE apps ADD COLUMN icon_override_png BLOB NULL"
))
conn.commit()
if "icon_ownership_split" not in apps_cols:
# Existing icon_png values predate package/override separation and must be
# classified from accepted source before either writer may replace them.
# New ORM-created rows explicitly write TRUE; a raw or interrupted insert
# remains safely eligible for startup reconciliation.
with eng.connect() as conn:
conn.execute(text(
"ALTER TABLE apps ADD COLUMN icon_ownership_split "
"BOOLEAN NOT NULL DEFAULT FALSE"
))
conn.commit()
# Per-app token nonce. Add the column, then backfill
# any NULL row with a fresh random nonce so existing apps get the same
# id-reuse protection as new ones. Two independent idempotent gates so a
Expand Down
64 changes: 64 additions & 0 deletions backend/app/icon_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Validation and normalization for every accepted app-icon source."""

from __future__ import annotations

import io
import warnings

from PIL import Image


# Pillow's default (~89M pixels) still permits a tiny hostile file to request
# a very large allocation. App icons never need that headroom.
Image.MAX_IMAGE_PIXELS = 32_000_000
MAX_ICON_DIMENSION = 4096


class InvalidIcon(ValueError):
"""The supplied bytes cannot become a bounded app icon."""


def normalize_icon(raw: bytes) -> bytes:
"""Return one bounded square RGB/RGBA PNG for install, apply, or override."""
try:
image = Image.open(io.BytesIO(raw))
# Header dimensions are available before load(), so reject oversized
# images before Pillow allocates their decoded pixel buffer.
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
width, height = image.size
if width > MAX_ICON_DIMENSION or height > MAX_ICON_DIMENSION:
raise InvalidIcon(
f"Icon dimensions {width}x{height} exceed "
f"{MAX_ICON_DIMENSION}x{MAX_ICON_DIMENSION} cap."
)
image.load()
except InvalidIcon:
raise
except (Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
raise InvalidIcon(f"Icon rejected as decompression bomb: {exc}") from exc
except Exception as exc:
raise InvalidIcon("Icon is not a valid image.") from exc

if image.mode not in ("RGB", "RGBA"):
# Palette PNG transparency lives in tRNS metadata. Treat palette images as
# potentially transparent so normalization never bakes black corners in.
has_alpha = (
"A" in image.mode
or "transparency" in image.info
or image.mode == "P"
)
image = image.convert("RGBA" if has_alpha else "RGB")

width, height = image.size
if width != height:
side = min(width, height)
left = (width - side) // 2
top = (height - side) // 2
image = image.crop((left, top, left + side, top + side))
if image.size[0] > 1024:
image = image.resize((1024, 1024), Image.LANCZOS)

output = io.BytesIO()
image.save(output, format="PNG", optimize=True)
return output.getvalue()
Loading