mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(sports): share the ESPN rejected-range memo with the background service BackgroundDataService always sent a season range first and, on a 400, fell back to chunks without recording the rejection, so every background season fetch spent a doomed request and live scoreboards learned nothing from it (or it from them). The worker now consults and sets the same 6-hour memo fetch_espn_scoreboard() uses: a known rejection goes straight to month/day chunks, and if every chunk fails the range is asked once for a real error without re-spending the chunks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): keep plugin asset and action routes inside their directories POST /plugins/assets/upload, GET /plugins/assets/list and POST /plugins/assets/delete joined the request's plugin_id onto assets/plugins unchecked, so '../../config' created, wrote, listed and deleted outside it. #561 guarded only the route that serves the files. All three now go through path_safety.resolve_under and answer 400 for anything but a plain name, and delete only unlinks a metadata path that resolves into that plugin's uploads directory. PluginManager.get_plugin_directory refuses ids that are not one plain path segment, so /plugins/action (which runs a manifest script from the returned directory) and every other caller get the guard; the action route also rejects such ids up front, covering its no-manager fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): report a no-op plugin update as already up to date update_plugin() returns True both for a real update and for "nothing to do" (a ZIP-installed monorepo plugin already at the registry version, a bundled plugin). With no git commit to compare, POST /plugins/update called every such success "updated successfully", so Check & Update All counted most official plugins as updated on every run. The route now reads what changed off the plugin itself (commit, else manifest version, else last_updated) and returns data.update_status (updated / up_to_date / local_only). The update-all toast is summarised by PluginInstallManager.summarizeUpdateResults from that status, falling back to the message for older servers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(sports): scoreboard scroll speed no longer follows target_fps sports_scroll computed the crisp speed ladder against the global target_fps whenever limit_refresh_rate_hz was the 100 Hz default. Since frame-locked presentation (#545) the helper steps a fixed number of whole pixels per presented frame and the panel presents at its real refresh, so the General tab's "Scroll Frame Rate" became a speed multiplier: 60 ran a 50 px/s scoreboard at 100 px/s, 200 ran it at 25 px/s. The ladder now uses the display manager's refresh_hz, then display.hardware.limit_refresh_rate_hz, then the default. target_fps is not consulted. Docstrings now say scroll_delay is ignored for pacing (no behaviour change there) and describe the fixed-step model. Tests: replace the tests that pinned target_fps as the ladder refresh and described time-based stepping; assert speed independence from target_fps (unit and end-to-end presented px/s against the real helper), that the fixed per-frame step is applied, and that scroll_delay does not change speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): escape registry and upload values in plugin manager inline handlers The store, saved-repository and custom-registry buttons built onclick='...(${JSON.stringify(id)})...'. JSON.stringify leaves ' alone, so a custom registry entry whose id contained ' closed the attribute and added its own handler. One helper, jsStringAttr(), now HTML-escapes the JSON literal for every one of those handlers, and the store View button opens only http(s) repo links. The live window.updateImageList (plugins_manager.js loads last, so its copy wins over the file-upload widget's) wrote the uploaded file's original name, path and ids into markup raw; they are escaped now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): note plugin asset, action and inline handler guards Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(update): let the root pip wrapper install web_interface/requirements.txt Update Code, the automatic update's health check and Install Base Requirements install web_interface/requirements.txt through safe_pip_install.sh, which only allowed the root requirements.txt. The first commit changing that file would fail its dependency install, and the automatic updater rolls back any update whose dependencies did not install -- on every device, for every newer commit. The wrapper now lists both core requirement files. Only their folders are resolved, so a requirements.txt symlinked out of the project is compared by its target and refused (previously the root file's own symlink target was what got allowed). The updater's file list is a named constant, and a test runs the real wrapper (pip stubbed) on every file Update Code and the rollback install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): do not retry plugin requests that got an HTTP answer PluginAPI.request wrapped everything that was not a structured error as NETWORK_ERROR: a proxy's 502 HTML page (response.json() throws) and a JSON error without error_code included. Check & Update All retries NETWORK_ERROR, so those updates were re-sent five more times with backoff, contrary to the #587 contract that an HTTP error response is the server's answer. NETWORK_ERROR now means only that fetch() rejected. Any HTTP response without an error_code, or with a body that is not JSON, is API_ERROR with the HTTP status attached. Tested against the shipped api_client.js. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scroll): restart the stats window when an idle gap is dropped by size #582 dropped an idle gap from the frame stats two ways: the reset_scroll() sentinel, which also restarts the 5s window timer, and a size guard for scrollers that never call reset_scroll(), which did not. On that path the first real frame after the gap found the boundary overdue and logged a stats line for a one-frame window. Both paths now share one seeding helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(update): leave plugins alone when update_core's own rollback fails update_core returns rollback_failed directly when a partial pull or an update whose health check never started cannot be rolled back. run() only held plugins back for 'verifying', so those devices still got new plugin versions and a display restart on top of a core in an unknown state -- the opposite of what the health-check path does, and of the 3.4.0 changelog (plugins are left alone if the rollback fails). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(api): make the REST reference match the api_v3 package Every documented request body, query parameter and response shape was re-checked against the handlers in web_interface/blueprints/api_v3/. Fixes calls that failed as documented (repo_url, action_id/params, files/image_id, font_file+font_family, ?font=, cache key, auto_enable_ap_mode, plugin limit keys), removes the font-override endpoints dropped in #566, corrects response shapes (plugins/config, plugins/schema, health, metrics, operation history, github-status, fonts/catalog, cache/list, logs, wifi, on-demand, SSE streams), and adds the 26 routes it omitted (backup, system auto-update/git, wifi radio, starlark editor, MQTT bridge, status endpoints, skins). Documents the merge semantics of partial JSON saves to /config/main and /plugins/config and the dim-schedule POST accepting GET's days shape, which land in the same change set. Replaces app.py line numbers and the removed api_v3.py path with file and function names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): remove the General-tab plugin system toggles that did nothing plugin_system.auto_discover, auto_load_enabled and development_mode had General-tab toggles whose help tips promised dormant plugins and verbose logging, but nothing reads them: every enabled plugin is discovered and loaded regardless. Remove the three toggles. The keys stay tolerated in stored configs. The save handler now stores a flag only when a client sends it; treating a missing key as an unchecked box would otherwise rewrite all three to false on every General-tab save, which still posts plugins_directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(scroll): remove dead code left by #523/#570 - Drop the optional scipy.ndimage import and HAS_SCIPY; nothing read them since the numpy blend replaced the scipy path. - Drop ScrollHelper._last_integer_position and frame_time_target, which were written but never read. - Keep target_fps and set_target_fps() but document them as informational: nothing paces off them, yet ledmatrix-elections' test_scroll_pacing.py reads helper.target_fps back and third-party plugins may call the setter. - Fix stale comments: fixed_pixels_per_frame's "use scroll_delay to throttle", set_sub_pixel_scrolling's "default: True", and set_frame_based_scrolling's claim that it steps. The plugins monorepo was grepped for every removed name; none is used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(fonts): point plugins at plugin_manager.font_manager; drop removed overrides UI FONT_MANAGER.md told plugins to read display_manager.font_manager, which does not exist, so a plugin following it failed to load with AttributeError. The shared FontManager lives on the PluginManager and BasePlugin._get_font_manager() returns it (with a fallback for harnesses). Also removes the Fonts-tab override workflow and element-override panels that #566 deleted, from FONT_MANAGER.md and WEB_INTERFACE_GUIDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(store): search via /plugins/store/list?query=; send Content-Type on registry curls /plugins/store/search does not exist (404) and the list endpoint reads query, not q. The registry guide's curl examples omitted the JSON Content-Type, so the handlers saw an empty body and answered 400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(config): use the shared core-key list in the last three private copies StartupValidator warned "Plugin 'auto_update' is enabled but not found" on every display start with auto-update or a dim schedule on; the reserved plugin-id check missed auto_update, sync, location and the rest; and ConfigManager's (uncalled) orphan cleanup would have deleted display, schedule and auto_update. All three now read src/core_config_keys.py, which also gains CORE_SECRETS_KEYS for the github/youtube secrets sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): partial JSON saves to /config/main change only what they send A JSON body with one field reset every checkbox in the sections it touched: the MQTT bridge's brightness slider turned off disable_hardware_pulsing, inverse_colors, show_refresh_rate and use_short_date_format, and a timezone-only save turned off web-UI autostart and weekly auto-updates. Missing-means-unchecked now applies only to form posts: form-encoded bodies and the v3 forms, which mark themselves with a hidden __form_section input. Also on the config routes: - vegas_min/max_cycle_duration no longer match the generic *_duration rule, so they stop landing in display_durations and a blank one no longer rejects the whole Display save; - saving from the Raw JSON editor calls start_setup_if_needed like the General form, so enabling auto-update there finishes its setup; - the schedule and dim-schedule POSTs accept the per-day days.<day> shape their GETs return, as well as the flat form keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scripts): install plugin dependencies from the configured plugins directory install_plugin_dependencies.sh scanned only plugins/, but the Plugin Store installs into plugin_system.plugins_directory (default plugin-repos), so the documented "Recommended" fix found 0 plugins on every store install. It now reads plugins_directory from config/config.json (relative to the project root or absolute, default plugin-repos) and also scans plugins/ for dev symlinks, installing a plugin reached through both only once. With set -e alone, `pip ... | tee` took tee's exit status, so a failed pip install was reported as success; set -o pipefail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: replace stale API names, line numbers and the api_v3.py path - ADVANCED_FEATURES: StreamManager methods that exist (get_next_segment, take_next_group, refresh, advance_cycle, ...), and the real on-demand status envelope ({status, data: {state, service}}) - app.py:199 / :144 / :607-619 line citations and web_interface/blueprints/api_v3.py (now a package) replaced with file and function names in ADVANCED_FEATURES, CONFIG_DEBUGGING, PLUGIN_ARCHITECTURE_SPEC, PLUGIN_QUICK_REFERENCE, PLUGIN_CONFIGURATION_TABS, TROUBLESHOOTING and web_interface/README - CONFIG_DEBUGGING: partial /config/main saves change only sent keys; use /config/raw/main to replace the file; describe where validation runs - TROUBLESHOOTING: clear_cache.py needs --clear-all (no args only prints usage) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scripts): verify the web interface that actually ships, on port 5000 verify_installation.sh failed every healthy install: it required the long-removed web_interface_v2.py and looked for a listener on port 5001, while the web interface binds 5000 (web_interface/start.py). It now checks the files ledmatrix-web.service runs (start_web_conditionally.py, web_interface/start.py, app.py) and port 5000. verify_web_ui.sh had the same 5001 port in its listen check, HTTP probe and printed URLs. Port matches are anchored so :50001 no longer counts as :5000. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(plugins): one display-size contract: display_manager.width/height CLAUDE.md (#580) says to read display_manager.width/height because matrix is None when hardware init fails; the development guide, the safety-harness doc and two DisplayManager docstrings still recommended matrix.width/height. The bundled starlark-apps plugin read matrix.width unguarded, so its magnify recommendation and frame scaling raised in fallback mode (e.g. after the Pi 5 hardware refusal). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(install): make install_service.sh --help print usage instead of installing install_service.sh parsed no arguments, so `sudo ./scripts/install/ install_service.sh --help` (presented as harmless in MIGRATION_GUIDE.md) rewrote ledmatrix.service, ledmatrix-web.service and both update-verify units and enabled/started them. It now handles -h/--help (usage, exit 0, no changes) and rejects any other argument with exit 2 before doing anything. Running it with no arguments, as first_time_install.sh does, is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(scroll): describe the fixed-step model and document frame_hold Since #545 a crisp speed from scroll_config.configure() makes the helper advance a fixed whole-pixel step per presented frame with no clock, and the display manager's frame hold is part of the speed. The docs still described the removed wall-clock model: - scroll_config's module and configure() docstrings said speed is applied in time-based mode and that omitting the hold "falls back to fractional pixels"; omitting it actually runs the scroll frame_hold times too fast. - SCROLL_PERFORMANCE.md said ScrollHelper accumulates elapsed time in both modes, and read a 20 ms stats median as missed refreshes although that is a healthy 50 px/s (hold 2) scroll. It now explains the fixed step, the hold-dependent healthy median, that target_fps plays no part, and that a hand-added scroll_pixels_per_second loses to a schema-default pair. - PLUGIN_API_REFERENCE.md documented set_scrolling_state(is_scrolling) without frame_hold; it now documents the parameter (core 3.4.0) with a configure() + set_scrolling_state example. - update_scroll_position/set_scroll_speed and set_scrolling_state docstrings say the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(config): mark target_fps legacy; describe what Vegas scroll_delay does - General tab "Scroll Frame Rate" (target_fps) is labelled legacy: after the sports_scroll fix nothing in core scrolling reads it. The field and its API validation stay so saved configs and plugins that read global_config['target_fps'] keep working. CONFIG_REFERENCE says the same. - Vegas frame_based_scrolling/scroll_delay were described as frame-count stepping at ~50 FPS. Neither steps nor sets a frame rate: frame-based mode converts the speed to px per scroll_delay, clamps it to 0.1-5, and still advances by elapsed time, so the applied speed is clamp(scroll_speed * scroll_delay, 0.1, 5) / scroll_delay px/s. The config comments, render_pipeline comment and CONFIG_REFERENCE rows now say so. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(deps): describe how plugin dependencies are really installed The guides said the web service runs as root, that installs pick --user from os.geteuid(), and quoted a warning and a PluginManager._install_plugin_dependencies() method that don't exist. The web unit runs as the installing user; store installs go through install_requirements_file() and sudo safe_pip_install.sh (root), with a user-level fallback that says so, and load-time installs run in the display service's own (root) interpreter. Manual paths now use the configured plugins directory (plugin-repos/ by default) instead of plugins/, which store installs no longer use, and install_plugin_dependencies.sh is described as scanning that directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(update): count local changes one way for the preflight and the pull The automatic update's preflight ignored mode-only changes and anything whose status line contained plugins/ or plugin-repos/, then promised "Automatic updates will not stash your changes". perform_core_update used plain git status (modes count) and ignored only 'plugins/', then ran 'git stash push -- :!plugins', which nothing ever pops. So an edit to a bundled plugin under plugin-repos/, or the installer's chmods on tracked scripts, passed the preflight and was stashed away for good. - auto_update.local_changes() is the one predicate both use: core.fileMode=false, porcelain -z, and plugins/ and plugin-repos/ excluded by leading folder rather than substring (a core file under web_interface/static/v3/js/plugins/ now counts). - Update Code's explicit stash leaves out both plugin folders; the pull's --autostash carries their edits and mode changes across and reapplies them. - The automatic updater calls perform_core_update(stash_local_changes= False), which refuses instead of stashing edits that appeared after the preflight; update_core reports that as 'blocked'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scripts): diagnostics follow the web autostart default and api_v3 package #556 made a missing web_display_autostart mean "start" (only an explicit false/off keeps the web interface down), but the diagnostics still said otherwise: diagnose_web_ui.sh reported a missing key as "defaults to false", diagnose_web_interface.sh said the web interface "will not start unless this is set to true" and recommended enabling it, and debug_web_manual.py printed False. Troubleshooting a down web UI pointed users at a non-cause. Both shell scripts now evaluate the setting with the launcher's own autostart_enabled() (inline fallback if it cannot be imported) and report on / off / not set (on) / unparseable config; debug_web_manual.py uses the same function. They also check web_interface/blueprints/api_v3/ __init__.py: api_v3.py became a package in #553, so every healthy checkout was reported as missing a file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(install): what install_service.sh installs; verify script port; no sudo for --help install_service.sh installs and starts ledmatrix, ledmatrix-web and the update-verify units, not only ledmatrix.service (systemd/README.md, README.md). MIGRATION_GUIDE presented 'sudo install_service.sh --help' as a harmless check; it now shows --help without sudo and warns what a real run does. SSH_UNAVAILABLE_AFTER_INSTALL: verify_installation.sh checks the web interface on port 5000. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): note update-all, plugin system settings and script fixes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(display): size the preview after orientation and pixel mappers display_geometry.physical_size claimed to give DisplayManager's answer but only computed cols*chain x rows*parallel. RGBMatrix.width/height are measured after the library's pixel mappers, so a Rotate:90 / orientation 90 chain previewed 128x32 for a 32x128 panel and a U-mapper chain of four 256x32 for 128x64. Model the built-in mappers' size effect as the pinned lib/pixel-mapper.cc does (Rotate, U-mapper, V-mapper, StackToRow, Remap; Mirror and unknown names leave it alone), and move the orientation composition here so DisplayManager and the preview share it. The module docstring no longer claims the sync handshake uses it; that imports only DEFAULT_CHAIN_LENGTH. Audit finding F18. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(display): refuse settings the rgbmatrix library aborts on, on every board The library answers several settings with a NULL matrix or abort() rather than an error, so the display service crash-looped (Restart=on-failure) instead of reaching fallback mode: rows above 64, chain_length above 255 (uint8_t binding setter, documented as "no upper limit"), a misspelled hardware_mapping, and parallel 2-3 on a single-output mapping, reachable from the Display form on the default adafruit-hat(-pwm) mapping. #586 only guarded the Pi 5 subset. - src/matrix_support.py holds the rules for every board (Options::Validate ranges, binding integer types, mapping names and outputs from lib/hardware-mapping.c) plus the Pi 5 ones, and is the one source of the API's numeric ranges. - DisplayManager checks them before building options and raises MatrixSettingsRefused, so a hand-edited config falls back with a logged, reported reason. Emulator mode only warns. - The config API refuses them with a 400 naming the setting; combinations are checked against stored values but reported only when the request sets a field involved. - The hardware status file gains "cause" (settings/library/forced). The fallback log and Display banner give the Pi 5 rebuild hint only for a library failure instead of rebuild + gpio_slowdown advice for every failure; one Pi 5 slowdown recommendation (1-3, start at 1). - The Display form offers classic/classic-pi1 and orientation 90/270 and renders any other stored mapping selected with a warning, so an unrelated save no longer rewrites them; the API accepts 90/270. Audit findings F03, F16, F19, F21. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(display): library limits, template defaults and Pi 5 slowdown - rows 8-64, chain_length 1-255, parallel limited by the mapping's outputs, classic/classic-pi1 mappings and orientation 90/270 documented. - Defaults are the config.template.json values: config migration adds missing keys from the template, so the listed "code defaults" never applied. - One Raspberry Pi 5 gpio_slowdown recommendation: 1-3 in PIO mode, starting at 1. - Troubleshooting describes the refused-settings fallback, and CHANGELOG corrects the Unreleased "no upper limit" entry. Audit findings F19, F20, F21. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scripts): scroll_speeds.py opens the panel with the service's options --measure and --demo built RGBMatrixOptions from a private copy of the display service's builder that had drifted: gpio_slowdown came from display.hardware (default 2) instead of display.runtime (default 3), and rp1_rio, panel_type, disable_hardware_pulsing, inverse_colors, pixel_mapper_config and orientation were skipped, with different defaults (hardware_mapping "regular", pwm_bits 11). A panel needing a high slowdown was measured -- or garbled -- in a setup the service never drives. The option filling in DisplayManager._setup_matrix moves, unchanged, into DisplayManager.apply_matrix_options(options, config), which _setup_matrix calls and the script reuses (overriding only limit_refresh_rate_hz for --measure). The script now loads the whole config rather than the hardware block. Tests pin the script's options to the service's attribute for attribute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scripts): scroll_speeds.py recommends keys the resolver honours The ladder ended by telling users to set display_options.scroll_pixels_per_second. scroll_config ranks that key below the scroll_speed + scroll_delay pair, deliberately, and several plugin schemas default the pair into config, so the advised key was silently ignored (a schema-default 1/0.02 pair plus an advised 66 still resolved to 50 px/s). The advice is now the pair that selects the crisp speed exactly (pixels_per_frame every frame_hold/refresh seconds), explains that the pair outranks scroll_pixels_per_second, and gives the scoreboards' per-league scroll_settings.scroll_speed (px/s) form. Tests resolve the printed pair over a schema-default pair and check it lands on the advertised speed and hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: withdraw the target_fps claim for sports_scroll; fix the Vegas speed formula - SPORTS_UNIFICATION.md still presented honouring global target_fps as sports_scroll's added behaviour and its one user-visible gain; note that it was withdrawn because it had become a speed multiplier. - ADVANCED_FEATURES.md gave Vegas scrolling as (scroll_speed / target_fps) * elapsed; the real rule is scroll_speed px/s by elapsed time, through a 0.1-5 px per scroll_delay clamp when frame_based_scrolling is on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): scroll model fixes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(dev): link-github links plugins from the ledmatrix-plugins monorepo link-github <name> cloned https://github.com/ChuckBuilds/ledmatrix-<name>.git, and those per-plugin repositories no longer exist: official plugins are directories in the ledmatrix-plugins monorepo. It now clones (or pulls) the monorepo once into the dev directory, finds plugins/<name>, plugins/ledmatrix-<name> or the plugin whose manifest id is <name>, and links it under its manifest id. With an explicit repo URL it still links a single-repository plugin as before. dev_plugins.json: github_user is honoured again (monorepo owner, e.g. a fork), plus plugins_repo and plugins_branch; github_pattern, which was documented but never read, is dropped and warned about. Ships dev_plugins.json.example and git-ignores dev_plugins.json, both of which the guide promised. Reading JSON falls back to python3 when jq is missing (get_plugin_id silently returned nothing without jq). update/status/list find the git checkout above a monorepo plugin directory (its .git is not in the plugin dir), and update pulls a shared checkout once. status no longer exits 1 when nothing is broken. Docs: PLUGIN_DEVELOPMENT_GUIDE (quick start, link-github, configuration, workflow, store integration, hello-world link, submission), and the nonexistent scripts/git-hooks/pre-push-plugin-version and scripts/bump_plugin_version.py replaced with the real rule: bump the manifest version and run update_registry.py. scripts/dev/README.md and CLAUDE.md updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(scripts): monorepo workspace layout; fix_perms and install READMEs MULTI_ROOT_WORKSPACE_SETUP described one sibling repository per plugin; setup_plugin_repos.py links ../ledmatrix-plugins/plugins/* into plugin-repos/ and update_plugin_repos.py pulls only the monorepo, and the workspace file opens LEDMatrix plus ../ledmatrix-plugins. scripts/fix_perms/README.md listed cache directories fix_cache_permissions.sh never touches and a 'ledmatrix' service user that doesn't exist (also in scripts/install/README.md); adds safe_pip_install.sh. install/README: install_service.sh installs the web and update-verify units too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(update): keep the rollback's pip retries inside the unit time limit The health check reinstalled the previous requirements by trying the next bash path after any failure, including a 600 s pip timeout. Two files, two paths: up to 40 minutes of pip alone, while systemd stops ledmatrix-update-verify.service at TimeoutStartSec=30min -- killing the rollback half-way and leaving the update 'verifying' until the web UI calls it lost. - Like permission_utils.install_requirements_file, only a sudo refusal moves on to the next bash; a pip that ran and failed or timed out is not repeated. The refusal wording is one list (permission_utils.SUDO_REFUSAL_PHRASES), mirrored in the stdlib-only verifier and pinned equal by a test. - All reinstalls in one rollback share a 600 s budget. - WORST_CASE_SECONDS adds up every timeout on the longest path (27.5 min); a test holds it under the unit's TimeoutStartSec and that under the web UI's VERIFY_LOST_SECONDS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(plugins): prepare plugin configs one way for load, saves, GET, hot reload and dev tools Plugin config was prepared differently depending on how it arrived: - JSON POST /plugins/config built a partial body on schema defaults, so {"enabled": true} reset every other setting of the plugin. It now merges onto the stored section first, as the form path already did. - Legacy-boolean normalization (#588) ran only at load: GET /plugins/config returned the raw boolean, posting it back failed validation, and hot reload handed plugins the raw section (a legacy dynamic_duration: true came back as a boolean). schema_manager.prepare_plugin_config (normalize, then defaults) is now used by PluginManager.load_plugin, both save paths, GET, the save notifications and DisplayController's hot-reload callback. - The JSON save's filter kept only enabled/display_duration/live_priority and dropped a submitted skin, skin_options or vegas_* tuning key. There is now one core-owned per-plugin list, schema_manager.CORE_PLUGIN_PROPERTIES, used by validation and by the save filter; PluginManager's CORE_OWNED_CONFIG_KEYS is its vegas subset. - Plugin sections posted to /config/main were stored verbatim, including values /plugins/config rejects. They now go through the same preparation (_prepare_plugin_config_for_save, extracted from save_plugin_config), and a failing section rejects the whole save before anything is written. - dev_server read only top-level defaults and let a schema enabled:false win; build_full_config shallow-merged overrides, dropping sibling defaults; the harness extracted defaults differently from the device. loading.build_config now uses the device's extraction and preparation, and dev_server, check_plugin, render_plugin and the harness all use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(mqtt-bridge): brightness changes apply live and touch nothing else The display service's hot reload applies a saved brightness within a few seconds, and /config/main no longer resets other display settings on a brightness-only JSON body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): automatic update hardening Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(config): rewrite PLUGIN_CONFIG_ARCHITECTURE for the v3 web UI It described web_interface_v2.py and index_v2.html (both gone), client-side form generation, one POST per field with {key, value}, and 'no nested objects'. The v3 UI renders plugin forms server-side from the schema (pages_v3 partial + plugin_config.html macros, nested sections and x-widgets), posts the whole form once, and save_plugin_config() merges onto the stored section, validates, splits x-secret fields and notifies the plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(mqtt): brightness saves apply via hot reload and leave other settings alone The bridge README said brightness is applied on the display's next restart; the display controller's config hot reload applies it within seconds. It also now states that the bridge's partial JSON save changes only brightness (the /config/main merge fix in this change set). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(update): don't log pip's output from the health check's reinstall pip can echo a private index URL with embedded credentials; permission_utils redacts it, the stdlib-only verifier cannot, so it logs the exit code only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(config): mark the plugin_system toggles as unused legacy keys auto_discover, auto_load_enabled and development_mode are read by nothing and leave the General tab in this change set (F40). CONFIG_REFERENCE said they were read by the plugin loader; PLUGIN_CONFIGURATION_GUIDE and the REST reference listed them as live settings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): docs and developer tools group Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): legacy plugin-system toggles no longer count as a General save auto_discover, auto_load_enabled and development_mode have left the General form, so a post carrying only one of them is not a general-settings save and must not treat web_display_autostart and auto_update as unchecked. The plugin_system block itself is left as on main for the branch that reworks it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): config-save and plugin-config preparation fixes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(claude): re-check matrix_support.py rules when the library submodule is bumped Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: address Codacy findings on the core audit PR - plugin_manager.prepare_plugin_config: when the fallback legacy-boolean pass also fails, log a warning instead of a bare except/pass. - api_client.js: request() refuses any endpoint that is not a plain path under /api/v3 ("//host", backslashes, ".." or "." segments, whitespace, control characters) with INVALID_ENDPOINT before calling fetch(), and plugin ids are URL-encoded wherever they are put into a URL (also in the app-shell batch load). - test_update_all.js: pins both against the shipped client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): check endpoint control characters without a control-character regex Codacy (ESLint no-control-regex, Biome noControlCharactersInRegex) flags the \x00-\x1f range in checkEndpoint's regex. Test the char codes instead; the endpoints refused are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auto-update): make the seed script executable on disk, not only in the index On Linux Repo.publish() commits with -a, which recorded scripts/run.sh as 100644 upstream because the seed file was never chmod +x. The pull then brought in the same mode the installer chmod had made locally, so installer_chmod saw no mode change left to check. The updater was fine: with the upstream commit at 100755 the --autostash carries the device's chmod across. Verified under Linux (WSL, git 2.43): the old helper fails exactly as CI did, the fixed one passes all 63 tests in the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
4109 lines
191 KiB
Python
4109 lines
191 KiB
Python
"""Plugin install, update, enable/disable, config and store routes.
|
|
|
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
|
endpoint names are unchanged by living here.
|
|
"""
|
|
from web_interface.blueprints.api_v3 import (
|
|
ErrorCode, OperationType, PROJECT_ROOT, Path, Response,
|
|
_CALENDAR_LIST_MAX_PAGES, _RENDERED_SECTION_FIELD, _SKIP_FIELD, _coerce_to_bool,
|
|
_do_transactional_uninstall, _enhance_schema_with_core_properties,
|
|
_filter_config_by_schema, _get_plugin_version, _get_schema_property,
|
|
_hidden_array_item_property, _installed_plugin_ids, _is_plugin_update_available,
|
|
_parse_form_value_with_schema, _prune_credential_backups,
|
|
_run_calendar_registration, _schema_allows_null, _schema_type_is,
|
|
_set_missing_booleans_to_false,
|
|
_set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app,
|
|
api_v3, datetime, deep_merge, describe_exception, error_response,
|
|
find_secret_fields, hashlib, json, jsonify, logger, logging,
|
|
merge_secrets, os, redact_text, remove_empty_secrets, request,
|
|
separate_secrets, shutil, stat, subprocess, success_response, sys,
|
|
tempfile, uuid, validate_request_json,
|
|
)
|
|
from src.common.path_safety import (
|
|
resolve_under, safe_path_component, safe_relative_parts,
|
|
)
|
|
import web_interface.blueprints.api_v3 as _pkg
|
|
# Read through the module rather than bound by value: tests patch these
|
|
# as module attributes, and a value binding would not see the patch.
|
|
# Several are also called from helpers that live in __init__, so the
|
|
# package is the only patch point that covers every caller.
|
|
|
|
|
|
@api_v3.route('/plugins/installed', methods=['GET'])
|
|
def get_installed_plugins():
|
|
"""Get installed plugins"""
|
|
try:
|
|
if not api_v3.plugin_manager or not api_v3.plugin_store_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
# Re-discover plugins to ensure we have the latest list
|
|
# This handles cases where plugins are added/removed after app startup
|
|
api_v3.plugin_manager.discover_plugins()
|
|
|
|
# Get all installed plugin info from the plugin manager
|
|
all_plugin_info = api_v3.plugin_manager.get_all_plugin_info()
|
|
|
|
# Load config once before the loop (not per-plugin)
|
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
|
|
|
def _build_plugin_entry(plugin_info):
|
|
plugin_id = plugin_info.get('id')
|
|
try:
|
|
return _build_plugin_entry_inner(plugin_info, plugin_id)
|
|
except Exception:
|
|
logger.exception("Error building plugin entry for %s — skipping", plugin_id)
|
|
return None
|
|
|
|
def _build_plugin_entry_inner(plugin_info, plugin_id):
|
|
# Capture runtime state (state machine + error context) before the
|
|
# manifest merge below can shadow the 'state' key. get_all_plugin_info
|
|
# attaches this via PluginStateManager.get_state_info(); surfacing it
|
|
# lets the UI show *why* a plugin isn't running instead of just
|
|
# 'loaded: false'.
|
|
state_info = plugin_info.get('state')
|
|
plugin_state = None
|
|
plugin_error_info = None
|
|
if isinstance(state_info, dict):
|
|
plugin_state = state_info.get('state')
|
|
plugin_error_info = state_info.get('error_info')
|
|
|
|
# Re-read manifest from disk to ensure we have the latest metadata
|
|
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
|
if manifest_path.exists():
|
|
try:
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
fresh_manifest = json.load(f)
|
|
if isinstance(fresh_manifest, dict):
|
|
plugin_info.update(fresh_manifest)
|
|
else:
|
|
logger.debug("Manifest for %s is not a dict (%s) — skipping merge",
|
|
plugin_id, type(fresh_manifest).__name__)
|
|
except (FileNotFoundError, PermissionError, json.JSONDecodeError) as e:
|
|
logger.debug("Could not read fresh manifest for %s: %s", plugin_id, e)
|
|
|
|
# Enabled status: config is source of truth, fall back to instance
|
|
enabled = None
|
|
plugin_config = full_config.get(plugin_id, {})
|
|
if 'enabled' in plugin_config:
|
|
enabled = bool(plugin_config['enabled'])
|
|
|
|
# Single get_plugin() call shared for both enabled fallback and Vegas mode
|
|
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
|
|
if enabled is None:
|
|
enabled = plugin_instance.enabled if plugin_instance else True
|
|
|
|
# Verified + latest published version from registry (no network call)
|
|
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
|
verified = store_info.get('verified', False) if store_info else False
|
|
latest_version = store_info.get('latest_version', '') if store_info else ''
|
|
installed_version = plugin_info.get('version', '')
|
|
update_available = _is_plugin_update_available(installed_version, latest_version)
|
|
|
|
# Local git info (single subprocess on cache miss, zero on hit)
|
|
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
|
|
local_git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path) if plugin_path.exists() else None
|
|
|
|
if local_git_info:
|
|
sha = local_git_info.get('sha', '')
|
|
last_commit = local_git_info.get('short_sha') or (sha[:7] if sha else None)
|
|
branch = local_git_info.get('branch')
|
|
last_updated = local_git_info.get('date_iso') or local_git_info.get('date')
|
|
else:
|
|
last_updated = plugin_info.get('last_updated')
|
|
last_commit = plugin_info.get('last_commit') or plugin_info.get('last_commit_sha')
|
|
branch = plugin_info.get('branch')
|
|
if store_info:
|
|
last_updated = last_updated or store_info.get('last_updated') or store_info.get('last_updated_iso')
|
|
last_commit = last_commit or store_info.get('last_commit') or store_info.get('last_commit_sha')
|
|
branch = branch or store_info.get('branch') or store_info.get('default_branch')
|
|
|
|
last_commit_message = plugin_info.get('last_commit_message')
|
|
if store_info and not last_commit_message:
|
|
last_commit_message = store_info.get('last_commit_message')
|
|
|
|
# Vegas mode from instance, overridden by explicit config value
|
|
vegas_mode = None
|
|
vegas_content_type = None
|
|
if plugin_instance:
|
|
try:
|
|
if hasattr(plugin_instance, 'get_vegas_display_mode'):
|
|
mode = plugin_instance.get_vegas_display_mode()
|
|
vegas_mode = mode.value if hasattr(mode, 'value') else str(mode)
|
|
except (AttributeError, TypeError, ValueError) as e:
|
|
logger.debug("[%s] Failed to get vegas_display_mode: %s", plugin_id, e)
|
|
try:
|
|
if hasattr(plugin_instance, 'get_vegas_content_type'):
|
|
vegas_content_type = plugin_instance.get_vegas_content_type()
|
|
except (AttributeError, TypeError, ValueError) as e:
|
|
logger.debug("[%s] Failed to get vegas_content_type: %s", plugin_id, e)
|
|
|
|
if 'vegas_mode' in plugin_config:
|
|
vegas_mode = plugin_config['vegas_mode']
|
|
|
|
return {
|
|
'id': plugin_id,
|
|
'name': plugin_info.get('name', plugin_id),
|
|
'version': plugin_info.get('version', ''),
|
|
'latest_version': latest_version,
|
|
'update_available': update_available,
|
|
'author': plugin_info.get('author', 'Unknown'),
|
|
'category': plugin_info.get('category', 'General'),
|
|
'description': plugin_info.get('description', 'No description available'),
|
|
'tags': plugin_info.get('tags', []),
|
|
'enabled': enabled,
|
|
'verified': verified,
|
|
'loaded': plugin_info.get('loaded', False),
|
|
'state': plugin_state,
|
|
'error_info': plugin_error_info,
|
|
'last_updated': last_updated,
|
|
'last_commit': last_commit,
|
|
'last_commit_message': last_commit_message,
|
|
'branch': branch,
|
|
'web_ui_actions': plugin_info.get('web_ui_actions', []),
|
|
'vegas_mode': vegas_mode,
|
|
'vegas_content_type': vegas_content_type,
|
|
}
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
results = list(executor.map(_build_plugin_entry, all_plugin_info))
|
|
plugins = [r for r in results if r is not None]
|
|
plugins.extend(_starlark_virtual_plugins())
|
|
|
|
return jsonify({'status': 'success', 'data': {'plugins': plugins}})
|
|
except Exception as e:
|
|
logger.error('Error in get_installed_plugins', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/health', methods=['GET'])
|
|
def get_plugin_health():
|
|
"""Get health metrics for all plugins"""
|
|
try:
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
# Check if health tracker is available
|
|
if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {},
|
|
'message': 'Health tracking not available'
|
|
})
|
|
|
|
tracker = api_v3.plugin_manager.health_tracker
|
|
# Build per-plugin summaries by ID so persisted (cross-process) health
|
|
# is included, then fold in any in-memory-only entries.
|
|
health_summaries = {}
|
|
for pid in _installed_plugin_ids():
|
|
try:
|
|
# force_reload: this process only reads; bypass the in-memory
|
|
# snapshot so each poll reflects the display service's latest
|
|
# persisted state.
|
|
health_summaries[pid] = tracker.get_health_summary(pid, force_reload=True)
|
|
except Exception:
|
|
logger.debug('Could not read health summary for %s', pid, exc_info=True)
|
|
try:
|
|
for pid, summary in tracker.get_all_health_summaries().items():
|
|
health_summaries.setdefault(pid, summary)
|
|
except Exception:
|
|
logger.debug('get_all_health_summaries failed', exc_info=True)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': health_summaries
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in get_plugin_health', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/health/<plugin_id>', methods=['GET'])
|
|
def get_plugin_health_single(plugin_id):
|
|
"""Get health metrics for a specific plugin"""
|
|
try:
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
# Check if health tracker is available
|
|
if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Health tracking not available'
|
|
}), 503
|
|
|
|
# Get health summary for specific plugin
|
|
health_summary = api_v3.plugin_manager.health_tracker.get_health_summary(plugin_id)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': health_summary
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in get_plugin_health_single', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/health/<plugin_id>/reset', methods=['POST'])
|
|
def reset_plugin_health(plugin_id):
|
|
"""Reset health state for a plugin (manual recovery)"""
|
|
try:
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
# Check if health tracker is available
|
|
if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Health tracking not available'
|
|
}), 503
|
|
|
|
# Reset health state
|
|
api_v3.plugin_manager.health_tracker.reset_health(plugin_id)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Health state reset for plugin {plugin_id}'
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in reset_plugin_health', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/metrics', methods=['GET'])
|
|
def get_plugin_metrics():
|
|
"""Get resource metrics for all plugins"""
|
|
try:
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
# Check if resource monitor is available
|
|
if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {},
|
|
'message': 'Resource monitoring not available'
|
|
})
|
|
|
|
monitor = api_v3.plugin_manager.resource_monitor
|
|
# Build per-plugin summaries by ID so persisted (cross-process) metrics
|
|
# are included, then fold in any in-memory-only entries.
|
|
metrics_summaries = {}
|
|
for pid in _installed_plugin_ids():
|
|
try:
|
|
# force_reload: read-only path — bypass the in-memory snapshot so
|
|
# each poll reflects the display service's latest persisted metrics.
|
|
metrics_summaries[pid] = monitor.get_metrics_summary(pid, force_reload=True)
|
|
except Exception:
|
|
logger.debug('Could not read metrics summary for %s', pid, exc_info=True)
|
|
try:
|
|
for pid, summary in monitor.get_all_metrics_summaries().items():
|
|
metrics_summaries.setdefault(pid, summary)
|
|
except Exception:
|
|
logger.debug('get_all_metrics_summaries failed', exc_info=True)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': metrics_summaries
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in get_plugin_metrics', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET'])
|
|
def get_plugin_metrics_single(plugin_id):
|
|
"""Get resource metrics for a specific plugin"""
|
|
try:
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
# Check if resource monitor is available
|
|
if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Resource monitoring not available'
|
|
}), 503
|
|
|
|
# Get metrics summary for specific plugin
|
|
metrics_summary = api_v3.plugin_manager.resource_monitor.get_metrics_summary(plugin_id)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': metrics_summary
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in get_plugin_metrics_single', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/metrics/<plugin_id>/reset', methods=['POST'])
|
|
def reset_plugin_metrics(plugin_id):
|
|
"""Reset metrics for a plugin"""
|
|
try:
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
# Check if resource monitor is available
|
|
if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Resource monitoring not available'
|
|
}), 503
|
|
|
|
# Reset metrics
|
|
api_v3.plugin_manager.resource_monitor.reset_metrics(plugin_id)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Metrics reset for plugin {plugin_id}'
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in reset_plugin_metrics', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
|
|
def manage_plugin_limits(plugin_id):
|
|
"""Get or set resource limits for a plugin"""
|
|
try:
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
# Check if resource monitor is available
|
|
if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Resource monitoring not available'
|
|
}), 503
|
|
|
|
if request.method == 'GET':
|
|
# Get limits
|
|
limits = api_v3.plugin_manager.resource_monitor.get_limits(plugin_id)
|
|
if limits:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'max_memory_mb': limits.max_memory_mb,
|
|
'max_cpu_percent': limits.max_cpu_percent,
|
|
'max_execution_time': limits.max_execution_time,
|
|
'warning_threshold': limits.warning_threshold
|
|
}
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': None,
|
|
'message': 'No limits configured for this plugin'
|
|
})
|
|
else:
|
|
# POST - Set limits
|
|
data = request.get_json(silent=True) or {}
|
|
from src.plugin_system.resource_monitor import ResourceLimits
|
|
|
|
limits = ResourceLimits(
|
|
max_memory_mb=data.get('max_memory_mb'),
|
|
max_cpu_percent=data.get('max_cpu_percent'),
|
|
max_execution_time=data.get('max_execution_time'),
|
|
warning_threshold=data.get('warning_threshold', 0.8)
|
|
)
|
|
|
|
api_v3.plugin_manager.resource_monitor.set_limits(plugin_id, limits)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Resource limits updated for plugin {plugin_id}'
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in manage_plugin_limits', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/toggle', methods=['POST'])
|
|
def toggle_plugin():
|
|
"""Toggle plugin enabled/disabled"""
|
|
try:
|
|
if not api_v3.plugin_manager or not api_v3.config_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500
|
|
|
|
# Support both JSON and form data (for HTMX submissions)
|
|
content_type = request.content_type or ''
|
|
|
|
if 'application/json' in content_type:
|
|
data = request.get_json(silent=True)
|
|
if not data or 'plugin_id' not in data or 'enabled' not in data:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id and enabled required'}), 400
|
|
plugin_id = data['plugin_id']
|
|
enabled = data['enabled']
|
|
else:
|
|
# Form data or query string (HTMX submission)
|
|
plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id')
|
|
if not plugin_id:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400
|
|
|
|
# For checkbox toggle, if form was submitted, the checkbox was checked (enabled)
|
|
# If using HTMX with hx-trigger="change", we need to check if checkbox is checked
|
|
# The checkbox value or 'enabled' form field indicates the state
|
|
enabled_str = request.form.get('enabled', request.args.get('enabled', ''))
|
|
|
|
# Handle various truthy/falsy values
|
|
if enabled_str.lower() in ('true', '1', 'on', 'yes'):
|
|
enabled = True
|
|
elif enabled_str.lower() in ('false', '0', 'off', 'no', ''):
|
|
# Empty string means checkbox was unchecked (toggle off)
|
|
enabled = False
|
|
else:
|
|
# Default: toggle based on current state
|
|
config = api_v3.config_manager.load_config()
|
|
current_enabled = config.get(plugin_id, {}).get('enabled', False)
|
|
enabled = not current_enabled
|
|
|
|
# A Starlark app is not a plugin in plugin_manager's sense -- it is an
|
|
# entry in starlark-apps' own manifest -- so its enable/disable is
|
|
# handled here rather than falling through to the check below, which
|
|
# would answer "Plugin not found".
|
|
if plugin_id.startswith('starlark:'):
|
|
return _toggle_starlark_app(plugin_id[len('starlark:'):], enabled)
|
|
|
|
# Check if plugin exists in manifests (discovered but may not be loaded)
|
|
if plugin_id not in _pkg._discovered_plugin_manifests(plugin_id):
|
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
|
|
|
# Update config (this is what the display controller reads)
|
|
config = api_v3.config_manager.load_config()
|
|
if plugin_id not in config:
|
|
config[plugin_id] = {}
|
|
config[plugin_id]['enabled'] = enabled
|
|
|
|
# Use atomic save if available
|
|
if hasattr(api_v3.config_manager, 'save_config_atomic'):
|
|
result = api_v3.config_manager.save_config_atomic(config, create_backup=True)
|
|
if result.status.value != 'success':
|
|
return error_response(
|
|
ErrorCode.CONFIG_SAVE_FAILED,
|
|
f"Failed to save configuration: {result.message}",
|
|
status_code=500
|
|
)
|
|
else:
|
|
api_v3.config_manager.save_config(config)
|
|
|
|
# Update state manager if available
|
|
if api_v3.plugin_state_manager:
|
|
api_v3.plugin_state_manager.set_plugin_enabled(plugin_id, enabled)
|
|
|
|
# Log operation
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"enable" if enabled else "disable",
|
|
plugin_id=plugin_id,
|
|
status="success"
|
|
)
|
|
|
|
# If plugin is loaded, also call its lifecycle methods
|
|
# Wrap in try/except to prevent lifecycle errors from failing the toggle
|
|
plugin = api_v3.plugin_manager.get_plugin(plugin_id)
|
|
if plugin:
|
|
try:
|
|
if enabled:
|
|
if hasattr(plugin, 'on_enable'):
|
|
plugin.on_enable()
|
|
else:
|
|
if hasattr(plugin, 'on_disable'):
|
|
plugin.on_disable()
|
|
except Exception as lifecycle_error:
|
|
# Log the error but don't fail the toggle - config is already saved
|
|
import logging
|
|
logging.warning(f"Lifecycle method error for {plugin_id}: {lifecycle_error}", exc_info=True)
|
|
|
|
return success_response(
|
|
message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully"
|
|
)
|
|
except Exception as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_OPERATION_CONFLICT)
|
|
if api_v3.operation_history:
|
|
toggle_type = "enable" if ('data' in locals() and data.get('enabled')) else "disable"
|
|
api_v3.operation_history.record_operation(
|
|
toggle_type,
|
|
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
|
|
status="failed",
|
|
error=str(e)
|
|
)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
context=error.context,
|
|
status_code=500
|
|
)
|
|
@api_v3.route('/plugins/operation/<operation_id>', methods=['GET'])
|
|
def get_operation_status(operation_id):
|
|
"""Get status of a plugin operation"""
|
|
try:
|
|
if not api_v3.operation_queue:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Operation queue not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
operation = api_v3.operation_queue.get_operation_status(operation_id)
|
|
if not operation:
|
|
return error_response(
|
|
ErrorCode.PLUGIN_NOT_FOUND,
|
|
f'Operation {operation_id} not found',
|
|
status_code=404
|
|
)
|
|
|
|
return success_response(data=operation.to_dict())
|
|
except Exception as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
status_code=500
|
|
)
|
|
@api_v3.route('/plugins/operation/history', methods=['GET'])
|
|
def get_operation_history() -> Response:
|
|
"""Get operation history from the audit log."""
|
|
if not api_v3.operation_history:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Operation history not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
try:
|
|
limit = request.args.get('limit', 50, type=int)
|
|
plugin_id = request.args.get('plugin_id')
|
|
operation_type = request.args.get('operation_type')
|
|
except (ValueError, TypeError) as e:
|
|
return error_response(ErrorCode.INVALID_INPUT, f'Invalid query parameter: {e}', status_code=400)
|
|
|
|
try:
|
|
history = api_v3.operation_history.get_history(
|
|
limit=limit,
|
|
plugin_id=plugin_id,
|
|
operation_type=operation_type
|
|
)
|
|
except (AttributeError, RuntimeError) as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
|
return error_response(error.error_code, error.message, details=error.details, status_code=500)
|
|
|
|
return success_response(data=[record.to_dict() for record in history])
|
|
@api_v3.route('/plugins/operation/history', methods=['DELETE'])
|
|
def clear_operation_history() -> Response:
|
|
"""Clear operation history."""
|
|
if not api_v3.operation_history:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Operation history not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
try:
|
|
api_v3.operation_history.clear_history()
|
|
except (OSError, RuntimeError) as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
|
return error_response(error.error_code, error.message, details=error.details, status_code=500)
|
|
|
|
return success_response(message='Operation history cleared')
|
|
@api_v3.route('/plugins/state', methods=['GET'])
|
|
def get_plugin_state():
|
|
"""Get plugin state from state manager"""
|
|
try:
|
|
if not api_v3.plugin_state_manager:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'State manager not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
plugin_id = request.args.get('plugin_id')
|
|
|
|
if plugin_id:
|
|
# Get state for specific plugin
|
|
state = api_v3.plugin_state_manager.get_plugin_state(plugin_id)
|
|
if not state:
|
|
return error_response(
|
|
ErrorCode.PLUGIN_NOT_FOUND,
|
|
f'Plugin {plugin_id} not found in state manager',
|
|
context={'plugin_id': plugin_id},
|
|
status_code=404
|
|
)
|
|
return success_response(data=state.to_dict())
|
|
else:
|
|
# Get all plugin states
|
|
all_states = api_v3.plugin_state_manager.get_all_states()
|
|
return success_response(data={
|
|
plugin_id: state.to_dict()
|
|
for plugin_id, state in all_states.items()
|
|
})
|
|
except Exception as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
context=error.context,
|
|
status_code=500
|
|
)
|
|
@api_v3.route('/plugins/state/reconcile', methods=['POST'])
|
|
def reconcile_plugin_state():
|
|
"""Reconcile plugin state across all sources"""
|
|
try:
|
|
if not api_v3.plugin_state_manager or not api_v3.plugin_manager:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'State manager or plugin manager not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
from src.plugin_system.state_reconciliation import StateReconciliation
|
|
|
|
# Parse optional `force` flag from request body, guarding against
|
|
# non-dict bodies (bare string, array, null) that would raise AttributeError.
|
|
payload = request.get_json(silent=True)
|
|
if not isinstance(payload, dict):
|
|
payload = {}
|
|
force = _coerce_to_bool(payload.get('force', False))
|
|
|
|
reconciler = StateReconciliation(
|
|
state_manager=api_v3.plugin_state_manager,
|
|
config_manager=api_v3.config_manager,
|
|
plugin_manager=api_v3.plugin_manager,
|
|
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
|
)
|
|
|
|
result = reconciler.reconcile_state(force=force)
|
|
|
|
return success_response(
|
|
data={
|
|
'inconsistencies_found': len(result.inconsistencies_found),
|
|
'inconsistencies_fixed': len(result.inconsistencies_fixed),
|
|
'inconsistencies_manual': len(result.inconsistencies_manual),
|
|
'inconsistencies': [
|
|
{
|
|
'plugin_id': inc.plugin_id,
|
|
'type': inc.inconsistency_type.value,
|
|
'description': inc.description,
|
|
'fix_action': inc.fix_action.value
|
|
}
|
|
for inc in result.inconsistencies_found
|
|
],
|
|
'fixed': [
|
|
{
|
|
'plugin_id': inc.plugin_id,
|
|
'type': inc.inconsistency_type.value,
|
|
'description': inc.description
|
|
}
|
|
for inc in result.inconsistencies_fixed
|
|
],
|
|
'manual_fix_required': [
|
|
{
|
|
'plugin_id': inc.plugin_id,
|
|
'type': inc.inconsistency_type.value,
|
|
'description': inc.description
|
|
}
|
|
for inc in result.inconsistencies_manual
|
|
]
|
|
},
|
|
message=result.message
|
|
)
|
|
except Exception as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
context=error.context,
|
|
status_code=500
|
|
)
|
|
def _drop_stale_reconciliation_findings(unresolved):
|
|
"""Re-check a stored reconciliation verdict against current state.
|
|
|
|
The verdict is a snapshot written once per run, and a run that could not
|
|
apply a fix also refuses to retry -- so a resolved condition was reported
|
|
indefinitely. Best-effort: any failure here returns the list untouched,
|
|
because showing a stale warning beats failing the endpoint.
|
|
|
|
Both sets come from the reconciliation module's own extractors rather than
|
|
being re-derived here. That matters for correctness, not tidiness: a plain
|
|
set(load_config()) also contains system keys, the secrets-file keys merged
|
|
in by load_config(), and non-dict values, and any directory holding a
|
|
manifest.json would count as installed even if that manifest does not
|
|
parse. Either looseness clears findings that are still true -- and a secrets
|
|
key read as a plugin is the very bug the filter exists to stop reporting.
|
|
"""
|
|
try:
|
|
from src.plugin_system.state_reconciliation import (
|
|
config_plugin_ids, disk_plugin_ids, ignored_config_keys,
|
|
still_unresolved,
|
|
)
|
|
|
|
cm = api_v3.config_manager
|
|
plugins_dir = getattr(api_v3.plugin_manager, 'plugins_dir', None)
|
|
installed = disk_plugin_ids(plugins_dir) if plugins_dir else set()
|
|
config_keys = config_plugin_ids(cm.load_config() or {},
|
|
ignored_config_keys(cm, installed))
|
|
return still_unresolved(unresolved, config_keys, installed)
|
|
except Exception:
|
|
logger.debug("[Reconciliation] Could not re-check stored findings", exc_info=True)
|
|
return unresolved
|
|
|
|
|
|
@api_v3.route('/plugins/reconciliation-status', methods=['GET'])
|
|
def get_reconciliation_status():
|
|
"""Return the result of the last startup reconciliation from /tmp status file."""
|
|
_recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
|
try:
|
|
st = os.lstat(_recon_path)
|
|
except FileNotFoundError:
|
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
|
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
|
|
logger.warning("[Reconciliation] Status file is not a regular file: %s", _recon_path)
|
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
|
try:
|
|
with open(_recon_path) as _f:
|
|
data = json.load(_f)
|
|
if data.get('unresolved'):
|
|
data['unresolved'] = _drop_stale_reconciliation_findings(data['unresolved'])
|
|
return jsonify({'status': 'success', 'data': data})
|
|
except json.JSONDecodeError:
|
|
logger.exception("[Reconciliation] Failed to parse status file: %s", _recon_path)
|
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
|
except PermissionError:
|
|
logger.exception("[Reconciliation] Permission denied reading status file: %s", _recon_path)
|
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
|
@api_v3.route('/plugins/config', methods=['GET'])
|
|
def get_plugin_config():
|
|
"""Get plugin configuration"""
|
|
try:
|
|
if not api_v3.config_manager:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Config manager not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
plugin_id = request.args.get('plugin_id')
|
|
if not plugin_id:
|
|
return error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
'plugin_id required',
|
|
context={'missing_params': ['plugin_id']},
|
|
status_code=400
|
|
)
|
|
|
|
# Get plugin configuration from config manager
|
|
main_config = api_v3.config_manager.load_config()
|
|
plugin_config = main_config.get(plugin_id, {})
|
|
|
|
# Merge with defaults from schema so form shows default values for
|
|
# missing fields, reading legacy booleans as objects first: what the
|
|
# plugin runs with, and what posts back through the JSON save
|
|
schema_mgr = api_v3.schema_manager
|
|
if schema_mgr:
|
|
try:
|
|
from src.plugin_system.schema_manager import prepare_plugin_config
|
|
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
|
plugin_config = prepare_plugin_config(
|
|
plugin_config, schema_mgr.load_schema(plugin_id, use_cache=True), defaults)
|
|
except Exception as e:
|
|
# Log but don't fail - defaults merge is best effort
|
|
import logging
|
|
logging.warning(f"Could not merge defaults for {plugin_id}: {e}")
|
|
|
|
# Special handling for of-the-day plugin: populate uploaded_files and categories from disk
|
|
if plugin_id == 'of-the-day' or plugin_id == 'ledmatrix-of-the-day':
|
|
# Get plugin directory - plugin_id in manifest is 'of-the-day', but directory is 'ledmatrix-of-the-day'
|
|
plugin_dir_name = 'ledmatrix-of-the-day'
|
|
if api_v3.plugin_manager:
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_dir_name)
|
|
# If not found, try with the plugin_id
|
|
if not plugin_dir or not Path(plugin_dir).exists():
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
|
else:
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_dir_name
|
|
if not plugin_dir.exists():
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
|
|
|
if plugin_dir and Path(plugin_dir).exists():
|
|
data_dir = Path(plugin_dir) / 'of_the_day'
|
|
if data_dir.exists():
|
|
# Scan for JSON files
|
|
uploaded_files = []
|
|
categories_from_files = {}
|
|
|
|
for json_file in data_dir.glob('*.json'):
|
|
try:
|
|
# Get file stats
|
|
stat = json_file.stat()
|
|
|
|
# Read JSON to count entries
|
|
with open(json_file, 'r', encoding='utf-8') as f:
|
|
json_data = json.load(f)
|
|
entry_count = len(json_data) if isinstance(json_data, dict) else 0
|
|
|
|
# Extract category name from filename
|
|
category_name = json_file.stem
|
|
filename = json_file.name
|
|
|
|
# Create file entry
|
|
file_entry = {
|
|
'id': category_name,
|
|
'category_name': category_name,
|
|
'filename': filename,
|
|
'original_filename': filename,
|
|
'path': f'of_the_day/{filename}',
|
|
'size': stat.st_size,
|
|
'uploaded_at': datetime.fromtimestamp(stat.st_mtime).isoformat() + 'Z',
|
|
'entry_count': entry_count
|
|
}
|
|
uploaded_files.append(file_entry)
|
|
|
|
# Create/update category entry if not in config
|
|
if category_name not in plugin_config.get('categories', {}):
|
|
display_name = category_name.replace('_', ' ').title()
|
|
categories_from_files[category_name] = {
|
|
'enabled': False, # Default to disabled, user can enable
|
|
'data_file': f'of_the_day/{filename}',
|
|
'display_name': display_name
|
|
}
|
|
else:
|
|
# Update with file info if needed
|
|
categories_from_files[category_name] = plugin_config['categories'][category_name]
|
|
# Ensure data_file is correct
|
|
categories_from_files[category_name]['data_file'] = f'of_the_day/{filename}'
|
|
|
|
except Exception as e:
|
|
logger.debug("Could not read json file: %s", e)
|
|
continue
|
|
|
|
# Update plugin_config with scanned files
|
|
if uploaded_files:
|
|
plugin_config['uploaded_files'] = uploaded_files
|
|
|
|
# Merge categories from files with existing config
|
|
# Start with existing categories (preserve user settings like enabled/disabled)
|
|
existing_categories = plugin_config.get('categories', {}).copy()
|
|
|
|
# Update existing categories with file info, add new ones from files
|
|
for cat_name, cat_data in categories_from_files.items():
|
|
if cat_name in existing_categories:
|
|
# Preserve existing enabled state and display_name, but update data_file path
|
|
existing_categories[cat_name]['data_file'] = cat_data['data_file']
|
|
if 'display_name' not in existing_categories[cat_name] or not existing_categories[cat_name]['display_name']:
|
|
existing_categories[cat_name]['display_name'] = cat_data['display_name']
|
|
else:
|
|
# Add new category from file (default to disabled)
|
|
existing_categories[cat_name] = cat_data
|
|
|
|
if existing_categories:
|
|
plugin_config['categories'] = existing_categories
|
|
|
|
# Update category_order to include all categories
|
|
category_order = plugin_config.get('category_order', []).copy()
|
|
all_category_names = set(existing_categories.keys())
|
|
for cat_name in all_category_names:
|
|
if cat_name not in category_order:
|
|
category_order.append(cat_name)
|
|
if category_order:
|
|
plugin_config['category_order'] = category_order
|
|
|
|
# If no config exists, return defaults
|
|
if not plugin_config:
|
|
plugin_config = {
|
|
'enabled': True,
|
|
'display_duration': 30
|
|
}
|
|
|
|
return success_response(data=plugin_config)
|
|
except Exception as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.CONFIG_LOAD_FAILED)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
context=error.context,
|
|
status_code=500
|
|
)
|
|
@api_v3.route('/plugins/update', methods=['POST'])
|
|
def update_plugin():
|
|
"""Update plugin"""
|
|
try:
|
|
# Support both JSON and form data
|
|
content_type = request.content_type or ''
|
|
|
|
if 'application/json' in content_type:
|
|
# JSON request
|
|
data, error = validate_request_json(['plugin_id'])
|
|
if error:
|
|
logger.debug("[UPDATE] JSON validation failed. Content-Type: %s", content_type)
|
|
return error
|
|
else:
|
|
# Form data or query string
|
|
plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id')
|
|
if not plugin_id:
|
|
logger.debug("[UPDATE] Missing plugin_id. Content-Type: %s", content_type)
|
|
return error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
'plugin_id required',
|
|
status_code=400
|
|
)
|
|
data = {'plugin_id': plugin_id}
|
|
|
|
# /plugins/installed lists installed Starlark apps as virtual
|
|
# 'starlark:<app_id>' entries. They are not plugin directories, so the
|
|
# store manager can only fail to find them -- which used to surface as
|
|
# a 500 "plugin not found" for an app that is installed and working.
|
|
raw_id = data.get('plugin_id')
|
|
if isinstance(raw_id, str) and raw_id.startswith('starlark:'):
|
|
return error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
f'{raw_id} is a Starlark app, not a plugin; Starlark apps are '
|
|
'not updated through the plugin updater',
|
|
status_code=400
|
|
)
|
|
|
|
if not api_v3.plugin_store_manager:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Plugin store manager not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
# The id names a directory this handler reads a manifest out of and
|
|
# hands to the store manager to run git in. It comes from the request
|
|
# body, so it is validated before anything is joined to a path, and
|
|
# the validated value -- not the raw one -- is what gets used below.
|
|
plugin_id = safe_path_component(data['plugin_id'])
|
|
if not plugin_id:
|
|
return error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
'Invalid plugin_id',
|
|
status_code=400
|
|
)
|
|
|
|
# Always do direct updates (they're fast git pull operations)
|
|
# Operation queue is reserved for longer operations like install/uninstall
|
|
plugins_base = Path(api_v3.plugin_store_manager.plugins_dir)
|
|
plugin_dir = plugins_base / plugin_id
|
|
manifest_path = resolve_under(plugin_dir, "manifest.json")
|
|
if manifest_path is None:
|
|
return error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
'Invalid plugin_id',
|
|
status_code=400
|
|
)
|
|
|
|
current_last_updated = None
|
|
current_version = None
|
|
current_commit = None
|
|
current_branch = None
|
|
|
|
if manifest_path.exists():
|
|
try:
|
|
import json
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
manifest = json.load(f)
|
|
current_last_updated = manifest.get('last_updated')
|
|
current_version = manifest.get('version')
|
|
if manifest.get('local_only'):
|
|
logger.debug("Skipping update for local-only plugin: %s", plugin_id)
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"update",
|
|
plugin_id=plugin_id,
|
|
status="skipped",
|
|
details={"reason": "local_only"}
|
|
)
|
|
return success_response(
|
|
data={'update_status': 'local_only'},
|
|
message=f'Plugin {plugin_id} is managed locally and does not receive registry updates')
|
|
except Exception as e:
|
|
logger.debug("Could not read local manifest for plugin: %s", e)
|
|
|
|
if api_v3.plugin_store_manager:
|
|
git_info_before = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
|
|
if git_info_before:
|
|
current_commit = git_info_before.get('sha')
|
|
current_branch = git_info_before.get('branch')
|
|
|
|
# Check if plugin is a git repo first (for better error messages)
|
|
# plugin_id is validated above; reuse the same directory rather
|
|
# than rebuilding it from a value that might not match.
|
|
plugin_path_dir = plugin_dir
|
|
is_git_repo = False
|
|
if plugin_path_dir.exists():
|
|
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path_dir)
|
|
is_git_repo = git_info is not None
|
|
if is_git_repo:
|
|
logger.debug("Plugin is a git repository, will update via git pull")
|
|
|
|
remote_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id, fetch_latest_from_github=True)
|
|
remote_commit = remote_info.get('last_commit_sha') if remote_info else None
|
|
remote_branch = remote_info.get('branch') if remote_info else None
|
|
|
|
# Update the plugin
|
|
success = api_v3.plugin_store_manager.update_plugin(plugin_id)
|
|
|
|
if success:
|
|
updated_last_updated = current_last_updated
|
|
updated_version = current_version
|
|
try:
|
|
if manifest_path.exists():
|
|
import json
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
manifest = json.load(f)
|
|
updated_last_updated = manifest.get('last_updated', current_last_updated)
|
|
updated_version = manifest.get('version', current_version)
|
|
except Exception as e:
|
|
logger.debug("Could not read updated manifest after update: %s", e)
|
|
|
|
updated_commit = None
|
|
updated_branch = remote_branch or current_branch
|
|
if api_v3.plugin_store_manager:
|
|
git_info_after = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
|
|
if git_info_after:
|
|
updated_commit = git_info_after.get('sha')
|
|
updated_branch = git_info_after.get('branch') or updated_branch
|
|
|
|
# update_plugin() answers True for "nothing to do" as well as for
|
|
# a real update (a ZIP-installed monorepo plugin already at the
|
|
# registry version, a bundled plugin), so what changed is read off
|
|
# the plugin itself: its git commit, else its manifest.
|
|
update_status = 'updated'
|
|
message = f'Plugin {plugin_id} updated successfully'
|
|
if current_commit and updated_commit and current_commit == updated_commit:
|
|
update_status = 'up_to_date'
|
|
message = f'Plugin {plugin_id} already up to date (commit {updated_commit[:7]})'
|
|
elif updated_commit:
|
|
message = f'Plugin {plugin_id} updated to commit {updated_commit[:7]}'
|
|
if updated_branch:
|
|
message += f' on branch {updated_branch}'
|
|
elif updated_version and updated_version != current_version:
|
|
message = f'Plugin {plugin_id} updated to version {updated_version}'
|
|
elif updated_last_updated and updated_last_updated != current_last_updated:
|
|
message = f'Plugin {plugin_id} refreshed (Last Updated {updated_last_updated})'
|
|
elif not current_commit:
|
|
update_status = 'up_to_date'
|
|
message = f'Plugin {plugin_id} already up to date'
|
|
if updated_version:
|
|
message += f' (version {updated_version})'
|
|
|
|
remote_commit_short = remote_commit[:7] if remote_commit else None
|
|
if remote_commit_short and updated_commit and remote_commit_short != updated_commit[:7]:
|
|
message += f' (remote latest {remote_commit_short})'
|
|
|
|
# Invalidate schema cache
|
|
if api_v3.schema_manager:
|
|
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
|
|
# Rediscover plugins
|
|
if api_v3.plugin_manager:
|
|
api_v3.plugin_manager.discover_plugins()
|
|
if plugin_id in api_v3.plugin_manager.plugins:
|
|
api_v3.plugin_manager.reload_plugin(plugin_id)
|
|
|
|
# Update state and history
|
|
if api_v3.plugin_state_manager:
|
|
api_v3.plugin_state_manager.update_plugin_state(
|
|
plugin_id,
|
|
{'last_updated': datetime.now()}
|
|
)
|
|
if api_v3.operation_history:
|
|
version = _get_plugin_version(plugin_id)
|
|
api_v3.operation_history.record_operation(
|
|
"update",
|
|
plugin_id=plugin_id,
|
|
status="success",
|
|
details={
|
|
"version": version,
|
|
"previous_commit": current_commit[:7] if current_commit else None,
|
|
"commit": updated_commit[:7] if updated_commit else None,
|
|
"branch": updated_branch,
|
|
"update_status": update_status
|
|
}
|
|
)
|
|
|
|
return success_response(
|
|
data={
|
|
'last_updated': updated_last_updated,
|
|
'commit': updated_commit,
|
|
'update_status': update_status
|
|
},
|
|
message=message
|
|
)
|
|
else:
|
|
plugin_path_dir = plugin_dir
|
|
if not plugin_path_dir.exists():
|
|
client_msg = 'Plugin update failed: plugin not found'
|
|
else:
|
|
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path_dir)
|
|
if not git_info:
|
|
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)
|
|
if not plugin_info:
|
|
client_msg = 'Plugin update failed: not found in registry'
|
|
else:
|
|
client_msg = 'Plugin update failed; check logs for details'
|
|
else:
|
|
client_msg = 'Plugin update failed; check logs for details'
|
|
logger.error("update_plugin failed for plugin_id=%s: %s", plugin_id, client_msg)
|
|
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"update",
|
|
plugin_id=plugin_id,
|
|
status="failed",
|
|
error=client_msg,
|
|
details={
|
|
"previous_commit": current_commit[:7] if current_commit else None,
|
|
"branch": current_branch
|
|
}
|
|
)
|
|
|
|
return error_response(
|
|
ErrorCode.PLUGIN_UPDATE_FAILED,
|
|
client_msg,
|
|
status_code=500
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error("Unhandled exception in update endpoint", exc_info=True)
|
|
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_UPDATE_FAILED)
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"update",
|
|
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
|
|
status="failed",
|
|
error=str(e)
|
|
)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
context=error.context,
|
|
status_code=500
|
|
)
|
|
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
|
def uninstall_plugin():
|
|
"""Uninstall plugin"""
|
|
try:
|
|
# Validate request
|
|
data, error = validate_request_json(['plugin_id'])
|
|
if error:
|
|
return error
|
|
|
|
if not api_v3.plugin_store_manager:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Plugin store manager not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
plugin_id = data['plugin_id']
|
|
preserve_config = data.get('preserve_config', False)
|
|
|
|
# Both queued and direct paths use the same transactional helper so
|
|
# snapshot/rollback behaviour is consistent regardless of deployment.
|
|
if api_v3.operation_queue:
|
|
def uninstall_callback(operation):
|
|
"""Callback to execute plugin uninstallation via transactional helper."""
|
|
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
|
if not success:
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"uninstall",
|
|
plugin_id=plugin_id,
|
|
status="failed",
|
|
error=error_msg
|
|
)
|
|
raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}')
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"uninstall",
|
|
plugin_id=plugin_id,
|
|
status="success",
|
|
details={"preserve_config": preserve_config}
|
|
)
|
|
return {'success': True, 'message': 'Plugin uninstalled successfully'}
|
|
|
|
# Enqueue operation
|
|
operation_id = api_v3.operation_queue.enqueue_operation(
|
|
OperationType.UNINSTALL,
|
|
plugin_id,
|
|
operation_callback=uninstall_callback
|
|
)
|
|
|
|
return success_response(
|
|
data={'operation_id': operation_id},
|
|
message='Plugin uninstallation queued'
|
|
)
|
|
else:
|
|
# Direct (non-queued) transactional uninstall
|
|
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
|
|
|
if success:
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"uninstall",
|
|
plugin_id=plugin_id,
|
|
status="success",
|
|
details={"preserve_config": preserve_config}
|
|
)
|
|
return success_response(message='Plugin uninstalled successfully')
|
|
else:
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"uninstall",
|
|
plugin_id=plugin_id,
|
|
status="failed",
|
|
error=error_msg
|
|
)
|
|
return error_response(
|
|
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
|
error_msg or 'Plugin uninstall failed',
|
|
status_code=500
|
|
)
|
|
|
|
except Exception as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_UNINSTALL_FAILED)
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"uninstall",
|
|
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
|
|
status="failed",
|
|
error=str(e)
|
|
)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
context=error.context,
|
|
status_code=500
|
|
)
|
|
@api_v3.route('/plugins/install', methods=['POST'])
|
|
def install_plugin():
|
|
"""Install plugin from store"""
|
|
try:
|
|
if not api_v3.plugin_store_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
|
|
|
data = request.get_json(silent=True)
|
|
if not data or 'plugin_id' not in data:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400
|
|
|
|
plugin_id = data['plugin_id']
|
|
branch = data.get('branch') # Optional branch parameter
|
|
|
|
# A registry skin would install but never render with the current
|
|
# scoreboards; refuse it with the reason rather than a generic failure
|
|
try:
|
|
registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
|
except Exception:
|
|
registry_entry = None
|
|
if isinstance(registry_entry, dict) and (registry_entry.get('type') or 'plugin') == 'skin':
|
|
from src.skin_system import SKINS_UNSUPPORTED_MESSAGE
|
|
return jsonify({'status': 'error', 'message': SKINS_UNSUPPORTED_MESSAGE}), 400
|
|
|
|
# Install the plugin
|
|
# Log the plugins directory being used for debugging
|
|
plugins_dir = api_v3.plugin_store_manager.plugins_dir
|
|
branch_info = f" (branch: {branch})" if branch else ""
|
|
logger.info("Installing plugin to directory: %s", plugins_dir)
|
|
|
|
# Use operation queue if available
|
|
if api_v3.operation_queue:
|
|
def install_callback(operation):
|
|
"""Callback to execute plugin installation."""
|
|
success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch)
|
|
|
|
if success:
|
|
# Invalidate schema cache
|
|
if api_v3.schema_manager:
|
|
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
|
|
# Discover and load the new plugin
|
|
if api_v3.plugin_manager:
|
|
api_v3.plugin_manager.discover_plugins()
|
|
api_v3.plugin_manager.load_plugin(plugin_id)
|
|
|
|
# Update state manager
|
|
if api_v3.plugin_state_manager:
|
|
api_v3.plugin_state_manager.set_plugin_installed(plugin_id)
|
|
|
|
# Record in history
|
|
if api_v3.operation_history:
|
|
version = _get_plugin_version(plugin_id)
|
|
api_v3.operation_history.record_operation(
|
|
"install",
|
|
plugin_id=plugin_id,
|
|
status="success",
|
|
details={"version": version, "branch": branch}
|
|
)
|
|
|
|
branch_msg = f" (branch: {branch})" if branch else ""
|
|
return {'success': True, 'message': f'Plugin {plugin_id} installed successfully{branch_msg}'}
|
|
else:
|
|
error_msg = f'Failed to install plugin {plugin_id}'
|
|
if branch:
|
|
error_msg += f' (branch: {branch})'
|
|
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)
|
|
if not plugin_info:
|
|
error_msg += ' (plugin not found in registry)'
|
|
|
|
# Record failure in history
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"install",
|
|
plugin_id=plugin_id,
|
|
status="failed",
|
|
error=error_msg,
|
|
details={"branch": branch}
|
|
)
|
|
|
|
raise Exception(error_msg)
|
|
|
|
# Enqueue operation
|
|
operation_id = api_v3.operation_queue.enqueue_operation(
|
|
OperationType.INSTALL,
|
|
plugin_id,
|
|
operation_callback=install_callback
|
|
)
|
|
|
|
branch_msg = f" (branch: {branch})" if branch else ""
|
|
return success_response(
|
|
data={'operation_id': operation_id},
|
|
message=f'Plugin {plugin_id} installation queued{branch_msg}'
|
|
)
|
|
else:
|
|
# Fallback to direct installation
|
|
success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch)
|
|
|
|
if success:
|
|
if api_v3.schema_manager:
|
|
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
if api_v3.plugin_manager:
|
|
api_v3.plugin_manager.discover_plugins()
|
|
api_v3.plugin_manager.load_plugin(plugin_id)
|
|
if api_v3.plugin_state_manager:
|
|
api_v3.plugin_state_manager.set_plugin_installed(plugin_id)
|
|
if api_v3.operation_history:
|
|
version = _get_plugin_version(plugin_id)
|
|
api_v3.operation_history.record_operation(
|
|
"install",
|
|
plugin_id=plugin_id,
|
|
status="success",
|
|
details={"version": version, "branch": branch}
|
|
)
|
|
|
|
branch_msg = f" (branch: {branch})" if branch else ""
|
|
return success_response(message=f'Plugin installed successfully{branch_msg}')
|
|
else:
|
|
error_msg = f'Failed to install plugin {plugin_id}'
|
|
if branch:
|
|
error_msg += f' (branch: {branch})'
|
|
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)
|
|
if not plugin_info:
|
|
error_msg += ' (plugin not found in registry)'
|
|
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"install",
|
|
plugin_id=plugin_id,
|
|
status="failed",
|
|
error=error_msg,
|
|
details={"branch": branch}
|
|
)
|
|
|
|
return error_response(
|
|
ErrorCode.PLUGIN_INSTALL_FAILED,
|
|
error_msg,
|
|
status_code=500
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error('Error in install_plugin', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/install-from-url', methods=['POST'])
|
|
def install_plugin_from_url():
|
|
"""Install plugin from custom GitHub URL"""
|
|
try:
|
|
if not api_v3.plugin_store_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
|
|
|
data = request.get_json(silent=True)
|
|
if not data or 'repo_url' not in data:
|
|
return jsonify({'status': 'error', 'message': 'repo_url required'}), 400
|
|
|
|
# A non-string repo_url is a client mistake, not a server fault:
|
|
# .strip() would raise and the catch-all would report it as a 500.
|
|
if not isinstance(data['repo_url'], str) or not data['repo_url'].strip():
|
|
return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400
|
|
|
|
repo_url = data['repo_url'].strip()
|
|
plugin_id = data.get('plugin_id') # Optional, for monorepo installations
|
|
plugin_path = data.get('plugin_path') # Optional, for monorepo subdirectory
|
|
branch = data.get('branch') # Optional branch parameter
|
|
|
|
# Install the plugin
|
|
result = api_v3.plugin_store_manager.install_from_url(
|
|
repo_url=repo_url,
|
|
plugin_id=plugin_id,
|
|
plugin_path=plugin_path,
|
|
branch=branch
|
|
)
|
|
|
|
if result.get('success'):
|
|
# Invalidate schema cache for the installed plugin
|
|
installed_plugin_id = result.get('plugin_id')
|
|
if api_v3.schema_manager and installed_plugin_id:
|
|
api_v3.schema_manager.invalidate_cache(installed_plugin_id)
|
|
|
|
# Discover and load the new plugin
|
|
if api_v3.plugin_manager and installed_plugin_id:
|
|
api_v3.plugin_manager.discover_plugins()
|
|
api_v3.plugin_manager.load_plugin(installed_plugin_id)
|
|
|
|
branch_msg = f" (branch: {result.get('branch', branch)})" if (result.get('branch') or branch) else ""
|
|
response_data = {
|
|
'status': 'success',
|
|
'message': f"Plugin {installed_plugin_id} installed successfully{branch_msg}",
|
|
'plugin_id': installed_plugin_id,
|
|
'name': result.get('name')
|
|
}
|
|
if result.get('branch'):
|
|
response_data['branch'] = result.get('branch')
|
|
return jsonify(response_data)
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': result.get('error', 'Failed to install plugin from URL')
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error('Error in install_plugin_from_url', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/registry-from-url', methods=['POST'])
|
|
def get_registry_from_url():
|
|
"""Get plugin list from a registry-style monorepo URL"""
|
|
try:
|
|
if not api_v3.plugin_store_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
|
|
|
data = request.get_json(silent=True)
|
|
if not data or 'repo_url' not in data:
|
|
return jsonify({'status': 'error', 'message': 'repo_url required'}), 400
|
|
|
|
# A non-string repo_url is a client mistake, not a server fault:
|
|
# .strip() would raise and the catch-all would report it as a 500.
|
|
if not isinstance(data['repo_url'], str) or not data['repo_url'].strip():
|
|
return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400
|
|
|
|
repo_url = data['repo_url'].strip()
|
|
|
|
# Get registry from the URL
|
|
registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url)
|
|
|
|
if registry:
|
|
# Skins aren't offered: current scoreboards don't render them
|
|
return jsonify({
|
|
'status': 'success',
|
|
'plugins': [
|
|
p for p in registry.get('plugins', [])
|
|
if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin')
|
|
],
|
|
'registry_url': repo_url
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Failed to fetch registry from URL or URL does not contain a valid registry'
|
|
}), 400
|
|
|
|
except Exception as e:
|
|
logger.error('Error in get_registry_from_url', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/saved-repositories', methods=['GET'])
|
|
def get_saved_repositories():
|
|
"""Get all saved repositories"""
|
|
try:
|
|
if not api_v3.saved_repositories_manager:
|
|
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
|
|
|
|
repositories = api_v3.saved_repositories_manager.get_all()
|
|
return jsonify({'status': 'success', 'data': {'repositories': repositories}})
|
|
except Exception as e:
|
|
logger.error('Error in get_saved_repositories', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/saved-repositories', methods=['POST'])
|
|
def add_saved_repository():
|
|
"""Add a repository to saved list"""
|
|
try:
|
|
if not api_v3.saved_repositories_manager:
|
|
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
|
|
|
|
data = request.get_json(silent=True)
|
|
if not data or 'repo_url' not in data:
|
|
return jsonify({'status': 'error', 'message': 'repo_url required'}), 400
|
|
|
|
# A non-string repo_url is a client mistake, not a server fault:
|
|
# .strip() would raise and the catch-all would report it as a 500.
|
|
if not isinstance(data['repo_url'], str) or not data['repo_url'].strip():
|
|
return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400
|
|
|
|
repo_url = data['repo_url'].strip()
|
|
name = data.get('name')
|
|
|
|
success = api_v3.saved_repositories_manager.add(repo_url, name)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Repository saved successfully',
|
|
'data': {'repositories': api_v3.saved_repositories_manager.get_all()}
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Repository already exists or failed to save'
|
|
}), 400
|
|
except Exception as e:
|
|
logger.error('Error in add_saved_repository', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/saved-repositories', methods=['DELETE'])
|
|
def remove_saved_repository():
|
|
"""Remove a repository from saved list"""
|
|
try:
|
|
if not api_v3.saved_repositories_manager:
|
|
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
|
|
|
|
data = request.get_json(silent=True)
|
|
if not data or 'repo_url' not in data:
|
|
return jsonify({'status': 'error', 'message': 'repo_url required'}), 400
|
|
|
|
repo_url = data['repo_url']
|
|
|
|
success = api_v3.saved_repositories_manager.remove(repo_url)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Repository removed successfully',
|
|
'data': {'repositories': api_v3.saved_repositories_manager.get_all()}
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Repository not found'
|
|
}), 404
|
|
except Exception as e:
|
|
logger.error('Error in remove_saved_repository', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/store/list', methods=['GET'])
|
|
def list_plugin_store():
|
|
"""Search plugin store"""
|
|
try:
|
|
if not api_v3.plugin_store_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
|
|
|
query = request.args.get('query', '')
|
|
category = request.args.get('category', '')
|
|
tags = request.args.getlist('tags')
|
|
# Default to fetching commit metadata to ensure accurate commit timestamps
|
|
fetch_commit_param = request.args.get('fetch_commit_info', request.args.get('fetch_latest_versions', '')).lower()
|
|
fetch_commit = fetch_commit_param != 'false'
|
|
|
|
# Search plugins from the registry (including saved repositories)
|
|
plugins = api_v3.plugin_store_manager.search_plugins(
|
|
query=query,
|
|
category=category,
|
|
tags=tags,
|
|
fetch_commit_info=fetch_commit,
|
|
include_saved_repos=True,
|
|
saved_repositories_manager=api_v3.saved_repositories_manager
|
|
)
|
|
|
|
# Format plugins for the web interface
|
|
formatted_plugins = []
|
|
for plugin in plugins:
|
|
# Registry skins install but never render with the current
|
|
# scoreboards, so the store doesn't offer them
|
|
if (plugin.get('type') or 'plugin') == 'skin':
|
|
continue
|
|
formatted_plugins.append({
|
|
'id': plugin.get('id'),
|
|
'name': plugin.get('name'),
|
|
'author': plugin.get('author'),
|
|
'category': plugin.get('category'),
|
|
'description': plugin.get('description'),
|
|
'tags': plugin.get('tags', []),
|
|
'stars': plugin.get('stars', 0),
|
|
'verified': plugin.get('verified', False),
|
|
'repo': plugin.get('repo', ''),
|
|
'last_updated': plugin.get('last_updated') or plugin.get('last_updated_iso', ''),
|
|
'last_updated_iso': plugin.get('last_updated_iso', ''),
|
|
'last_commit': plugin.get('last_commit') or plugin.get('last_commit_sha'),
|
|
'last_commit_message': plugin.get('last_commit_message'),
|
|
'last_commit_author': plugin.get('last_commit_author'),
|
|
'version': plugin.get('latest_version') or plugin.get('version', ''),
|
|
'branch': plugin.get('branch') or plugin.get('default_branch'),
|
|
'default_branch': plugin.get('default_branch'),
|
|
'plugin_path': plugin.get('plugin_path', '')
|
|
})
|
|
|
|
return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}})
|
|
except Exception as e:
|
|
logger.error('Error in list_plugin_store', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/store/github-status', methods=['GET'])
|
|
def get_github_auth_status():
|
|
"""Check if GitHub authentication is configured and validate token"""
|
|
try:
|
|
if not api_v3.plugin_store_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
|
|
|
token = api_v3.plugin_store_manager.github_token
|
|
|
|
# Check if GitHub token is configured
|
|
if not token or len(token) == 0:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'token_status': 'none',
|
|
'authenticated': False,
|
|
'rate_limit': 60,
|
|
'message': 'No GitHub token configured',
|
|
'error': None
|
|
}
|
|
})
|
|
|
|
# Validate the token
|
|
is_valid, error_message = api_v3.plugin_store_manager._validate_github_token(token)
|
|
|
|
if is_valid:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'token_status': 'valid',
|
|
'authenticated': True,
|
|
'rate_limit': 5000,
|
|
'message': 'GitHub API authenticated',
|
|
'error': None
|
|
}
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'token_status': 'invalid',
|
|
'authenticated': False,
|
|
'rate_limit': 60,
|
|
'message': f'GitHub token is invalid: {error_message}' if error_message else 'GitHub token is invalid',
|
|
'error': error_message
|
|
}
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in get_github_auth_status', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/store/refresh', methods=['POST'])
|
|
def refresh_plugin_store():
|
|
"""Refresh plugin store repository"""
|
|
try:
|
|
if not api_v3.plugin_store_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
fetch_commit_info = data.get('fetch_commit_info', data.get('fetch_latest_versions', False))
|
|
|
|
# Force refresh the registry
|
|
registry = api_v3.plugin_store_manager.fetch_registry(force_refresh=True)
|
|
plugin_count = len(registry.get('plugins', []))
|
|
|
|
message = 'Plugin store refreshed'
|
|
if fetch_commit_info:
|
|
message += ' (with refreshed commit metadata from GitHub)'
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': message,
|
|
'plugin_count': plugin_count
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in refresh_plugin_store', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/config', methods=['POST'])
|
|
def save_plugin_config():
|
|
"""Save plugin configuration, separating secrets from regular config"""
|
|
try:
|
|
if not api_v3.config_manager:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Config manager not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
# Support both JSON and form data (for HTMX submissions)
|
|
content_type = request.content_type or ''
|
|
|
|
if 'application/json' in content_type:
|
|
# JSON request
|
|
data, error = validate_request_json(['plugin_id'])
|
|
if error:
|
|
return error
|
|
plugin_id = data['plugin_id']
|
|
submitted_config = data.get('config', {})
|
|
if not isinstance(submitted_config, dict):
|
|
return error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
'config must be a JSON object',
|
|
status_code=400
|
|
)
|
|
plugin_config = _merge_onto_stored_plugin_config(plugin_id, submitted_config)
|
|
else:
|
|
# Form data (HTMX submission)
|
|
# plugin_id comes from query string, config from form fields
|
|
plugin_id = request.args.get('plugin_id')
|
|
if not plugin_id:
|
|
return error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
'plugin_id required in query string',
|
|
status_code=400
|
|
)
|
|
|
|
# Load existing config as base (partial form updates should merge, not replace)
|
|
existing_config = {}
|
|
if api_v3.config_manager:
|
|
full_config = api_v3.config_manager.load_config()
|
|
existing_config = full_config.get(plugin_id, {}).copy()
|
|
|
|
# Get schema manager instance (needed for type conversion)
|
|
schema_mgr = api_v3.schema_manager
|
|
if not schema_mgr:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Schema manager not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
# Load plugin schema BEFORE processing form data (needed for type conversion)
|
|
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
|
|
|
|
# Start with existing config and apply form updates
|
|
plugin_config = existing_config
|
|
|
|
# Convert form data to config dict
|
|
# Form fields can use dot notation for nested values (e.g., "transition.type")
|
|
form_data = request.form.to_dict()
|
|
# Meta fields describe the submission, they are not config paths.
|
|
# Unknown keys are otherwise written straight into config.json by
|
|
# the non-indexed pass below.
|
|
form_data = {k: v for k, v in form_data.items()
|
|
if not k.startswith('__')}
|
|
|
|
# First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group)
|
|
# These fields use getlist() to preserve all values, then replace in form_data
|
|
# Sentinel empty value ("") allows clearing array to [] when all checkboxes unchecked
|
|
bracket_array_fields = {} # Maps base field path to list of values
|
|
for key in request.form.keys():
|
|
# Check if key ends with "[]" (bracket notation for array fields)
|
|
if key.endswith('[]'):
|
|
base_path = key[:-2] # Remove "[]" suffix
|
|
values = request.form.getlist(key)
|
|
# Filter out sentinel empty string - if only sentinel present, array should be []
|
|
# If sentinel + values present, use the actual values
|
|
filtered_values = [v for v in values if v and v.strip()]
|
|
# If no non-empty values but key exists, it means all checkboxes unchecked (empty array)
|
|
bracket_array_fields[base_path] = filtered_values
|
|
# Remove the bracket notation key from form_data if present
|
|
if key in form_data:
|
|
del form_data[key]
|
|
|
|
# Process bracket notation fields and set directly in plugin_config
|
|
# Use JSON encoding instead of comma-join to handle values containing commas
|
|
import json
|
|
for base_path, values in bracket_array_fields.items():
|
|
# Get schema property to verify it's an array
|
|
base_prop = _get_schema_property(schema, base_path)
|
|
if base_prop and base_prop.get('type') == 'array':
|
|
# Filter out empty values and sentinel empty strings
|
|
filtered_values = [v for v in values if v and v.strip()]
|
|
# Set directly in plugin_config (values are already strings, no need to parse)
|
|
# Empty array (all unchecked) is represented as []
|
|
_set_nested_value(plugin_config, base_path, filtered_values)
|
|
logger.debug(f"Processed bracket notation array field {base_path}: {values} -> {filtered_values}")
|
|
# Remove from form_data to avoid double processing
|
|
if base_path in form_data:
|
|
del form_data[base_path]
|
|
|
|
# Second pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array)
|
|
# This handles cases where forms send array fields as indexed inputs
|
|
array_fields = {} # Maps base field path to list of (index, value) tuples
|
|
processed_keys = set()
|
|
indexed_base_paths = set() # Track which base paths have indexed fields
|
|
|
|
for key, value in form_data.items():
|
|
# Check if this looks like an array index field (ends with .0, .1, .2, etc.)
|
|
if '.' in key:
|
|
parts = key.rsplit('.', 1) # Split on last dot
|
|
if len(parts) == 2:
|
|
base_path, last_part = parts
|
|
# Check if last part is a numeric string (array index)
|
|
if last_part.isdigit():
|
|
# Get schema property for the base path to verify it's an array
|
|
base_prop = _get_schema_property(schema, base_path)
|
|
if base_prop and _schema_type_is(base_prop, 'array'):
|
|
# This is an array index field
|
|
index = int(last_part)
|
|
if base_path not in array_fields:
|
|
array_fields[base_path] = []
|
|
array_fields[base_path].append((index, value))
|
|
processed_keys.add(key)
|
|
indexed_base_paths.add(base_path)
|
|
continue
|
|
|
|
# Process combined array fields
|
|
for base_path, index_values in array_fields.items():
|
|
# Sort by index and extract values
|
|
index_values.sort(key=lambda x: x[0])
|
|
values = [v for _, v in index_values]
|
|
# Every channel blank on a nullable field means "unset", not
|
|
# an empty array: joining them would produce ", , ", which
|
|
# parses to [] and then fails the minItems the array
|
|
# declares. This is how a per-mode colour override says
|
|
# "inherit the base colour".
|
|
base_prop_for_null = _get_schema_property(schema, base_path)
|
|
if (_schema_allows_null(base_prop_for_null)
|
|
and all(str(v).strip() == '' for v in values)):
|
|
_set_nested_value(plugin_config, base_path, None)
|
|
continue
|
|
# Combine values into comma-separated string for parsing
|
|
combined_value = ', '.join(str(v) for v in values)
|
|
# Parse as array using schema
|
|
parsed_value = _parse_form_value_with_schema(combined_value, base_path, schema)
|
|
# Debug logging
|
|
logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}")
|
|
# Only set if not skipped
|
|
if parsed_value is not _SKIP_FIELD:
|
|
_set_nested_value(plugin_config, base_path, parsed_value)
|
|
|
|
# Process remaining (non-indexed) fields
|
|
# Skip any base paths that were processed as indexed arrays
|
|
for key, value in form_data.items():
|
|
if key not in processed_keys:
|
|
# Skip if this key is a base path that was processed as indexed array
|
|
# (to avoid overwriting the combined array with a single value)
|
|
if key not in indexed_base_paths:
|
|
# Parse value using schema to determine correct type
|
|
parsed_value = _SKIP_FIELD
|
|
decoded = False
|
|
# A hidden property inside an array row is carried
|
|
# through the form JSON-encoded (a posted row replaces
|
|
# the stored item, so it must be posted at all). Decode
|
|
# it exactly: the generic parse would turn an id "1"
|
|
# into the integer 1 and fail validation.
|
|
if _hidden_array_item_property(schema, key) is not None:
|
|
try:
|
|
parsed_value = json.loads(value)
|
|
decoded = True
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if not decoded:
|
|
parsed_value = _parse_form_value_with_schema(value, key, schema)
|
|
# Debug logging for array fields
|
|
if schema:
|
|
prop = _get_schema_property(schema, key)
|
|
if prop and prop.get('type') == 'array':
|
|
logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}")
|
|
# Use helper to set nested values correctly (skips if _SKIP_FIELD)
|
|
if parsed_value is not _SKIP_FIELD:
|
|
_set_nested_value(plugin_config, key, parsed_value)
|
|
|
|
# Post-process: Fix array fields that might have been incorrectly structured
|
|
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
|
|
def fix_array_structures(config_dict, schema_props, prefix=''):
|
|
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)"""
|
|
for prop_key, prop_schema in schema_props.items():
|
|
prop_type = prop_schema.get('type')
|
|
|
|
if prop_type == 'array':
|
|
# Navigate to the field location
|
|
if prefix:
|
|
parent_parts = prefix.split('.')
|
|
parent = config_dict
|
|
for part in parent_parts:
|
|
if isinstance(parent, dict) and part in parent:
|
|
parent = parent[part]
|
|
else:
|
|
parent = None
|
|
break
|
|
|
|
if parent is not None and isinstance(parent, dict) and prop_key in parent:
|
|
current_value = parent[prop_key]
|
|
# If it's a dict with numeric string keys, convert to array
|
|
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
|
try:
|
|
# Check if all keys are numeric strings (array indices)
|
|
keys = [k for k in current_value.keys()]
|
|
if all(k.isdigit() for k in keys):
|
|
# Convert to sorted array by index
|
|
sorted_keys = sorted(keys, key=int)
|
|
array_value = [current_value[k] for k in sorted_keys]
|
|
# Convert array elements to correct types based on schema
|
|
items_schema = prop_schema.get('items', {})
|
|
item_type = items_schema.get('type')
|
|
if item_type in ('number', 'integer'):
|
|
converted_array = []
|
|
for v in array_value:
|
|
if isinstance(v, str):
|
|
try:
|
|
if item_type == 'integer':
|
|
converted_array.append(int(v))
|
|
else:
|
|
converted_array.append(float(v))
|
|
except (ValueError, TypeError, OverflowError):
|
|
converted_array.append(v)
|
|
else:
|
|
converted_array.append(v)
|
|
array_value = converted_array
|
|
parent[prop_key] = array_value
|
|
current_value = array_value # Update for length check below
|
|
except (ValueError, KeyError, TypeError):
|
|
# Conversion failed, check if we should use default
|
|
pass
|
|
|
|
# If it's an array, ensure correct types and check minItems
|
|
if isinstance(current_value, list):
|
|
# First, ensure array elements are correct types
|
|
items_schema = prop_schema.get('items', {})
|
|
item_type = items_schema.get('type')
|
|
if item_type in ('number', 'integer'):
|
|
converted_array = []
|
|
for v in current_value:
|
|
if isinstance(v, str):
|
|
try:
|
|
if item_type == 'integer':
|
|
converted_array.append(int(v))
|
|
else:
|
|
converted_array.append(float(v))
|
|
except (ValueError, TypeError, OverflowError):
|
|
converted_array.append(v)
|
|
else:
|
|
converted_array.append(v)
|
|
parent[prop_key] = converted_array
|
|
current_value = converted_array
|
|
|
|
# Then check minItems
|
|
min_items = prop_schema.get('minItems')
|
|
if min_items is not None and len(current_value) < min_items:
|
|
# Use default if available, otherwise keep as-is (validation will catch it)
|
|
default = prop_schema.get('default')
|
|
if default and isinstance(default, list) and len(default) >= min_items:
|
|
parent[prop_key] = default
|
|
else:
|
|
# Top-level field
|
|
if prop_key in config_dict:
|
|
current_value = config_dict[prop_key]
|
|
# If it's a dict with numeric string keys, convert to array
|
|
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
|
try:
|
|
keys = list(current_value.keys())
|
|
if keys and all(str(k).isdigit() for k in keys):
|
|
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
|
array_value = [current_value[k] for k in sorted_keys]
|
|
# Convert array elements to correct types based on schema
|
|
items_schema = prop_schema.get('items', {})
|
|
item_type = items_schema.get('type')
|
|
if item_type in ('number', 'integer'):
|
|
converted_array = []
|
|
for v in array_value:
|
|
if isinstance(v, str):
|
|
try:
|
|
if item_type == 'integer':
|
|
converted_array.append(int(v))
|
|
else:
|
|
converted_array.append(float(v))
|
|
except (ValueError, TypeError, OverflowError):
|
|
converted_array.append(v)
|
|
else:
|
|
converted_array.append(v)
|
|
array_value = converted_array
|
|
config_dict[prop_key] = array_value
|
|
current_value = array_value # Update for length check below
|
|
except (ValueError, KeyError, TypeError) as e:
|
|
logger.debug(f"Failed to convert {prop_key} to array: {e}")
|
|
|
|
# If it's an array, ensure correct types and check minItems
|
|
if isinstance(current_value, list):
|
|
# First, ensure array elements are correct types
|
|
items_schema = prop_schema.get('items', {})
|
|
item_type = items_schema.get('type')
|
|
if item_type in ('number', 'integer'):
|
|
converted_array = []
|
|
for v in current_value:
|
|
if isinstance(v, str):
|
|
try:
|
|
if item_type == 'integer':
|
|
converted_array.append(int(v))
|
|
else:
|
|
converted_array.append(float(v))
|
|
except (ValueError, TypeError, OverflowError):
|
|
converted_array.append(v)
|
|
else:
|
|
converted_array.append(v)
|
|
config_dict[prop_key] = converted_array
|
|
current_value = converted_array
|
|
|
|
# Then check minItems
|
|
min_items = prop_schema.get('minItems')
|
|
if min_items is not None and len(current_value) < min_items:
|
|
default = prop_schema.get('default')
|
|
if default and isinstance(default, list) and len(default) >= min_items:
|
|
config_dict[prop_key] = default
|
|
|
|
# Recurse into nested objects
|
|
elif prop_type == 'object' and 'properties' in prop_schema:
|
|
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
|
if prefix:
|
|
parent_parts = prefix.split('.')
|
|
parent = config_dict
|
|
for part in parent_parts:
|
|
if isinstance(parent, dict) and part in parent:
|
|
parent = parent[part]
|
|
else:
|
|
parent = None
|
|
break
|
|
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
|
|
else:
|
|
nested_dict = config_dict.get(prop_key)
|
|
|
|
if isinstance(nested_dict, dict):
|
|
# Pass no prefix: config_dict is already the navigated sub-dict,
|
|
# so path segments from the parent would mis-navigate it.
|
|
fix_array_structures(nested_dict, prop_schema['properties'])
|
|
|
|
# Also ensure array fields that are None get converted to empty arrays
|
|
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
|
"""Recursively ensure array fields have defaults if None"""
|
|
for prop_key, prop_schema in schema_props.items():
|
|
prop_type = prop_schema.get('type')
|
|
|
|
if prop_type == 'array':
|
|
if prefix:
|
|
parent_parts = prefix.split('.')
|
|
parent = config_dict
|
|
for part in parent_parts:
|
|
if isinstance(parent, dict) and part in parent:
|
|
parent = parent[part]
|
|
else:
|
|
parent = None
|
|
break
|
|
|
|
if parent is not None and isinstance(parent, dict):
|
|
if prop_key not in parent or parent[prop_key] is None:
|
|
default = prop_schema.get('default', [])
|
|
parent[prop_key] = default if default else []
|
|
else:
|
|
if prop_key not in config_dict or config_dict[prop_key] is None:
|
|
default = prop_schema.get('default', [])
|
|
config_dict[prop_key] = default if default else []
|
|
|
|
elif prop_type == 'object' and 'properties' in prop_schema:
|
|
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
|
if prefix:
|
|
parent_parts = prefix.split('.')
|
|
parent = config_dict
|
|
for part in parent_parts:
|
|
if isinstance(parent, dict) and part in parent:
|
|
parent = parent[part]
|
|
else:
|
|
parent = None
|
|
break
|
|
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
|
|
else:
|
|
nested_dict = config_dict.get(prop_key)
|
|
|
|
if nested_dict is None:
|
|
if prefix:
|
|
parent_parts = prefix.split('.')
|
|
parent = config_dict
|
|
for part in parent_parts:
|
|
if part not in parent:
|
|
parent[part] = {}
|
|
parent = parent[part]
|
|
if prop_key not in parent:
|
|
parent[prop_key] = {}
|
|
nested_dict = parent[prop_key]
|
|
else:
|
|
if prop_key not in config_dict:
|
|
config_dict[prop_key] = {}
|
|
nested_dict = config_dict[prop_key]
|
|
|
|
if isinstance(nested_dict, dict):
|
|
# Pass no prefix: config_dict is already navigated.
|
|
ensure_array_defaults(nested_dict, prop_schema['properties'])
|
|
|
|
if schema and 'properties' in schema:
|
|
# First, fix any dict structures that should be arrays
|
|
# This must be called BEFORE validation to convert dicts with numeric keys to arrays
|
|
fix_array_structures(plugin_config, schema['properties'])
|
|
# Then, ensure None arrays get defaults
|
|
ensure_array_defaults(plugin_config, schema['properties'])
|
|
|
|
# Debug: Log the structure after fixing
|
|
if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}):
|
|
custom_feeds = plugin_config['feeds']['custom_feeds']
|
|
logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}")
|
|
|
|
# Force fix for feeds.custom_feeds if it's still a dict (fallback)
|
|
if 'feeds' in plugin_config:
|
|
feeds_config = plugin_config.get('feeds') or {}
|
|
if feeds_config and 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict):
|
|
custom_feeds_dict = feeds_config['custom_feeds']
|
|
# Check if all keys are numeric
|
|
keys = list(custom_feeds_dict.keys())
|
|
if keys and all(str(k).isdigit() for k in keys):
|
|
# Convert to array
|
|
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
|
feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys]
|
|
logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items")
|
|
|
|
# Fix unchecked boolean checkboxes: HTML checkboxes don't submit values
|
|
# when unchecked, so the existing config value (potentially True) persists.
|
|
# Walk the schema and set any boolean fields missing from form data to False.
|
|
if schema and 'properties' in schema:
|
|
form_keys = set(request.form.keys())
|
|
# The rendered form reports which top-level sections it drew, so
|
|
# an unchecked box can be told apart from a field the caller
|
|
# never had in front of it. A caller that sends none gets the
|
|
# evidence-based fallback in _boolean_is_in_scope.
|
|
rendered_sections = set(request.form.getlist(_RENDERED_SECTION_FIELD))
|
|
_set_missing_booleans_to_false(
|
|
plugin_config, schema['properties'], form_keys,
|
|
sections=rendered_sections or None)
|
|
|
|
# Get schema manager instance (for JSON requests)
|
|
schema_mgr = api_v3.schema_manager
|
|
if not schema_mgr:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Schema manager not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
# Load plugin schema using SchemaManager (force refresh to get latest schema)
|
|
# For JSON requests, schema wasn't loaded yet
|
|
if 'application/json' in content_type:
|
|
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
|
|
|
|
regular_config, secrets_config, error = _prepare_plugin_config_for_save(
|
|
plugin_id, plugin_config, schema, schema_mgr,
|
|
is_json='application/json' in content_type)
|
|
if error:
|
|
return error
|
|
|
|
# Get current configs
|
|
current_config = api_v3.config_manager.load_config()
|
|
current_secrets = api_v3.config_manager.get_raw_file_content('secrets')
|
|
|
|
# Deep merge plugin configuration in main config (preserves nested structures)
|
|
if plugin_id not in current_config:
|
|
current_config[plugin_id] = {}
|
|
|
|
current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config)
|
|
|
|
# Deep merge plugin secrets in secrets config
|
|
if secrets_config:
|
|
if plugin_id not in current_secrets:
|
|
current_secrets[plugin_id] = {}
|
|
# See above -- secrets lists must merge element-wise.
|
|
current_secrets[plugin_id] = merge_secrets(
|
|
current_secrets[plugin_id], secrets_config)
|
|
# Save secrets file
|
|
try:
|
|
api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
|
|
except PermissionError as e:
|
|
# Log the error with more details
|
|
import os
|
|
secrets_path = api_v3.config_manager.secrets_path
|
|
secrets_dir = os.path.dirname(secrets_path) if secrets_path else None
|
|
|
|
# Check permissions
|
|
dir_readable = os.access(secrets_dir, os.R_OK) if secrets_dir and os.path.exists(secrets_dir) else False
|
|
dir_writable = os.access(secrets_dir, os.W_OK) if secrets_dir and os.path.exists(secrets_dir) else False
|
|
file_writable = os.access(secrets_path, os.W_OK) if secrets_path and os.path.exists(secrets_path) else False
|
|
|
|
logger.error(
|
|
f"Permission error saving secrets config for {plugin_id}: {e}\n"
|
|
f"Secrets path: {secrets_path}\n"
|
|
f"Directory readable: {dir_readable}, writable: {dir_writable}\n"
|
|
f"File writable: {file_writable}",
|
|
exc_info=True
|
|
)
|
|
return error_response(
|
|
ErrorCode.CONFIG_SAVE_FAILED,
|
|
f"Failed to save secrets configuration: Permission denied. Check file permissions on {secrets_path}",
|
|
status_code=500
|
|
)
|
|
except Exception as e:
|
|
# Log the error but don't fail the entire config save
|
|
import os
|
|
secrets_path = api_v3.config_manager.secrets_path
|
|
logger.error("Error saving secrets config for %s (path=%s)", plugin_id, secrets_path, exc_info=True)
|
|
# Return error response with more context
|
|
return error_response(
|
|
ErrorCode.CONFIG_SAVE_FAILED,
|
|
"Failed to save secrets configuration; see logs for details",
|
|
status_code=500
|
|
)
|
|
|
|
# Save the updated main config using atomic save
|
|
success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config, create_backup=True)
|
|
if not success:
|
|
return error_response(
|
|
ErrorCode.CONFIG_SAVE_FAILED,
|
|
f"Failed to save configuration: {error_msg}",
|
|
status_code=500
|
|
)
|
|
|
|
# If the plugin is loaded, notify it of the config change with merged config
|
|
try:
|
|
if api_v3.plugin_manager:
|
|
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
|
|
if plugin_instance:
|
|
# Reload merged config (includes secrets) and pass the plugin-specific section
|
|
merged_config = api_v3.config_manager.load_config()
|
|
plugin_full_config = _pkg._prepared_plugin_config(
|
|
plugin_id, merged_config.get(plugin_id, {}))
|
|
if hasattr(plugin_instance, 'on_config_change'):
|
|
plugin_instance.on_config_change(plugin_full_config)
|
|
|
|
# Update plugin state manager and call lifecycle methods based on enabled state
|
|
# This ensures the plugin state is synchronized with the config
|
|
enabled = plugin_full_config.get('enabled', plugin_instance.enabled)
|
|
|
|
# Update state manager if available
|
|
if api_v3.plugin_state_manager:
|
|
api_v3.plugin_state_manager.set_plugin_enabled(plugin_id, enabled)
|
|
|
|
# Call lifecycle methods to ensure plugin state matches config
|
|
try:
|
|
if enabled:
|
|
if hasattr(plugin_instance, 'on_enable'):
|
|
plugin_instance.on_enable()
|
|
else:
|
|
if hasattr(plugin_instance, 'on_disable'):
|
|
plugin_instance.on_disable()
|
|
except Exception as lifecycle_error:
|
|
# Log the error but don't fail the save - config is already saved
|
|
import logging
|
|
logging.warning(f"Lifecycle method error for {plugin_id}: {lifecycle_error}", exc_info=True)
|
|
except Exception as hook_err:
|
|
# Do not fail the save if hook fails; just log
|
|
logger.warning("on_config_change failed: %s", hook_err)
|
|
|
|
secret_count = len(secrets_config)
|
|
message = f'Plugin {plugin_id} configuration saved successfully'
|
|
if secret_count > 0:
|
|
message += f' ({secret_count} secret field(s) saved to config_secrets.json)'
|
|
|
|
return success_response(message=message)
|
|
except Exception as e:
|
|
from src.web_interface.errors import WebInterfaceError
|
|
error = WebInterfaceError.from_exception(e, ErrorCode.CONFIG_SAVE_FAILED)
|
|
if api_v3.operation_history:
|
|
api_v3.operation_history.record_operation(
|
|
"configure",
|
|
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
|
|
status="failed",
|
|
error=str(e)
|
|
)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
context=error.context,
|
|
status_code=500
|
|
)
|
|
def _merge_onto_stored_plugin_config(plugin_id, submitted_config, current_config=None):
|
|
"""A JSON plugin-config body merged onto the plugin's stored section.
|
|
|
|
A JSON body carries the settings being changed, as a form post does: the
|
|
rest keep their stored values. Built from defaults alone, a body such as
|
|
``{"enabled": true}`` reset every unsent setting of the plugin.
|
|
``current_config`` is the loaded main config when the caller has one.
|
|
"""
|
|
import copy
|
|
if current_config is None:
|
|
current_config = api_v3.config_manager.load_config()
|
|
stored = current_config.get(plugin_id)
|
|
base = copy.deepcopy(stored) if isinstance(stored, dict) else {}
|
|
return deep_merge(base, submitted_config)
|
|
|
|
|
|
def _prepare_plugin_config_for_save(plugin_id, plugin_config, schema, schema_mgr, is_json):
|
|
"""Turn a submitted plugin config into what gets stored.
|
|
|
|
Fixes array shapes, keeps the enabled state, reads legacy booleans and
|
|
applies schema defaults, normalizes value types, filters to the schema plus
|
|
the core-owned per-plugin properties, validates, and splits out secrets.
|
|
Shared by POST /plugins/config and plugin sections posted to
|
|
POST /config/main, so both store the same thing.
|
|
|
|
Returns ``(regular_config, secrets_config, None)``, or
|
|
``(None, None, error_response)`` when validation fails.
|
|
"""
|
|
# JSON path: fix numeric-keyed dicts that should be arrays.
|
|
# JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}}
|
|
# instead of [{name:...}]. The form-data path has fix_array_structures for this;
|
|
# mirror that logic here for JSON submissions.
|
|
if is_json and schema and 'properties' in schema:
|
|
def _fix_json_arrays(cfg, props):
|
|
for k, ps in props.items():
|
|
if not isinstance(cfg, dict) or k not in cfg:
|
|
continue
|
|
pt = ps.get('type')
|
|
val = cfg[k]
|
|
if pt == 'array':
|
|
items_schema = ps.get('items', {})
|
|
item_type = items_schema.get('type')
|
|
if isinstance(val, dict):
|
|
keys = list(val.keys())
|
|
if keys and all(str(x).isdigit() for x in keys):
|
|
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
|
arr = [val[sk] for sk in sorted_keys]
|
|
if item_type in ('integer', 'number'):
|
|
converted = []
|
|
for v in arr:
|
|
if isinstance(v, str):
|
|
try:
|
|
converted.append(int(v) if item_type == 'integer' else float(v))
|
|
except (ValueError, TypeError, OverflowError):
|
|
converted.append(v)
|
|
else:
|
|
converted.append(v)
|
|
arr = converted
|
|
cfg[k] = arr
|
|
elif not keys:
|
|
cfg[k] = []
|
|
# Recurse into each element when items are objects with properties,
|
|
# covering both freshly-converted and already-list values.
|
|
if item_type == 'object' and 'properties' in items_schema:
|
|
for elem in (cfg[k] if isinstance(cfg[k], list) else []):
|
|
if isinstance(elem, dict):
|
|
_fix_json_arrays(elem, items_schema['properties'])
|
|
elif pt == 'object' and 'properties' in ps and isinstance(val, dict):
|
|
_fix_json_arrays(val, ps['properties'])
|
|
_fix_json_arrays(plugin_config, schema['properties'])
|
|
|
|
# PRE-PROCESSING: Preserve 'enabled' state if not in request
|
|
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
|
|
if 'enabled' not in plugin_config:
|
|
try:
|
|
current_config = api_v3.config_manager.load_config()
|
|
if plugin_id in current_config and 'enabled' in current_config[plugin_id]:
|
|
plugin_config['enabled'] = current_config[plugin_id]['enabled']
|
|
# logger.debug(f"Preserving enabled state for {plugin_id}: {plugin_config['enabled']}")
|
|
elif api_v3.plugin_manager:
|
|
# Fallback to plugin instance if config doesn't have it
|
|
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
|
|
if plugin_instance:
|
|
plugin_config['enabled'] = plugin_instance.enabled
|
|
# Final fallback: default to True if plugin is loaded (matches BasePlugin default)
|
|
if 'enabled' not in plugin_config:
|
|
plugin_config['enabled'] = True
|
|
except Exception as e:
|
|
logger.debug("Error preserving enabled state: %s", e)
|
|
# Default to True on error to avoid disabling plugins
|
|
plugin_config['enabled'] = True
|
|
|
|
# Find secret fields (supports nested schemas and array-item secrets)
|
|
secret_fields = set()
|
|
|
|
if schema and 'properties' in schema:
|
|
secret_fields = find_secret_fields(schema['properties'])
|
|
|
|
# Apply defaults from schema to config BEFORE validation
|
|
# This ensures required fields with defaults are present before validation
|
|
# Store preserved enabled value before merge to protect it from defaults
|
|
preserved_enabled = None
|
|
if 'enabled' in plugin_config:
|
|
preserved_enabled = plugin_config['enabled']
|
|
|
|
if schema:
|
|
# Legacy booleans read as {"enabled": ...} objects first (#588), the
|
|
# same preparation loading applies, so a config the device reads
|
|
# without complaint also saves.
|
|
from src.plugin_system.schema_manager import prepare_plugin_config
|
|
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
|
plugin_config = prepare_plugin_config(plugin_config, schema, defaults)
|
|
|
|
# After merging defaults, replace any None array values with their schema defaults.
|
|
# merge_with_defaults gives user config higher priority, so a None submitted by
|
|
# the client can survive the merge — this pass cleans those up.
|
|
def _fix_none_arrays(cfg, props):
|
|
for k, pschema in props.items():
|
|
if pschema.get('type') == 'array':
|
|
if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None):
|
|
cfg[k] = pschema.get('default', [])
|
|
elif pschema.get('type') == 'object' and 'properties' in pschema:
|
|
if isinstance(cfg, dict) and isinstance(cfg.get(k), dict):
|
|
_fix_none_arrays(cfg[k], pschema['properties'])
|
|
|
|
if schema and 'properties' in schema and isinstance(plugin_config, dict):
|
|
_fix_none_arrays(plugin_config, schema['properties'])
|
|
|
|
# Ensure enabled state is preserved after defaults merge
|
|
# Defaults should not overwrite an explicitly preserved enabled value
|
|
if preserved_enabled is not None:
|
|
# Restore preserved value if it was changed by defaults merge
|
|
if plugin_config.get('enabled') != preserved_enabled:
|
|
plugin_config['enabled'] = preserved_enabled
|
|
|
|
# Normalize config data: convert string numbers to integers/floats where schema expects numbers
|
|
# This handles form data which sends everything as strings
|
|
def normalize_config_values(config, schema_props, prefix=''):
|
|
"""Recursively normalize config values based on schema types"""
|
|
if not isinstance(config, dict) or not isinstance(schema_props, dict):
|
|
return config
|
|
|
|
normalized = {}
|
|
for key, value in config.items():
|
|
field_path = f"{prefix}.{key}" if prefix else key
|
|
|
|
if key not in schema_props:
|
|
# Field not in schema, keep as-is (will be caught by additionalProperties check if needed)
|
|
normalized[key] = value
|
|
continue
|
|
|
|
prop_schema = schema_props[key]
|
|
prop_type = prop_schema.get('type')
|
|
|
|
# Handle union types (e.g., ["integer", "null"])
|
|
if isinstance(prop_type, list):
|
|
# Check if null is allowed and value is empty/null
|
|
if 'null' in prop_type:
|
|
# Handle various representations of null/empty
|
|
if value is None:
|
|
normalized[key] = None
|
|
continue
|
|
elif isinstance(value, str):
|
|
# Strip whitespace and check for null representations
|
|
value_stripped = value.strip()
|
|
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
|
|
normalized[key] = None
|
|
continue
|
|
|
|
# Try to normalize based on non-null types in the union
|
|
# Check integer first (more specific than number)
|
|
if 'integer' in prop_type:
|
|
if isinstance(value, str):
|
|
value_stripped = value.strip()
|
|
if value_stripped == '':
|
|
# Empty string with null allowed - already handled above, but double-check
|
|
if 'null' in prop_type:
|
|
normalized[key] = None
|
|
continue
|
|
try:
|
|
normalized[key] = int(value_stripped)
|
|
continue
|
|
except (ValueError, TypeError, OverflowError):
|
|
pass
|
|
elif isinstance(value, (int, float)):
|
|
normalized[key] = int(value)
|
|
continue
|
|
|
|
# Check number (less specific, but handles floats)
|
|
if 'number' in prop_type:
|
|
if isinstance(value, str):
|
|
value_stripped = value.strip()
|
|
if value_stripped == '':
|
|
# Empty string with null allowed - already handled above, but double-check
|
|
if 'null' in prop_type:
|
|
normalized[key] = None
|
|
continue
|
|
try:
|
|
normalized[key] = float(value_stripped)
|
|
continue
|
|
except (ValueError, TypeError, OverflowError):
|
|
pass
|
|
elif isinstance(value, (int, float)):
|
|
normalized[key] = float(value)
|
|
continue
|
|
|
|
# Check boolean
|
|
if 'boolean' in prop_type:
|
|
if isinstance(value, str):
|
|
normalized[key] = value.strip().lower() in ('true', '1', 'on', 'yes')
|
|
continue
|
|
|
|
# If no conversion worked and null is allowed, try to set to None
|
|
# This handles cases where the value is an empty string or can't be converted
|
|
if 'null' in prop_type:
|
|
if isinstance(value, str):
|
|
value_stripped = value.strip()
|
|
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
|
|
normalized[key] = None
|
|
continue
|
|
# If it's already None, keep it
|
|
if value is None:
|
|
normalized[key] = None
|
|
continue
|
|
|
|
# If no conversion worked, keep original value (will fail validation, but that's expected)
|
|
# Log a warning for debugging
|
|
logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}")
|
|
normalized[key] = value
|
|
continue
|
|
|
|
if isinstance(value, dict) and prop_type == 'object' and 'properties' in prop_schema:
|
|
# Recursively normalize nested objects
|
|
normalized[key] = normalize_config_values(value, prop_schema['properties'], field_path)
|
|
elif isinstance(value, list) and prop_type == 'array' and 'items' in prop_schema:
|
|
# Normalize array items
|
|
items_schema = prop_schema['items']
|
|
item_type = items_schema.get('type')
|
|
|
|
# Handle union types in array items
|
|
if isinstance(item_type, list):
|
|
normalized_array = []
|
|
for v in value:
|
|
# Check if null is allowed
|
|
if 'null' in item_type:
|
|
if v is None or v == '' or (isinstance(v, str) and v.lower() in ('null', 'none')):
|
|
normalized_array.append(None)
|
|
continue
|
|
|
|
# Try to normalize based on non-null types
|
|
if 'integer' in item_type:
|
|
if isinstance(v, str):
|
|
try:
|
|
normalized_array.append(int(v))
|
|
continue
|
|
except (ValueError, TypeError, OverflowError):
|
|
pass
|
|
elif isinstance(v, (int, float)):
|
|
normalized_array.append(int(v))
|
|
continue
|
|
elif 'number' in item_type:
|
|
if isinstance(v, str):
|
|
try:
|
|
normalized_array.append(float(v))
|
|
continue
|
|
except (ValueError, TypeError, OverflowError):
|
|
pass
|
|
elif isinstance(v, (int, float)):
|
|
normalized_array.append(float(v))
|
|
continue
|
|
|
|
# If no conversion worked, keep original value
|
|
normalized_array.append(v)
|
|
normalized[key] = normalized_array
|
|
elif item_type == 'integer':
|
|
# Convert string numbers to integers
|
|
normalized_array = []
|
|
for v in value:
|
|
if isinstance(v, str):
|
|
try:
|
|
normalized_array.append(int(v))
|
|
except (ValueError, TypeError, OverflowError):
|
|
normalized_array.append(v)
|
|
elif isinstance(v, (int, float)):
|
|
normalized_array.append(int(v))
|
|
else:
|
|
normalized_array.append(v)
|
|
normalized[key] = normalized_array
|
|
elif item_type == 'number':
|
|
# Convert string numbers to floats
|
|
normalized_array = []
|
|
for v in value:
|
|
if isinstance(v, str):
|
|
try:
|
|
normalized_array.append(float(v))
|
|
except (ValueError, TypeError, OverflowError):
|
|
normalized_array.append(v)
|
|
else:
|
|
normalized_array.append(v)
|
|
normalized[key] = normalized_array
|
|
elif item_type == 'object' and 'properties' in items_schema:
|
|
# Recursively normalize array of objects
|
|
normalized_array = []
|
|
for v in value:
|
|
if isinstance(v, dict):
|
|
normalized_array.append(
|
|
normalize_config_values(v, items_schema['properties'], f"{field_path}[]")
|
|
)
|
|
else:
|
|
normalized_array.append(v)
|
|
normalized[key] = normalized_array
|
|
else:
|
|
normalized[key] = value
|
|
elif prop_type == 'integer':
|
|
# Convert string to integer
|
|
if isinstance(value, str):
|
|
try:
|
|
normalized[key] = int(value)
|
|
except (ValueError, TypeError, OverflowError):
|
|
normalized[key] = value
|
|
else:
|
|
normalized[key] = value
|
|
elif prop_type == 'number':
|
|
# Convert string to float
|
|
if isinstance(value, str):
|
|
try:
|
|
normalized[key] = float(value)
|
|
except (ValueError, TypeError, OverflowError):
|
|
normalized[key] = value
|
|
else:
|
|
normalized[key] = value
|
|
elif prop_type == 'boolean':
|
|
# Convert string booleans
|
|
if isinstance(value, str):
|
|
normalized[key] = value.lower() in ('true', '1', 'on', 'yes')
|
|
else:
|
|
normalized[key] = value
|
|
else:
|
|
normalized[key] = value
|
|
|
|
return normalized
|
|
|
|
# Normalize config before validation
|
|
if schema and 'properties' in schema:
|
|
plugin_config = normalize_config_values(plugin_config, schema['properties'])
|
|
|
|
# Filter config to only include schema-defined fields (important when additionalProperties is false)
|
|
# Use enhanced schema with core properties to ensure core properties are preserved during filtering
|
|
if schema and 'properties' in schema:
|
|
enhanced_schema_for_filtering = _enhance_schema_with_core_properties(schema)
|
|
plugin_config = _filter_config_by_schema(plugin_config, enhanced_schema_for_filtering)
|
|
|
|
# Debug logging for union type fields (temporary)
|
|
if 'rotation_settings' in plugin_config and 'random_seed' in plugin_config.get('rotation_settings', {}):
|
|
seed_value = plugin_config['rotation_settings']['random_seed']
|
|
logger.debug(f"After normalization, random_seed value: {repr(seed_value)}, type: {type(seed_value)}")
|
|
|
|
# Validate configuration against schema before saving
|
|
if schema:
|
|
# Log what we're validating for debugging
|
|
logger.info(f"Validating config for {plugin_id}")
|
|
# Only the shape. plugin_config still holds the submitted secret
|
|
# values at this point -- separate_secrets does not run until
|
|
# below -- so logging it wrote live credentials to the journal.
|
|
logger.info(f"Config keys being validated: {list(plugin_config.keys())}")
|
|
|
|
# Schema keys including the injected core properties, for the error
|
|
enhanced_schema = _enhance_schema_with_core_properties(schema)
|
|
|
|
is_valid, validation_errors = schema_mgr.validate_config_against_schema(
|
|
plugin_config, schema, plugin_id
|
|
)
|
|
if not is_valid:
|
|
# Log validation errors for debugging
|
|
logger.error(f"Config validation failed for {plugin_id}")
|
|
logger.error(f"Validation errors: {validation_errors}")
|
|
# Keys only, for the same reason as above.
|
|
logger.error(f"Config keys that failed: {list(plugin_config.keys())}")
|
|
logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}")
|
|
|
|
# Also print to console for immediate visibility
|
|
logger.warning("Config validation failed for plugin (see debug logs)")
|
|
|
|
return None, None, error_response(
|
|
ErrorCode.CONFIG_VALIDATION_FAILED,
|
|
'Configuration validation failed',
|
|
details='; '.join(validation_errors) if validation_errors else 'Unknown validation error',
|
|
context={
|
|
'plugin_id': plugin_id,
|
|
'validation_errors': validation_errors,
|
|
'config_keys': list(plugin_config.keys()),
|
|
'schema_keys': list(enhanced_schema.get('properties', {}).keys())
|
|
},
|
|
suggested_fixes=[
|
|
'Review validation errors above',
|
|
'Check config against schema',
|
|
'Verify all required fields are present'
|
|
],
|
|
status_code=400
|
|
)
|
|
|
|
# Separate secrets from regular config (handles nested configs and
|
|
# array-item secrets — see src/web_interface/secret_helpers.py)
|
|
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
|
|
# The config form renders secrets masked, so every save posts
|
|
# them back blank. Without this the blank is merged over the
|
|
# stored value and the credential is destroyed by the act of
|
|
# changing an unrelated setting. A blank means "unchanged".
|
|
secrets_config = remove_empty_secrets(secrets_config)
|
|
|
|
return regular_config, secrets_config, None
|
|
|
|
|
|
@api_v3.route('/plugins/schema', methods=['GET'])
|
|
def get_plugin_schema():
|
|
"""Get plugin configuration schema"""
|
|
try:
|
|
plugin_id = request.args.get('plugin_id')
|
|
if not plugin_id:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400
|
|
|
|
# Get schema manager instance
|
|
schema_mgr = api_v3.schema_manager
|
|
if not schema_mgr:
|
|
return jsonify({'status': 'error', 'message': 'Schema manager not initialized'}), 500
|
|
|
|
# Load schema using SchemaManager (uses caching)
|
|
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
|
|
|
if schema:
|
|
# No "Visual Skin" dropdown: the current scoreboard plugins don't
|
|
# render skins (src.skin_system.SKINS_UNSUPPORTED_MESSAGE), so
|
|
# schema_mgr.inject_skin_selector is deliberately not called. A
|
|
# stored "skin" value is unaffected: validation still allows it
|
|
# and a form save deep-merges over the stored section, keeping it.
|
|
return jsonify({'status': 'success', 'data': {'schema': schema}})
|
|
|
|
# Return a simple default schema if file not found
|
|
default_schema = {
|
|
'type': 'object',
|
|
'properties': {
|
|
'enabled': {
|
|
'type': 'boolean',
|
|
'title': 'Enable Plugin',
|
|
'description': 'Enable or disable this plugin',
|
|
'default': True
|
|
},
|
|
'display_duration': {
|
|
'type': 'integer',
|
|
'title': 'Display Duration',
|
|
'description': 'How long to show content (seconds)',
|
|
'minimum': 5,
|
|
'maximum': 300,
|
|
'default': 30
|
|
}
|
|
}
|
|
}
|
|
|
|
return jsonify({'status': 'success', 'data': {'schema': default_schema}})
|
|
except Exception as e:
|
|
logger.error('Error in get_plugin_schema', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/config/reset', methods=['POST'])
|
|
def reset_plugin_config():
|
|
"""Reset plugin configuration to schema defaults"""
|
|
try:
|
|
if not api_v3.config_manager:
|
|
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
plugin_id = data.get('plugin_id')
|
|
preserve_secrets = data.get('preserve_secrets', True)
|
|
|
|
if not plugin_id:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400
|
|
|
|
# Get schema manager instance
|
|
schema_mgr = api_v3.schema_manager
|
|
if not schema_mgr:
|
|
return jsonify({'status': 'error', 'message': 'Schema manager not initialized'}), 500
|
|
|
|
# Generate defaults from schema
|
|
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
|
|
|
# Get current configs
|
|
current_config = api_v3.config_manager.load_config()
|
|
current_secrets = api_v3.config_manager.get_raw_file_content('secrets')
|
|
|
|
# Load schema to identify secret fields
|
|
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
|
secret_fields = set()
|
|
|
|
if schema and 'properties' in schema:
|
|
secret_fields = find_secret_fields(schema['properties'])
|
|
|
|
# Separate defaults into regular and secret configs
|
|
default_regular, default_secrets = separate_secrets(defaults, secret_fields)
|
|
|
|
# Update main config with defaults
|
|
current_config[plugin_id] = default_regular
|
|
|
|
# Update secrets config (preserve existing secrets if preserve_secrets=True)
|
|
if preserve_secrets:
|
|
# Keep existing secrets for this plugin
|
|
if plugin_id in current_secrets:
|
|
# Merge defaults with existing secrets
|
|
existing_secrets = current_secrets[plugin_id]
|
|
for key, value in default_secrets.items():
|
|
if key not in existing_secrets or not existing_secrets[key]:
|
|
existing_secrets[key] = value
|
|
else:
|
|
current_secrets[plugin_id] = default_secrets
|
|
else:
|
|
# Replace all secrets with defaults
|
|
current_secrets[plugin_id] = default_secrets
|
|
|
|
# Save updated configs
|
|
api_v3.config_manager.save_config(current_config)
|
|
if default_secrets or not preserve_secrets:
|
|
api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
|
|
|
|
# Notify plugin of config change if loaded
|
|
try:
|
|
if api_v3.plugin_manager:
|
|
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
|
|
if plugin_instance:
|
|
merged_config = api_v3.config_manager.load_config()
|
|
plugin_full_config = merged_config.get(plugin_id, {})
|
|
if hasattr(plugin_instance, 'on_config_change'):
|
|
plugin_instance.on_config_change(plugin_full_config)
|
|
except Exception as hook_err:
|
|
logger.warning("on_config_change failed: %s", hook_err)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Plugin {plugin_id} configuration reset to defaults',
|
|
'data': {'config': defaults}
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in reset_plugin_config', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/action', methods=['POST'])
|
|
def execute_plugin_action():
|
|
"""Execute a plugin-defined action (e.g., authentication)"""
|
|
try:
|
|
# Try to get JSON data, with better error handling
|
|
try:
|
|
data = request.get_json(force=True) or {}
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"Error parsing JSON in execute_plugin_action: {e}")
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Invalid JSON in request body',
|
|
'content_type': request.content_type }), 400
|
|
|
|
plugin_id = data.get('plugin_id')
|
|
action_id = data.get('action_id')
|
|
action_params = data.get('params', {})
|
|
|
|
if not plugin_id or not action_id:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'plugin_id and action_id required',
|
|
'received': {'plugin_id': plugin_id, 'action_id': action_id, 'has_params': bool(action_params)}
|
|
}), 400
|
|
|
|
# plugin_id comes from the request body and picks the directory whose
|
|
# manifest names the script to run, so it must be a plain name.
|
|
plugin_id = safe_path_component(plugin_id)
|
|
if plugin_id is None:
|
|
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
|
|
|
|
# Get plugin directory
|
|
if api_v3.plugin_manager:
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
|
else:
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
|
|
|
if not plugin_dir or not Path(plugin_dir).exists():
|
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
|
|
|
# Load manifest to get action definition
|
|
manifest_path = Path(plugin_dir) / 'manifest.json'
|
|
if not manifest_path.exists():
|
|
return jsonify({'status': 'error', 'message': 'Plugin manifest not found'}), 404
|
|
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
manifest = json.load(f)
|
|
|
|
web_ui_actions = manifest.get('web_ui_actions', [])
|
|
action_def = None
|
|
for action in web_ui_actions:
|
|
if action.get('id') == action_id:
|
|
action_def = action
|
|
break
|
|
|
|
if not action_def:
|
|
return jsonify({'status': 'error', 'message': f'Action {action_id} not found in plugin manifest'}), 404
|
|
|
|
# Set LEDMATRIX_ROOT environment variable
|
|
env = os.environ.copy()
|
|
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
|
|
|
# Execute action based on type
|
|
action_type = action_def.get('type', 'script')
|
|
|
|
if action_type == 'script':
|
|
# Execute a Python script
|
|
script_path = action_def.get('script')
|
|
if not script_path:
|
|
return jsonify({'status': 'error', 'message': 'Script path not defined for action'}), 400
|
|
|
|
script_file = Path(plugin_dir) / script_path
|
|
if not script_file.exists():
|
|
return jsonify({'status': 'error', 'message': f'Script not found: {script_path}'}), 404
|
|
|
|
# Handle multi-step actions (like Spotify OAuth)
|
|
step = action_params.get('step')
|
|
|
|
if step == '2' and action_params.get('redirect_url'):
|
|
# Step 2: Complete authentication with redirect URL
|
|
redirect_url = action_params.get('redirect_url')
|
|
import tempfile
|
|
import json as json_lib
|
|
|
|
redirect_url_escaped = json_lib.dumps(redirect_url)
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
|
wrapper.write(f'''import sys
|
|
import subprocess
|
|
import os
|
|
|
|
# Set LEDMATRIX_ROOT
|
|
os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}"
|
|
|
|
# Run the script and provide redirect URL
|
|
proc = subprocess.Popen(
|
|
[sys.executable, r"{script_file}"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
env=os.environ
|
|
)
|
|
|
|
# Send redirect URL to stdin
|
|
redirect_url = {redirect_url_escaped}
|
|
stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120)
|
|
print(stdout)
|
|
sys.exit(proc.returncode)
|
|
''')
|
|
wrapper_path = wrapper.name
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['python3', wrapper_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
env=env
|
|
)
|
|
os.unlink(wrapper_path)
|
|
|
|
if result.returncode == 0:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': action_def.get('success_message', 'Action completed successfully'),
|
|
'output': result.stdout
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': action_def.get('error_message', 'Action failed'),
|
|
'output': result.stdout + result.stderr
|
|
}), 400
|
|
except subprocess.TimeoutExpired:
|
|
if os.path.exists(wrapper_path):
|
|
os.unlink(wrapper_path)
|
|
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
|
else:
|
|
# Regular script execution - pass params via stdin if provided
|
|
if action_params:
|
|
# Pass params as JSON via stdin
|
|
import tempfile
|
|
import json as json_lib
|
|
|
|
params_json = json_lib.dumps(action_params)
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
|
wrapper.write(f'''import sys
|
|
import subprocess
|
|
import os
|
|
import json
|
|
|
|
# Set LEDMATRIX_ROOT
|
|
os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}"
|
|
|
|
# Run the script and provide params as JSON via stdin
|
|
proc = subprocess.Popen(
|
|
[sys.executable, r"{script_file}"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
env=os.environ
|
|
)
|
|
|
|
# Send params as JSON to stdin
|
|
params = {params_json}
|
|
stdout, _ = proc.communicate(input=json.dumps(params), timeout=120)
|
|
print(stdout)
|
|
sys.exit(proc.returncode)
|
|
''')
|
|
wrapper_path = wrapper.name
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['python3', wrapper_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
env=env
|
|
)
|
|
os.unlink(wrapper_path)
|
|
|
|
# Try to parse output as JSON
|
|
try:
|
|
output_data = json.loads(result.stdout)
|
|
if result.returncode == 0:
|
|
return jsonify(output_data)
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': output_data.get('message', action_def.get('error_message', 'Action failed')),
|
|
'output': result.stdout + result.stderr
|
|
}), 400
|
|
except json.JSONDecodeError:
|
|
# Output is not JSON, return as text
|
|
if result.returncode == 0:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': action_def.get('success_message', 'Action completed successfully'),
|
|
'output': result.stdout
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': action_def.get('error_message', 'Action failed'),
|
|
'output': result.stdout + result.stderr
|
|
}), 400
|
|
except subprocess.TimeoutExpired:
|
|
if os.path.exists(wrapper_path):
|
|
os.unlink(wrapper_path)
|
|
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
|
else:
|
|
# No params - check for OAuth flow first, then run script normally
|
|
# Step 1: Get initial data (like auth URL)
|
|
# For OAuth flows, we might need to import the script as a module
|
|
if action_def.get('oauth_flow'):
|
|
# Import script as module to get auth URL
|
|
import sys
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("plugin_action", script_file)
|
|
action_module = importlib.util.module_from_spec(spec)
|
|
sys.modules["plugin_action"] = action_module
|
|
|
|
try:
|
|
spec.loader.exec_module(action_module)
|
|
|
|
# Try to get auth URL using common patterns
|
|
auth_url = None
|
|
if hasattr(action_module, 'get_auth_url'):
|
|
auth_url = action_module.get_auth_url()
|
|
elif hasattr(action_module, 'load_spotify_credentials'):
|
|
# Spotify-specific pattern
|
|
client_id, client_secret, redirect_uri = action_module.load_spotify_credentials()
|
|
if all([client_id, client_secret, redirect_uri]):
|
|
from spotipy.oauth2 import SpotifyOAuth
|
|
sp_oauth = SpotifyOAuth(
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
redirect_uri=redirect_uri,
|
|
scope=getattr(action_module, 'SCOPE', ''),
|
|
cache_path=getattr(action_module, 'SPOTIFY_AUTH_CACHE_PATH', None),
|
|
open_browser=False
|
|
)
|
|
auth_url = sp_oauth.get_authorize_url()
|
|
|
|
if auth_url:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': action_def.get('step1_message', 'Authorization URL generated'),
|
|
'auth_url': auth_url,
|
|
'requires_step2': True
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Could not generate authorization URL'
|
|
}), 400
|
|
except Exception as e:
|
|
logger.error("Error executing action step 1", exc_info=True)
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
|
}), 500
|
|
else:
|
|
# Simple script execution
|
|
result = subprocess.run(
|
|
['python3', str(script_file)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
env=env
|
|
)
|
|
|
|
# Try to parse output as JSON
|
|
try:
|
|
import json as json_module
|
|
output_data = json_module.loads(result.stdout)
|
|
if result.returncode == 0:
|
|
return jsonify(output_data)
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': output_data.get('message', action_def.get('error_message', 'Action failed')),
|
|
'output': result.stdout + result.stderr
|
|
}), 400
|
|
except json.JSONDecodeError:
|
|
# Output is not JSON, return as text
|
|
if result.returncode == 0:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': action_def.get('success_message', 'Action completed successfully'),
|
|
'output': result.stdout
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': action_def.get('error_message', 'Action failed'),
|
|
'output': result.stdout + result.stderr
|
|
}), 400
|
|
|
|
elif action_type == 'endpoint':
|
|
# Call a plugin-defined HTTP endpoint (future feature)
|
|
return jsonify({'status': 'error', 'message': 'Endpoint actions not yet implemented'}), 501
|
|
|
|
else:
|
|
return jsonify({'status': 'error', 'message': f'Unknown action type: {action_type}'}), 400
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
|
except Exception as e:
|
|
logger.error('Error in execute_plugin_action', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/authenticate/spotify', methods=['POST'])
|
|
def authenticate_spotify():
|
|
"""Run Spotify authentication script"""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
redirect_url = data.get('redirect_url', '').strip()
|
|
|
|
# Get plugin directory
|
|
plugin_id = 'ledmatrix-music'
|
|
if api_v3.plugin_manager:
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
|
else:
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
|
|
|
if not plugin_dir or not Path(plugin_dir).exists():
|
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
|
|
|
auth_script = Path(plugin_dir) / 'authenticate_spotify.py'
|
|
if not auth_script.exists():
|
|
return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404
|
|
|
|
# Set LEDMATRIX_ROOT environment variable
|
|
env = os.environ.copy()
|
|
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
|
|
|
if redirect_url:
|
|
# Step 2: Complete authentication with redirect URL
|
|
# Create a wrapper script that provides the redirect URL as input
|
|
import tempfile
|
|
|
|
# Create a wrapper script that provides the redirect URL
|
|
import json
|
|
redirect_url_escaped = json.dumps(redirect_url) # Properly escape the URL
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
|
wrapper.write(f'''import sys
|
|
import subprocess
|
|
import os
|
|
|
|
# Set LEDMATRIX_ROOT
|
|
os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}"
|
|
|
|
# Run the auth script and provide redirect URL
|
|
proc = subprocess.Popen(
|
|
[sys.executable, r"{auth_script}"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
env=os.environ
|
|
)
|
|
|
|
# Send redirect URL to stdin
|
|
redirect_url = {redirect_url_escaped}
|
|
stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120)
|
|
print(stdout)
|
|
sys.exit(proc.returncode)
|
|
''')
|
|
wrapper_path = wrapper.name
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['python3', wrapper_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
env=env
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Spotify authentication completed successfully',
|
|
'output': result.stdout
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Spotify authentication failed',
|
|
'output': result.stdout + result.stderr
|
|
}), 400
|
|
except subprocess.TimeoutExpired:
|
|
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
|
|
finally:
|
|
# The wrapper carries the user's redirect URL, so it must not
|
|
# survive the request on any path — including a failure to
|
|
# launch, which the previous per-branch unlinks missed.
|
|
if os.path.exists(wrapper_path):
|
|
os.unlink(wrapper_path)
|
|
else:
|
|
# Step 1: Get authorization URL
|
|
# Import the script's functions directly to get the auth URL
|
|
import sys
|
|
import importlib.util
|
|
|
|
# Load the authentication script as a module
|
|
spec = importlib.util.spec_from_file_location("auth_spotify", auth_script)
|
|
auth_module = importlib.util.module_from_spec(spec)
|
|
sys.modules["auth_spotify"] = auth_module
|
|
|
|
# Set LEDMATRIX_ROOT before loading
|
|
os.environ['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
|
|
|
try:
|
|
spec.loader.exec_module(auth_module)
|
|
|
|
# Get credentials and create OAuth object
|
|
client_id, client_secret, redirect_uri = auth_module.load_spotify_credentials()
|
|
if not all([client_id, client_secret, redirect_uri]):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Could not load Spotify credentials. Please check config/config_secrets.json.'
|
|
}), 400
|
|
|
|
from spotipy.oauth2 import SpotifyOAuth
|
|
sp_oauth = SpotifyOAuth(
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
redirect_uri=redirect_uri,
|
|
scope=auth_module.SCOPE,
|
|
cache_path=auth_module.SPOTIFY_AUTH_CACHE_PATH,
|
|
open_browser=False
|
|
)
|
|
|
|
auth_url = sp_oauth.get_authorize_url()
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Authorization URL generated',
|
|
'auth_url': auth_url
|
|
})
|
|
except Exception as e:
|
|
logger.error("Error getting Spotify auth URL", exc_info=True)
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error('Error in authenticate_spotify', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/authenticate/ytm', methods=['POST'])
|
|
def authenticate_ytm():
|
|
"""Run YouTube Music authentication script"""
|
|
try:
|
|
# Get plugin directory
|
|
plugin_id = 'ledmatrix-music'
|
|
if api_v3.plugin_manager:
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
|
else:
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
|
|
|
if not plugin_dir or not Path(plugin_dir).exists():
|
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
|
|
|
auth_script = Path(plugin_dir) / 'authenticate_ytm.py'
|
|
if not auth_script.exists():
|
|
return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404
|
|
|
|
# Set LEDMATRIX_ROOT environment variable
|
|
env = os.environ.copy()
|
|
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
|
|
|
# Run the authentication script
|
|
result = subprocess.run(
|
|
['python3', str(auth_script)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
env=env
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'YouTube Music authentication completed successfully',
|
|
'output': result.stdout
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'YouTube Music authentication failed',
|
|
'output': result.stdout + result.stderr
|
|
}), 400
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
|
|
except Exception as e:
|
|
logger.error('Error in authenticate_ytm', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
def _plugin_uploads_dir(plugin_id):
|
|
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None.
|
|
|
|
Same guard as the serving route in app.py: one plain path segment,
|
|
resolved and contained under assets/plugins.
|
|
"""
|
|
return resolve_under(PROJECT_ROOT / 'assets' / 'plugins', plugin_id, 'uploads')
|
|
|
|
|
|
@api_v3.route('/plugins/assets/upload', methods=['POST'])
|
|
def upload_plugin_asset():
|
|
"""Upload asset files for a plugin"""
|
|
try:
|
|
plugin_id = request.form.get('plugin_id')
|
|
if not plugin_id:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
|
|
|
|
if 'files' not in request.files:
|
|
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
|
|
|
|
files = request.files.getlist('files')
|
|
if not files or all(not f.filename for f in files):
|
|
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
|
|
|
|
# Validate file count
|
|
if len(files) > 10:
|
|
return jsonify({'status': 'error', 'message': 'Maximum 10 files per upload'}), 400
|
|
|
|
# Setup plugin assets directory. plugin_id is a form field: without
|
|
# the guard '../../config' created, listed and wrote into directories
|
|
# outside assets/plugins (the serving route was fixed in #561).
|
|
assets_dir = _plugin_uploads_dir(plugin_id)
|
|
if assets_dir is None:
|
|
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
|
|
plugin_id = safe_path_component(plugin_id)
|
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Load metadata file
|
|
metadata_file = assets_dir / '.metadata.json'
|
|
if metadata_file.exists():
|
|
with open(metadata_file, 'r') as f:
|
|
metadata = json.load(f)
|
|
else:
|
|
metadata = {}
|
|
|
|
uploaded_files = []
|
|
total_size = 0
|
|
max_size_per_file = 5 * 1024 * 1024 # 5MB
|
|
max_total_size = 50 * 1024 * 1024 # 50MB
|
|
|
|
# Calculate current total size
|
|
for entry in metadata.values():
|
|
if 'size' in entry:
|
|
total_size += entry.get('size', 0)
|
|
|
|
for file in files:
|
|
if not file.filename:
|
|
continue
|
|
|
|
# Validate file type
|
|
allowed_extensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif']
|
|
file_ext = '.' + file.filename.lower().split('.')[-1]
|
|
if file_ext not in allowed_extensions:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Invalid file type: {file_ext}. Allowed: {allowed_extensions}'
|
|
}), 400
|
|
|
|
# Read file to check size and validate
|
|
file.seek(0, os.SEEK_END)
|
|
file_size = file.tell()
|
|
file.seek(0)
|
|
|
|
if file_size > max_size_per_file:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'File {file.filename} exceeds 5MB limit'
|
|
}), 400
|
|
|
|
if total_size + file_size > max_total_size:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Upload would exceed 50MB total storage limit'
|
|
}), 400
|
|
|
|
# Validate file is actually an image (check magic bytes)
|
|
file_content = file.read(8)
|
|
file.seek(0)
|
|
is_valid_image = False
|
|
if file_content.startswith(b'\x89PNG\r\n\x1a\n'): # PNG
|
|
is_valid_image = True
|
|
elif file_content[:2] == b'\xff\xd8': # JPEG
|
|
is_valid_image = True
|
|
elif file_content[:2] == b'BM': # BMP
|
|
is_valid_image = True
|
|
elif file_content[:6] in [b'GIF87a', b'GIF89a']: # GIF
|
|
is_valid_image = True
|
|
|
|
if not is_valid_image:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'File {file.filename} is not a valid image file'
|
|
}), 400
|
|
|
|
# Generate unique filename
|
|
timestamp = int(_pkg.time.time())
|
|
file_hash = hashlib.md5(file_content + file.filename.encode()).hexdigest()[:8]
|
|
safe_filename = f"image_{timestamp}_{file_hash}{file_ext}"
|
|
file_path = assets_dir / safe_filename
|
|
|
|
# Ensure filename is unique
|
|
counter = 1
|
|
while file_path.exists():
|
|
safe_filename = f"image_{timestamp}_{file_hash}_{counter}{file_ext}"
|
|
file_path = assets_dir / safe_filename
|
|
counter += 1
|
|
|
|
# Save file
|
|
file.save(str(file_path))
|
|
|
|
# Make file readable
|
|
os.chmod(file_path, 0o644)
|
|
|
|
# Generate unique ID
|
|
image_id = str(uuid.uuid4())
|
|
|
|
# Store metadata
|
|
relative_path = f"assets/plugins/{plugin_id}/uploads/{safe_filename}"
|
|
metadata[image_id] = {
|
|
'id': image_id,
|
|
'filename': safe_filename,
|
|
'path': relative_path,
|
|
'size': file_size,
|
|
'uploaded_at': datetime.utcnow().isoformat() + 'Z',
|
|
'original_filename': file.filename
|
|
}
|
|
|
|
uploaded_files.append({
|
|
'id': image_id,
|
|
'filename': safe_filename,
|
|
'path': relative_path,
|
|
'size': file_size,
|
|
'uploaded_at': metadata[image_id]['uploaded_at']
|
|
})
|
|
|
|
total_size += file_size
|
|
|
|
# Save metadata
|
|
with open(metadata_file, 'w') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'uploaded_files': uploaded_files,
|
|
'total_files': len(metadata)
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error('Unhandled exception', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST'])
|
|
def upload_of_the_day_json():
|
|
"""Upload JSON files for of-the-day plugin"""
|
|
try:
|
|
if 'files' not in request.files:
|
|
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
|
|
|
|
files = request.files.getlist('files')
|
|
if not files or all(not f.filename for f in files):
|
|
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
|
|
|
|
# Get plugin directory
|
|
plugin_id = 'ledmatrix-of-the-day'
|
|
if api_v3.plugin_manager:
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
|
else:
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
|
|
|
if not plugin_dir or not Path(plugin_dir).exists():
|
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
|
|
|
# Setup of_the_day directory
|
|
data_dir = Path(plugin_dir) / 'of_the_day'
|
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
uploaded_files = []
|
|
max_size_per_file = 5 * 1024 * 1024 # 5MB
|
|
|
|
for file in files:
|
|
if not file.filename:
|
|
continue
|
|
|
|
# Validate file extension
|
|
if not file.filename.lower().endswith('.json'):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'File {file.filename} must be a JSON file (.json)'
|
|
}), 400
|
|
|
|
# Read and validate file size
|
|
file.seek(0, os.SEEK_END)
|
|
file_size = file.tell()
|
|
file.seek(0)
|
|
|
|
if file_size > max_size_per_file:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'File {file.filename} exceeds 5MB limit'
|
|
}), 400
|
|
|
|
# Read and validate JSON content
|
|
try:
|
|
file_content = file.read().decode('utf-8')
|
|
json_data = json.loads(file_content)
|
|
except json.JSONDecodeError as e:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Invalid JSON in request body'
|
|
}), 400
|
|
except UnicodeDecodeError:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'File {file.filename} is not valid UTF-8 text'
|
|
}), 400
|
|
|
|
# Validate JSON structure (must be object with day number keys)
|
|
if not isinstance(json_data, dict):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'JSON in {file.filename} must be an object with day numbers (1-365) as keys'
|
|
}), 400
|
|
|
|
# Check if keys are valid day numbers
|
|
for key in json_data.keys():
|
|
try:
|
|
day_num = int(key)
|
|
if day_num < 1 or day_num > 365:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Day number {day_num} in {file.filename} is out of range (must be 1-365)'
|
|
}), 400
|
|
except ValueError:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Invalid key "{key}" in {file.filename}: must be a day number (1-365)'
|
|
}), 400
|
|
|
|
# Generate safe filename from original (preserve user's filename)
|
|
original_filename = file.filename
|
|
safe_filename = original_filename.lower().replace(' ', '_')
|
|
# Ensure it's a valid filename
|
|
safe_filename = ''.join(c for c in safe_filename if c.isalnum() or c in '._-')
|
|
if not safe_filename.endswith('.json'):
|
|
safe_filename += '.json'
|
|
|
|
file_path = data_dir / safe_filename
|
|
|
|
# If file exists, add counter
|
|
counter = 1
|
|
base_name = safe_filename.replace('.json', '')
|
|
while file_path.exists():
|
|
safe_filename = f"{base_name}_{counter}.json"
|
|
file_path = data_dir / safe_filename
|
|
counter += 1
|
|
|
|
# Save file
|
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
json.dump(json_data, f, indent=2, ensure_ascii=False)
|
|
|
|
# Make file readable
|
|
os.chmod(file_path, 0o644)
|
|
|
|
# Extract category name from filename (remove .json extension)
|
|
category_name = safe_filename.replace('.json', '')
|
|
display_name = category_name.replace('_', ' ').title()
|
|
|
|
# Update plugin config to add category
|
|
try:
|
|
sys.path.insert(0, str(plugin_dir))
|
|
from scripts.update_config import add_category_to_config
|
|
add_category_to_config(category_name, f'of_the_day/{safe_filename}', display_name)
|
|
except Exception as e:
|
|
logger.warning("Could not update config: %s", e)
|
|
# Continue anyway - file is uploaded
|
|
|
|
# Generate file ID (use category name as ID for simplicity)
|
|
file_id = category_name
|
|
|
|
uploaded_files.append({
|
|
'id': file_id,
|
|
'filename': safe_filename,
|
|
'original_filename': original_filename,
|
|
'path': f'of_the_day/{safe_filename}',
|
|
'size': file_size,
|
|
'uploaded_at': datetime.utcnow().isoformat() + 'Z',
|
|
'category_name': category_name,
|
|
'display_name': display_name,
|
|
'entry_count': len(json_data)
|
|
})
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'uploaded_files': uploaded_files,
|
|
'total_files': len(uploaded_files)
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error('Unhandled exception', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST'])
|
|
def delete_of_the_day_json():
|
|
"""Delete a JSON file from of-the-day plugin"""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
file_id = data.get('file_id') # This is the category_name
|
|
|
|
if not file_id:
|
|
return jsonify({'status': 'error', 'message': 'file_id is required'}), 400
|
|
|
|
# file_id names a file that is about to be unlinked, and it arrives
|
|
# straight from the request body. Unvalidated, a file_id of
|
|
# "../../../../etc/cron" made this endpoint delete any .json file on
|
|
# the device the service could write to.
|
|
safe_file_id = safe_path_component(file_id)
|
|
if not safe_file_id:
|
|
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
|
|
|
|
# Get plugin directory
|
|
plugin_id = 'ledmatrix-of-the-day'
|
|
if api_v3.plugin_manager:
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
|
else:
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
|
|
|
if not plugin_dir or not Path(plugin_dir).exists():
|
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
|
|
|
filename = f"{safe_file_id}.json"
|
|
file_path = resolve_under(Path(plugin_dir) / 'of_the_day', filename)
|
|
if file_path is None:
|
|
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
|
|
|
|
if not file_path.exists():
|
|
return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404
|
|
|
|
# Delete file
|
|
file_path.unlink()
|
|
|
|
# Update config to remove category
|
|
try:
|
|
sys.path.insert(0, str(plugin_dir))
|
|
from scripts.update_config import remove_category_from_config
|
|
remove_category_from_config(safe_file_id)
|
|
except Exception as e:
|
|
logger.warning("Could not update config: %s", e)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'File {filename} deleted successfully'
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error('Unhandled exception', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET'])
|
|
def serve_plugin_static(plugin_id, file_path):
|
|
"""Serve static files from plugin directory.
|
|
|
|
Both URL parts are validated before anything is opened. This handler used
|
|
to read whatever the path resolved to as long as ``str(file).startswith``
|
|
the plugin directory, which let two different things through:
|
|
|
|
* ``plugin_id`` of ``..`` -- Flask's default converter forbids a slash but
|
|
not dots, and ``get_plugin_directory('..')`` happily returned the parent
|
|
of the plugins directory because it exists. Every file under the project
|
|
root then "started with" that directory, ``config/config_secrets.json``
|
|
included.
|
|
* a sibling directory sharing a prefix: with the plugin directory
|
|
``plugin-repos/foo``, ``../foo-evil/x`` resolves to
|
|
``plugin-repos/foo-evil/x``, whose string does start with
|
|
``plugin-repos/foo``.
|
|
"""
|
|
try:
|
|
safe_plugin_id = safe_path_component(plugin_id)
|
|
if not safe_plugin_id:
|
|
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
|
|
|
safe_parts = safe_relative_parts(file_path)
|
|
if not safe_parts:
|
|
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 400
|
|
|
|
# Get plugin directory
|
|
if api_v3.plugin_manager:
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(safe_plugin_id)
|
|
else:
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / safe_plugin_id
|
|
|
|
if not plugin_dir or not Path(plugin_dir).exists():
|
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
|
|
|
# Containment is still checked after resolving: name validation cannot
|
|
# see a symlink inside the plugin directory that points out of it.
|
|
requested_file = resolve_under(plugin_dir, *safe_parts)
|
|
if requested_file is None:
|
|
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
|
|
|
# Check if file exists
|
|
if not requested_file.exists() or not requested_file.is_file():
|
|
return jsonify({'status': 'error', 'message': 'File not found'}), 404
|
|
|
|
# Determine content type
|
|
content_type = 'text/plain'
|
|
name = requested_file.name
|
|
if name.endswith('.html'):
|
|
content_type = 'text/html'
|
|
elif name.endswith('.js'):
|
|
content_type = 'application/javascript'
|
|
elif name.endswith('.css'):
|
|
content_type = 'text/css'
|
|
elif name.endswith('.json'):
|
|
content_type = 'application/json'
|
|
|
|
# Read and return file
|
|
with open(requested_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
return Response(content, mimetype=content_type)
|
|
|
|
except Exception as e:
|
|
logger.error('Unhandled exception', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/calendar/upload-credentials', methods=['POST'])
|
|
def upload_calendar_credentials():
|
|
"""Upload credentials.json file for calendar plugin"""
|
|
try:
|
|
if 'file' not in request.files:
|
|
return jsonify({'status': 'error', 'message': 'No file provided'}), 400
|
|
|
|
file = request.files['file']
|
|
if not file or not file.filename:
|
|
return jsonify({'status': 'error', 'message': 'No file provided'}), 400
|
|
|
|
# Validate file extension
|
|
if not file.filename.lower().endswith('.json'):
|
|
return jsonify({'status': 'error', 'message': 'File must be a JSON file (.json)'}), 400
|
|
|
|
# Validate file size (max 1MB for credentials)
|
|
file.seek(0, os.SEEK_END)
|
|
file_size = file.tell()
|
|
file.seek(0)
|
|
|
|
if file_size > 1024 * 1024: # 1MB
|
|
return jsonify({'status': 'error', 'message': 'File exceeds 1MB limit'}), 400
|
|
|
|
# Validate it's valid JSON
|
|
try:
|
|
file_content = file.read()
|
|
file.seek(0)
|
|
creds_data = json.loads(file_content)
|
|
except json.JSONDecodeError:
|
|
return jsonify({'status': 'error', 'message': 'File is not valid JSON'}), 400
|
|
|
|
# Validate it looks like Google OAuth credentials. A bare scalar, a
|
|
# list, true/null — all valid JSON, none of them credentials. Reject
|
|
# rather than save: a file written as credentials.json but unusable
|
|
# as credentials only fails later, somewhere less obvious.
|
|
if not isinstance(creds_data, dict) or not (
|
|
'installed' in creds_data or 'web' in creds_data):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'File does not appear to be a valid Google OAuth credentials file'
|
|
}), 400
|
|
|
|
# Get plugin directory
|
|
plugin_id = 'calendar'
|
|
if api_v3.plugin_manager:
|
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
|
else:
|
|
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
|
|
|
if not plugin_dir or not Path(plugin_dir).exists():
|
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
|
|
|
# Save file to plugin directory
|
|
credentials_path = Path(plugin_dir) / 'credentials.json'
|
|
|
|
# Backup existing file if it exists
|
|
if credentials_path.exists():
|
|
backup_path = Path(plugin_dir) / f'credentials.json.backup.{int(_pkg.time.time())}'
|
|
import shutil
|
|
shutil.copy2(credentials_path, backup_path)
|
|
_prune_credential_backups(Path(plugin_dir))
|
|
|
|
# Save new file
|
|
file.save(str(credentials_path))
|
|
|
|
# Set proper permissions
|
|
os.chmod(credentials_path, 0o600) # Read/write for owner only
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Credentials file uploaded successfully',
|
|
'path': str(credentials_path)
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error('Error in upload_calendar_credentials', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
|
|
def authenticate_calendar():
|
|
"""Google OAuth for the calendar plugin, in the two steps it requires.
|
|
|
|
Step 1 (no body) returns the consent URL to open. Step 2 posts back the
|
|
URL Google redirected to -- it fails to load, because the redirect points
|
|
at a loopback address nothing is listening on, but the address bar carries
|
|
the authorization code -- and the script exchanges it for a token.
|
|
|
|
Two calls rather than one because the user has to visit Google in between.
|
|
The script persists the PKCE verifier from step 1 for step 2 to reuse; the
|
|
exchange fails with "Missing code verifier" otherwise.
|
|
"""
|
|
try:
|
|
plugin_dir = _pkg._calendar_plugin_dir()
|
|
if plugin_dir is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'The calendar plugin is not installed'
|
|
}), 404
|
|
|
|
if not (plugin_dir / 'credentials.json').exists():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': ('No credentials.json yet. Upload your Google OAuth '
|
|
'client file first (Step 1).')
|
|
}), 400
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
redirect_url = (data.get('redirect_url') or data.get('code') or '').strip()
|
|
|
|
payload, error = _run_calendar_registration(plugin_dir, redirect_url)
|
|
if error:
|
|
return jsonify({'status': 'error', 'message': error}), 500
|
|
if payload.get('status') != 'success':
|
|
# The script's own diagnosis is more useful than anything that
|
|
# could be reconstructed here -- but it interpolates exceptions
|
|
# into its messages, so it reaches the client redacted and the
|
|
# original goes to the log.
|
|
logger.error('calendar authentication failed: %s', payload)
|
|
safe = dict(payload)
|
|
safe['message'] = redact_text(str(payload.get('message', '')
|
|
or 'Authentication failed'))
|
|
return jsonify(safe), 400
|
|
return jsonify(payload)
|
|
|
|
except Exception as e:
|
|
logger.error('Error in authenticate_calendar', exc_info=True)
|
|
return jsonify({'status': 'error',
|
|
'message': 'An error occurred; see logs for details',
|
|
'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
|
|
def list_calendar_calendars():
|
|
"""The calendars this account can see, for the config picker.
|
|
|
|
Reads the token the OAuth flow wrote rather than shelling out again: the
|
|
picker is used interactively and a subprocess per click is slower than the
|
|
API call it would be wrapping.
|
|
"""
|
|
try:
|
|
plugin_dir = _pkg._calendar_plugin_dir()
|
|
if plugin_dir is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'The calendar plugin is not installed'
|
|
}), 404
|
|
|
|
token_file = plugin_dir / 'token.pickle'
|
|
if not token_file.exists():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': ('Not authenticated with Google yet. Complete Step 2 '
|
|
'first, then load your calendars.')
|
|
}), 400
|
|
|
|
try:
|
|
import pickle
|
|
from google.auth.transport.requests import Request as GoogleRequest
|
|
from googleapiclient.discovery import build as build_google_service
|
|
except ImportError as e:
|
|
return jsonify({
|
|
'status': 'error',
|
|
# The name of the missing module is the whole diagnosis, but it
|
|
# arrives as an exception, so it goes through the redactor like
|
|
# any other -- an ImportError can quote a path.
|
|
'message': ('The Google API libraries are not installed. Install '
|
|
"the calendar plugin's requirements.txt. (%s)"
|
|
% describe_exception(e))
|
|
}), 500
|
|
|
|
with open(token_file, 'rb') as handle:
|
|
# Written only by this plugin's own OAuth flow, into its own
|
|
# directory, and read here exactly as the plugin itself reads it.
|
|
creds = pickle.load(handle) # nosec B301 - locally generated token
|
|
|
|
if creds and creds.expired and creds.refresh_token:
|
|
creds.refresh(GoogleRequest())
|
|
with open(token_file, 'wb') as handle:
|
|
pickle.dump(creds, handle)
|
|
os.chmod(token_file, 0o600)
|
|
|
|
if not creds or not creds.valid:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': ('Stored Google credentials are no longer valid. '
|
|
'Run Step 2 again to re-authenticate.')
|
|
}), 400
|
|
|
|
service = build_google_service('calendar', 'v3', credentials=creds)
|
|
|
|
# calendarList.list returns 100 entries per page by default and caps at
|
|
# 250, handing back a nextPageToken when there are more. Taking only
|
|
# the first page would silently hide calendars from the picker, and the
|
|
# user would have no way to tell the list was truncated.
|
|
entries = []
|
|
page_token = None
|
|
for _ in range(_CALENDAR_LIST_MAX_PAGES):
|
|
response = service.calendarList().list(
|
|
maxResults=250, pageToken=page_token).execute()
|
|
entries.extend(response.get('items', []))
|
|
page_token = response.get('nextPageToken')
|
|
if not page_token:
|
|
break
|
|
else:
|
|
# 2500 calendars in, something is wrong with the account or the
|
|
# token is looping; show what was collected rather than spin.
|
|
logger.warning(
|
|
'calendarList paging stopped at %d pages with more remaining',
|
|
_CALENDAR_LIST_MAX_PAGES)
|
|
|
|
calendars = [{
|
|
'id': entry.get('id'),
|
|
# The picker labels each row with summary and falls back to the id
|
|
# only in its own display, so send something either way.
|
|
'summary': entry.get('summary') or entry.get('id'),
|
|
'primary': bool(entry.get('primary', False)),
|
|
} for entry in entries if entry.get('id')]
|
|
|
|
# Primary first, then alphabetically: the list is usually short but the
|
|
# one the user wants is almost always their own calendar.
|
|
calendars.sort(key=lambda c: (not c['primary'], c['summary'].lower()))
|
|
|
|
return jsonify({'status': 'success', 'calendars': calendars})
|
|
|
|
except Exception as e:
|
|
logger.error('Error in list_calendar_calendars', exc_info=True)
|
|
return jsonify({'status': 'error',
|
|
'message': 'An error occurred; see logs for details',
|
|
'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
|
def delete_plugin_asset():
|
|
"""Delete an asset file for a plugin"""
|
|
try:
|
|
data = request.get_json()
|
|
plugin_id = data.get('plugin_id')
|
|
image_id = data.get('image_id')
|
|
|
|
if not plugin_id or not image_id:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400
|
|
|
|
# Get asset directory
|
|
assets_dir = _plugin_uploads_dir(plugin_id)
|
|
if assets_dir is None:
|
|
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
|
|
metadata_file = assets_dir / '.metadata.json'
|
|
|
|
if not metadata_file.exists():
|
|
return jsonify({'status': 'error', 'message': 'Metadata file not found'}), 404
|
|
|
|
# Load metadata
|
|
with open(metadata_file, 'r') as f:
|
|
metadata = json.load(f)
|
|
|
|
if image_id not in metadata:
|
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
|
|
|
# Delete file. The stored path is data, not a trusted location: only
|
|
# unlink it when it resolves to a file directly inside this plugin's
|
|
# uploads. An entry pointing anywhere else is dropped from the
|
|
# metadata without touching the file it names.
|
|
entry = metadata[image_id] if isinstance(metadata[image_id], dict) else {}
|
|
parts = safe_relative_parts(entry.get('path'))
|
|
file_path = resolve_under(PROJECT_ROOT, *parts) if parts else None
|
|
if file_path is None or file_path.parent != assets_dir:
|
|
logger.warning('Asset %s has a path outside its uploads directory; '
|
|
'removing the entry without deleting a file', image_id)
|
|
elif file_path.exists():
|
|
file_path.unlink()
|
|
|
|
# Remove from metadata
|
|
del metadata[image_id]
|
|
|
|
# Save metadata
|
|
with open(metadata_file, 'w') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
return jsonify({'status': 'success', 'message': 'Image deleted successfully'})
|
|
|
|
except Exception as e:
|
|
logger.error('Unhandled exception', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/plugins/assets/list', methods=['GET'])
|
|
def list_plugin_assets():
|
|
"""List asset files for a plugin"""
|
|
try:
|
|
plugin_id = request.args.get('plugin_id')
|
|
if not plugin_id:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
|
|
|
|
# Get asset directory
|
|
assets_dir = _plugin_uploads_dir(plugin_id)
|
|
if assets_dir is None:
|
|
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
|
|
metadata_file = assets_dir / '.metadata.json'
|
|
|
|
if not metadata_file.exists():
|
|
return jsonify({'status': 'success', 'data': {'assets': []}})
|
|
|
|
# Load metadata
|
|
with open(metadata_file, 'r') as f:
|
|
metadata = json.load(f)
|
|
|
|
# Convert to list
|
|
assets = list(metadata.values())
|
|
|
|
return jsonify({'status': 'success', 'data': {'assets': assets}})
|
|
|
|
except Exception as e:
|
|
logger.error('Unhandled exception', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|