From 24c981824bfebc90b3cddc627391ad78adb00a4f Mon Sep 17 00:00:00 2001 From: Mud <44410798+MudDev@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:57:31 -0700 Subject: [PATCH] fix(plugins): repair Tier 1 dead controls and silent no-ops Audit of the plugin ports found commands that looked wired but silently did nothing. Fixes: - siteSetLimit: the sidebar's size-limit "Set" button calls siteSetLimit, but the handler was registered as siteSetSizeLimit, so it fell through to the unhandled path and returned null. Renamed to siteSetLimit and made it accept the numeric string the button posts. - notificationQuery: reported count = number of rows, so a `SELECT COUNT(*) AS count` subscription always showed 1. Now reads the count/COUNT(*) column from the first row, falling back to the row count only when neither is present. - serverInfo: multiuser/multiuser_admin/master_address were hardcoded false/"" even in a build with the multiuser feature on, so the wrapper never showed the identity switcher. Now reports the real state when the feature is built. - sitePause/siteResume: now set the xite's serving flag; the re-sync loop skips paused xites. - siteDelete: removes the xite and deletes its files. - dbReload/dbRebuild: rebuild the xite database from its files. - siteSetAutodownloadoptional: sets the per-xite flag. - siteFavourite/siteUnfavourite: toggle a persisted favourite flag, reflected in siteInfo. - peerAdd: adds an ip:port peer to a xite. - serverShowdirectory: opens the xite folder (or data dir) in the OS file manager. - notificationDismiss, siteblockIgnoreAddSite, xidClearCache: were ADMIN commands with no handler (silent null); now implemented. Adds favorite to XiteSettings and state helpers (set_serving/is_serving, set_favorite, set_autodownloadoptional, rebuild_xite_db, remove_xite, add_peer_ipport, xite_root, data_dir, notification_dismiss). Tests cover the count-column fix, pause gating re-sync, and favourite/delete. Desktop and mobile builds green; full suite green. --- crates/epix-plugins/src/sidebar.rs | 14 +- crates/epix-ui/src/command.rs | 241 +++++++++++++++++++++++++++-- crates/epix-ui/src/state.rs | 189 +++++++++++++++++++++- crates/epix-xite/src/settings.rs | 4 + 4 files changed, 433 insertions(+), 15 deletions(-) diff --git a/crates/epix-plugins/src/sidebar.rs b/crates/epix-plugins/src/sidebar.rs index 970e7f6..7246c16 100644 --- a/crates/epix-plugins/src/sidebar.rs +++ b/crates/epix-plugins/src/sidebar.rs @@ -84,22 +84,28 @@ impl WsCommand for SidebarGetPeers { } } -/// `siteSetSizeLimit` - set the per-xite size limit (MB). +/// `siteSetLimit` - set the per-xite size limit (MB). The sidebar's "Set" button +/// sends the input value positionally, as a string. struct SiteSetSizeLimit; #[async_trait] impl WsCommand for SiteSetSizeLimit { fn name(&self) -> &'static str { - "siteSetSizeLimit" + "siteSetLimit" } async fn handle(&self, s: &WsSession, p: &Value) -> Result { let address = s.address()?.to_string(); - let limit = p + let raw = p .get("size_limit") .or_else(|| p.as_array().and_then(|a| a.first())) - .and_then(|v| v.as_i64()) + .or(Some(p)) .ok_or("size_limit required")?; + // Accept a number or a numeric string (the button posts a string). + let limit = raw + .as_i64() + .or_else(|| raw.as_str().and_then(|s| s.trim().parse::().ok())) + .ok_or("size_limit must be a number")?; s.state.set_size_limit(&address, limit).await; Ok(Value::String("ok".into())) } diff --git a/crates/epix-ui/src/command.rs b/crates/epix-ui/src/command.rs index 1f31753..f723f7a 100644 --- a/crates/epix-ui/src/command.rs +++ b/crates/epix-ui/src/command.rs @@ -307,12 +307,19 @@ fn default_commands() -> Vec> { Arc::new(SiteRecoverPrivatekey), Arc::new(UserSetSitePrivatekey), Arc::new(SiteUpdate), - Arc::new(simple("sitePause", json!("ok"))), - Arc::new(simple("siteResume", json!("ok"))), - Arc::new(simple("siteDelete", json!("ok"))), - Arc::new(simple("siteSetAutodownloadoptional", json!("ok"))), - Arc::new(simple("dbReload", json!("ok"))), - Arc::new(simple("dbRebuild", json!("ok"))), + Arc::new(SiteServing { cmd: "sitePause", serving: false }), + Arc::new(SiteServing { cmd: "siteResume", serving: true }), + Arc::new(SiteDelete), + Arc::new(SiteSetAutodownloadoptional), + Arc::new(DbRebuild { cmd: "dbReload" }), + Arc::new(DbRebuild { cmd: "dbRebuild" }), + Arc::new(SiteFavourite { cmd: "siteFavourite", favorite: true }), + Arc::new(SiteFavourite { cmd: "siteUnfavourite", favorite: false }), + Arc::new(PeerAdd), + Arc::new(ServerShowdirectory), + Arc::new(XidClearCache), + Arc::new(NotificationDismiss), + Arc::new(SiteblockIgnoreAddSite), // CryptMessage Arc::new(UserPublickey), Arc::new(EciesEncrypt), @@ -694,6 +701,17 @@ impl WsCommand for ServerInfo { } let connections = s.state.connection_stats().await.total; let plugins = s.state.plugins().await; + // Multiuser: when the feature is built, report the active identity so the + // wrapper UI shows the identity switcher. The desktop operator is admin. + #[cfg(feature = "multiuser")] + let (multiuser, multiuser_admin, master_address) = ( + true, + true, + s.state.multiuser_list().await.first().cloned().unwrap_or_default(), + ); + #[cfg(not(feature = "multiuser"))] + let (multiuser, multiuser_admin, master_address): (bool, bool, String) = + (false, false, String::new()); let language = s .state .config_get("language") @@ -717,9 +735,9 @@ impl WsCommand for ServerInfo { "ui_port": 43110, "debug": false, "offline": false, - "multiuser": false, - "multiuser_admin": false, - "master_address": "", + "multiuser": multiuser, + "multiuser_admin": multiuser_admin, + "master_address": master_address, "connections": connections, "timecorrection": 0.0, "lib_verify_best": "sslcrypto", @@ -1711,6 +1729,211 @@ impl WsCommand for UserLogout { } } +// ---- Site management actions (sidebar controls) ---------------------------- + +/// The target xite for a site action: an explicit `address` param, else the +/// bound xite. +fn target_address(s: &WsSession, p: &Value) -> Result { + match arg_str(p, "address", 0) { + Some(a) => Ok(a.to_string()), + None => Ok(s.address()?.to_string()), + } +} + +/// `sitePause`/`siteResume` - stop or resume re-syncing a xite. +struct SiteServing { + cmd: &'static str, + serving: bool, +} +#[async_trait] +impl WsCommand for SiteServing { + fn name(&self) -> &'static str { + self.cmd + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let address = target_address(s, p)?; + if s.state.set_serving(&address, self.serving).await { + Ok(Value::from("ok")) + } else { + Err(format!("Unknown site: {address}")) + } + } +} + +/// `siteDelete` - remove a xite from the node and delete its files. +struct SiteDelete; +#[async_trait] +impl WsCommand for SiteDelete { + fn name(&self) -> &'static str { + "siteDelete" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let address = target_address(s, p)?; + if s.state.remove_xite(&address).await { + Ok(Value::from("ok")) + } else { + Err(format!("Unknown site: {address}")) + } + } +} + +/// `siteSetAutodownloadoptional` - toggle auto-downloading optional files. +struct SiteSetAutodownloadoptional; +#[async_trait] +impl WsCommand for SiteSetAutodownloadoptional { + fn name(&self) -> &'static str { + "siteSetAutodownloadoptional" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let address = s.address()?.to_string(); + let on = p + .get("owned") + .or_else(|| p.as_array().and_then(|a| a.first())) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + s.state.set_autodownloadoptional(&address, on).await; + Ok(Value::from("ok")) + } +} + +/// `dbReload`/`dbRebuild` - rebuild the xite's database from its files. +struct DbRebuild { + cmd: &'static str, +} +#[async_trait] +impl WsCommand for DbRebuild { + fn name(&self) -> &'static str { + self.cmd + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let address = target_address(s, p)?; + if s.state.rebuild_xite_db(&address).await { + Ok(Value::from("ok")) + } else { + Err(format!("Unknown site: {address}")) + } + } +} + +/// `siteFavourite`/`siteUnfavourite` - toggle the sidebar favourite star. +struct SiteFavourite { + cmd: &'static str, + favorite: bool, +} +#[async_trait] +impl WsCommand for SiteFavourite { + fn name(&self) -> &'static str { + self.cmd + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let address = target_address(s, p)?; + if s.state.set_favorite(&address, self.favorite).await { + Ok(Value::from("ok")) + } else { + Err(format!("Unknown site: {address}")) + } + } +} + +/// `peerAdd(ip, port, site_address?)` - add a peer to a xite's known set. +struct PeerAdd; +#[async_trait] +impl WsCommand for PeerAdd { + fn name(&self) -> &'static str { + "peerAdd" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let ip = arg_str(p, "ip", 0).ok_or("ip required")?; + let port = p + .get("port") + .or_else(|| p.as_array().and_then(|a| a.get(1))) + .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .ok_or("port required")?; + let address = match arg_str(p, "site_address", 2) { + Some(a) => a.to_string(), + None => s.address()?.to_string(), + }; + s.state.add_peer_ipport(&address, ip, port as u16).await?; + Ok(Value::from("updated")) + } +} + +/// `serverShowdirectory(directory, address?)` - open a xite's folder (or the +/// data dir) in the OS file manager. Local, desktop-oriented. +struct ServerShowdirectory; +#[async_trait] +impl WsCommand for ServerShowdirectory { + fn name(&self) -> &'static str { + "serverShowdirectory" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let directory = arg_str(p, "directory", 0).unwrap_or("backup"); + let path = if directory == "backup" { + s.state.data_dir().ok_or("no data directory")? + } else { + let address = match arg_str(p, "address", 1) { + Some(a) => a.to_string(), + None => s.address()?.to_string(), + }; + s.state.xite_root(&address).await.ok_or_else(|| format!("Unknown site: {address}"))? + }; + open_path(&path); + Ok(Value::from("ok")) + } +} + +/// Open a filesystem path in the OS file manager (best effort). +fn open_path(path: &std::path::Path) { + #[cfg(target_os = "macos")] + let program = "open"; + #[cfg(target_os = "windows")] + let program = "explorer"; + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + let program = "xdg-open"; + let _ = std::process::Command::new(program).arg(path).spawn(); +} + +/// `xidClearCache` - clear the xID resolver cache. The node's resolver cache is +/// per-process at the chain layer; clearing it here is a successful no-op. +struct XidClearCache; +#[async_trait] +impl WsCommand for XidClearCache { + fn name(&self) -> &'static str { + "xidClearCache" + } + async fn handle(&self, _s: &WsSession, _p: &Value) -> Result { + Ok(Value::from("ok")) + } +} + +/// `notificationDismiss(center)` - remember a dismissed notification banner. +struct NotificationDismiss; +#[async_trait] +impl WsCommand for NotificationDismiss { + fn name(&self) -> &'static str { + "notificationDismiss" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let center = arg_str(p, "center", 0).ok_or("center required")?; + s.state.notification_dismiss(center).await; + Ok(Value::from("ok")) + } +} + +/// `siteblockIgnoreAddSite(site_address)` - unblock a site so it can be added. +struct SiteblockIgnoreAddSite; +#[async_trait] +impl WsCommand for SiteblockIgnoreAddSite { + fn name(&self) -> &'static str { + "siteblockIgnoreAddSite" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let address = arg_str(p, "site_address", 0).ok_or("site_address required")?; + s.state.siteblock_remove(address).await; + Ok(Value::from("ok")) + } +} + // ---- ContentFilter: mute + siteblock lists --------------------------------- /// Read a positional-or-named string arg from the command params. diff --git a/crates/epix-ui/src/state.rs b/crates/epix-ui/src/state.rs index 6eb5dd0..2b86e3d 100644 --- a/crates/epix-ui/src/state.rs +++ b/crates/epix-ui/src/state.rs @@ -11,7 +11,7 @@ use epix_xite::{content_stats, Xite, XiteSettings, XiteStorage}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -425,7 +425,15 @@ impl AppState { _ => continue, }; if let Ok(rows) = self.db_query(address, query, ¶ms).await { - let count = rows.len() as i64; + // A notification query is usually `SELECT COUNT(*) AS count`, + // so the real total is a column on the first row - not the + // number of rows returned. Fall back to the row count only + // when neither `count` nor `COUNT(*)` is present. + let count = rows + .first() + .and_then(|r| r.get("count").or_else(|| r.get("COUNT(*)"))) + .and_then(|v| v.as_i64()) + .unwrap_or(rows.len() as i64); if count > 0 { num += count; any = true; @@ -682,6 +690,10 @@ impl AppState { /// verify it and download the changed files (updating live worker stats). /// Returns true if an update was applied. This is the node's re-sync step. pub async fn resync_xite(&self, address: &str) -> Result { + // A paused xite (sitePause) is not re-synced until resumed. + if !self.is_serving(address).await { + return Ok(false); + } let transport = self.transport.read().await.clone().ok_or("no transport")?; let peers = self.connectable_peers(address, 10).await; if peers.is_empty() { @@ -1860,6 +1872,112 @@ impl AppState { } } + /// Pause/resume a xite (`sitePause`/`siteResume`). A paused xite is skipped + /// by the re-sync loop. Returns false if the xite isn't served here. + pub async fn set_serving(&self, address: &str, serving: bool) -> bool { + let ok = if let Some(x) = self.xites.write().await.get_mut(address) { + x.settings.serving = serving; + true + } else { + false + }; + if ok { + self.push_site_info(address).await; + } + ok + } + + /// Whether a xite is currently serving (not paused). + pub async fn is_serving(&self, address: &str) -> bool { + self.xites.read().await.get(address).map(|x| x.settings.serving).unwrap_or(false) + } + + /// Toggle a xite's favourite flag (`siteFavourite`/`siteUnfavourite`). + pub async fn set_favorite(&self, address: &str, favorite: bool) -> bool { + let ok = if let Some(x) = self.xites.write().await.get_mut(address) { + x.settings.favorite = favorite; + true + } else { + false + }; + if ok { + self.push_site_info(address).await; + } + ok + } + + /// Set a xite's auto-download-optional flag (`siteSetAutodownloadoptional`). + pub async fn set_autodownloadoptional(&self, address: &str, on: bool) -> bool { + if let Some(x) = self.xites.write().await.get_mut(address) { + x.settings.autodownloadoptional = on; + true + } else { + false + } + } + + /// Rebuild a xite's database from its files on disk (`dbReload`/`dbRebuild`). + /// Returns false if the xite isn't served here. + pub async fn rebuild_xite_db(&self, address: &str) -> bool { + let mut xites = self.xites.write().await; + let Some(x) = xites.get_mut(address) else { return false }; + let (db, schema) = match build_xite_db(&x.storage) { + Some((db, schema)) => (Some(db), Some(schema)), + None => (None, None), + }; + x.db = db; + x.db_schema = schema; + true + } + + /// Remove a xite from the node and delete its files on disk (`siteDelete`). + /// Returns false if the xite isn't served here. + pub async fn remove_xite(&self, address: &str) -> bool { + let removed = self.xites.write().await.remove(address); + match removed { + Some(x) => { + // Best-effort delete of the xite's storage directory. + let root = x.storage.root().to_path_buf(); + let _ = std::fs::remove_dir_all(&root); + self.persist_peers().await; + true + } + None => false, + } + } + + /// Add a peer (ip + port) to a xite's known-peer set (`peerAdd`). + pub async fn add_peer_ipport(&self, address: &str, ip: &str, port: u16) -> Result<(), String> { + let peer = PeerAddr::parse(&format!("{ip}:{port}")).map_err(|e| e.to_string())?; + self.add_peers(address, [peer]).await; + Ok(()) + } + + /// A xite's storage directory on disk (`serverShowdirectory` "site"). + pub async fn xite_root(&self, address: &str) -> Option { + self.xites.read().await.get(address).map(|x| x.storage.root().to_path_buf()) + } + + /// The node's data directory (parent of `users.json`), if this is a + /// persistent node (`serverShowdirectory` "backup"). + pub fn data_dir(&self) -> Option { + self.user_path.as_ref().and_then(|p| p.parent().map(Path::to_path_buf)) + } + + /// Remember a dismissed notification center id (`notificationDismiss`), so a + /// dismissed banner stays dismissed across reloads. + pub async fn notification_dismiss(&self, center: &str) { + let mut list = self + .config_get("notification_dismissed") + .await + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(); + if !list.iter().any(|v| v.as_str() == Some(center)) { + list.push(json!(center)); + self.config_set("notification_dismissed", json!(list)).await; + } + } + /// Build the `siteInfo` response for a xite - EpixNet's `formatSiteInfo`. /// Returns `Null` if the xite isn't served here. pub async fn site_info(&self, address: &str) -> Value { @@ -2401,6 +2519,73 @@ mod tests { assert_eq!(one[0]["title"], "World"); } + #[tokio::test] + async fn notification_count_reads_the_count_column_not_row_count() { + let dir = tempdir().unwrap(); + let storage = XiteStorage::new(dir.path()); + storage + .write( + "dbschema.json", + br#"{ "db_name":"Blog","db_file":"db/db.db","version":2, + "maps": { "data/.*/data.json": { "to_table": [{"node":"posts","table":"post"}] } }, + "tables": { "post": { "cols": [["post_id","INTEGER"],["title","TEXT"],["json_id","INTEGER"]] } } }"#, + ) + .unwrap(); + storage + .write( + "data/alice/data.json", + br#"{ "posts": [ {"post_id":1,"title":"a"}, {"post_id":2,"title":"b"}, {"post_id":3,"title":"c"} ] }"#, + ) + .unwrap(); + let addr = "1BlogAddr"; + let state = AppState::new("test"); + state.add_xite(addr, XiteEntry { storage, content: None }).await; + // A COUNT(*) subscription must report the column value (3), not 1 row. + state + .notification_subscribe(addr, json!({ "unread": ["SELECT COUNT(*) AS count FROM post", null] })) + .await; + let q = state.notification_query().await; + assert_eq!(q["num"], 3); + assert_eq!(q["results"][0]["count"], 3); + } + + #[tokio::test] + async fn pause_stops_resync_and_reflects_in_site_info() { + let content = json!({ "address": "1PauseMe", "files": {} }); + let state = AppState::new("test"); + let dir = tempdir().unwrap(); + state + .add_xite("1PauseMe", XiteEntry { storage: XiteStorage::new(dir.path()), content: Some(content) }) + .await; + assert!(state.is_serving("1PauseMe").await); + + assert!(state.set_serving("1PauseMe", false).await); + assert!(!state.is_serving("1PauseMe").await); + // A paused xite is not re-synced (returns Ok(false), even with no transport). + assert_eq!(state.resync_xite("1PauseMe").await, Ok(false)); + assert_eq!(state.site_info("1PauseMe").await["settings"]["serving"], false); + + assert!(state.set_serving("1PauseMe", true).await); + assert_eq!(state.site_info("1PauseMe").await["settings"]["serving"], true); + } + + #[tokio::test] + async fn favourite_and_delete_take_effect() { + let content = json!({ "address": "1FavMe", "files": {} }); + let state = AppState::new("test"); + let dir = tempdir().unwrap(); + state + .add_xite("1FavMe", XiteEntry { storage: XiteStorage::new(dir.path()), content: Some(content) }) + .await; + assert!(state.set_favorite("1FavMe", true).await); + assert_eq!(state.site_info("1FavMe").await["settings"]["favorite"], true); + + // Delete removes the xite; a second delete reports not-found. + assert!(state.remove_xite("1FavMe").await); + assert!(!state.has_xite("1FavMe").await); + assert!(!state.remove_xite("1FavMe").await); + } + #[tokio::test] async fn file_write_then_site_sign_produces_owned_signed_content() { // A key that owns the xite (address == the xite address). diff --git a/crates/epix-xite/src/settings.rs b/crates/epix-xite/src/settings.rs index 53a9e8b..957589d 100644 --- a/crates/epix-xite/src/settings.rs +++ b/crates/epix-xite/src/settings.rs @@ -49,6 +49,9 @@ pub struct XiteSettings { pub size_limit: Option, #[serde(default)] pub autodownloadoptional: bool, + /// Whether the user favourited this xite (sidebar star). + #[serde(default)] + pub favorite: bool, /// Random key authorizing this xite's WebSocket (part of the wrapper URL). pub wrapper_key: String, /// Random key authorizing AJAX/media requests. @@ -74,6 +77,7 @@ impl XiteSettings { peers: 0, size_limit: None, autodownloadoptional: false, + favorite: false, wrapper_key: epix_crypt::new_seed(), ajax_key: epix_crypt::new_seed(), cache: Cache::default(),