v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts propagate only through the CDN (pull + header-diff neighbor propagation). Encrypted posts propagate only through pull with merged author-or-recipient match. There is no remaining sender→recipient traffic correlation signal on the wire for content. Protocol (network-breaking): - Retire MessageType 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are deleted along with the handlers and senders. - SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender functions targeting audience are deleted; the existing handlers stay (both already dead code on the send side). Core removals: - `push_to_audience`, `notify_post`, `push_delete`, `push_disconnect_to_audience`, `push_address_update_to_audience`, `send_audience_request`, `send_audience_response`, `send_to_audience` — all gone from network.rs. - `handle_post_notification` removed from connection.rs. - `request_audience`, `approve_audience`, `deny_audience`, `remove_audience`, `list_audience_members`, `list_audience` removed from Node. - `audience_pushed` step removed from post creation. - `AudienceDirection`, `AudienceStatus`, `AudienceRecord`, `AudienceApprovalMode` removed from types. - Storage: `store_audience`, `list_audience`, `list_audience_members`, `remove_audience`, `row_to_audience_record`, `audience_crud` test, the `audience` CREATE TABLE, and the audience-dependent social route rebuild branch all removed. Upgraded DBs retain the orphan `audience` table; nothing touches it. Follow-on cleanups: - `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`. The Display/FromStr impl accepts legacy "audience"/"mutual" strings from pre-v0.6.2 DBs and maps them to Follow. - Blob-eviction priority function drops the audience factor; relationship is now own-author vs followed vs other. Tests updated accordingly. - `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the author's public follows (`list_public_follows`) rather than a separate audience table. `ModerationMode::AudienceOnly` similarly renamed. - Follow/unfollow routines simplified: no audience downgrade logic; unfollow removes the social route entirely. UI: - CLI: `audience*` commands removed. - Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`, `request_audience`, `approve_audience`, `remove_audience` commands removed from invoke_handler. Frontend: audience panel and audience/mutual badges removed; compose permission dropdown shows "Followers" instead of "Audience"; `loadAudience` is a no-op stub that hides any leftover DOM. Tests: 111 / 111 core tests pass. Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete propagation, visibility updates, direct post push, post notifications, or audience requests. Upgrade both ends.
909 lines
39 KiB
Rust
909 lines
39 KiB
Rust
use std::io::{self, BufRead, Write};
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use itsgoin_core::node::Node;
|
|
use itsgoin_core::types::DeviceProfile;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "info,iroh=warn,swarm_discovery=warn".parse().unwrap()),
|
|
)
|
|
.init();
|
|
|
|
let args: Vec<String> = std::env::args().collect();
|
|
|
|
// Parse flags before anything else
|
|
let mut data_dir = String::from("./itsgoin-data");
|
|
let mut import_key: Option<String> = None;
|
|
let mut bind_addr: Option<SocketAddr> = None;
|
|
let mut daemon = false;
|
|
let mut mobile = false;
|
|
let mut web_port: Option<u16> = None;
|
|
let mut i = 1;
|
|
while i < args.len() {
|
|
match args[i].as_str() {
|
|
"--import-key" => {
|
|
if i + 1 >= args.len() {
|
|
eprintln!("Error: --import-key requires a 64-char hex key argument");
|
|
std::process::exit(1);
|
|
}
|
|
import_key = Some(args[i + 1].clone());
|
|
i += 2;
|
|
}
|
|
"--bind" => {
|
|
if i + 1 >= args.len() {
|
|
eprintln!("Error: --bind requires an address argument (e.g. 0.0.0.0:4433)");
|
|
std::process::exit(1);
|
|
}
|
|
bind_addr = Some(args[i + 1].parse().unwrap_or_else(|e| {
|
|
eprintln!("Error: invalid bind address '{}': {}", args[i + 1], e);
|
|
std::process::exit(1);
|
|
}));
|
|
i += 2;
|
|
}
|
|
"--daemon" => {
|
|
daemon = true;
|
|
i += 1;
|
|
}
|
|
"--mobile" => {
|
|
mobile = true;
|
|
i += 1;
|
|
}
|
|
"--web" => {
|
|
if i + 1 >= args.len() {
|
|
eprintln!("Error: --web requires a port argument (e.g. --web 8080)");
|
|
std::process::exit(1);
|
|
}
|
|
web_port = Some(args[i + 1].parse().unwrap_or_else(|e| {
|
|
eprintln!("Error: invalid web port '{}': {}", args[i + 1], e);
|
|
std::process::exit(1);
|
|
}));
|
|
i += 2;
|
|
}
|
|
_ => {
|
|
if data_dir == "./itsgoin-data" {
|
|
data_dir = args[i].clone();
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle --import-key before opening the node
|
|
if let Some(hex_key) = import_key {
|
|
println!("Importing identity key into {}...", data_dir);
|
|
match Node::import_identity(Path::new(&data_dir), &hex_key) {
|
|
Ok(()) => println!("Identity key imported successfully."),
|
|
Err(e) => {
|
|
eprintln!("Error importing key: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
let profile = if mobile { DeviceProfile::Mobile } else { DeviceProfile::Desktop };
|
|
println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile);
|
|
let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?);
|
|
|
|
// Wait briefly for address resolution
|
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
|
|
let node_id_hex = hex::encode(node.node_id);
|
|
let addr = node.endpoint_addr();
|
|
let sockets: Vec<_> = addr.ip_addrs().collect();
|
|
|
|
// Show our display name if set
|
|
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
|
|
let name_display = my_name.as_deref().unwrap_or("(not set)");
|
|
|
|
println!();
|
|
println!("========================================");
|
|
println!(" ItsGoin node running");
|
|
println!(" Name: {}", name_display);
|
|
println!(" Node ID: {}", node_id_hex);
|
|
for sock in &sockets {
|
|
println!(" Listen: {}", sock);
|
|
}
|
|
println!("========================================");
|
|
// Print a connect string others can paste
|
|
if let Some(sock) = sockets.first() {
|
|
println!(" Share this to connect:");
|
|
println!(" connect {}@{}", node_id_hex, sock);
|
|
}
|
|
println!();
|
|
println!("Commands:");
|
|
println!(" post <text> Create a public post");
|
|
println!(" post-to <target> <text> Encrypted post (circle name, node_id, or 'friends')");
|
|
println!(" feed Show your feed");
|
|
println!(" posts Show all known posts");
|
|
println!(" follow <node_id> Follow a node");
|
|
println!(" unfollow <node_id> Unfollow a node");
|
|
println!(" follows List followed nodes");
|
|
println!(" connect <id>@<ip:port> Connect to a peer and sync");
|
|
println!(" connect <node_id> Connect via address resolution");
|
|
println!(" sync Sync with all known peers");
|
|
println!(" peers List known peers");
|
|
println!(" circles List circles");
|
|
println!(" create-circle <name> Create a circle");
|
|
println!(" delete-circle <name> Delete a circle");
|
|
println!(" add-to-circle <circle> <node_id> Add member to circle");
|
|
println!(" remove-from-circle <circle> <id> Remove member from circle");
|
|
println!(" delete <post_id_hex> Delete one of your posts");
|
|
println!(" revoke <id> <node_id> [mode] Revoke access (mode: sync|reencrypt)");
|
|
println!(" revoke-circle <circle> <nid> [m] Revoke circle access for a node");
|
|
println!(" redundancy Show replica counts for your posts");
|
|
println!(" worm <node_id> Worm lookup (find peer beyond 3-hop map)");
|
|
println!(" connections Show mesh connections");
|
|
println!(" social-routes Show social routing cache");
|
|
println!(" name <display_name> Set your display name");
|
|
println!(" stats Show node stats");
|
|
println!(" export-key Export identity key (KEEP SECRET)");
|
|
println!(" id Show this node's ID");
|
|
println!(" quit Shut down");
|
|
println!();
|
|
|
|
// Start background tasks (v2: mesh connections)
|
|
let _accept_handle = node.start_accept_loop();
|
|
let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle
|
|
let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff
|
|
let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance
|
|
let _growth_handle = node.start_growth_loop(); // reactive mesh growth
|
|
let _recovery_handle = node.start_recovery_loop(); // reactive anchor reconnect on mesh loss
|
|
let _checkin_handle = node.start_social_checkin_cycle(3600); // 1 hour social checkin
|
|
let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register
|
|
let _upnp_handle = node.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped)
|
|
let _upnp_tcp_handle = node.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal
|
|
let _http_handle = node.start_http_server(); // HTTP post delivery (if publicly reachable)
|
|
let _bootstrap_check = node.start_bootstrap_connectivity_check(); // 24h isolation check
|
|
let _replication_handle = node.start_replication_cycle(600); // 10 min active replication
|
|
|
|
// Start blob eviction cycle (every 5 min, default 1 GB cache limit)
|
|
let cache_max_bytes: u64 = {
|
|
let storage = node.storage.get().await;
|
|
storage.get_setting("cache_size_bytes")
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(1_073_741_824u64)
|
|
};
|
|
let _eviction_handle = Node::start_eviction_cycle(Arc::clone(&node), 300, cache_max_bytes);
|
|
let _web_handle = if let Some(wp) = web_port {
|
|
Some(node.start_web_handler(wp))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if daemon {
|
|
println!("Running as daemon. Press Ctrl+C to stop.");
|
|
tokio::signal::ctrl_c().await?;
|
|
println!("Shutting down...");
|
|
return Ok(());
|
|
}
|
|
|
|
// Interactive command loop
|
|
let stdin = io::stdin();
|
|
let reader = stdin.lock();
|
|
|
|
print!("> ");
|
|
io::stdout().flush()?;
|
|
|
|
for line in reader.lines() {
|
|
let line = line?;
|
|
let line = line.trim().to_string();
|
|
|
|
if line.is_empty() {
|
|
print!("> ");
|
|
io::stdout().flush()?;
|
|
continue;
|
|
}
|
|
|
|
let parts: Vec<&str> = line.splitn(2, ' ').collect();
|
|
let cmd = parts[0];
|
|
let arg = parts.get(1).map(|s| s.trim());
|
|
|
|
match cmd {
|
|
"post" => {
|
|
if let Some(text) = arg {
|
|
match node.create_post(text.to_string()).await {
|
|
Ok((id, _post)) => {
|
|
println!("Posted! ID: {}", hex::encode(id));
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
} else {
|
|
println!("Usage: post <text>");
|
|
}
|
|
}
|
|
|
|
"feed" => match node.get_feed().await {
|
|
Ok(posts) => {
|
|
if posts.is_empty() {
|
|
println!("(feed is empty - follow some nodes first)");
|
|
}
|
|
for (id, post, vis, decrypted) in posts {
|
|
print_post(&id, &post, &vis, decrypted.as_deref(), &node).await;
|
|
}
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
|
|
"posts" => match node.get_all_posts().await {
|
|
Ok(posts) => {
|
|
if posts.is_empty() {
|
|
println!("(no posts yet)");
|
|
}
|
|
for (id, post, vis, decrypted) in posts {
|
|
print_post(&id, &post, &vis, decrypted.as_deref(), &node).await;
|
|
}
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
|
|
"follow" => {
|
|
if let Some(id_hex) = arg {
|
|
match itsgoin_core::parse_node_id_hex(id_hex) {
|
|
Ok(nid) => {
|
|
node.follow(&nid).await?;
|
|
println!("Following {}", &id_hex[..16.min(id_hex.len())]);
|
|
}
|
|
Err(e) => println!("Invalid node ID: {}", e),
|
|
}
|
|
} else {
|
|
println!("Usage: follow <node_id_hex>");
|
|
}
|
|
}
|
|
|
|
"unfollow" => {
|
|
if let Some(id_hex) = arg {
|
|
match itsgoin_core::parse_node_id_hex(id_hex) {
|
|
Ok(nid) => {
|
|
node.unfollow(&nid).await?;
|
|
println!("Unfollowed {}", &id_hex[..16.min(id_hex.len())]);
|
|
}
|
|
Err(e) => println!("Invalid node ID: {}", e),
|
|
}
|
|
} else {
|
|
println!("Usage: unfollow <node_id_hex>");
|
|
}
|
|
}
|
|
|
|
"follows" => match node.list_follows().await {
|
|
Ok(follows) => {
|
|
if follows.is_empty() {
|
|
println!("(not following anyone)");
|
|
}
|
|
for nid in follows {
|
|
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
|
let label = if nid == node.node_id { " (you)" } else { "" };
|
|
if let Some(name) = name {
|
|
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
|
|
} else {
|
|
println!(" {}{}", hex::encode(nid), label);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
|
|
"connect" => {
|
|
if let Some(addr_str) = arg {
|
|
if addr_str.contains('@') {
|
|
// Full connect string: node_id@ip:port
|
|
match itsgoin_core::parse_connect_string(addr_str) {
|
|
Ok((nid, endpoint_addr)) => {
|
|
println!("Connecting and syncing...");
|
|
node.add_peer(nid).await?;
|
|
node.follow(&nid).await?;
|
|
match node.sync_with_addr(endpoint_addr).await {
|
|
Ok(()) => {
|
|
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
|
if let Some(name) = name {
|
|
println!("Sync complete with {}!", name);
|
|
} else {
|
|
println!("Sync complete!");
|
|
}
|
|
}
|
|
Err(e) => println!("Sync failed: {}", e),
|
|
}
|
|
}
|
|
Err(e) => println!("Invalid address: {}", e),
|
|
}
|
|
} else {
|
|
// Node ID only: use address resolution (N2/N3 + worm)
|
|
match itsgoin_core::parse_node_id_hex(addr_str) {
|
|
Ok(nid) => {
|
|
println!("Resolving address...");
|
|
node.add_peer(nid).await?;
|
|
node.follow(&nid).await?;
|
|
match node.sync_with(nid).await {
|
|
Ok(()) => {
|
|
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
|
if let Some(name) = name {
|
|
println!("Connected to {}!", name);
|
|
} else {
|
|
println!("Connected!");
|
|
}
|
|
}
|
|
Err(e) => println!("Address resolution failed: {}", e),
|
|
}
|
|
}
|
|
Err(e) => println!("Invalid node ID: {}", e),
|
|
}
|
|
}
|
|
} else {
|
|
println!("Usage: connect <node_id@ip:port> or connect <node_id>");
|
|
}
|
|
}
|
|
|
|
"sync" => {
|
|
println!("Syncing with all known peers...");
|
|
match node.sync_all().await {
|
|
Ok(()) => println!("Sync complete!"),
|
|
Err(e) => println!("Sync error: {}", e),
|
|
}
|
|
}
|
|
|
|
"post-to" => {
|
|
if let Some(rest) = arg {
|
|
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
|
if parts.len() < 2 {
|
|
println!("Usage: post-to <target> <text>");
|
|
println!(" target: 'friends', circle name, or node_id hex");
|
|
} else {
|
|
let target = parts[0];
|
|
let text = parts[1];
|
|
let intent = if target == "friends" {
|
|
itsgoin_core::types::VisibilityIntent::Friends
|
|
} else if let Ok(nid) = itsgoin_core::parse_node_id_hex(target) {
|
|
itsgoin_core::types::VisibilityIntent::Direct(vec![nid])
|
|
} else {
|
|
itsgoin_core::types::VisibilityIntent::Circle(target.to_string())
|
|
};
|
|
match node
|
|
.create_post_with_visibility(text.to_string(), intent, vec![])
|
|
.await
|
|
{
|
|
Ok((id, _post, _vis)) => {
|
|
println!("Encrypted post! ID: {}", hex::encode(id));
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
} else {
|
|
println!("Usage: post-to <target> <text>");
|
|
}
|
|
}
|
|
|
|
"circles" => match node.list_circles().await {
|
|
Ok(circles) => {
|
|
if circles.is_empty() {
|
|
println!("(no circles)");
|
|
}
|
|
for c in circles {
|
|
println!(
|
|
" {} ({} members, created {})",
|
|
c.name,
|
|
c.members.len(),
|
|
chrono_lite(c.created_at / 1000)
|
|
);
|
|
for m in &c.members {
|
|
let name = node.get_display_name(m).await.unwrap_or(None);
|
|
let label = name.unwrap_or_else(|| hex::encode(m)[..12].to_string());
|
|
println!(" - {}", label);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
|
|
"create-circle" => {
|
|
if let Some(name) = arg {
|
|
match node.create_circle(name.to_string()).await {
|
|
Ok(()) => println!("Circle '{}' created", name),
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
} else {
|
|
println!("Usage: create-circle <name>");
|
|
}
|
|
}
|
|
|
|
"delete-circle" => {
|
|
if let Some(name) = arg {
|
|
match node.delete_circle(name.to_string()).await {
|
|
Ok(()) => println!("Circle '{}' deleted", name),
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
} else {
|
|
println!("Usage: delete-circle <name>");
|
|
}
|
|
}
|
|
|
|
"add-to-circle" => {
|
|
if let Some(rest) = arg {
|
|
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
|
if parts.len() < 2 {
|
|
println!("Usage: add-to-circle <circle_name> <node_id_hex>");
|
|
} else {
|
|
match itsgoin_core::parse_node_id_hex(parts[1]) {
|
|
Ok(nid) => {
|
|
match node.add_to_circle(parts[0].to_string(), nid).await {
|
|
Ok(()) => println!("Added to circle '{}'", parts[0]),
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
Err(e) => println!("Invalid node ID: {}", e),
|
|
}
|
|
}
|
|
} else {
|
|
println!("Usage: add-to-circle <circle_name> <node_id_hex>");
|
|
}
|
|
}
|
|
|
|
"remove-from-circle" => {
|
|
if let Some(rest) = arg {
|
|
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
|
if parts.len() < 2 {
|
|
println!("Usage: remove-from-circle <circle_name> <node_id_hex>");
|
|
} else {
|
|
match itsgoin_core::parse_node_id_hex(parts[1]) {
|
|
Ok(nid) => {
|
|
match node.remove_from_circle(parts[0].to_string(), nid).await {
|
|
Ok(()) => println!("Removed from circle '{}'", parts[0]),
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
Err(e) => println!("Invalid node ID: {}", e),
|
|
}
|
|
}
|
|
} else {
|
|
println!("Usage: remove-from-circle <circle_name> <node_id_hex>");
|
|
}
|
|
}
|
|
|
|
"delete" => {
|
|
if let Some(id_hex) = arg {
|
|
match itsgoin_core::parse_node_id_hex(id_hex) {
|
|
Ok(post_id) => match node.delete_post(&post_id).await {
|
|
Ok(()) => println!("Post deleted: {}", &id_hex[..16.min(id_hex.len())]),
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
Err(e) => println!("Invalid post ID: {}", e),
|
|
}
|
|
} else {
|
|
println!("Usage: delete <post_id_hex>");
|
|
}
|
|
}
|
|
|
|
"revoke" => {
|
|
if let Some(rest) = arg {
|
|
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
|
|
if parts.len() < 2 {
|
|
println!("Usage: revoke <post_id_hex> <node_id_hex> [sync|reencrypt]");
|
|
} else {
|
|
let mode = match parts.get(2).unwrap_or(&"sync") {
|
|
&"reencrypt" => itsgoin_core::types::RevocationMode::ReEncrypt,
|
|
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
|
|
};
|
|
match (
|
|
itsgoin_core::parse_node_id_hex(parts[0]),
|
|
itsgoin_core::parse_node_id_hex(parts[1]),
|
|
) {
|
|
(Ok(post_id), Ok(node_id)) => {
|
|
match node.revoke_post_access(&post_id, &node_id, mode).await {
|
|
Ok(Some(new_id)) => {
|
|
println!("Re-encrypted. New post ID: {}", hex::encode(new_id));
|
|
}
|
|
Ok(None) => println!("Access revoked (sync mode)"),
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
(Err(e), _) => println!("Invalid post ID: {}", e),
|
|
(_, Err(e)) => println!("Invalid node ID: {}", e),
|
|
}
|
|
}
|
|
} else {
|
|
println!("Usage: revoke <post_id_hex> <node_id_hex> [sync|reencrypt]");
|
|
}
|
|
}
|
|
|
|
"revoke-circle" => {
|
|
if let Some(rest) = arg {
|
|
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
|
|
if parts.len() < 2 {
|
|
println!("Usage: revoke-circle <circle_name> <node_id_hex> [sync|reencrypt]");
|
|
} else {
|
|
let mode = match parts.get(2).unwrap_or(&"sync") {
|
|
&"reencrypt" => itsgoin_core::types::RevocationMode::ReEncrypt,
|
|
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
|
|
};
|
|
match itsgoin_core::parse_node_id_hex(parts[1]) {
|
|
Ok(node_id) => {
|
|
match node.revoke_circle_access(parts[0], &node_id, mode).await {
|
|
Ok(count) => println!("Revoked access on {} posts", count),
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
Err(e) => println!("Invalid node ID: {}", e),
|
|
}
|
|
}
|
|
} else {
|
|
println!("Usage: revoke-circle <circle_name> <node_id_hex> [sync|reencrypt]");
|
|
}
|
|
}
|
|
|
|
"redundancy" => {
|
|
match node.get_redundancy_summary().await {
|
|
Ok((total, zero, one, two_plus)) => {
|
|
println!("Redundancy for your {} authored posts:", total);
|
|
println!(" No replicas: {} posts", zero);
|
|
println!(" 1 replica: {} posts", one);
|
|
println!(" 2+ replicas: {} posts", two_plus);
|
|
if zero > 0 {
|
|
println!(" WARNING: {} posts have no peer replicas!", zero);
|
|
}
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
|
|
"peers" => match node.list_peer_records().await {
|
|
Ok(records) => {
|
|
if records.is_empty() {
|
|
println!("(no known peers)");
|
|
}
|
|
for rec in records {
|
|
let name = node.get_display_name(&rec.node_id).await.unwrap_or(None);
|
|
let id_short = &hex::encode(rec.node_id)[..12];
|
|
let label = if let Some(name) = name {
|
|
format!("{} ({})", name, id_short)
|
|
} else {
|
|
hex::encode(rec.node_id)
|
|
};
|
|
let anchor = if rec.is_anchor { " [anchor]" } else { "" };
|
|
let addrs = if rec.addresses.is_empty() {
|
|
String::from("(mDNS only)")
|
|
} else {
|
|
rec.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>().join(", ")
|
|
};
|
|
let intro = if let Some(ib) = rec.introduced_by {
|
|
let ib_name = node.get_display_name(&ib).await.unwrap_or(None);
|
|
format!(
|
|
" via {}",
|
|
ib_name.unwrap_or_else(|| hex::encode(ib)[..12].to_string())
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
println!(" {}{} [{}]{}", label, anchor, addrs, intro);
|
|
}
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
|
|
"name" => {
|
|
if let Some(display_name) = arg {
|
|
match node.set_profile(display_name.to_string(), String::new()).await {
|
|
Ok(profile) => {
|
|
println!("Display name set to: {}", profile.display_name);
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
} else {
|
|
// Show current name
|
|
match node.my_profile().await {
|
|
Ok(Some(profile)) => println!("Your name: {}", profile.display_name),
|
|
Ok(None) => println!("No display name set. Usage: name <display_name>"),
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
"stats" => match node.stats().await {
|
|
Ok(stats) => {
|
|
println!("Posts: {}", stats.post_count);
|
|
println!("Peers: {}", stats.peer_count);
|
|
println!("Following: {}", stats.follow_count);
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
|
|
"personas" => match node.list_posting_identities().await {
|
|
Ok(identities) => {
|
|
let default = {
|
|
let s = node.storage.get().await;
|
|
s.get_default_posting_id().ok().flatten()
|
|
};
|
|
if identities.is_empty() {
|
|
println!("(no posting identities)");
|
|
}
|
|
for id in identities {
|
|
let mark = if Some(id.node_id) == default { " *" } else { "" };
|
|
let name = if id.display_name.is_empty() { "(unnamed)" } else { &id.display_name };
|
|
println!(" {} {} {}{}", &hex::encode(id.node_id)[..12], name, id.created_at, mark);
|
|
}
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
|
|
"create-persona" => {
|
|
let name = arg.unwrap_or("").to_string();
|
|
match node.create_posting_identity(name).await {
|
|
Ok(id) => {
|
|
println!("Created posting identity: {}", hex::encode(id.node_id));
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
|
|
"delete-persona" => {
|
|
if let Some(id_hex) = arg {
|
|
match itsgoin_core::parse_node_id_hex(id_hex) {
|
|
Ok(nid) => match node.delete_posting_identity(&nid).await {
|
|
Ok(()) => println!("Deleted posting identity"),
|
|
Err(e) => println!("Error: {}", e),
|
|
},
|
|
Err(e) => println!("Invalid node id: {}", e),
|
|
}
|
|
} else {
|
|
println!("Usage: delete-persona <node_id>");
|
|
}
|
|
}
|
|
|
|
"post-as" => {
|
|
if let Some(rest) = arg {
|
|
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
|
if parts.len() < 2 {
|
|
println!("Usage: post-as <posting_id_hex> <text>");
|
|
} else {
|
|
match itsgoin_core::parse_node_id_hex(parts[0]) {
|
|
Ok(nid) => {
|
|
match node
|
|
.create_post_as(&nid, parts[1].to_string(),
|
|
itsgoin_core::types::VisibilityIntent::Public, vec![])
|
|
.await
|
|
{
|
|
Ok((id, _, _)) => println!("Posted as persona! ID: {}", hex::encode(id)),
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
Err(e) => println!("Invalid node id: {}", e),
|
|
}
|
|
}
|
|
} else {
|
|
println!("Usage: post-as <posting_id_hex> <text>");
|
|
}
|
|
}
|
|
|
|
"export-key" => {
|
|
match node.export_identity_hex() {
|
|
Ok(hex_key) => {
|
|
println!("WARNING: This is your SECRET identity key. Anyone with");
|
|
println!("this key can impersonate you. Keep it safe!");
|
|
println!();
|
|
println!("{}", hex_key);
|
|
println!();
|
|
println!("To import on another device, use:");
|
|
println!(" itsgoin <data_dir> --import-key {}", hex_key);
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
|
|
"id" => {
|
|
let addr = node.endpoint_addr();
|
|
let sockets: Vec<_> = addr.ip_addrs().collect();
|
|
println!("Node ID: {}", node_id_hex);
|
|
for sock in &sockets {
|
|
if let Some(first_sock) = sockets.first() {
|
|
println!("Connect: {}@{}", node_id_hex, first_sock);
|
|
break;
|
|
}
|
|
println!("Listen: {}", sock);
|
|
}
|
|
}
|
|
|
|
"worm" => {
|
|
if let Some(id_hex) = arg {
|
|
match itsgoin_core::parse_node_id_hex(id_hex) {
|
|
Ok(nid) => {
|
|
println!("Worm lookup for {}...", &id_hex[..16.min(id_hex.len())]);
|
|
let start = std::time::Instant::now();
|
|
match node.worm_lookup(&nid).await {
|
|
Ok(Some(wr)) => {
|
|
let elapsed = start.elapsed();
|
|
if wr.node_id == nid {
|
|
println!("Found! ({:.1}ms)", elapsed.as_secs_f64() * 1000.0);
|
|
} else {
|
|
println!("Found via recent peer {}! ({:.1}ms)",
|
|
&hex::encode(wr.node_id)[..12],
|
|
elapsed.as_secs_f64() * 1000.0);
|
|
}
|
|
if wr.addresses.is_empty() {
|
|
println!(" (no address resolved)");
|
|
} else {
|
|
for addr in &wr.addresses {
|
|
println!(" Address: {}", addr);
|
|
}
|
|
}
|
|
println!(" Reporter: {}", &hex::encode(wr.reporter)[..12]);
|
|
}
|
|
Ok(None) => {
|
|
let elapsed = start.elapsed();
|
|
println!("Not found ({:.1}ms)", elapsed.as_secs_f64() * 1000.0);
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
Err(e) => println!("Invalid node ID: {}", e),
|
|
}
|
|
} else {
|
|
println!("Usage: worm <node_id_hex>");
|
|
}
|
|
}
|
|
|
|
"connections" => {
|
|
let conns = node.list_connections().await;
|
|
if conns.is_empty() {
|
|
println!("(no mesh connections)");
|
|
} else {
|
|
println!("Mesh connections ({}):", conns.len());
|
|
for (nid, slot_kind, connected_at) in conns {
|
|
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
|
let id_short = &hex::encode(nid)[..12];
|
|
let label = name.map(|n| format!("{} ({})", n, id_short))
|
|
.unwrap_or_else(|| hex::encode(nid));
|
|
let duration_secs = {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64;
|
|
(now.saturating_sub(connected_at)) / 1000
|
|
};
|
|
println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs);
|
|
}
|
|
}
|
|
}
|
|
|
|
"social-routes" => {
|
|
match node.list_social_routes().await {
|
|
Ok(routes) => {
|
|
if routes.is_empty() {
|
|
println!("(no social routes)");
|
|
} else {
|
|
println!("Social routes ({}):", routes.len());
|
|
for r in routes {
|
|
let name = node.get_display_name(&r.node_id).await.unwrap_or(None);
|
|
let id_short = &hex::encode(r.node_id)[..12];
|
|
let label = name.map(|n| format!("{} ({})", n, id_short))
|
|
.unwrap_or_else(|| hex::encode(r.node_id));
|
|
let addrs = if r.addresses.is_empty() {
|
|
String::from("(no addr)")
|
|
} else {
|
|
r.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>().join(", ")
|
|
};
|
|
println!(" {} [{:?}] {} [{}] via {:?} peers:{}",
|
|
label, r.relation, r.status, addrs,
|
|
r.reach_method, r.peer_addresses.len());
|
|
}
|
|
}
|
|
}
|
|
Err(e) => println!("Error: {}", e),
|
|
}
|
|
}
|
|
|
|
"quit" | "exit" | "q" => {
|
|
println!("Shutting down...");
|
|
break;
|
|
}
|
|
|
|
_ => {
|
|
println!("Unknown command: {}", cmd);
|
|
}
|
|
}
|
|
|
|
print!("> ");
|
|
io::stdout().flush()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn print_post(
|
|
id: &[u8; 32],
|
|
post: &itsgoin_core::types::Post,
|
|
vis: &itsgoin_core::types::PostVisibility,
|
|
decrypted: Option<&str>,
|
|
node: &Node,
|
|
) {
|
|
let author_hex = hex::encode(post.author);
|
|
let author_short = &author_hex[..12];
|
|
let is_me = &post.author == &node.node_id;
|
|
|
|
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
|
|
let author_label = match (author_name, is_me) {
|
|
(Some(name), true) => format!("{} (you)", name),
|
|
(Some(name), false) => name,
|
|
(None, true) => format!("{} (you)", author_short),
|
|
(None, false) => author_short.to_string(),
|
|
};
|
|
|
|
let vis_label = match vis {
|
|
itsgoin_core::types::PostVisibility::Public => String::new(),
|
|
itsgoin_core::types::PostVisibility::Encrypted { recipients } => {
|
|
format!(" [encrypted, {} recipients]", recipients.len())
|
|
}
|
|
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
|
|
format!(" [group-encrypted, epoch {}]", epoch)
|
|
}
|
|
};
|
|
|
|
let ts = post.timestamp_ms / 1000;
|
|
let datetime = chrono_lite(ts);
|
|
|
|
let display_content = match vis {
|
|
itsgoin_core::types::PostVisibility::Public => post.content.as_str().to_string(),
|
|
itsgoin_core::types::PostVisibility::Encrypted { .. }
|
|
| itsgoin_core::types::PostVisibility::GroupEncrypted { .. } => match decrypted {
|
|
Some(text) => text.to_string(),
|
|
None => "(encrypted)".to_string(),
|
|
},
|
|
};
|
|
|
|
println!("---");
|
|
println!(" {} | {}{}", author_label, datetime, vis_label);
|
|
println!(" {}", display_content);
|
|
println!(" id: {}", &hex::encode(id)[..16]);
|
|
}
|
|
|
|
/// Minimal timestamp formatting without pulling in chrono
|
|
fn chrono_lite(unix_secs: u64) -> String {
|
|
let secs_per_min = 60u64;
|
|
let secs_per_hour = 3600u64;
|
|
let secs_per_day = 86400u64;
|
|
|
|
let days_since_epoch = unix_secs / secs_per_day;
|
|
let time_of_day = unix_secs % secs_per_day;
|
|
|
|
let hours = time_of_day / secs_per_hour;
|
|
let minutes = (time_of_day % secs_per_hour) / secs_per_min;
|
|
|
|
let mut year = 1970u64;
|
|
let mut remaining_days = days_since_epoch;
|
|
loop {
|
|
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
|
if remaining_days < days_in_year {
|
|
break;
|
|
}
|
|
remaining_days -= days_in_year;
|
|
year += 1;
|
|
}
|
|
let month_days: Vec<u64> = if is_leap(year) {
|
|
vec![31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
} else {
|
|
vec![31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
};
|
|
let mut month = 1u64;
|
|
for &d in &month_days {
|
|
if remaining_days < d {
|
|
break;
|
|
}
|
|
remaining_days -= d;
|
|
month += 1;
|
|
}
|
|
let day = remaining_days + 1;
|
|
|
|
format!(
|
|
"{:04}-{:02}-{:02} {:02}:{:02} UTC",
|
|
year, month, day, hours, minutes
|
|
)
|
|
}
|
|
|
|
fn is_leap(year: u64) -> bool {
|
|
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
|
}
|