diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3a9339..f584e87e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: - 'src/infrastructure/services/plugins/**' - 'src/application/ports/plugin_ports.rs' - 'src/application/adapters/plugin_lifecycle_hook.rs' + - 'src/application/adapters/plugin_user_lifecycle_hook.rs' frontend-check: name: Frontend — CSS and JS checks (format, lint, css-rules, types) diff --git a/.gitignore b/.gitignore index 9dc7a3d6..468d6c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ # Processed static assets (generated by build.rs in release mode) /static-dist/ +# Built plugin bundles (just plugin-example-zip) +/dist/ + # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html # Cargo.lock diff --git a/devenv.nix b/devenv.nix index fb5e4ee2..cf2ebd75 100644 --- a/devenv.nix +++ b/devenv.nix @@ -53,6 +53,14 @@ ''; }; + # Keep cargo's build output OUT of the repo. The devenv shell is entered via + # `use flake "path:$PWD"` (.envrc), and a `path:` flake copies the whole working + # tree into the Nix store WITHOUT honoring .gitignore — so a multi-GB `target/` + # makes every direnv evaluation hang "copying … to the store". Redirecting + # CARGO_TARGET_DIR under $HOME (per-user, so it stays portable) keeps the source + # tree small. `builtins.getEnv` is fine here: the flake is evaluated --impure. + env.CARGO_TARGET_DIR = "${builtins.getEnv "HOME"}/.cache/oxicloud/target"; + # Load .env (justfile uses `set dotenv-load`); contributor should `cp example.env .env`. dotenv.enable = true; diff --git a/justfile b/justfile index f223fea9..29f47595 100644 --- a/justfile +++ b/justfile @@ -75,6 +75,11 @@ test-plugins: plugin-build: bash scripts/build-plugin-hello.sh +# Build the example plugin and bundle plugin.toml + .wasm into an installable +# .zip at dist/oxicloud-plugin-hello.zip (upload via the admin Plugins tab). +plugin-example-zip: + bash scripts/build-plugin-zip.sh + # fmt + clippy the example plugin crate (standalone workspace, wasm32 target). plugin-check: cd wasm/oxicloud-plugin-hello; cargo fmt --all diff --git a/scripts/build-plugin-hello.sh b/scripts/build-plugin-hello.sh index 4e7212fd..737df488 100755 --- a/scripts/build-plugin-hello.sh +++ b/scripts/build-plugin-hello.sh @@ -17,6 +17,10 @@ cd "$(dirname "$0")/.." CRATE=wasm/oxicloud-plugin-hello OUT=tests/fixtures/plugins +# Pin the wasm build to the crate-local target dir. The devenv sets a global +# CARGO_TARGET_DIR (outside the repo) which would otherwise relocate the +# artifact away from the path below. +export CARGO_TARGET_DIR="$PWD/$CRATE/target" ARTIFACT="$CRATE/target/wasm32-unknown-unknown/release/oxicloud_plugin_hello.wasm" # Needs the wasm32-unknown-unknown target's std. In the devenv this comes from @@ -37,10 +41,11 @@ build() { } build hello -build panic --features panic -build sleep --features sleep -build net --features net -build wrong_abi --features wrong_abi +build panic --features panic +build sleep --features sleep +build net --features net +build wrong_abi --features wrong_abi +build omit_login --features omit_login echo "Built fixtures:" ls -la "$OUT"/*.wasm | awk '{print " " $9 " (" $5 " bytes)"}' diff --git a/scripts/build-plugin-zip.sh b/scripts/build-plugin-zip.sh new file mode 100755 index 00000000..f810e0f0 --- /dev/null +++ b/scripts/build-plugin-zip.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Build the example plugin (wasm/oxicloud-plugin-hello) and bundle its +# plugin.toml + compiled .wasm into an installable .zip — the exact shape the +# admin "Install a plugin" upload (and POST /api/admin/plugins) expects. +# +# Output: dist/oxicloud-plugin-hello.zip (plugin.toml + hello.wasm at the root) +# +# Requirements: the wasm32-unknown-unknown target. The devenv provides it via +# `languages.rust.targets` in devenv.nix; otherwise: +# rustup target add wasm32-unknown-unknown + +set -euo pipefail +cd "$(dirname "$0")/.." + +CRATE=wasm/oxicloud-plugin-hello +# Pin the wasm build to the crate-local target dir. The devenv sets a global +# CARGO_TARGET_DIR (outside the repo) which would otherwise relocate the +# artifact away from the path below. +export CARGO_TARGET_DIR="$PWD/$CRATE/target" +ARTIFACT="$CRATE/target/wasm32-unknown-unknown/release/oxicloud_plugin_hello.wasm" +OUT_DIR=dist +OUT_ZIP="$OUT_DIR/oxicloud-plugin-hello.zip" +OUT_ZIP_ABS="$PWD/$OUT_ZIP" + +echo "building $CRATE on wasm32-unknown-unknown…" +cargo build \ + --manifest-path "$CRATE/Cargo.toml" \ + --target wasm32-unknown-unknown \ + --release + +# Stage plugin.toml + the module under the entrypoint name the manifest declares +# (entrypoint = "hello.wasm"), then zip the staging dir's contents at the root. +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +cp "$CRATE/plugin.toml" "$STAGE/plugin.toml" +cp "$ARTIFACT" "$STAGE/hello.wasm" + +mkdir -p "$OUT_DIR" +rm -f "$OUT_ZIP" +# `zip` isn't in the devenv toolchain; python3 is. `-c` creates an archive, +# storing the given paths. Run from the staging dir so entries are at the root. +( cd "$STAGE" && python3 -m zipfile -c "$OUT_ZIP_ABS" plugin.toml hello.wasm ) + +echo "bundled → $OUT_ZIP" +python3 -m zipfile -l "$OUT_ZIP" diff --git a/src/application/adapters/mod.rs b/src/application/adapters/mod.rs index 89a7dd00..60ec06b9 100644 --- a/src/application/adapters/mod.rs +++ b/src/application/adapters/mod.rs @@ -3,6 +3,7 @@ pub mod caldav_adapter; pub mod carddav_adapter; pub mod plugin_lifecycle_hook; +pub mod plugin_user_lifecycle_hook; pub mod webdav_adapter; #[cfg(test)] diff --git a/src/application/adapters/plugin_lifecycle_hook.rs b/src/application/adapters/plugin_lifecycle_hook.rs index 2892db56..072114bb 100644 --- a/src/application/adapters/plugin_lifecycle_hook.rs +++ b/src/application/adapters/plugin_lifecycle_hook.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use crate::application::ports::file_lifecycle::FileLifecycleHook; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::plugin_ports::{ - EVENT_FILE_UPLOADED, FileUploadedEvent, PluginDispatchPort, + EVENT_FILE_UPLOADED, PluginDispatchPort, PluginEvent, }; use crate::application::services::FileRetrievalService; @@ -59,12 +59,15 @@ impl PluginLifecycleHook { } }; - dispatch.dispatch_file_uploaded(FileUploadedEvent { - path: dto.path, - size: dto.size, - mime: dto.mime_type.to_string(), + dispatch.dispatch(PluginEvent { + name: EVENT_FILE_UPLOADED, user_id: dto.owner_id, invocation_id: Uuid::new_v4().to_string(), + payload: serde_json::json!({ + "path": dto.path, + "size": dto.size, + "mime": dto.mime_type.to_string(), + }), }); }); } diff --git a/src/application/adapters/plugin_user_lifecycle_hook.rs b/src/application/adapters/plugin_user_lifecycle_hook.rs new file mode 100644 index 00000000..0114539a --- /dev/null +++ b/src/application/adapters/plugin_user_lifecycle_hook.rs @@ -0,0 +1,160 @@ +//! Bridges the [`UserLifecycleHook`] fan-out to the plugin runtime. +//! +//! `UserLifecycleService` already notifies hooks on user create/login/logout/ +//! delete. This adapter turns the *login* event into a `user.login` plugin +//! event. It references only the [`PluginDispatchPort`] trait (not Extism), so +//! it is always compiled and the Extism dependency stays in the infrastructure +//! layer. +//! +//! Privacy note: the `user.login` payload includes the user's email — PII handed +//! to untrusted plugins with no permission gate in M0. This is acceptable only +//! because plugins are admin-installed today; when the permissions system lands, +//! sensitive payload fields should be gated behind a granted permission. + +use std::sync::Arc; + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::application::ports::plugin_ports::{EVENT_USER_LOGIN, PluginDispatchPort, PluginEvent}; +use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook}; +use crate::common::errors::DomainError; +use crate::domain::entities::user::User; + +/// Lifecycle hook that forwards successful logins to subscribed plugins. +pub struct PluginUserLifecycleHook { + dispatch: Arc, +} + +impl PluginUserLifecycleHook { + pub fn new(dispatch: Arc) -> Self { + Self { dispatch } + } +} + +#[async_trait] +impl UserLifecycleHook for PluginUserLifecycleHook { + fn name(&self) -> &'static str { + "plugins" + } + + async fn on_user_login(&self, user: &User) -> Result<(), DomainError> { + if self.dispatch.has_subscribers(EVENT_USER_LOGIN) { + self.dispatch.dispatch(PluginEvent { + name: EVENT_USER_LOGIN, + user_id: Some(user.id().to_string()), + invocation_id: Uuid::new_v4().to_string(), + payload: serde_json::json!({ + "user_id": user.id().to_string(), + "username": user.username(), + "email": user.email(), + "first_login": user.last_login_at().is_none(), + "is_external": user.is_external(), + }), + }); + } + // Returns immediately — dispatch is fire-and-forget (the runtime runs the + // plugin on the blocking pool), so login latency is unaffected. + Ok(()) + } + + // M0 emits only `user.login`. The trait forces an explicit decision on the + // other three events; they are deliberate no-ops (reserved for future events + // like `user.created` / `user.deleted`). + async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> { + Ok(()) + } + + async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> { + Ok(()) + } + + async fn on_user_deleted( + &self, + _user: &User, + _mode: DeletionMode, + _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + use crate::domain::entities::user::UserRole; + + /// Records dispatched events so the test can assert the bridge built the + /// right `user.login` event without a runtime or DB. + #[derive(Default)] + struct RecordingDispatch { + events: Mutex>, + } + + impl PluginDispatchPort for RecordingDispatch { + fn dispatch(&self, event: PluginEvent) { + self.events.lock().unwrap().push(event); + } + fn has_subscribers(&self, _event: &str) -> bool { + true + } + } + + #[tokio::test] + async fn on_user_login_dispatches_user_login_event() { + let recorder = Arc::new(RecordingDispatch::default()); + let hook = PluginUserLifecycleHook::new(recorder.clone()); + + let user = User::new( + "alice@example.com".to_string(), + Some("alice".to_string()), + None, + None, + None, + UserRole::User, + 0, + false, + ) + .unwrap(); + + hook.on_user_login(&user).await.unwrap(); + + let events = recorder.events.lock().unwrap(); + assert_eq!(events.len(), 1, "exactly one event dispatched"); + let ev = &events[0]; + assert_eq!(ev.name, EVENT_USER_LOGIN); + assert_eq!(ev.user_id.as_deref(), Some(user.id().to_string().as_str())); + assert_eq!(ev.payload["email"], "alice@example.com"); + assert_eq!(ev.payload["username"], "alice"); + assert_eq!(ev.payload["first_login"], true); // last_login_at is None + assert_eq!(ev.payload["is_external"], false); + } + + #[tokio::test] + async fn skips_dispatch_when_no_subscribers() { + struct NoSubscribers; + impl PluginDispatchPort for NoSubscribers { + fn dispatch(&self, _event: PluginEvent) { + panic!("must not dispatch when nothing subscribes"); + } + fn has_subscribers(&self, _event: &str) -> bool { + false + } + } + let hook = PluginUserLifecycleHook::new(Arc::new(NoSubscribers)); + let user = User::new( + "bob@example.com".to_string(), + None, + None, + None, + None, + UserRole::User, + 0, + false, + ) + .unwrap(); + hook.on_user_login(&user).await.unwrap(); + } +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 68400c05..b19c940d 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -14,6 +14,7 @@ pub mod grant_dto; pub mod i18n_dto; pub mod pagination; pub mod playlist_dto; +pub mod plugin_dto; pub mod recent_dto; pub mod search_dto; pub mod settings_dto; diff --git a/src/application/dtos/plugin_dto.rs b/src/application/dtos/plugin_dto.rs new file mode 100644 index 00000000..7e687bb5 --- /dev/null +++ b/src/application/dtos/plugin_dto.rs @@ -0,0 +1,37 @@ +//! DTOs for the admin plugin-management API. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::application::ports::plugin_ports::PluginInfo; + +/// A single installed plugin as returned by `GET /api/admin/plugins`. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PluginInfoDto { + pub id: String, + pub name: String, + pub version: String, + pub abi: u32, + /// Events the plugin subscribes to (e.g. `file.uploaded`). + pub subscriptions: Vec, + pub enabled: bool, +} + +impl From for PluginInfoDto { + fn from(p: PluginInfo) -> Self { + Self { + id: p.id, + name: p.name, + version: p.version, + abi: p.abi, + subscriptions: p.subscriptions, + enabled: p.enabled, + } + } +} + +/// Request body for `PUT /api/admin/plugins/{id}/enabled`. +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetEnabledDto { + pub enabled: bool, +} diff --git a/src/application/ports/plugin_ports.rs b/src/application/ports/plugin_ports.rs index 475fcdac..27505440 100644 --- a/src/application/ports/plugin_ports.rs +++ b/src/application/ports/plugin_ports.rs @@ -7,7 +7,9 @@ //! //! The ABI is intentionally tiny (see the M0 spec): //! - constant [`OXICLOUD_PLUGIN_ABI`] / namespace [`HOST_NAMESPACE`]; -//! - plugin exports `abi_version` + `handle`; +//! - plugin exports `abi_version` plus one handler per event it subscribes to, +//! named `on_` (see [`event_export_name`]) — e.g. `on_file_uploaded`, +//! `on_user_login`; //! - one host import `log` (observe-only — the only authority a plugin has). use serde::{Deserialize, Serialize}; @@ -21,33 +23,121 @@ pub const OXICLOUD_PLUGIN_ABI: u32 = 0; /// part of the import path so a future `v1` is a *different* symbol. pub const HOST_NAMESPACE: &str = "oxicloud:host:v0"; -/// The only event emitted in M0. +/// File committed (created or content-replaced). Payload is metadata only. pub const EVENT_FILE_UPLOADED: &str = "file.uploaded"; +/// A user authenticated successfully. +pub const EVENT_USER_LOGIN: &str = "user.login"; + +/// Every event the host can emit. Manifest validation accepts only these — a +/// `subscribe` entry outside this set rejects the plugin at load. Adding an +/// event is purely additive (no ABI bump): append its name here, build the +/// payload in a bridge, register that bridge in DI. +pub const KNOWN_EVENTS: &[&str] = &[EVENT_FILE_UPLOADED, EVENT_USER_LOGIN]; + +/// The plugin export the host calls for `event`: `on_` with dots replaced +/// by underscores (a WASM export must be a valid identifier). A plugin handles an +/// event by exporting this symbol; the host calls exactly the export matching the +/// dispatched event. `file.uploaded` → `on_file_uploaded`; `user.login` → +/// `on_user_login`. +pub fn event_export_name(event: &str) -> String { + format!("on_{}", event.replace('.', "_")) +} /// Outbound port: the application asks the (infrastructure) plugin runtime to /// dispatch an event to every subscribed plugin. Dispatch is fire-and-forget — /// the implementation owns all isolation, timeouts, and fault handling, and the -/// caller (a `FileLifecycleHook`) never awaits it. +/// caller (a lifecycle hook bridge) never awaits it. pub trait PluginDispatchPort: Send + Sync + 'static { - /// Dispatch a `file.uploaded` event (metadata only) to subscribed plugins. - fn dispatch_file_uploaded(&self, event: FileUploadedEvent); + /// Dispatch an event to every plugin subscribed to `event.name`. + fn dispatch(&self, event: PluginEvent); - /// Cheap predicate so the bridge hook can skip the metadata lookup entirely - /// when no plugin subscribes to `event`. + /// Cheap predicate so a bridge can skip building the payload entirely when + /// no plugin subscribes to `event`. fn has_subscribers(&self, event: &str) -> bool; } -/// Metadata describing a freshly committed file. Carries **no file contents** — -/// only path, size, and MIME (privacy goal). +/// Inbound port: admin management of installed plugins (list / toggle / install +/// / remove). The concrete implementation (the infrastructure +/// `ExtismPluginManager`) owns the same in-memory plugin set the dispatch port +/// reads, so a toggle or install takes effect on the live dispatch path with no +/// restart. All operations are admin-gated at the HTTP layer. +pub trait PluginManagementPort: Send + Sync + 'static { + /// Every installed plugin, enabled or not, with its load-time metadata. + fn list(&self) -> Vec; + + /// Enable or disable a plugin by id. The change is persisted so it survives + /// a restart, and is reflected immediately by [`PluginDispatchPort`]. + fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), PluginMgmtError>; + + /// Validate and install a new plugin from its `plugin.toml` text and `.wasm` + /// bytes, writing it to the plugins directory and loading it (enabled). The + /// id is taken from the manifest; a clash with an existing plugin is + /// rejected with [`PluginMgmtError::IdExists`]. + fn install(&self, manifest_toml: &str, wasm: Vec) -> Result; + + /// Install a plugin from a `.zip` bundle containing `plugin.toml` and the + /// `.wasm` named by its `entrypoint` (both at the archive root or together + /// under a single top-level folder). Extracts the two and delegates to + /// [`PluginManagementPort::install`]. + fn install_bundle(&self, zip: Vec) -> Result; + + /// Unload a plugin and delete its directory. + fn remove(&self, id: &str) -> Result<(), PluginMgmtError>; +} + +/// A single installed plugin's load-time metadata, as surfaced to the admin UI. #[derive(Debug, Clone)] -pub struct FileUploadedEvent { - pub path: String, - pub size: u64, - pub mime: String, - /// Opaque owner id of the file, when known. +pub struct PluginInfo { + pub id: String, + pub name: String, + pub version: String, + pub abi: u32, + pub subscriptions: Vec, + pub enabled: bool, +} + +/// Why a management operation failed. `reason()` yields the stable, machine +/// readable key used in audit logs and surfaced to the UI. +#[derive(Debug)] +pub enum PluginMgmtError { + /// No plugin with that id is installed. + NotFound, + /// An install was attempted for an id that already exists. + IdExists, + /// The bundle failed manifest or runtime validation. Carries the stable + /// reason key from `ManifestError::reason()` / `InvokeOutcome::reason()`, + /// plus a few install-only keys (`bad_id`, `bad_entrypoint`, `bad_zip`, + /// `no_manifest_in_zip`, `entrypoint_not_in_zip`). + Rejected(&'static str), + /// A filesystem error while writing or removing the plugin. + Io(String), +} + +impl PluginMgmtError { + /// Stable key for `tracing` audit lines; never reworded across releases. + pub fn reason(&self) -> &'static str { + match self { + PluginMgmtError::NotFound => "not_found", + PluginMgmtError::IdExists => "id_exists", + PluginMgmtError::Rejected(r) => r, + PluginMgmtError::Io(_) => "io_error", + } + } +} + +/// A single event to fan out to plugins. The `payload` JSON shape is specific to +/// each `name` and is built by that event's bridge — the runtime is event-blind +/// and never inspects it. Payloads carry metadata only, never file contents. +#[derive(Debug, Clone)] +pub struct PluginEvent { + /// One of [`KNOWN_EVENTS`]. + pub name: &'static str, + /// Opaque id of the user the event concerns, when known. pub user_id: Option, /// Unique id minted per dispatch, correlating host logs with plugin output. pub invocation_id: String, + /// Event-specific payload handed to the plugin as `PluginInput.payload`. + pub payload: serde_json::Value, } // ---- Wire DTOs (ABI v0 JSON shapes, §3.4 of the spec) ---------------------- diff --git a/src/common/di.rs b/src/common/di.rs index 259a8158..1898e811 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -442,6 +442,7 @@ impl AppServiceFactory { } /// Initializes the application services + #[allow(clippy::too_many_arguments)] // DI composition root — params are services, not a smell pub fn create_application_services( &self, core: &CoreServices, @@ -450,6 +451,9 @@ impl AppServiceFactory { authz: &Arc, storage_usage: &Arc, content_index: Option>, + plugin_dispatch: Option< + Arc, + >, ) -> ApplicationServices { // Main services let folder_service = Arc::new(FolderService::new( @@ -470,7 +474,8 @@ impl AppServiceFactory { // Effective lifecycle dispatcher: the core hooks (thumbnails, metadata) // plus, when the plugins feature is enabled, the WASM plugin bridge. - let file_lifecycle = self.effective_file_lifecycle(core, &file_retrieval_service); + let file_lifecycle = + self.effective_file_lifecycle(core, &file_retrieval_service, plugin_dispatch); let file_upload_service = Arc::new( FileUploadService::new_with_read( @@ -559,22 +564,20 @@ impl AppServiceFactory { /// Builds the file lifecycle dispatcher handed to the upload/management /// services. By default this is just the core dispatcher (thumbnails, - /// metadata). When the `plugins` feature is built and enabled, it wraps the - /// core dispatcher together with the WASM plugin bridge so plugins observe - /// `file.uploaded` events without any of the core hooks being aware of them. + /// metadata). When a plugin dispatch is present, it wraps the core dispatcher + /// together with the WASM plugin bridge so plugins observe `file.uploaded` + /// events without any of the core hooks being aware of them. fn effective_file_lifecycle( &self, core: &CoreServices, file_retrieval: &Arc, + plugin_dispatch: Option< + Arc, + >, ) -> Arc { - #[cfg(feature = "plugins")] - if self.config.plugins.enabled - && let Some(manager) = self.create_plugin_manager() - { + if let Some(dispatch) = plugin_dispatch { use crate::application::adapters::plugin_lifecycle_hook::PluginLifecycleHook; - use crate::application::ports::plugin_ports::PluginDispatchPort; - let dispatch: Arc = manager; let bridge = Arc::new(PluginLifecycleHook::new(dispatch, file_retrieval.clone())); let composite = FileLifecycleService::new() .with_hook(core.file_lifecycle.clone()) @@ -582,37 +585,54 @@ impl AppServiceFactory { return Arc::new(composite); } - let _ = file_retrieval; core.file_lifecycle.clone() } - /// Discovers and loads WASM plugins when the `plugins` feature is enabled and - /// `OXICLOUD_ENABLE_PLUGINS=true`. Plugins live under - /// `OXICLOUD_PLUGINS_DIR` (default `{storage_path}/.plugins`); a missing or - /// empty directory simply yields no plugins. - #[cfg(feature = "plugins")] - fn create_plugin_manager( + /// The single plugin manager, exposed as the two ports it serves: the + /// dispatch port (shared by every event bridge — file, user, …) and the + /// management port (stored on `AppState` for the admin API). Both wrap the + /// *same* `Arc`, so an install or toggle through the management port takes + /// effect on the live dispatch path with no restart. + /// + /// Returns trait objects so call sites stay feature-agnostic; the + /// `#[cfg(feature = "plugins")]` is confined to this body. Both are `None` + /// when the feature is off or `OXICLOUD_ENABLE_PLUGINS` is false. + #[allow(clippy::type_complexity)] + fn create_plugin_ports( &self, - ) -> Option> { - if !self.config.plugins.enabled { - return None; + ) -> ( + Option>, + Option>, + ) { + #[cfg(feature = "plugins")] + { + if self.config.plugins.enabled { + let dir = self + .config + .plugins + .plugins_dir + .clone() + .unwrap_or_else(|| self.config.storage_path.join(".plugins")); + let manager = Arc::new( + crate::infrastructure::services::plugins::ExtismPluginManager::load_from_dir( + self.config.plugins.clone(), + &dir, + ), + ); + tracing::info!( + target: "oxicloud::plugins", + loaded = manager.loaded_count(), + "plugin manager initialized" + ); + let dispatch: Arc = + manager.clone(); + let management: Arc< + dyn crate::application::ports::plugin_ports::PluginManagementPort, + > = manager; + return (Some(dispatch), Some(management)); + } } - let dir = self - .config - .plugins - .plugins_dir - .clone() - .unwrap_or_else(|| self.config.storage_path.join(".plugins")); - let manager = crate::infrastructure::services::plugins::ExtismPluginManager::load_from_dir( - self.config.plugins.clone(), - &dir, - ); - tracing::info!( - target: "oxicloud::plugins", - loaded = manager.loaded_count(), - "plugin manager initialized" - ); - Some(Arc::new(manager)) + (None, None) } /// Creates the audio metadata service (extracts ID3 tags from audio files) @@ -916,6 +936,13 @@ impl AppServiceFactory { // worker starts further down with the maintenance pool. let content_index = self.create_content_index(); + // Single plugin manager, surfaced as its two ports: the dispatch port + // (shared by every event bridge — file uploads here, user logins at the + // auth-services wiring below) and the management port (stored on + // AppState for the admin API). Created once so plugins load exactly once + // regardless of how many events they observe. + let (plugin_dispatch, plugin_management) = self.create_plugin_ports(); + // 4. Application services (with trash + authz already wired) let mut apps = self.create_application_services( &core, @@ -924,6 +951,7 @@ impl AppServiceFactory { &authorization, &storage_usage, content_index.as_ref().map(|(idx, _)| idx.clone()), + plugin_dispatch.clone(), ); // 5. Share service @@ -1013,7 +1041,7 @@ impl AppServiceFactory { pool.clone(), ), ); - let user_lifecycle = Arc::new( + let mut user_lifecycle_builder = crate::application::services::user_lifecycle_service::UserLifecycleService::new() .with_hook(Arc::new( crate::application::services::user_lifecycle_service::AuditLifecycleHook, @@ -1036,8 +1064,20 @@ impl AppServiceFactory { .with_hook(Arc::new( crate::application::services::external_identity_service::ExternalIdentityLifecycleHook::new() .with_magic_link_repo(magic_link_repo.clone()), - )), - ); + )); + + // Plugin user.login bridge — shares the single plugin dispatch with + // the file-upload bridge. Registered only when plugins are active; + // inert until auth is enabled (the dispatcher is never fired otherwise). + if let Some(dispatch) = &plugin_dispatch { + user_lifecycle_builder = user_lifecycle_builder.with_hook(Arc::new( + crate::application::adapters::plugin_user_lifecycle_hook::PluginUserLifecycleHook::new( + dispatch.clone(), + ), + )); + } + + let user_lifecycle = Arc::new(user_lifecycle_builder); // Auth services. Folder service no longer threaded here — // PR 3 moved home-folder provisioning into @@ -1171,6 +1211,7 @@ impl AppServiceFactory { nextcloud: nextcloud_services, admin_settings_service: None, storage_settings_service: None, + plugin_management, migration_state: Arc::new(tokio::sync::RwLock::new(MigrationState::default())), trash_service, share_service, @@ -1611,6 +1652,11 @@ pub struct AppState { pub auth_service: Option, pub nextcloud: Option, pub admin_settings_service: Option>, + /// WASM plugin management (list/install/toggle/remove), backing the admin + /// Plugins tab. `None` when the `plugins` feature is compiled out or + /// `OXICLOUD_ENABLE_PLUGINS` is false — the admin endpoints return 503 then. + pub plugin_management: + Option>, pub storage_settings_service: Option>, pub migration_state: Arc>, pub trash_service: Option>, diff --git a/src/infrastructure/services/plugins/manager.rs b/src/infrastructure/services/plugins/manager.rs index 4d6e9118..9cdf9128 100644 --- a/src/infrastructure/services/plugins/manager.rs +++ b/src/infrastructure/services/plugins/manager.rs @@ -1,37 +1,72 @@ -//! Plugin discovery + dispatch. Implements [`PluginDispatchPort`] over the -//! Extism [`PluginRuntime`]. +//! Plugin discovery + dispatch + admin management. Implements +//! [`PluginDispatchPort`] and [`PluginManagementPort`] over the Extism +//! [`PluginRuntime`]. //! //! Discovery scans a directory of plugin subdirectories (each `plugin.toml` + //! `.wasm`) at startup; a plugin that fails validation or load is audit-logged //! and skipped, never fatal. Dispatch builds a fresh sandbox per invocation on //! the blocking pool, so a slow or hostile plugin never stalls async workers or //! the upload path that triggered it. +//! +//! The same in-memory plugin set backs both ports, guarded by an `RwLock`: a +//! management op (install / toggle / remove) takes the write lock and is +//! reflected on the live dispatch path with no restart. Enable/disable state is +//! persisted as a `.disabled` marker file in the plugin's own directory so it +//! survives a restart without a database. use std::collections::HashSet; -use std::path::Path; -use std::sync::Arc; - -use serde_json::json; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; use super::manifest; use super::runtime::{InvokeOutcome, PluginRuntime}; use crate::application::ports::plugin_ports::{ - EVENT_FILE_UPLOADED, FileUploadedEvent, OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort, - PluginInput, + OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort, PluginEvent, PluginInfo, PluginInput, + PluginManagementPort, PluginMgmtError, event_export_name, }; use crate::common::config::PluginConfig; +/// Name of the marker file that, when present in a plugin's directory, loads it +/// disabled. Created/removed by [`PluginManagementPort::set_enabled`]. +const DISABLED_MARKER: &str = ".disabled"; + /// A validated, loadable plugin held in memory. struct LoadedPlugin { id: String, + name: String, + version: String, + abi: u32, subscribe: HashSet, + /// Whether dispatch delivers events to this plugin. Mirrors the on-disk + /// `.disabled` marker. + enabled: bool, + /// The plugin's own directory (not necessarily named after `id`). Used to + /// write the disabled marker and to delete the plugin on removal. + dir: PathBuf, runtime: Arc, } +impl LoadedPlugin { + fn info(&self) -> PluginInfo { + let mut subscriptions: Vec = self.subscribe.iter().cloned().collect(); + subscriptions.sort(); + PluginInfo { + id: self.id.clone(), + name: self.name.clone(), + version: self.version.clone(), + abi: self.abi, + subscriptions, + enabled: self.enabled, + } + } +} + /// Owns all loaded plugins and dispatches events to them. pub struct ExtismPluginManager { config: PluginConfig, - plugins: Vec, + /// Root directory plugins are discovered in and installed into. + root_dir: PathBuf, + plugins: RwLock>, } impl ExtismPluginManager { @@ -51,7 +86,11 @@ impl ExtismPluginManager { error = %e, "plugins directory not readable; no plugins loaded" ); - return Self { config, plugins }; + return Self { + config, + root_dir: dir.to_path_buf(), + plugins: RwLock::new(plugins), + }; } }; @@ -65,6 +104,7 @@ impl ExtismPluginManager { tracing::info!( target: "oxicloud::plugins", plugin_id = %loaded.id, + enabled = loaded.enabled, dir = %path.display(), "plugin loaded" ); @@ -90,7 +130,11 @@ impl ExtismPluginManager { dir = %dir.display(), "plugin discovery complete" ); - Self { config, plugins } + Self { + config, + root_dir: dir.to_path_buf(), + plugins: RwLock::new(plugins), + } } /// Validate and load a single plugin directory. Returns a stable audit @@ -108,47 +152,73 @@ impl ExtismPluginManager { let wasm_bytes = std::fs::read(&wasm_path).map_err(|_| "wasm_unreadable")?; let runtime = PluginRuntime::new(manifest.plugin.id.clone(), wasm_bytes); - // Probe abi_version on a throwaway instance; rejects lying/unloadable wasm. - match runtime.check_loadable(config) { - InvokeOutcome::Ok => {} - InvokeOutcome::AbiMismatch { .. } => return Err("abi_mismatch"), - _ => return Err("not_loadable"), - } + // Probe a throwaway instance: abi must match AND every subscribed event + // must have its `on_` handler exported. + let required_exports: Vec = manifest + .events + .subscribe + .iter() + .map(|e| event_export_name(e)) + .collect(); + Self::probe(config, &runtime, &required_exports)?; Ok(LoadedPlugin { id: manifest.plugin.id, + name: manifest.plugin.name, + version: manifest.plugin.version, + abi: manifest.plugin.abi, subscribe: manifest.events.subscribe.into_iter().collect(), + enabled: !dir.join(DISABLED_MARKER).exists(), + dir: dir.to_path_buf(), runtime: Arc::new(runtime), }) } + /// Probe loadability, mapping the runtime outcome to a stable reason key. + fn probe( + config: &PluginConfig, + runtime: &PluginRuntime, + required_exports: &[String], + ) -> Result<(), &'static str> { + match runtime.check_loadable(config, required_exports) { + InvokeOutcome::Ok => Ok(()), + InvokeOutcome::AbiMismatch { .. } => Err("abi_mismatch"), + InvokeOutcome::MissingExport(_) => Err("missing_export"), + _ => Err("not_loadable"), + } + } + /// Number of successfully loaded plugins (used by DI for the startup summary /// and by tests). pub fn loaded_count(&self) -> usize { - self.plugins.len() + self.read_plugins().len() + } + + fn read_plugins(&self) -> std::sync::RwLockReadGuard<'_, Vec> { + self.plugins.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write_plugins(&self) -> std::sync::RwLockWriteGuard<'_, Vec> { + self.plugins.write().unwrap_or_else(|e| e.into_inner()) } } impl PluginDispatchPort for ExtismPluginManager { - fn dispatch_file_uploaded(&self, event: FileUploadedEvent) { - for plugin in &self.plugins { - if !plugin.subscribe.contains(EVENT_FILE_UPLOADED) { + fn dispatch(&self, event: PluginEvent) { + for plugin in self.read_plugins().iter() { + if !plugin.enabled || !plugin.subscribe.contains(event.name) { continue; } let input = PluginInput { abi: OXICLOUD_PLUGIN_ABI, - event: EVENT_FILE_UPLOADED.to_string(), + event: event.name.to_string(), context: PluginContext { plugin_id: plugin.id.clone(), user_id: event.user_id.clone(), invocation_id: event.invocation_id.clone(), }, - payload: json!({ - "path": event.path, - "size": event.size, - "mime": event.mime, - }), + payload: event.payload.clone(), }; let input_json = match serde_json::to_string(&input) { Ok(j) => j, @@ -167,11 +237,12 @@ impl PluginDispatchPort for ExtismPluginManager { let config = self.config.clone(); let plugin_id = plugin.id.clone(); let invocation_id = event.invocation_id.clone(); + let export = event_export_name(event.name); // Run the synchronous wasm call off the async workers. Fire-and-forget: // the upload already succeeded; plugins are post-hoc observers. tokio::task::spawn_blocking(move || { - let result = runtime.invoke(&config, &invocation_id, &input_json); + let result = runtime.invoke(&config, &export, &invocation_id, &input_json); if !result.outcome.is_ok() { tracing::warn!( target: "audit", @@ -188,6 +259,172 @@ impl PluginDispatchPort for ExtismPluginManager { } fn has_subscribers(&self, event: &str) -> bool { - self.plugins.iter().any(|p| p.subscribe.contains(event)) + self.read_plugins() + .iter() + .any(|p| p.enabled && p.subscribe.contains(event)) } } + +impl PluginManagementPort for ExtismPluginManager { + fn list(&self) -> Vec { + let mut infos: Vec = self.read_plugins().iter().map(|p| p.info()).collect(); + infos.sort_by(|a, b| a.id.cmp(&b.id)); + infos + } + + fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), PluginMgmtError> { + let mut plugins = self.write_plugins(); + let plugin = plugins + .iter_mut() + .find(|p| p.id == id) + .ok_or(PluginMgmtError::NotFound)?; + + let marker = plugin.dir.join(DISABLED_MARKER); + if enabled { + match std::fs::remove_file(&marker) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(PluginMgmtError::Io(e.to_string())), + } + } else { + std::fs::write(&marker, b"").map_err(|e| PluginMgmtError::Io(e.to_string()))?; + } + plugin.enabled = enabled; + Ok(()) + } + + fn install(&self, manifest_toml: &str, wasm: Vec) -> Result { + // Validate the manifest and the wasm before touching the filesystem. + let manifest = manifest::parse_and_validate(manifest_toml) + .map_err(|e| PluginMgmtError::Rejected(e.reason()))?; + + // `id` becomes a directory name and `entrypoint` a filename — both must + // be single, traversal-free path components. + if !is_safe_component(&manifest.plugin.id) { + return Err(PluginMgmtError::Rejected("bad_id")); + } + if !is_safe_component(&manifest.plugin.entrypoint) { + return Err(PluginMgmtError::Rejected("bad_entrypoint")); + } + + let required_exports: Vec = manifest + .events + .subscribe + .iter() + .map(|e| event_export_name(e)) + .collect(); + let runtime = PluginRuntime::new(manifest.plugin.id.clone(), wasm.clone()); + Self::probe(&self.config, &runtime, &required_exports) + .map_err(PluginMgmtError::Rejected)?; + + let id = manifest.plugin.id.clone(); + let target = self.root_dir.join(&id); + + // Hold the write lock across the collision check and the directory swap + // so two concurrent installs of the same id cannot race. Admin installs + // are rare; readers block only briefly. + let mut plugins = self.write_plugins(); + if plugins.iter().any(|p| p.id == id) || target.exists() { + return Err(PluginMgmtError::IdExists); + } + + std::fs::create_dir_all(&self.root_dir).map_err(|e| PluginMgmtError::Io(e.to_string()))?; + // Write to a temp dir then rename, so a crash mid-write never leaves a + // half-written plugin discoverable. + let tmp = tempfile::Builder::new() + .prefix(".tmp-install-") + .tempdir_in(&self.root_dir) + .map_err(|e| PluginMgmtError::Io(e.to_string()))?; + std::fs::write(tmp.path().join("plugin.toml"), manifest_toml) + .map_err(|e| PluginMgmtError::Io(e.to_string()))?; + std::fs::write(tmp.path().join(&manifest.plugin.entrypoint), &wasm) + .map_err(|e| PluginMgmtError::Io(e.to_string()))?; + let tmp_path = tmp.keep(); + if let Err(e) = std::fs::rename(&tmp_path, &target) { + let _ = std::fs::remove_dir_all(&tmp_path); + return Err(PluginMgmtError::Io(e.to_string())); + } + + let loaded = LoadedPlugin { + id: id.clone(), + name: manifest.plugin.name.clone(), + version: manifest.plugin.version.clone(), + abi: manifest.plugin.abi, + subscribe: manifest.events.subscribe.iter().cloned().collect(), + enabled: true, + dir: target, + runtime: Arc::new(runtime), + }; + let info = loaded.info(); + plugins.push(loaded); + Ok(info) + } + + fn install_bundle(&self, zip: Vec) -> Result { + use std::io::{Cursor, Read}; + + let mut archive = zip::ZipArchive::new(Cursor::new(zip)) + .map_err(|_| PluginMgmtError::Rejected("bad_zip"))?; + + // Locate `plugin.toml` — at the archive root or under a single wrapping + // folder (e.g. `myplugin/plugin.toml`). + let manifest_name = archive + .file_names() + .find(|n| !n.ends_with('/') && (*n == "plugin.toml" || n.ends_with("/plugin.toml"))) + .map(str::to_owned) + .ok_or(PluginMgmtError::Rejected("no_manifest_in_zip"))?; + + let mut manifest_toml = String::new(); + archive + .by_name(&manifest_name) + .map_err(|_| PluginMgmtError::Rejected("no_manifest_in_zip"))? + .read_to_string(&mut manifest_toml) + .map_err(|_| PluginMgmtError::Rejected("bad_zip"))?; + + // Parse just to learn the entrypoint name; `install` does the full + // validation (and rejects a traversal-unsafe entrypoint). + let manifest = manifest::parse_and_validate(&manifest_toml) + .map_err(|e| PluginMgmtError::Rejected(e.reason()))?; + + // Resolve the entrypoint relative to the manifest's folder in the zip. + let prefix = match manifest_name.rfind('/') { + Some(i) => &manifest_name[..=i], + None => "", + }; + let wasm_name = format!("{prefix}{}", manifest.plugin.entrypoint); + + let mut wasm = Vec::new(); + archive + .by_name(&wasm_name) + .map_err(|_| PluginMgmtError::Rejected("entrypoint_not_in_zip"))? + .read_to_end(&mut wasm) + .map_err(|_| PluginMgmtError::Rejected("bad_zip"))?; + + self.install(&manifest_toml, wasm) + } + + fn remove(&self, id: &str) -> Result<(), PluginMgmtError> { + let mut plugins = self.write_plugins(); + let pos = plugins + .iter() + .position(|p| p.id == id) + .ok_or(PluginMgmtError::NotFound)?; + let removed = plugins.remove(pos); + match std::fs::remove_dir_all(&removed.dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(PluginMgmtError::Io(e.to_string())), + } + } +} + +/// Whether `s` is a single, traversal-free path component safe to use as a +/// directory or file name under the plugins root. +fn is_safe_component(s: &str) -> bool { + !s.is_empty() + && s != "." + && s != ".." + && !s.contains('/') + && !s.contains('\\') + && !s.contains('\0') +} diff --git a/src/infrastructure/services/plugins/manager_test.rs b/src/infrastructure/services/plugins/manager_test.rs new file mode 100644 index 00000000..ab351cf5 --- /dev/null +++ b/src/infrastructure/services/plugins/manager_test.rs @@ -0,0 +1,248 @@ +//! Manager-level tests for the admin management surface (install / toggle / +//! remove) and disabled-state persistence. These drive a real Extism sandbox, +//! so they run only under `cargo test --features plugins`. +//! +//! The `.wasm` fixtures are the same ones the runtime tests use, built by +//! `scripts/build-plugin-hello.sh`. + +use super::ExtismPluginManager; +use crate::application::ports::plugin_ports::{PluginDispatchPort, PluginManagementPort}; +use crate::common::config::PluginConfig; + +fn cfg() -> PluginConfig { + PluginConfig::default() +} + +fn fixture(name: &str) -> Vec { + let path = format!( + "{}/tests/fixtures/plugins/{}", + env!("CARGO_MANIFEST_DIR"), + name + ); + std::fs::read(&path).unwrap_or_else(|e| { + panic!("missing fixture {path}: {e}\n run scripts/build-plugin-hello.sh to (re)build it") + }) +} + +/// A valid manifest for the `hello.wasm` fixture (subscribes to both events). +fn hello_manifest() -> String { + r#" +[plugin] +id = "com.example.hello" +name = "Hello" +version = "0.1.0" +abi = 0 +entrypoint = "hello.wasm" + +[events] +subscribe = ["file.uploaded", "user.login"] +"# + .to_string() +} + +/// A manifest that parses fine but points at the `wrong_abi.wasm` fixture, +/// which reports ABI 1 at runtime. +fn wrong_abi_manifest() -> String { + r#" +[plugin] +id = "com.example.wrongabi" +name = "Wrong ABI" +version = "0.1.0" +abi = 0 +entrypoint = "wrong_abi.wasm" + +[events] +subscribe = ["file.uploaded"] +"# + .to_string() +} + +#[test] +fn install_loads_plugin_and_writes_files() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + assert_eq!(mgr.loaded_count(), 0); + + let info = mgr + .install(&hello_manifest(), fixture("hello.wasm")) + .expect("install should succeed"); + + assert_eq!(info.id, "com.example.hello"); + assert_eq!(info.name, "Hello"); + assert!(info.enabled); + assert_eq!(info.subscriptions, vec!["file.uploaded", "user.login"]); + assert_eq!(mgr.loaded_count(), 1); + + let plugin_dir = tmp.path().join("com.example.hello"); + assert!(plugin_dir.join("plugin.toml").exists()); + assert!(plugin_dir.join("hello.wasm").exists()); + + // The live dispatch path sees it immediately. + assert!(mgr.has_subscribers("file.uploaded")); +} + +/// Build an in-memory `.zip` with the given entries (name, bytes). +fn make_zip(entries: &[(&str, &[u8])]) -> Vec { + use std::io::Write; + use zip::write::SimpleFileOptions; + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in entries { + writer + .start_file(*name, SimpleFileOptions::default()) + .unwrap(); + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap().into_inner() +} + +#[test] +fn install_bundle_from_zip_loads_plugin() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + + // Wrap everything under a top-level folder to exercise prefix resolution. + let zip = make_zip(&[ + ("hello/plugin.toml", hello_manifest().as_bytes()), + ("hello/hello.wasm", &fixture("hello.wasm")), + ]); + + let info = mgr + .install_bundle(zip) + .expect("bundle install should succeed"); + assert_eq!(info.id, "com.example.hello"); + assert!(info.enabled); + assert_eq!(mgr.loaded_count(), 1); + assert!( + tmp.path() + .join("com.example.hello") + .join("hello.wasm") + .exists() + ); +} + +#[test] +fn install_bundle_without_manifest_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + + let zip = make_zip(&[("hello.wasm", &fixture("hello.wasm"))]); + let err = mgr + .install_bundle(zip) + .expect_err("a zip without plugin.toml must be rejected"); + assert_eq!(err.reason(), "no_manifest_in_zip"); + assert_eq!(mgr.loaded_count(), 0); +} + +#[test] +fn install_bundle_missing_entrypoint_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + + // Manifest declares entrypoint = "hello.wasm", but the zip omits it. + let zip = make_zip(&[("plugin.toml", hello_manifest().as_bytes())]); + let err = mgr + .install_bundle(zip) + .expect_err("a zip missing the entrypoint wasm must be rejected"); + assert_eq!(err.reason(), "entrypoint_not_in_zip"); + assert_eq!(mgr.loaded_count(), 0); +} + +#[test] +fn install_bundle_with_garbage_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + let err = mgr + .install_bundle(b"not a zip file".to_vec()) + .expect_err("non-zip bytes must be rejected"); + assert_eq!(err.reason(), "bad_zip"); +} + +#[test] +fn install_duplicate_id_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + mgr.install(&hello_manifest(), fixture("hello.wasm")) + .unwrap(); + + let err = mgr + .install(&hello_manifest(), fixture("hello.wasm")) + .expect_err("second install of the same id must fail"); + assert_eq!(err.reason(), "id_exists"); + assert_eq!(mgr.loaded_count(), 1); +} + +#[test] +fn install_wrong_abi_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + + let err = mgr + .install(&wrong_abi_manifest(), fixture("wrong_abi.wasm")) + .expect_err("a plugin reporting the wrong ABI must be rejected"); + assert_eq!(err.reason(), "abi_mismatch"); + assert_eq!(mgr.loaded_count(), 0); + // Nothing should have been written to disk. + assert!(!tmp.path().join("com.example.wrongabi").exists()); +} + +#[test] +fn disable_stops_dispatch_and_persists_across_reload() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + mgr.install(&hello_manifest(), fixture("hello.wasm")) + .unwrap(); + assert!(mgr.has_subscribers("file.uploaded")); + + mgr.set_enabled("com.example.hello", false).unwrap(); + assert!(!mgr.has_subscribers("file.uploaded")); + assert!( + tmp.path() + .join("com.example.hello") + .join(".disabled") + .exists() + ); + + // A fresh manager re-reads the marker and loads it disabled. + drop(mgr); + let reloaded = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + assert_eq!(reloaded.loaded_count(), 1); + let info = reloaded.list(); + assert_eq!(info.len(), 1); + assert!(!info[0].enabled); + assert!(!reloaded.has_subscribers("file.uploaded")); + + // Re-enabling removes the marker. + reloaded.set_enabled("com.example.hello", true).unwrap(); + assert!(reloaded.has_subscribers("file.uploaded")); + assert!( + !tmp.path() + .join("com.example.hello") + .join(".disabled") + .exists() + ); +} + +#[test] +fn set_enabled_unknown_id_is_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + let err = mgr.set_enabled("does.not.exist", false).unwrap_err(); + assert_eq!(err.reason(), "not_found"); +} + +#[test] +fn remove_unloads_and_deletes_directory() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + mgr.install(&hello_manifest(), fixture("hello.wasm")) + .unwrap(); + let plugin_dir = tmp.path().join("com.example.hello"); + assert!(plugin_dir.exists()); + + mgr.remove("com.example.hello").unwrap(); + assert_eq!(mgr.loaded_count(), 0); + assert!(!plugin_dir.exists()); + + let err = mgr.remove("com.example.hello").unwrap_err(); + assert_eq!(err.reason(), "not_found"); +} diff --git a/src/infrastructure/services/plugins/manifest.rs b/src/infrastructure/services/plugins/manifest.rs index 9f4b4456..bb35f601 100644 --- a/src/infrastructure/services/plugins/manifest.rs +++ b/src/infrastructure/services/plugins/manifest.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; -use crate::application::ports::plugin_ports::{EVENT_FILE_UPLOADED, OXICLOUD_PLUGIN_ABI}; +use crate::application::ports::plugin_ports::{KNOWN_EVENTS, OXICLOUD_PLUGIN_ABI}; /// Parsed `plugin.toml`. `#[serde(deny_unknown_fields)]` on every struct turns /// stray keys into load errors rather than silently ignored config. @@ -90,7 +90,7 @@ pub fn parse_and_validate(toml_str: &str) -> Result` export in the module. + MissingExport(String), + /// The event handler returned bytes that are not a valid `PluginOutput`. MalformedOutput(String), /// The serialized input exceeded the configured cap; nothing was invoked. MalformedInput { size: usize, max: usize }, @@ -78,6 +80,7 @@ impl InvokeOutcome { InvokeOutcome::Timeout => "timeout", InvokeOutcome::LoadError(_) => "load_error", InvokeOutcome::AbiMismatch { .. } => "abi_mismatch", + InvokeOutcome::MissingExport(_) => "missing_export", InvokeOutcome::MalformedOutput(_) => "malformed_output", InvokeOutcome::MalformedInput { .. } => "malformed_input", } @@ -126,25 +129,35 @@ impl PluginRuntime { .build() } - /// Probe `abi_version` on a throwaway instance. Used at load time so a lying - /// or unloadable plugin is rejected before it is ever registered. - pub fn check_loadable(&self, cfg: &PluginConfig) -> InvokeOutcome { + /// Probe a throwaway instance at load time: check `abi_version`, then verify + /// every `required_export` (the `on_` symbol for each subscribed + /// event) actually exists in the module. Rejects lying, unloadable, or + /// incompletely-implemented plugins before they are ever registered. + pub fn check_loadable(&self, cfg: &PluginConfig, required_exports: &[String]) -> InvokeOutcome { let logs = UserData::new(LogContext::default()); let mut plugin = match self.build(cfg, logs) { Ok(p) => p, Err(e) => return InvokeOutcome::LoadError(e.to_string()), }; match plugin.call::<(), u32>("abi_version", ()) { - Ok(v) if v == OXICLOUD_PLUGIN_ABI => InvokeOutcome::Ok, - Ok(v) => InvokeOutcome::AbiMismatch { got: v }, - Err(e) => classify_call_error(e), + Ok(v) if v == OXICLOUD_PLUGIN_ABI => {} + Ok(v) => return InvokeOutcome::AbiMismatch { got: v }, + Err(e) => return classify_call_error(e), } + for export in required_exports { + if !plugin.function_exists(export) { + return InvokeOutcome::MissingExport(export.clone()); + } + } + InvokeOutcome::Ok } - /// Run one `handle` invocation, fully fault-isolated. + /// Run one event-handler invocation, fully fault-isolated. `export` is the + /// `on_` symbol to call (see `event_export_name`). pub fn invoke( &self, cfg: &PluginConfig, + export: &str, invocation_id: &str, input_json: &str, ) -> InvokeResult { @@ -192,7 +205,7 @@ impl PluginRuntime { } // The actual call. Traps, timeouts, and OOM all surface here as Err. - let outcome = match plugin.call::<&str, String>("handle", input_json) { + let outcome = match plugin.call::<&str, String>(export, input_json) { Ok(out) => match serde_json::from_str::(&out) { Ok(parsed) if parsed.ok => InvokeOutcome::Ok, Ok(parsed) => { diff --git a/src/infrastructure/services/plugins/runtime_test.rs b/src/infrastructure/services/plugins/runtime_test.rs index c377aa1e..d27bcf7a 100644 --- a/src/infrastructure/services/plugins/runtime_test.rs +++ b/src/infrastructure/services/plugins/runtime_test.rs @@ -9,6 +9,7 @@ use std::time::{Duration, Instant}; use super::ExtismPluginManager; use super::manifest; use super::runtime::{InvokeOutcome, PluginRuntime}; +use crate::application::ports::plugin_ports::event_export_name; use crate::common::config::PluginConfig; fn cfg() -> PluginConfig { @@ -27,7 +28,7 @@ fn fixture(name: &str) -> Vec { }) } -fn sample_input() -> String { +fn file_uploaded_input() -> String { serde_json::json!({ "abi": 0, "event": "file.uploaded", @@ -41,21 +42,38 @@ fn sample_input() -> String { .to_string() } -// ---- The M0 exit criterion: the full loop ----------------------------------- +fn user_login_input() -> String { + serde_json::json!({ + "abi": 0, + "event": "user.login", + "context": { + "plugin_id": "com.example.hello", + "user_id": "u_test", + "invocation_id": "inv_login_0001" + }, + "payload": { + "user_id": "u_test", + "username": "alice", + "email": "alice@example.com", + "first_login": true, + "is_external": false + } + }) + .to_string() +} + +// ---- The M0 exit criterion: the full loop, per event ------------------------ #[test] -fn acceptance_hello_returns_ok_and_calls_host_log() { +fn acceptance_file_uploaded_returns_ok_and_calls_host_log() { let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm")); - let result = rt.invoke(&cfg(), "inv_test_0001", &sample_input()); + let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input()); - // 1. handle returned a well-formed PluginOutput with ok = true. assert!( result.outcome.is_ok(), "plugin did not complete: {:?}", result.outcome ); - - // 2. The plugin called the host `log` function (plugin -> host). assert!( result.logs.iter().any(|(level, msg)| level == "info" && msg.contains("hello plugin saw upload: /photos/2026/cat.jpg")), @@ -64,6 +82,24 @@ fn acceptance_hello_returns_ok_and_calls_host_log() { ); } +#[test] +fn acceptance_user_login_returns_ok_and_calls_host_log() { + let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm")); + let result = rt.invoke(&cfg(), "on_user_login", "inv", &user_login_input()); + + assert!( + result.outcome.is_ok(), + "plugin did not complete: {:?}", + result.outcome + ); + assert!( + result.logs.iter().any(|(level, msg)| level == "info" + && msg.contains("hello plugin saw login: user u_test (first_login=true)")), + "expected the plugin's user.login log line, got: {:?}", + result.logs + ); +} + // ---- The guarantees, not just the happy path -------------------------------- #[test] @@ -71,17 +107,45 @@ fn rejects_wrong_abi() { let rt = PluginRuntime::new("com.example.wrong-abi", fixture("wrong_abi.wasm")); assert!( matches!( - rt.check_loadable(&cfg()), + rt.check_loadable(&cfg(), &[]), InvokeOutcome::AbiMismatch { got: 1 } ), "wrong-abi plugin should be rejected at load" ); } +#[test] +fn load_requires_subscribed_event_exports() { + let cfg = cfg(); + let login_export = vec![event_export_name("user.login")]; + + // hello.wasm exports both handlers -> loadable for user.login. + let hello = PluginRuntime::new("com.example.hello", fixture("hello.wasm")); + assert!(matches!( + hello.check_loadable(&cfg, &login_export), + InvokeOutcome::Ok + )); + + // omit_login.wasm lacks on_user_login -> rejected when it claims user.login. + let omit = PluginRuntime::new("com.example.omit", fixture("omit_login.wasm")); + assert!( + matches!( + omit.check_loadable(&cfg, &login_export), + InvokeOutcome::MissingExport(ref e) if e == "on_user_login" + ), + "omit_login must be rejected for a user.login subscription" + ); + // …but it is fine for file.uploaded, which it does export. + assert!(matches!( + omit.check_loadable(&cfg, &[event_export_name("file.uploaded")]), + InvokeOutcome::Ok + )); +} + #[test] fn contains_a_panicking_plugin() { let rt = PluginRuntime::new("com.example.panic", fixture("panic.wasm")); - let result = rt.invoke(&cfg(), "inv", &sample_input()); + let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input()); assert!( matches!(result.outcome, InvokeOutcome::Trap(_)), "expected a contained trap, got {:?}", @@ -94,7 +158,7 @@ fn contains_a_panicking_plugin() { fn enforces_timeout() { let rt = PluginRuntime::new("com.example.sleep", fixture("sleep.wasm")); let start = Instant::now(); - let result = rt.invoke(&cfg(), "inv", &sample_input()); + let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input()); let elapsed = start.elapsed(); assert!( @@ -111,9 +175,7 @@ fn enforces_timeout() { #[test] fn no_network() { let rt = PluginRuntime::new("com.example.net", fixture("net.wasm")); - let result = rt.invoke(&cfg(), "inv", &sample_input()); - // No allowed_hosts are granted, so the outbound call is denied and the - // plugin cannot complete successfully. + let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input()); assert!( !result.outcome.is_ok(), "network access should be denied, got {:?}", @@ -121,47 +183,75 @@ fn no_network() { ); } -#[tokio::test] -async fn manager_loads_and_dispatches() { - use crate::application::ports::plugin_ports::{FileUploadedEvent, PluginDispatchPort}; +// ---- Manager discovery + dispatch ------------------------------------------ +/// Write a one-plugin directory (plugin.toml + the given wasm) under a tempdir +/// and load a manager from it. +fn manager_with(wasm_name: &str, subscribe_toml: &str) -> (tempfile::TempDir, ExtismPluginManager) { let tmp = tempfile::tempdir().unwrap(); - let plugin_dir = tmp.path().join("hello"); - std::fs::create_dir_all(&plugin_dir).unwrap(); - std::fs::write(plugin_dir.join("hello.wasm"), fixture("hello.wasm")).unwrap(); + let dir = tmp.path().join("plugin"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("plugin.wasm"), fixture(wasm_name)).unwrap(); std::fs::write( - plugin_dir.join("plugin.toml"), - r#" + dir.join("plugin.toml"), + format!( + r#" [plugin] -id = "com.example.hello" -name = "Hello" +id = "com.example.test" +name = "Test" version = "0.1.0" abi = 0 -entrypoint = "hello.wasm" +entrypoint = "plugin.wasm" [events] -subscribe = ["file.uploaded"] -"#, +subscribe = {subscribe_toml} +"# + ), ) .unwrap(); - let manager = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + (tmp, manager) +} + +#[tokio::test] +async fn manager_loads_and_dispatches_both_events() { + use crate::application::ports::plugin_ports::{ + EVENT_FILE_UPLOADED, EVENT_USER_LOGIN, PluginDispatchPort, PluginEvent, + }; + + let (_tmp, manager) = manager_with("hello.wasm", r#"["file.uploaded", "user.login"]"#); assert_eq!(manager.loaded_count(), 1, "the valid plugin should load"); assert!(manager.has_subscribers("file.uploaded")); + assert!(manager.has_subscribers("user.login")); assert!(!manager.has_subscribers("file.deleted")); - // Dispatch runs the plugin on the blocking pool; it must not panic or block. - manager.dispatch_file_uploaded(FileUploadedEvent { - path: "/a.txt".into(), - size: 3, - mime: "text/plain".into(), + // Both dispatches run the plugin on the blocking pool; neither may panic. + manager.dispatch(PluginEvent { + name: EVENT_FILE_UPLOADED, user_id: Some("u_test".into()), - invocation_id: "inv_dispatch".into(), + invocation_id: "inv_upload".into(), + payload: serde_json::json!({ "path": "/a.txt", "size": 3, "mime": "text/plain" }), + }); + manager.dispatch(PluginEvent { + name: EVENT_USER_LOGIN, + user_id: Some("u_test".into()), + invocation_id: "inv_login".into(), + payload: serde_json::json!({ "user_id": "u_test", "first_login": false }), }); - // Give the spawned task time to complete before the test runtime shuts down. tokio::time::sleep(Duration::from_millis(300)).await; } +#[test] +fn manager_rejects_plugin_missing_a_subscribed_export() { + // omit_login.wasm subscribes to user.login but doesn't export on_user_login. + let (_tmp, rejected) = manager_with("omit_login.wasm", r#"["user.login"]"#); + assert_eq!(rejected.loaded_count(), 0, "missing export -> not loaded"); + + // The same wasm is fine when it only claims an event it actually exports. + let (_tmp2, loaded) = manager_with("omit_login.wasm", r#"["file.uploaded"]"#); + assert_eq!(loaded.loaded_count(), 1); +} + // ---- Manifest validation (no wasm needed) ----------------------------------- const VALID_MANIFEST: &str = r#" @@ -182,6 +272,15 @@ fn manifest_accepts_valid() { assert_eq!(m.plugin.id, "com.example.hello"); } +#[test] +fn manifest_accepts_user_login_and_combined() { + let login = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["user.login"]"#); + assert!(manifest::parse_and_validate(&login).is_ok()); + + let both = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["file.uploaded", "user.login"]"#); + assert!(manifest::parse_and_validate(&both).is_ok()); +} + #[test] fn manifest_rejects_unknown_field() { let toml = format!("{VALID_MANIFEST}\nbogus_top_level = true\n"); diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 711d0716..a6a7b4d6 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -1,17 +1,19 @@ use axum::{ Router, - extract::{Json, Path, Query, State}, + extract::{Json, Multipart, Path, Query, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, routing::{delete, get, post, put}, }; +use crate::application::dtos::plugin_dto::{PluginInfoDto, SetEnabledDto}; use crate::application::dtos::settings_dto::{ AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto, }; +use crate::application::ports::plugin_ports::{PluginManagementPort, PluginMgmtError}; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::admin::require_admin; @@ -60,6 +62,11 @@ pub fn admin_routes() -> Router> { .route("/audio/metadata/reextract", post(reextract_audio_metadata)) // Image/video capture metadata (Photos timeline backfill) .route("/photos/metadata/reextract", post(reextract_image_metadata)) + // Plugin management + .route("/plugins", get(list_plugins)) + .route("/plugins", post(install_plugin)) + .route("/plugins/{id}/enabled", put(set_plugin_enabled)) + .route("/plugins/{id}", delete(delete_plugin)) // SMTP diagnostics .route("/smtp/info", get(get_smtp_info)) .route("/smtp/test", post(send_smtp_test)) @@ -1440,3 +1447,180 @@ async fn send_smtp_test( Ok(Json(result)) } + +// ---- Plugin management ----------------------------------------------------- + +/// Resolve the plugin-management port, or 503 when plugins are compiled out or +/// disabled via `OXICLOUD_ENABLE_PLUGINS`. The admin UI treats this 503 as the +/// "plugins disabled" state rather than an error. +fn plugin_mgmt(state: &AppState) -> Result<&Arc, AppError> { + state.plugin_management.as_ref().ok_or_else(|| { + AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "Plugins are disabled", + "PluginsDisabled", + ) + }) +} + +/// Map a management-layer error to an HTTP error. NotFound → 404, IdExists → +/// 409, Rejected → 400 (with the stable reason key in the message), Io → 500. +fn map_mgmt_err(err: &PluginMgmtError) -> AppError { + match err { + PluginMgmtError::NotFound => AppError::not_found("Plugin not found"), + PluginMgmtError::IdExists => { + AppError::conflict("A plugin with this id is already installed") + } + PluginMgmtError::Rejected(reason) => AppError::new( + StatusCode::BAD_REQUEST, + format!("Plugin rejected: {reason}"), + "PluginRejected", + ), + PluginMgmtError::Io(msg) => { + AppError::internal_error(format!("Plugin operation failed: {msg}")) + } + } +} + +/// GET /api/admin/plugins — list installed plugins. +pub async fn list_plugins( + State(state): State>, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + let plugins: Vec = mgmt.list().into_iter().map(PluginInfoDto::from).collect(); + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "enabled": true, "plugins": plugins })), + )) +} + +/// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin. +pub async fn set_plugin_enabled( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + mgmt.set_enabled(&id, dto.enabled) + .map_err(|e| map_mgmt_err(&e))?; + + if dto.enabled { + tracing::info!( + target: "audit", + event = "plugin.enabled", + plugin_id = %id, + admin_id = %admin_id, + "👮🏻‍♂️ plugin enabled by admin" + ); + } else { + tracing::info!( + target: "audit", + event = "plugin.disabled", + plugin_id = %id, + admin_id = %admin_id, + "👮🏻‍♂️ plugin disabled by admin" + ); + } + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": if dto.enabled { "Plugin enabled" } else { "Plugin disabled" }, + "id": id, + "enabled": dto.enabled, + })), + )) +} + +/// POST /api/admin/plugins — install a plugin from a multipart body with a +/// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`. +pub async fn install_plugin( + State(state): State>, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + + let mut bundle: Option> = None; + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::bad_request(format!("Invalid multipart body: {e}")))? + { + if field.name() == Some("bundle") { + bundle = Some( + field + .bytes() + .await + .map_err(|e| AppError::bad_request(format!("Invalid bundle part: {e}")))? + .to_vec(), + ); + } + } + + let bundle = match bundle { + Some(b) => b, + None => { + tracing::warn!( + target: "audit", + event = "plugin.install_rejected", + reason = "missing_part", + admin_id = %admin_id, + "👮🏻‍♂️ plugin install rejected: missing 'bundle' part" + ); + return Err(AppError::bad_request("A 'bundle' (.zip) part is required")); + } + }; + + match mgmt.install_bundle(bundle) { + Ok(info) => { + tracing::info!( + target: "audit", + event = "plugin.installed", + plugin_id = %info.id, + admin_id = %admin_id, + "👮🏻‍♂️ plugin installed by admin" + ); + Ok((StatusCode::CREATED, Json(PluginInfoDto::from(info)))) + } + Err(e) => { + tracing::warn!( + target: "audit", + event = "plugin.install_rejected", + reason = e.reason(), + admin_id = %admin_id, + "👮🏻‍♂️ plugin install rejected" + ); + Err(map_mgmt_err(&e)) + } + } +} + +/// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files. +pub async fn delete_plugin( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?; + + tracing::info!( + target: "audit", + event = "plugin.removed", + plugin_id = %id, + admin_id = %admin_id, + "👮🏻‍♂️ plugin removed by admin" + ); + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "message": "Plugin removed", "id": id })), + )) +} diff --git a/static/admin.html b/static/admin.html index 88f7a70b..0e00d0aa 100644 --- a/static/admin.html +++ b/static/admin.html @@ -65,6 +65,9 @@ +
@@ -677,6 +680,64 @@
+ +
+ + + +
diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index 864778a2..5cf9ab35 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -152,6 +152,7 @@ function switchTab(name, el) { if (name === 'dashboard') loadDashboard(); if (name === 'storage') loadStorage(); if (name === 'smtp') loadSmtp(); + if (name === 'plugins') loadPlugins(); } async function loadDashboard() { @@ -1280,6 +1281,195 @@ async function sendSmtpTest() { } } +/* ── Plugins ── */ + +/** + * @typedef {Object} PluginInfo + * @property {string} id + * @property {string} name + * @property {string} version + * @property {number} abi + * @property {string[]} subscriptions + * @property {boolean} enabled + */ + +/** + * Load installed plugins into the table. A 503 means plugins are disabled on + * the server — show the explanatory banner instead of the management UI. + */ +async function loadPlugins() { + const tbody = document.getElementById('plugins-tbody'); + const disabledEl = document.getElementById('plugins-disabled'); + const mainEl = document.getElementById('plugins-main'); + if (!tbody || !disabledEl || !mainEl) return; + try { + const resp = await fetch(`${API}/admin/plugins`, { + headers: headers(), + credentials: 'same-origin' + }); + if (resp.status === 503) { + disabledEl.classList.remove('hidden'); + mainEl.classList.add('hidden'); + return; + } + disabledEl.classList.add('hidden'); + mainEl.classList.remove('hidden'); + if (!resp.ok) { + tbody.innerHTML = ` ${escapeHtml(`HTTP ${resp.status}`)}`; + return; + } + /** @type {{enabled: boolean, plugins: PluginInfo[]}} */ + const data = await resp.json(); + renderPluginRows(data.plugins || []); + } catch (e) { + tbody.innerHTML = ` ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}`; + } +} + +/** @param {PluginInfo[]} plugins */ +function renderPluginRows(plugins) { + const tbody = document.getElementById('plugins-tbody'); + if (!tbody) return; + if (plugins.length === 0) { + tbody.innerHTML = `${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}`; + return; + } + tbody.innerHTML = plugins + .map((p) => { + const events = (p.subscriptions || []).map((ev) => `${escapeHtml(ev)}`).join(' ') || '—'; + const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled'; + const statusBadge = `${escapeHtml(statusLabel)}`; + const toggleTitle = p.enabled ? i18n.t('admin.plugins_disable') || 'Disable' : i18n.t('admin.plugins_enable') || 'Enable'; + const toggleBtn = + ``; + const deleteBtn = + `'; + return ( + '' + + `${escapeHtml(p.name)}` + + `${escapeHtml(p.id)}` + + `${escapeHtml(p.version)}` + + `${events}` + + `${statusBadge}` + + `
${toggleBtn}${deleteBtn}
` + + '' + ); + }) + .join(''); + + /** @type {NodeListOf} */ (document.querySelectorAll('#plugins-tbody .plugin-action-btn')).forEach((btn) => { + btn.addEventListener('click', () => { + const action = btn.dataset.action; + if (action === 'toggle') togglePlugin(btn.dataset.pid, btn.dataset.enabled !== 'true'); + else if (action === 'delete') deletePlugin(btn.dataset.pid, btn.dataset.pname); + }); + }); +} + +/** + * @param {string|undefined} id + * @param {boolean} enable + */ +async function togglePlugin(id, enable) { + if (!id) return; + try { + const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/enabled`, { + method: 'PUT', + headers: headers(), + credentials: 'same-origin', + body: JSON.stringify({ enabled: enable }) + }); + if (resp.ok) { + loadPlugins(); + } else { + const e = await resp.json().catch(() => ({})); + alert(e.message || i18n.t('admin.error_generic')); + } + } catch (e) { + alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); + } +} + +/** + * @param {string|undefined} id + * @param {string|undefined} name + */ +async function deletePlugin(id, name) { + if (!id) return; + const ok = await showConfirm(i18n.t('admin.plugins_confirm_delete', { name: name || id })); + if (!ok) return; + try { + const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: headers(), + credentials: 'same-origin' + }); + if (resp.ok) { + loadPlugins(); + } else { + const e = await resp.json().catch(() => ({})); + alert(e.message || i18n.t('admin.error_generic')); + } + } catch (e) { + alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); + } +} + +/** + * Install a plugin from the selected .zip bundle. The multipart Content-Type + * (with its boundary) is set by the browser — do not override it. + */ +async function installPlugin() { + const bundleInput = /** @type {HTMLInputElement | null} */ (document.getElementById('plugin-bundle-file')); + const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('btn-plugin-install')); + const resultEl = document.getElementById('plugin-install-result'); + if (!bundleInput || !resultEl) return; + + const bundleFile = bundleInput.files?.[0]; + if (!bundleFile) { + resultEl.className = 'alert alert-error'; + resultEl.style.display = 'block'; + resultEl.textContent = i18n.t('admin.plugins_install_missing_bundle') || 'Select a plugin bundle (.zip).'; + return; + } + + const form = new FormData(); + form.append('bundle', bundleFile); + + if (btn) btn.disabled = true; + resultEl.className = 'alert alert-info'; + resultEl.style.display = 'block'; + resultEl.textContent = i18n.t('admin.plugins_installing') || 'Installing…'; + + try { + const resp = await fetch(`${API}/admin/plugins`, { + method: 'POST', + headers: { ...getCsrfHeaders() }, + credentials: 'same-origin', + body: form + }); + if (!resp.ok) { + const e = await resp.json().catch(() => ({})); + resultEl.className = 'alert alert-error'; + resultEl.textContent = e.message || `HTTP ${resp.status}`; + return; + } + /** @type {PluginInfo} */ + const info = await resp.json(); + resultEl.className = 'alert alert-success'; + resultEl.textContent = i18n.t('admin.plugins_installed', { name: info.name }) || `Installed ${info.name}.`; + bundleInput.value = ''; + loadPlugins(); + } catch (e) { + resultEl.className = 'alert alert-error'; + resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }); + } finally { + if (btn) btn.disabled = false; + } +} + /* ── Apply i18n when translations load / change ── */ document.addEventListener('translationsLoaded', () => { i18n.translatePage(); @@ -1312,8 +1502,14 @@ document.getElementById('tab-btn-smtp').addEventListener('click', function () { switchTab('smtp', this); }); +document.getElementById('tab-btn-plugins').addEventListener('click', function () { + switchTab('plugins', this); +}); + document.getElementById('btn-smtp-test').addEventListener('click', sendSmtpTest); +document.getElementById('btn-plugin-install').addEventListener('click', installPlugin); + document.getElementById('ds-registration').addEventListener('change', function () { toggleRegistration(/** @type {HTMLInputElement} */ (this).checked); }); diff --git a/static/locales/en.json b/static/locales/en.json index bc489e06..df1381b7 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -731,6 +731,31 @@ "migration_verify_failed": "Verification failed", "migration_failed_blobs": "failed blobs", "testing": "Testing…", + "tab_plugins": "Plugins", + "plugins_title": "Plugins", + "plugins_disabled": "Plugins are disabled on this server. Set OXICLOUD_ENABLE_PLUGINS=true (and build with the \"plugins\" feature) to manage WASM plugins here.", + "plugins_install_title": "Install a plugin", + "plugins_install_intro": "Upload a plugin bundle (.zip) containing plugin.toml and its compiled WebAssembly module (.wasm). The manifest is validated and the module is probed before installation.", + "plugins_bundle_label": "Plugin bundle (.zip)", + "plugins_install": "Install plugin", + "plugins_installed_title": "Installed plugins", + "plugins_col_name": "Name", + "plugins_col_id": "ID", + "plugins_col_version": "Version", + "plugins_col_events": "Events", + "plugins_col_status": "Status", + "plugins_col_actions": "Actions", + "plugins_loading": "Loading plugins…", + "plugins_none": "No plugins installed.", + "plugins_enabled": "Enabled", + "plugins_disabled_badge": "Disabled", + "plugins_enable": "Enable", + "plugins_disable": "Disable", + "plugins_delete": "Delete", + "plugins_confirm_delete": "Delete plugin \"{{name}}\"? Its files will be removed from the server.", + "plugins_installing": "Installing…", + "plugins_installed": "Installed {{name}}.", + "plugins_install_missing_bundle": "Select a plugin bundle (.zip).", "tab_smtp": "SMTP", "smtp_title": "Outbound Email (SMTP)", "smtp_intro": "SMTP is configured exclusively via environment variables (OXICLOUD_SMTP_*). The values below are read from the running server — to change them, edit the environment and restart OxiCloud.", diff --git a/tests/fixtures/plugins/hello.wasm b/tests/fixtures/plugins/hello.wasm index 8502b66d..f1518195 100755 Binary files a/tests/fixtures/plugins/hello.wasm and b/tests/fixtures/plugins/hello.wasm differ diff --git a/tests/fixtures/plugins/net.wasm b/tests/fixtures/plugins/net.wasm index c1f832a5..d7100fa7 100755 Binary files a/tests/fixtures/plugins/net.wasm and b/tests/fixtures/plugins/net.wasm differ diff --git a/tests/fixtures/plugins/omit_login.wasm b/tests/fixtures/plugins/omit_login.wasm new file mode 100755 index 00000000..686e1812 Binary files /dev/null and b/tests/fixtures/plugins/omit_login.wasm differ diff --git a/tests/fixtures/plugins/panic.wasm b/tests/fixtures/plugins/panic.wasm index a220960e..18f47360 100755 Binary files a/tests/fixtures/plugins/panic.wasm and b/tests/fixtures/plugins/panic.wasm differ diff --git a/tests/fixtures/plugins/sleep.wasm b/tests/fixtures/plugins/sleep.wasm index 18c4ef8e..a6527a35 100755 Binary files a/tests/fixtures/plugins/sleep.wasm and b/tests/fixtures/plugins/sleep.wasm differ diff --git a/tests/fixtures/plugins/wrong_abi.wasm b/tests/fixtures/plugins/wrong_abi.wasm index c9b163f6..f3108bc3 100755 Binary files a/tests/fixtures/plugins/wrong_abi.wasm and b/tests/fixtures/plugins/wrong_abi.wasm differ diff --git a/wasm/oxicloud-plugin-hello/Cargo.toml b/wasm/oxicloud-plugin-hello/Cargo.toml index 0ad59401..b1c75ae8 100644 --- a/wasm/oxicloud-plugin-hello/Cargo.toml +++ b/wasm/oxicloud-plugin-hello/Cargo.toml @@ -20,10 +20,11 @@ serde_json = "1" default = [] # Misbehaving variants for the failure-isolation tests. Each builds a separate # fixture under tests/fixtures/plugins/ via the build script. -panic = [] # `handle` panics -> host must contain the trap -sleep = [] # `handle` busy-loops past the timeout -net = [] # `handle` attempts an outbound HTTP call (denied: no allowed_hosts) +panic = [] # `on_file_uploaded` panics -> host must contain the trap +sleep = [] # `on_file_uploaded` busy-loops past the timeout +net = [] # `on_file_uploaded` attempts an outbound HTTP call (denied: no allowed_hosts) wrong_abi = [] # `abi_version` returns 1 -> host must reject at load +omit_login = [] # drops the `on_user_login` export -> host rejects a user.login subscription [profile.release] opt-level = "s" diff --git a/wasm/oxicloud-plugin-hello/plugin.toml b/wasm/oxicloud-plugin-hello/plugin.toml new file mode 100644 index 00000000..9e6d0d5a --- /dev/null +++ b/wasm/oxicloud-plugin-hello/plugin.toml @@ -0,0 +1,11 @@ +# Manifest for the example OxiCloud plugin (ABI v0). `just plugin-example-zip` +# bundles this together with the compiled module into an installable .zip. +[plugin] +id = "com.example.hello" +name = "Hello" +version = "0.1.0" +abi = 0 +entrypoint = "hello.wasm" + +[events] +subscribe = ["file.uploaded", "user.login"] diff --git a/wasm/oxicloud-plugin-hello/src/lib.rs b/wasm/oxicloud-plugin-hello/src/lib.rs index 3e9c2983..cb6b5bce 100644 --- a/wasm/oxicloud-plugin-hello/src/lib.rs +++ b/wasm/oxicloud-plugin-hello/src/lib.rs @@ -1,12 +1,17 @@ //! Example OxiCloud plugin — ABI v0 (M0 walking skeleton). //! -//! The default build is the well-behaved "hello" plugin: it reads the -//! `file.uploaded` event metadata, calls the host `log` function (the only +//! The default build is the well-behaved "hello" plugin. It exports one handler +//! per event it subscribes to — `on_file_uploaded` and `on_user_login` — each of +//! which reads the event metadata, calls the host `log` function (the only //! authority a plugin has), and returns `{"ok": true}`. //! -//! Cargo features select the misbehaving variants the host's failure-isolation -//! tests load (`panic`, `sleep`, `net`, `wrong_abi`). See -//! `scripts/build-plugin-hello.sh`. +//! Cargo features select the variants the host's tests load: +//! - `panic` / `sleep` / `net` — make `on_file_uploaded` misbehave (failure +//! isolation, timeout, network-denial tests); +//! - `wrong_abi` — `abi_version` returns 1 (load-rejection test); +//! - `omit_login` — drops the `on_user_login` export (missing-export test). +//! +//! See `scripts/build-plugin-hello.sh`. use extism_pdk::*; @@ -31,9 +36,9 @@ pub fn abi_version() -> FnResult { Ok(1) } -/// Required export: the single event entry point. +/// Handler for the `file.uploaded` event. #[plugin_fn] -pub fn handle(input: String) -> FnResult { +pub fn on_file_uploaded(input: String) -> FnResult { // --- misbehaving variants (compiled in only under their feature) --------- #[cfg(feature = "panic")] panic!("intentional panic: exercises host failure isolation"); @@ -52,24 +57,39 @@ pub fn handle(input: String) -> FnResult { { // Attempt an outbound HTTP call. The host grants no `allowed_hosts`, so // Extism denies this before any socket is opened (offline-deterministic) - // and the error propagates out of `handle`. + // and the error propagates out of the handler. let req = HttpRequest::new("https://example.com/"); let _ = http::request::<()>(&req, None)?; } - // --- well-behaved "hello" path ------------------------------------------ + // --- well-behaved path --------------------------------------------------- let ev: serde_json::Value = serde_json::from_str(&input)?; let path = ev["payload"]["path"].as_str().unwrap_or(""); let size = ev["payload"]["size"].as_u64().unwrap_or(0); - // Prove plugin -> host: call the only authority we have. unsafe { log( "info".to_string(), format!("hello plugin saw upload: {path} ({size} bytes)"), )?; } - - // Prove plugin -> host return path. + Ok(serde_json::json!({ "ok": true }).to_string()) +} + +/// Handler for the `user.login` event. Dropped by the `omit_login` variant so +/// the host's missing-export validation has something to reject. +#[cfg(not(feature = "omit_login"))] +#[plugin_fn] +pub fn on_user_login(input: String) -> FnResult { + let ev: serde_json::Value = serde_json::from_str(&input)?; + let user_id = ev["payload"]["user_id"].as_str().unwrap_or(""); + let first_login = ev["payload"]["first_login"].as_bool().unwrap_or(false); + + unsafe { + log( + "info".to_string(), + format!("hello plugin saw login: user {user_id} (first_login={first_login})"), + )?; + } Ok(serde_json::json!({ "ok": true }).to_string()) }