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 1878 additions and 772 deletions
|
|
@ -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! {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue