add frontend
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +45,7 @@ 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)"}'
|
||||
|
||||
Executable
+45
@@ -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"
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<dyn PluginDispatchPort>,
|
||||
}
|
||||
|
||||
impl PluginUserLifecycleHook {
|
||||
pub fn new(dispatch: Arc<dyn PluginDispatchPort>) -> 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<Vec<PluginEvent>>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl From<PluginInfo> 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,
|
||||
}
|
||||
@@ -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_<event>` (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_<event>` 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<PluginInfo>;
|
||||
|
||||
/// 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<u8>) -> Result<PluginInfo, PluginMgmtError>;
|
||||
|
||||
/// 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<u8>) -> Result<PluginInfo, PluginMgmtError>;
|
||||
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
/// 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) ----------------------
|
||||
|
||||
+72
-26
@@ -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<PgAclEngine>,
|
||||
storage_usage: &Arc<StorageUsageService>,
|
||||
content_index: Option<Arc<TantivyContentIndex>>,
|
||||
plugin_dispatch: Option<
|
||||
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
|
||||
>,
|
||||
) -> 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<FileRetrievalService>,
|
||||
plugin_dispatch: Option<
|
||||
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
|
||||
>,
|
||||
) -> Arc<dyn crate::application::ports::file_lifecycle::FileLifecycleHook> {
|
||||
#[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<dyn PluginDispatchPort> = 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<Arc<crate::infrastructure::services::plugins::ExtismPluginManager>> {
|
||||
if !self.config.plugins.enabled {
|
||||
return None;
|
||||
}
|
||||
) -> (
|
||||
Option<Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>>,
|
||||
Option<Arc<dyn crate::application::ports::plugin_ports::PluginManagementPort>>,
|
||||
) {
|
||||
#[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 = crate::infrastructure::services::plugins::ExtismPluginManager::load_from_dir(
|
||||
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"
|
||||
);
|
||||
Some(Arc::new(manager))
|
||||
let dispatch: Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort> =
|
||||
manager.clone();
|
||||
let management: Arc<
|
||||
dyn crate::application::ports::plugin_ports::PluginManagementPort,
|
||||
> = manager;
|
||||
return (Some(dispatch), Some(management));
|
||||
}
|
||||
}
|
||||
(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<AuthServices>,
|
||||
pub nextcloud: Option<NextcloudServices>,
|
||||
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
||||
/// 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<Arc<dyn crate::application::ports::plugin_ports::PluginManagementPort>>,
|
||||
pub storage_settings_service: Option<Arc<StorageSettingsService>>,
|
||||
pub migration_state: Arc<tokio::sync::RwLock<MigrationState>>,
|
||||
pub trash_service: Option<Arc<TrashService>>,
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<PluginRuntime>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
fn info(&self) -> PluginInfo {
|
||||
let mut subscriptions: Vec<String> = 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<LoadedPlugin>,
|
||||
/// Root directory plugins are discovered in and installed into.
|
||||
root_dir: PathBuf,
|
||||
plugins: RwLock<Vec<LoadedPlugin>>,
|
||||
}
|
||||
|
||||
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_<event>` handler exported.
|
||||
let required_exports: Vec<String> = 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<LoadedPlugin>> {
|
||||
self.plugins.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn write_plugins(&self) -> std::sync::RwLockWriteGuard<'_, Vec<LoadedPlugin>> {
|
||||
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<PluginInfo> {
|
||||
let mut infos: Vec<PluginInfo> = 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<u8>) -> Result<PluginInfo, PluginMgmtError> {
|
||||
// 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<String> = 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<u8>) -> Result<PluginInfo, PluginMgmtError> {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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");
|
||||
}
|
||||
@@ -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<PluginManifest, ManifestErro
|
||||
return Err(ManifestError::NoEvents);
|
||||
}
|
||||
for event in &manifest.events.subscribe {
|
||||
if event != EVENT_FILE_UPLOADED {
|
||||
if !KNOWN_EVENTS.contains(&event.as_str()) {
|
||||
return Err(ManifestError::UnknownEvent(event.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,7 @@ pub mod runtime;
|
||||
|
||||
pub use manager::ExtismPluginManager;
|
||||
|
||||
#[cfg(test)]
|
||||
mod manager_test;
|
||||
#[cfg(test)]
|
||||
mod runtime_test;
|
||||
|
||||
@@ -58,7 +58,9 @@ pub enum InvokeOutcome {
|
||||
LoadError(String),
|
||||
/// `abi_version` returned a value the host does not speak.
|
||||
AbiMismatch { got: u32 },
|
||||
/// `handle` returned bytes that are not a valid `PluginOutput`.
|
||||
/// A subscribed event has no matching `on_<event>` 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_<event>` 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_<event>` 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::<PluginOutput>(&out) {
|
||||
Ok(parsed) if parsed.ok => InvokeOutcome::Ok,
|
||||
Ok(parsed) => {
|
||||
|
||||
@@ -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<u8> {
|
||||
})
|
||||
}
|
||||
|
||||
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"),
|
||||
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");
|
||||
|
||||
@@ -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<Arc<AppState>> {
|
||||
.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<dyn PluginManagementPort>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
let plugins: Vec<PluginInfoDto> = 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<SetEnabledDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
|
||||
let mut bundle: Option<Vec<u8>> = 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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 })),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
<button class="admin-tab" id="tab-btn-smtp">
|
||||
<i class="fas fa-envelope"></i> <span data-i18n="admin.tab_smtp">SMTP</span>
|
||||
</button>
|
||||
<button class="admin-tab" id="tab-btn-plugins">
|
||||
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.tab_plugins">Plugins</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-dashboard" class="tab-content active">
|
||||
@@ -677,6 +680,64 @@
|
||||
<div id="smtp-test-result" class="alert" style="display:none; margin-top:14px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-plugins" class="tab-content">
|
||||
<div id="plugins-disabled" class="admin-card hidden">
|
||||
<h2>
|
||||
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.plugins_title">Plugins</span>
|
||||
</h2>
|
||||
<p class="muted" data-i18n="admin.plugins_disabled">
|
||||
Plugins are disabled on this server. Set OXICLOUD_ENABLE_PLUGINS=true (and build with the "plugins" feature) to manage WASM plugins here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="plugins-main" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-upload"></i> <span data-i18n="admin.plugins_install_title">Install a plugin</span>
|
||||
</h2>
|
||||
<p class="muted" data-i18n="admin.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.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="plugin-bundle-file" data-i18n="admin.plugins_bundle_label">Plugin bundle (.zip)</label>
|
||||
<input id="plugin-bundle-file" type="file" accept=".zip,application/zip" />
|
||||
</div>
|
||||
<button id="btn-plugin-install" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> <span data-i18n="admin.plugins_install">Install plugin</span>
|
||||
</button>
|
||||
<div id="plugin-install-result" class="alert" style="display:none; margin-top:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.plugins_installed_title">Installed plugins</span>
|
||||
</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.plugins_col_name">Name</th>
|
||||
<th data-i18n="admin.plugins_col_id">ID</th>
|
||||
<th data-i18n="admin.plugins_col_version">Version</th>
|
||||
<th data-i18n="admin.plugins_col_events">Events</th>
|
||||
<th data-i18n="admin.plugins_col_status">Status</th>
|
||||
<th data-i18n="admin.plugins_col_actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plugins-tbody">
|
||||
<tr>
|
||||
<td colspan="6" class="table-loading-cell">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span data-i18n="admin.plugins_loading">Loading plugins…</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = `<tr><td colspan="6" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(`HTTP ${resp.status}`)}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
/** @type {{enabled: boolean, plugins: PluginInfo[]}} */
|
||||
const data = await resp.json();
|
||||
renderPluginRows(data.plugins || []);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {PluginInfo[]} plugins */
|
||||
function renderPluginRows(plugins) {
|
||||
const tbody = document.getElementById('plugins-tbody');
|
||||
if (!tbody) return;
|
||||
if (plugins.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = plugins
|
||||
.map((p) => {
|
||||
const events = (p.subscriptions || []).map((ev) => `<code>${escapeHtml(ev)}</code>`).join(' ') || '—';
|
||||
const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled';
|
||||
const statusBadge = `<span class="badge badge-${p.enabled ? 'active' : 'inactive'}">${escapeHtml(statusLabel)}</span>`;
|
||||
const toggleTitle = p.enabled ? i18n.t('admin.plugins_disable') || 'Disable' : i18n.t('admin.plugins_enable') || 'Enable';
|
||||
const toggleBtn =
|
||||
`<button class="btn btn-sm ${p.enabled ? 'btn-secondary' : 'btn-success'} plugin-action-btn" data-action="toggle" data-pid="${_escJs(p.id)}" data-enabled="${p.enabled}" title="${escapeHtml(toggleTitle)}">` +
|
||||
`<i class="fas fa-${p.enabled ? 'pause' : 'play'}"></i></button>`;
|
||||
const deleteBtn =
|
||||
`<button class="btn btn-sm btn-danger plugin-action-btn" data-action="delete" data-pid="${_escJs(p.id)}" data-pname="${_escJs(p.name)}" title="${escapeHtml(i18n.t('admin.plugins_delete') || 'Delete')}">` +
|
||||
'<i class="fas fa-trash-alt"></i></button>';
|
||||
return (
|
||||
'<tr>' +
|
||||
`<td>${escapeHtml(p.name)}</td>` +
|
||||
`<td><code>${escapeHtml(p.id)}</code></td>` +
|
||||
`<td>${escapeHtml(p.version)}</td>` +
|
||||
`<td>${events}</td>` +
|
||||
`<td>${statusBadge}</td>` +
|
||||
`<td><div class="actions-row">${toggleBtn}${deleteBtn}</div></td>` +
|
||||
'</tr>'
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
/** @type {NodeListOf<HTMLButtonElement>} */ (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);
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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<u32> {
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// Required export: the single event entry point.
|
||||
/// Handler for the `file.uploaded` event.
|
||||
#[plugin_fn]
|
||||
pub fn handle(input: String) -> FnResult<String> {
|
||||
pub fn on_file_uploaded(input: String) -> FnResult<String> {
|
||||
// --- 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<String> {
|
||||
{
|
||||
// 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("<unknown>");
|
||||
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<String> {
|
||||
let ev: serde_json::Value = serde_json::from_str(&input)?;
|
||||
let user_id = ev["payload"]["user_id"].as_str().unwrap_or("<unknown>");
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user