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 1878 additions and 772 deletions

View file

@ -59,7 +59,12 @@ use crate::{
tags,
tasks,
},
plugin_ui::{PluginRegistry, PluginViewRenderer},
plugin_ui::{
PluginRegistry,
PluginViewRenderer,
WidgetContainer,
WidgetLocation,
},
styles,
};
@ -109,6 +114,41 @@ impl View {
}
}
/// Parse a route string from a plugin `navigate_to` action into an app
/// [`View`].
///
/// Supports all built-in routes (`/library`, `/search`, etc.) and plugin pages
/// (`/plugins/{plugin_id}/{page_id}`). Unknown routes fall back to Library with
/// a warning log.
fn parse_plugin_route(route: &str) -> View {
let parts: Vec<&str> = route.trim_start_matches('/').split('/').collect();
match parts.as_slice() {
[] | [""] | ["library"] => View::Library,
["search"] => View::Search,
["settings"] => View::Settings,
["tags"] => View::Tags,
["collections"] => View::Collections,
["books"] => View::Books,
["audit"] => View::Audit,
["import"] => View::Import,
["duplicates"] => View::Duplicates,
["statistics"] => View::Statistics,
["tasks"] => View::Tasks,
["database"] => View::Database,
["graph"] => View::Graph,
["plugins", plugin_id, page_id] => {
View::PluginView {
plugin_id: plugin_id.to_string(),
page_id: page_id.to_string(),
}
},
_ => {
tracing::warn!(route = %route, "Unknown navigation route from plugin action");
View::Library
},
}
}
#[component]
pub fn App() -> Element {
let base_url = std::env::var("PINAKES_SERVER_URL")
@ -193,6 +233,7 @@ pub fn App() -> Element {
let mut play_queue = use_signal(PlayQueue::default);
let mut plugin_registry = use_signal(PluginRegistry::default);
let all_widgets = use_memo(move || plugin_registry.read().all_widgets());
let mut current_theme = use_signal(|| "dark".to_string());
let mut system_prefers_dark = use_signal(|| true);
@ -319,15 +360,27 @@ pub fn App() -> Element {
use_effect(move || {
let c = client.read().clone();
spawn(async move {
match c.get_plugin_ui_pages().await {
Ok(pages) => {
let mut reg = PluginRegistry::default();
for (plugin_id, page) in pages {
reg.register_page(plugin_id, page);
}
let mut reg = PluginRegistry::new(c.clone());
match reg.refresh().await {
Ok(()) => {
let vars = reg.theme_vars().clone();
plugin_registry.set(reg);
if !vars.is_empty() {
spawn(async move {
let js: String = vars
.iter()
.map(|(k, v)| {
format!(
"document.documentElement.style.setProperty('{}','{}');",
k, v
)
})
.collect();
let _ = document::eval(&js).await;
});
}
},
Err(e) => tracing::debug!("Plugin pages unavailable: {e}"),
Err(e) => tracing::debug!("Plugin UI unavailable: {e}"),
}
});
});
@ -431,7 +484,19 @@ pub fn App() -> Element {
}
};
let view_title = use_memo(move || current_view.read().title());
let view_title = use_memo(move || {
let view = current_view.read();
match &*view {
View::PluginView { plugin_id, page_id } => {
plugin_registry
.read()
.get_page(plugin_id, page_id)
.map(|p| p.page.title.clone())
.unwrap_or_else(|| "Plugin".to_string())
},
v => v.title().to_string(),
}
});
let _total_pages = use_memo(move || {
let ps = *media_page_size.read();
let tc = *media_total_count.read();
@ -753,12 +818,17 @@ pub fn App() -> Element {
if !plugin_registry.read().is_empty() {
div { class: "nav-section",
div { class: "nav-label", "Plugins" }
for page in plugin_registry.read().all_pages() {
div { class: "nav-label",
"Plugins"
span { class: "nav-label-count", " ({plugin_registry.read().len()})" }
}
for (pid, pageid, route) in plugin_registry.read().routes() {
{
let pid = page.plugin_id.clone();
let pageid = page.page.id.clone();
let title = page.page.title.clone();
let title = plugin_registry
.read()
.get_page(&pid, &pageid)
.map(|p| p.page.title.clone())
.unwrap_or_default();
let is_active = *current_view.read()
== View::PluginView {
plugin_id: pid.clone(),
@ -767,6 +837,7 @@ pub fn App() -> Element {
rsx! {
button {
class: if is_active { "nav-item active" } else { "nav-item" },
title: "{route}",
onclick: move |_| {
current_view.set(View::PluginView {
plugin_id: pid.clone(),
@ -778,6 +849,17 @@ pub fn App() -> Element {
}
}
}
{
let sync_time_opt = plugin_registry
.read()
.last_refresh()
.map(|ts| ts.format("%H:%M").to_string());
rsx! {
if let Some(sync_time) = sync_time_opt {
div { class: "nav-sync-time", "Synced {sync_time}" }
}
}
}
}
}
@ -923,6 +1005,11 @@ pub fn App() -> Element {
// Reload full config
match *current_view.read() {
View::Library => rsx! {
WidgetContainer {
location: WidgetLocation::LibraryHeader,
widgets: all_widgets.read().clone(),
client,
}
div { class: "stats-grid",
div { class: "stat-card",
div { class: "stat-value", "{media_total_count}" }
@ -937,6 +1024,11 @@ pub fn App() -> Element {
div { class: "stat-label", "Collections" }
}
}
WidgetContainer {
location: WidgetLocation::LibrarySidebar,
widgets: all_widgets.read().clone(),
client,
}
library::Library {
media: media_list.read().clone(),
tags: tags_list.read().clone(),
@ -1133,6 +1225,11 @@ pub fn App() -> Element {
}
},
View::Search => rsx! {
WidgetContainer {
location: WidgetLocation::SearchFilters,
widgets: all_widgets.read().clone(),
client,
}
search::Search {
results: search_results.read().clone(),
total_count: *search_total.read(),
@ -1265,6 +1362,11 @@ pub fn App() -> Element {
let media_ref = selected_media.read();
match media_ref.as_ref() {
Some(media) => rsx! {
WidgetContainer {
location: WidgetLocation::DetailPanel,
widgets: all_widgets.read().clone(),
client,
}
detail::Detail {
media: media.clone(),
media_tags: media_tags.read().clone(),
@ -2558,6 +2660,11 @@ pub fn App() -> Element {
let cfg_ref = config_data.read();
match cfg_ref.as_ref() {
Some(cfg) => rsx! {
WidgetContainer {
location: WidgetLocation::SettingsSection,
widgets: all_widgets.read().clone(),
client,
}
settings::Settings {
config: cfg.clone(),
on_add_root: {
@ -2712,6 +2819,11 @@ pub fn App() -> Element {
plugin_id: pid,
page: plugin_page.page,
client,
allowed_endpoints: plugin_page.allowed_endpoints.clone(),
on_navigate: move |route: String| {
current_view
.set(parse_plugin_route(&route));
},
}
},
None => rsx! {