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(),