add frontend
This commit is contained in:
@@ -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) ----------------------
|
||||
|
||||
Reference in New Issue
Block a user