init plugins

This commit is contained in:
Bradley Nelson
2026-06-16 17:57:57 -06:00
parent 23e78e9a9c
commit 87d68c5b6f
27 changed files with 3099 additions and 24 deletions
+1
View File
@@ -2,6 +2,7 @@
pub mod caldav_adapter;
pub mod carddav_adapter;
pub mod plugin_lifecycle_hook;
pub mod webdav_adapter;
#[cfg(test)]
@@ -0,0 +1,100 @@
//! Bridges the existing [`FileLifecycleHook`] fan-out to the plugin runtime.
//!
//! `FileLifecycleService` already notifies hooks on every file create/update.
//! This adapter turns those notifications into `file.uploaded` plugin events.
//! Because the hook signature carries only `file_id` (not path/size), it looks
//! the metadata up via [`FileRetrievalUseCase::get_file`] — off the request
//! path, and skipped entirely when no plugin subscribes.
use std::sync::Arc;
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,
};
use crate::application::services::FileRetrievalService;
/// Lifecycle hook that forwards file create/update events to subscribed plugins.
pub struct PluginLifecycleHook {
dispatch: Arc<dyn PluginDispatchPort>,
retrieval: Arc<FileRetrievalService>,
}
impl PluginLifecycleHook {
pub fn new(
dispatch: Arc<dyn PluginDispatchPort>,
retrieval: Arc<FileRetrievalService>,
) -> Self {
Self {
dispatch,
retrieval,
}
}
/// Look up the file's metadata and dispatch a `file.uploaded` event. Cheap
/// early-out when nothing subscribes; otherwise the DB read and the plugin
/// run happen on a background task, never blocking the caller.
fn dispatch_upload(&self, file_id: &str) {
if !self.dispatch.has_subscribers(EVENT_FILE_UPLOADED) {
return;
}
let dispatch = self.dispatch.clone();
let retrieval = self.retrieval.clone();
let file_id = file_id.to_string();
tokio::spawn(async move {
let dto = match retrieval.get_file(&file_id).await {
Ok(dto) => dto,
Err(e) => {
tracing::warn!(
target: "oxicloud::plugins",
file_id = %file_id,
error = %e,
"plugin bridge: file metadata lookup failed; skipping dispatch"
);
return;
}
};
dispatch.dispatch_file_uploaded(FileUploadedEvent {
path: dto.path,
size: dto.size,
mime: dto.mime_type.to_string(),
user_id: dto.owner_id,
invocation_id: Uuid::new_v4().to_string(),
});
});
}
}
impl FileLifecycleHook for PluginLifecycleHook {
fn on_file_created(
&self,
file_id: &str,
_blob_hash: &str,
_content_type: &str,
_is_new_blob: bool,
) {
self.dispatch_upload(file_id);
}
fn on_file_updated(&self, file_id: &str, _blob_hash: &str, _content_type: &str) {
self.dispatch_upload(file_id);
}
// A copy creates a new file record, but its content already existed and was
// already observed on its original upload; M0 does not re-dispatch for it.
fn on_file_copied(
&self,
_file_id: &str,
_blob_hash: &str,
_content_type: &str,
_source_file_id: &str,
) {
}
fn on_file_deleted(&self, _file_id: &str) {}
}
+1
View File
@@ -17,6 +17,7 @@ pub mod folder_ports;
pub mod inbound;
pub mod music_ports;
pub mod outbound;
pub mod plugin_ports;
pub mod recent_ports;
pub mod share_ports;
pub mod storage_ports;
+80
View File
@@ -0,0 +1,80 @@
//! WASM plugin runtime ports (ABI v0 — M0 walking skeleton).
//!
//! This module is the *entire* contract surface the rest of the application
//! talks to. Concrete Extism types live in the infrastructure layer behind
//! [`PluginDispatchPort`], keeping the hexagonal boundary intact: nothing in
//! `application/` or `domain/` depends on the WASM runtime.
//!
//! The ABI is intentionally tiny (see the M0 spec):
//! - constant [`OXICLOUD_PLUGIN_ABI`] / namespace [`HOST_NAMESPACE`];
//! - plugin exports `abi_version` + `handle`;
//! - one host import `log` (observe-only — the only authority a plugin has).
use serde::{Deserialize, Serialize};
/// The single ABI version this host speaks. A breaking change bumps this and
/// the namespace suffix ([`HOST_NAMESPACE`]); plugins built against a different
/// value are rejected at load, never silently mis-run.
pub const OXICLOUD_PLUGIN_ABI: u32 = 0;
/// Namespace of the host functions a plugin may import. The `:v0` suffix is
/// 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.
pub const EVENT_FILE_UPLOADED: &str = "file.uploaded";
/// 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.
pub trait PluginDispatchPort: Send + Sync + 'static {
/// Dispatch a `file.uploaded` event (metadata only) to subscribed plugins.
fn dispatch_file_uploaded(&self, event: FileUploadedEvent);
/// Cheap predicate so the bridge hook can skip the metadata lookup 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).
#[derive(Debug, Clone)]
pub struct FileUploadedEvent {
pub path: String,
pub size: u64,
pub mime: String,
/// Opaque owner id of the file, when known.
pub user_id: Option<String>,
/// Unique id minted per dispatch, correlating host logs with plugin output.
pub invocation_id: String,
}
// ---- Wire DTOs (ABI v0 JSON shapes, §3.4 of the spec) ----------------------
/// Serialized host → plugin and handed to `handle` as a UTF-8 JSON string.
#[derive(Debug, Clone, Serialize)]
pub struct PluginInput {
pub abi: u32,
pub event: String,
pub context: PluginContext,
pub payload: serde_json::Value,
}
/// Invocation context. `user_id` is the owner of the event; because each
/// invocation is a fresh instance, a plugin never sees two users at once.
#[derive(Debug, Clone, Serialize)]
pub struct PluginContext {
pub plugin_id: String,
pub user_id: Option<String>,
pub invocation_id: String,
}
/// Returned from `handle`. M0 has no `actions` array — the plugin cannot ask the
/// host to do anything (observe-only). Unknown fields are ignored.
#[derive(Debug, Clone, Deserialize)]
pub struct PluginOutput {
pub ok: bool,
#[serde(default)]
pub error: Option<String>,
}