pinakes-ui: enforce plugin endpoint allowlist; replace inline styles with CSS custom properties
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I751e5c7ec66f045ee1f0bad6c72759416a6a6964
This commit is contained in:
parent
ada1c07f66
commit
9389af9fda
11 changed files with 1880 additions and 772 deletions
|
|
@ -27,15 +27,18 @@ use crate::client::ApiClient;
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct PluginPage {
|
||||
/// Plugin ID that provides this page
|
||||
pub plugin_id: String,
|
||||
pub plugin_id: String,
|
||||
/// Page definition from schema
|
||||
pub page: UiPage,
|
||||
pub page: UiPage,
|
||||
/// Endpoint paths this plugin is allowed to fetch (empty means no
|
||||
/// restriction)
|
||||
pub allowed_endpoints: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginPage {
|
||||
/// Full route including plugin prefix
|
||||
/// The canonical route for this page, taken directly from the page schema.
|
||||
pub fn full_route(&self) -> String {
|
||||
format!("/plugins/{}/{}", self.plugin_id, self.page.id)
|
||||
self.page.route.clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,10 +49,12 @@ impl PluginPage {
|
|||
pub struct PluginRegistry {
|
||||
/// API client for fetching pages from server
|
||||
client: ApiClient,
|
||||
/// Cached pages: (plugin_id, page_id) -> PluginPage
|
||||
/// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage`
|
||||
pages: HashMap<(String, String), PluginPage>,
|
||||
/// Cached widgets: (plugin_id, widget_id) -> UiWidget
|
||||
/// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget`
|
||||
widgets: Vec<(String, UiWidget)>,
|
||||
/// Merged CSS custom property overrides from all enabled plugins
|
||||
theme_vars: HashMap<String, String>,
|
||||
/// Last refresh timestamp
|
||||
last_refresh: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
|
@ -61,25 +66,57 @@ impl PluginRegistry {
|
|||
client,
|
||||
pages: HashMap::new(),
|
||||
widgets: Vec::new(),
|
||||
theme_vars: HashMap::new(),
|
||||
last_refresh: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new registry with pre-loaded pages
|
||||
pub fn with_pages(client: ApiClient, pages: Vec<(String, UiPage)>) -> Self {
|
||||
let mut registry = Self::new(client);
|
||||
for (plugin_id, page) in pages {
|
||||
registry.register_page(plugin_id, page);
|
||||
}
|
||||
registry
|
||||
/// Get merged CSS custom property overrides from all loaded plugins.
|
||||
pub fn theme_vars(&self) -> &HashMap<String, String> {
|
||||
&self.theme_vars
|
||||
}
|
||||
|
||||
/// Register a page from a plugin
|
||||
pub fn register_page(&mut self, plugin_id: String, page: UiPage) {
|
||||
///
|
||||
/// Pages that fail schema validation are silently skipped with a warning log.
|
||||
pub fn register_page(
|
||||
&mut self,
|
||||
plugin_id: String,
|
||||
page: UiPage,
|
||||
allowed_endpoints: Vec<String>,
|
||||
) {
|
||||
if let Err(e) = page.validate() {
|
||||
tracing::warn!(
|
||||
plugin_id = %plugin_id,
|
||||
page_id = %page.id,
|
||||
"Skipping invalid page '{}' from '{}': {e}",
|
||||
page.id,
|
||||
plugin_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let page_id = page.id.clone();
|
||||
self
|
||||
.pages
|
||||
.insert((plugin_id.clone(), page_id), PluginPage { plugin_id, page });
|
||||
// Check for duplicate page_id across different plugins. Same-plugin
|
||||
// re-registration of the same page is allowed to overwrite.
|
||||
let has_duplicate = self.pages.values().any(|existing| {
|
||||
existing.page.id == page_id && existing.plugin_id != plugin_id
|
||||
});
|
||||
if has_duplicate {
|
||||
tracing::warn!(
|
||||
plugin_id = %plugin_id,
|
||||
page_id = %page_id,
|
||||
"skipping plugin page: page ID conflicts with an existing page from another plugin"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.pages.insert(
|
||||
(plugin_id.clone(), page_id),
|
||||
PluginPage {
|
||||
plugin_id,
|
||||
page,
|
||||
allowed_endpoints,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a specific page by plugin ID and page ID
|
||||
|
|
@ -94,29 +131,37 @@ impl PluginRegistry {
|
|||
}
|
||||
|
||||
/// Register a widget from a plugin
|
||||
///
|
||||
/// Widgets that fail schema validation are silently skipped with a warning
|
||||
/// log.
|
||||
pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) {
|
||||
if let Err(e) = widget.validate() {
|
||||
tracing::warn!(
|
||||
plugin_id = %plugin_id,
|
||||
widget_id = %widget.id,
|
||||
"Skipping invalid widget '{}' from '{}': {e}",
|
||||
widget.id,
|
||||
plugin_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.widgets.push((plugin_id, widget));
|
||||
}
|
||||
|
||||
/// Get all widgets (for use with WidgetContainer)
|
||||
/// Get all widgets (for use with `WidgetContainer`)
|
||||
pub fn all_widgets(&self) -> Vec<(String, UiWidget)> {
|
||||
self.widgets.clone()
|
||||
}
|
||||
|
||||
/// Get all pages
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "used in tests and may be needed by future callers"
|
||||
)]
|
||||
pub fn all_pages(&self) -> Vec<&PluginPage> {
|
||||
self.pages.values().collect()
|
||||
}
|
||||
|
||||
/// Get all page routes for navigation
|
||||
pub fn routes(&self) -> Vec<(String, String, String)> {
|
||||
self
|
||||
.pages
|
||||
.values()
|
||||
.map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if any pages are registered
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pages.is_empty()
|
||||
|
|
@ -127,20 +172,50 @@ impl PluginRegistry {
|
|||
self.pages.len()
|
||||
}
|
||||
|
||||
/// Refresh pages from server
|
||||
/// Get all page routes for navigation
|
||||
///
|
||||
/// Returns `(plugin_id, page_id, full_route)` triples.
|
||||
pub fn routes(&self) -> Vec<(String, String, String)> {
|
||||
self
|
||||
.pages
|
||||
.values()
|
||||
.map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Refresh pages and widgets from server
|
||||
pub async fn refresh(&mut self) -> Result<(), String> {
|
||||
match self.client.get_plugin_ui_pages().await {
|
||||
Ok(pages) => {
|
||||
self.pages.clear();
|
||||
self.widgets.clear();
|
||||
for (plugin_id, page) in pages {
|
||||
self.register_page(plugin_id, page);
|
||||
}
|
||||
self.last_refresh = Some(chrono::Utc::now());
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => Err(format!("Failed to refresh plugin pages: {e}")),
|
||||
let pages = self
|
||||
.client
|
||||
.get_plugin_ui_pages()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh plugin pages: {e}"))?;
|
||||
|
||||
// Build into a temporary registry to avoid a window where state appears
|
||||
// empty during the two async fetches.
|
||||
let mut tmp = Self::new(self.client.clone());
|
||||
for (plugin_id, page, endpoints) in pages {
|
||||
tmp.register_page(plugin_id, page, endpoints);
|
||||
}
|
||||
match self.client.get_plugin_ui_widgets().await {
|
||||
Ok(widgets) => {
|
||||
for (plugin_id, widget) in widgets {
|
||||
tmp.register_widget(plugin_id, widget);
|
||||
}
|
||||
},
|
||||
Err(e) => tracing::warn!("Failed to refresh plugin widgets: {e}"),
|
||||
}
|
||||
match self.client.get_plugin_ui_theme_extensions().await {
|
||||
Ok(vars) => tmp.theme_vars = vars,
|
||||
Err(e) => tracing::warn!("Failed to refresh plugin theme extensions: {e}"),
|
||||
}
|
||||
|
||||
// Atomic swap: no window where the registry appears empty.
|
||||
self.pages = tmp.pages;
|
||||
self.widgets = tmp.widgets;
|
||||
self.theme_vars = tmp.theme_vars;
|
||||
self.last_refresh = Some(chrono::Utc::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get last refresh time
|
||||
|
|
@ -173,6 +248,7 @@ mod tests {
|
|||
padding: None,
|
||||
},
|
||||
data_sources: HashMap::new(),
|
||||
actions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +257,7 @@ mod tests {
|
|||
let client = ApiClient::default();
|
||||
let registry = PluginRegistry::new(client);
|
||||
assert!(registry.is_empty());
|
||||
assert_eq!(registry.len(), 0);
|
||||
assert_eq!(registry.all_pages().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -190,10 +266,10 @@ mod tests {
|
|||
let mut registry = PluginRegistry::new(client);
|
||||
let page = create_test_page("demo", "Demo Page");
|
||||
|
||||
registry.register_page("my-plugin".to_string(), page.clone());
|
||||
registry.register_page("my-plugin".to_string(), page.clone(), vec![]);
|
||||
|
||||
assert!(!registry.is_empty());
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
|
||||
let retrieved = registry.get_page("my-plugin", "demo");
|
||||
assert!(retrieved.is_some());
|
||||
|
|
@ -210,18 +286,6 @@ mod tests {
|
|||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_full_route() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
let page = create_test_page("demo", "Demo Page");
|
||||
|
||||
registry.register_page("my-plugin".to_string(), page.clone());
|
||||
|
||||
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
|
||||
assert_eq!(plugin_page.full_route(), "/plugins/my-plugin/demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_pages() {
|
||||
let client = ApiClient::default();
|
||||
|
|
@ -230,33 +294,18 @@ mod tests {
|
|||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("page1", "Page 1"),
|
||||
vec![],
|
||||
);
|
||||
registry.register_page(
|
||||
"plugin2".to_string(),
|
||||
create_test_page("page2", "Page 2"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let all = registry.all_pages();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("page1", "Page 1"),
|
||||
);
|
||||
|
||||
let routes = registry.routes();
|
||||
assert_eq!(routes.len(), 1);
|
||||
assert_eq!(routes[0].0, "plugin1");
|
||||
assert_eq!(routes[0].1, "page1");
|
||||
assert_eq!(routes[0].2, "/plugins/plugin1/page1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_widget_and_all_widgets() {
|
||||
let client = ApiClient::default();
|
||||
|
|
@ -277,31 +326,23 @@ mod tests {
|
|||
assert_eq!(widgets[0].1.id, "my-widget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_pages_builds_registry() {
|
||||
let client = ApiClient::default();
|
||||
let pages = vec![
|
||||
("plugin1".to_string(), create_test_page("page1", "Page 1")),
|
||||
("plugin2".to_string(), create_test_page("page2", "Page 2")),
|
||||
];
|
||||
|
||||
let registry = PluginRegistry::with_pages(client, pages);
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert!(registry.get_page("plugin1", "page1").is_some());
|
||||
assert!(registry.get_page("plugin2", "page2").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_page_overwrites_same_key() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
registry
|
||||
.register_page("plugin1".to_string(), create_test_page("p", "Original"));
|
||||
registry
|
||||
.register_page("plugin1".to_string(), create_test_page("p", "Updated"));
|
||||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("p", "Original"),
|
||||
vec![],
|
||||
);
|
||||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("p", "Updated"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
assert_eq!(
|
||||
registry.get_page("plugin1", "p").unwrap().page.title,
|
||||
"Updated"
|
||||
|
|
@ -312,16 +353,73 @@ mod tests {
|
|||
fn test_default_registry_is_empty() {
|
||||
let registry = PluginRegistry::default();
|
||||
assert!(registry.is_empty());
|
||||
assert_eq!(registry.len(), 0);
|
||||
assert_eq!(registry.all_pages().len(), 0);
|
||||
assert!(registry.last_refresh().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_len() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
assert_eq!(registry.len(), 0);
|
||||
registry.register_page("p".to_string(), create_test_page("a", "A"), vec![]);
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_full_route() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
registry.register_page(
|
||||
"my-plugin".to_string(),
|
||||
create_test_page("demo", "Demo Page"),
|
||||
vec![],
|
||||
);
|
||||
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
|
||||
// full_route() returns page.route directly; create_test_page sets it as
|
||||
// "/plugins/test/{id}"
|
||||
assert_eq!(plugin_page.full_route(), "/plugins/test/demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("page1", "Page 1"),
|
||||
vec![],
|
||||
);
|
||||
let routes = registry.routes();
|
||||
assert_eq!(routes.len(), 1);
|
||||
assert_eq!(routes[0].0, "plugin1");
|
||||
assert_eq!(routes[0].1, "page1");
|
||||
assert_eq!(routes[0].2, "/plugins/test/page1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_pages_builds_registry() {
|
||||
let client = ApiClient::default();
|
||||
let pages = vec![
|
||||
("plugin1".to_string(), create_test_page("page1", "Page 1")),
|
||||
("plugin2".to_string(), create_test_page("page2", "Page 2")),
|
||||
];
|
||||
// Build via register_page loop (equivalent to old with_pages)
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
for (plugin_id, page) in pages {
|
||||
registry.register_page(plugin_id, page, vec![]);
|
||||
}
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert!(registry.get_page("plugin1", "page1").is_some());
|
||||
assert!(registry.get_page("plugin2", "page2").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_pages_returns_references() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
registry.register_page("p1".to_string(), create_test_page("a", "A"));
|
||||
registry.register_page("p2".to_string(), create_test_page("b", "B"));
|
||||
registry.register_page("p1".to_string(), create_test_page("a", "A"), vec![]);
|
||||
registry.register_page("p2".to_string(), create_test_page("b", "B"), vec![]);
|
||||
|
||||
let pages = registry.all_pages();
|
||||
assert_eq!(pages.len(), 2);
|
||||
|
|
@ -332,27 +430,145 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_plugins_same_page_id_both_stored() {
|
||||
fn test_different_plugins_same_page_id_second_rejected() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
// First plugin registers "stats" - should succeed.
|
||||
registry.register_page(
|
||||
"plugin-a".to_string(),
|
||||
create_test_page("stats", "A Stats"),
|
||||
vec![],
|
||||
);
|
||||
// Second plugin attempts to register the same page ID "stats" - should be
|
||||
// rejected to avoid route collisions at /plugins/stats.
|
||||
registry.register_page(
|
||||
"plugin-b".to_string(),
|
||||
create_test_page("stats", "B Stats"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
// Only one page should be registered; the second was rejected.
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
assert_eq!(
|
||||
registry.get_page("plugin-a", "stats").unwrap().page.title,
|
||||
"A Stats"
|
||||
);
|
||||
assert!(
|
||||
registry.get_page("plugin-b", "stats").is_none(),
|
||||
"plugin-b's page with duplicate ID should have been rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_plugin_same_page_id_overwrites() {
|
||||
// Same plugin re-registering the same page ID should still be allowed
|
||||
// (overwrite semantics, not a cross-plugin conflict).
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
registry.register_page(
|
||||
"plugin-a".to_string(),
|
||||
create_test_page("home", "A Home"),
|
||||
create_test_page("stats", "A Stats v1"),
|
||||
vec![],
|
||||
);
|
||||
registry.register_page(
|
||||
"plugin-b".to_string(),
|
||||
create_test_page("home", "B Home"),
|
||||
"plugin-a".to_string(),
|
||||
create_test_page("stats", "A Stats v2"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
assert_eq!(
|
||||
registry.get_page("plugin-a", "home").unwrap().page.title,
|
||||
"A Home"
|
||||
);
|
||||
assert_eq!(
|
||||
registry.get_page("plugin-b", "home").unwrap().page.title,
|
||||
"B Home"
|
||||
registry.get_page("plugin-a", "stats").unwrap().page.title,
|
||||
"A Stats v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_invalid_page_is_skipped() {
|
||||
use pinakes_plugin_api::UiElement;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
// A page with an empty ID fails validation
|
||||
let invalid_page = UiPage {
|
||||
id: String::new(), // invalid: empty
|
||||
title: "Bad Page".to_string(),
|
||||
route: "/plugins/bad".to_string(),
|
||||
icon: None,
|
||||
root_element: UiElement::Container {
|
||||
children: vec![],
|
||||
gap: 16,
|
||||
padding: None,
|
||||
},
|
||||
data_sources: HashMap::new(),
|
||||
actions: HashMap::new(),
|
||||
};
|
||||
|
||||
registry.register_page("test-plugin".to_string(), invalid_page, vec![]);
|
||||
assert!(registry.is_empty(), "invalid page should have been skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_valid_page_after_invalid() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
use pinakes_plugin_api::UiElement;
|
||||
|
||||
// Invalid page
|
||||
let invalid_page = UiPage {
|
||||
id: String::new(),
|
||||
title: "Bad".to_string(),
|
||||
route: "/bad".to_string(),
|
||||
icon: None,
|
||||
root_element: UiElement::Container {
|
||||
children: vec![],
|
||||
gap: 0,
|
||||
padding: None,
|
||||
},
|
||||
data_sources: HashMap::new(),
|
||||
actions: HashMap::new(),
|
||||
};
|
||||
registry.register_page("p".to_string(), invalid_page, vec![]);
|
||||
assert_eq!(registry.all_pages().len(), 0);
|
||||
|
||||
// Valid page; should still register fine
|
||||
registry.register_page("p".to_string(), create_test_page("good", "Good"), vec![]);
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_invalid_widget_is_skipped() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
let widget: pinakes_plugin_api::UiWidget =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "my-widget",
|
||||
"target": "library_header",
|
||||
"content": { "type": "badge", "text": "hi", "variant": "default" }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// Mutate: create an invalid widget with empty id
|
||||
let invalid_widget = pinakes_plugin_api::UiWidget {
|
||||
id: String::new(), // invalid
|
||||
target: "library_header".to_string(),
|
||||
content: widget.content.clone(),
|
||||
};
|
||||
|
||||
assert!(registry.all_widgets().is_empty());
|
||||
registry.register_widget("test-plugin".to_string(), invalid_widget);
|
||||
assert!(
|
||||
registry.all_widgets().is_empty(),
|
||||
"invalid widget should have been skipped"
|
||||
);
|
||||
|
||||
// Valid widget is still accepted
|
||||
registry.register_widget("test-plugin".to_string(), widget);
|
||||
assert_eq!(registry.all_widgets().len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue