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:
raf 2026-03-11 17:00:37 +03:00
commit 9389af9fda
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
11 changed files with 1880 additions and 772 deletions

View file

@ -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);
}
}