Files
Juan Denis 55d8fb1e44 Read route bodies through json_object()
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.

Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
2026-10-10 20:53:43 -04:00

99 lines
4.4 KiB
Python

"""Marketplace API — the extension registry surface.
The legacy DB-seeded ``Extension``/``ExtensionInstall`` catalog was retired
(#51): nothing ever populated it on a real panel, so it fed Browse an empty
third lane that was redundant with the three real sources — the builtin
folder scan (``/plugins/builtin``), the remote registry (below), and live
``InstalledPlugin`` state. This blueprint now serves only the registry.
"""
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required
from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
from app.middleware.rbac import get_current_user
from app.error_reporting import unexpected_response
from app.api._request import json_object
marketplace_bp = Blueprint('marketplace', __name__)
@marketplace_bp.route('/registry', methods=['GET'])
@jwt_required()
def list_registry():
"""Return the remote-registry extensions (with live install state), for the
Browse merge. Read-only; offline-tolerant (falls back to a bundled index).
Bundled entries (builtins listed for the public catalog) are excluded by
default to avoid duplicating the builtin cards; pass
``?include_bundled=true`` for the complete catalog."""
from app.services import registry_service
include_bundled = request.args.get('include_bundled', '').lower() in ('1', 'true', 'yes')
return jsonify({
'extensions': registry_service.list_catalog(include_bundled=include_bundled),
'source': registry_service.registry_source_label(),
})
@marketplace_bp.route('/registry/<slug>/install', methods=['POST'])
@jwt_required()
def install_registry(slug):
"""Install a registry extension by slug. Checksum-verified (the entry's
sha256, when present, must match before extraction)."""
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
# Trust gate: an unreviewed community extension, or any entry with no
# pinned checksum (possible even for first_party), installs only after an
# explicit risk acknowledgment — the Marketplace shows a confirmation
# dialog and resends with acknowledge_risk: true. first_party / reviewed
# entries install as-is. `reason` tells the UI which case it is so the
# dialog copy stays accurate.
from app.services import registry_service
entry = registry_service.get_entry(slug)
# Hidden means not installable: unreviewed entries only exist in
# development contexts (see registry_service._show_unreviewed).
if entry is not None and entry.get('trust') == 'unreviewed' \
and not registry_service._show_unreviewed():
return jsonify({'error': 'Extension not found in the registry'}), 404
body = json_object(optional=True)
trust = (entry or {}).get('trust', 'unreviewed')
acknowledged = body.get('acknowledge_risk') is True
# Consent gate (shared with the update path — audits M2/M3):
# unreviewed (dev-stage) > unverified (no checksum) > signature gate
# (untrusted key, or unsigned first-party = possible downgrade).
reason, message = registry_service.consent_gate(entry, acknowledged)
if reason:
return jsonify({
'error': f'{message} Resend with acknowledge_risk: true to proceed.',
'trust': trust,
'reason': reason,
'requires_acknowledgment': True,
}), 409
from app.services.plugin_service import install_registry_extension
try:
plugin = install_registry_extension(slug, user_id=user.id)
details = {'name': plugin.name, 'version': plugin.version, 'source': 'registry'}
if acknowledged:
details['trust'] = trust
details['acknowledged_risk'] = True
AuditService.log(
action=AuditLog.ACTION_RESOURCE_CREATE,
user_id=user.id,
target_type='plugin',
target_id=plugin.id,
details=details,
)
# Opt-in anonymous install ping (default OFF; #17). Best-effort — never
# affects the install result.
try:
registry_service.record_install(plugin.slug, plugin.version)
except Exception:
pass
return jsonify(plugin.to_dict()), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)