You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [00be3a29] invite system Side A — unified diff (full patch): diff --git a/bb.edn b/bb.edn index 50be8232847e672b3f273a2fb25ddd1d12adb7e2..818f850765370d12d58f239e287764fc3649d78b 100644 --- a/bb.edn +++ b/bb.edn @@ -47,14 +47,16 @@ "RUST_LOG" "info"})})))} test - {:doc "Full test suite: integration + auth + grants" + {:doc "Full test suite: integration + auth + grants + invites" :requires ([test.integration :as integration] [test.auth :as auth] - [test.grants :as grants]) + [test.grants :as grants] + [test.invites :as invites]) :task (do (integration/integration) (auth/auth-test) - (grants/grants-test))} + (grants/grants-test) + (invites/invites-test))} perf {:doc "Performance test: concurrent HTTP requests to detect blocking I/O" diff --git a/cli/src/main.rs b/cli/src/main.rs index e5833b0b93d8e667b94c574ba2b0f8cb758ff3df..8eda9f485bd1f7392f1e34be27176c21e20354eb 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -105,6 +105,23 @@ enum ScopedCmd { #[arg(long)] json: bool, }, + + /// Mint a shareable invite link (24h TTL, in-memory until redeemed). Requires Manage on the room. + InviteLink { + /// Comma-separated: view, post, vote, add_item, manage + #[arg(long = "caps", value_delimiter = ',')] + caps: Vec, + #[arg(long, default_value_t = 1)] + uses: usize, + #[arg(long)] + json: bool, + }, + + /// List principals granted access in this room (requires View or Manage) + Audit { + #[arg(long)] + json: bool, + }, } #[derive(Subcommand, Debug)] @@ -514,17 +531,35 @@ fn print_thread(resp: &ThreadDetailResponse) { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64; - if resp.total > resp.posts.len() { - let end = resp.offset + resp.posts.len(); - eprintln!("# showing {}-{} of {} posts (--offset N --limit N to paginate)", resp.offset, end.saturating_sub(1), resp.total); + if resp.total > resp.items.len() { + let end = resp.offset + resp.items.len(); + eprintln!( + "# showing {}-{} of {} rows (--offset N --limit N to paginate)", + resp.offset, + end.saturating_sub(1), + resp.total + ); } - for (i, post) in resp.posts.iter().enumerate() { - let timeago = slug_types::timeago::timeago_compact(now_ms, post.ts); - let body = &post.body.trim(); - println!("", post.index, timeago); - println!("{}", body); - println!(""); - if i + 1 < resp.posts.len() { + for (i, item) in resp.items.iter().enumerate() { + match item { + ThreadItem::Post { + index, + ts, + body, + .. + } => { + let timeago = slug_types::timeago::timeago_compact(now_ms, *ts); + let body = body.trim(); + println!("", index, timeago); + println!("{}", body); + println!(""); + } + ThreadItem::System { ts, text } => { + let timeago = slug_types::timeago::timeago_compact(now_ms, *ts); + println!("{}", timeago, text.trim()); + } + } + if i + 1 < resp.items.len() { println!(); println!(); } @@ -1036,6 +1071,95 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { } } }, + ScopedCmd::InviteLink { caps, uses, json } => { + let caps: Vec = caps + .into_iter() + .flat_map(|s| { + s.split(',') + .map(|p| p.trim().to_lowercase()) + .filter(|p| !p.is_empty()) + .collect::>() + }) + .collect(); + if caps.is_empty() { + return Err(anyhow!("--caps is required (e.g. --caps view,post,vote)")); + } + let bearer = effective_bearer().ok_or_else(|| { + anyhow!( + "no bearer token: run `slugsocial identity start --rig --model ` \ + then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token" + ) + })?; + let batch = send_rpc( + &client, + base, + Some(&bearer), + vec![RpcCommand::RoomMintInvite { + room: room.to_string(), + capabilities: caps, + max_uses: uses, + }], + ) + .await?; + match rpc_line_ok(&batch.results[0])? { + RpcResult::RoomInviteMinted { + invite_url, + expires_at_ms, + max_uses, + } => { + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "invite_url": invite_url, + "expires_at_ms": expires_at_ms, + "max_uses": max_uses, + }))? + ); + } else { + println!("{invite_url}"); + println!("(Expires in 24 hours. Max uses: {max_uses})"); + } + } + _ => return Err(anyhow!("unexpected RPC result")), + } + } + ScopedCmd::Audit { json } => { + let bearer = effective_bearer().ok_or_else(|| { + anyhow!( + "no bearer token: run `slugsocial identity start --rig --model ` \ + then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token" + ) + })?; + let batch = send_rpc( + &client, + base, + Some(&bearer), + vec![RpcCommand::RoomAudit { + room: room.to_string(), + }], + ) + .await?; + match rpc_line_ok(&batch.results[0])? { + RpcResult::RoomAudit(resp) => { + if json { + println!("{}", serde_json::to_string_pretty(&resp)?); + } else { + println!("room {}", resp.room); + if resp.grants.is_empty() { + println!("(no grants recorded)"); + } else { + let w_user = resp.grants.iter().map(|g| g.username.len()).max().unwrap_or(0); + for g in &resp.grants { + let caps = g.capabilities.join(", "); + println!("{: return Err(anyhow!("unexpected RPC result")), + } + } ScopedCmd::Check { file, json } => { let mut text = String::new(); match file { diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index 995ce4a61d29b024c399c656134f541ecfd880cf..b45ba39419c84af8bf2333fc9b7d47e98525c45f 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -12,12 +12,61 @@ use tokio::sync::RwLock; use crate::{ api::helpers::{api_error, now_ms, sha256_hex}, - events::{Event, TokenIssued, UserRegistered}, + events::{Event, GrantAdded, TokenIssued, UserRegistered}, identity::{parse_agent, parse_username}, html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page}, state::{AppState, PendingSession}, }; +/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent). +const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join"; + +async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> { + let now = now_ms(); + let ga = { + let mut invites = state.invites.write().await; + let Some(inv) = invites.get_mut(invite_token) else { + return Err("invite not found".into()); + }; + if now > inv.expires_at_ms { + invites.remove(invite_token); + return Err("invite expired".into()); + } + if inv.current_uses >= inv.max_uses { + return Err("invite exhausted".into()); + } + inv.current_uses += 1; + Event::GrantAdded(GrantAdded { + ts: now, + room_id: inv.room_id.clone(), + username: grantee_username.to_string(), + capabilities: inv.capabilities.clone(), + granted_by: inv.inviter.clone(), + }) + }; + + match state.event_log.append(&ga).await { + Ok(()) => { + let mut reduced = state.reduced.write().await; + reduced.apply_event(ga); + let mut invites = state.invites.write().await; + if let Some(inv) = invites.get(invite_token) { + if inv.current_uses >= inv.max_uses { + invites.remove(invite_token); + } + } + Ok(()) + } + Err(e) => { + let mut invites = state.invites.write().await; + if let Some(inv) = invites.get_mut(invite_token) { + inv.current_uses = inv.current_uses.saturating_sub(1); + } + Err(format!("{e}")) + } + } +} + fn pending_sessions(state: &AppState) -> Arc>> { state.pending_sessions.clone() } @@ -115,6 +164,42 @@ pub struct AuthLoginQuery { pub session: String, } +pub async fn get_join_invite(Path(token): Path, State(state): State) -> impl IntoResponse { + let token = token.trim().to_string(); + if token.is_empty() { + return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response(); + } + let now = now_ms(); + let valid = { + let invites = state.invites.read().await; + match invites.get(&token) { + None => false, + Some(inv) => now <= inv.expires_at_ms && inv.current_uses < inv.max_uses, + } + }; + if !valid { + return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response(); + } + + let session = format!("p_{}", uuid::Uuid::new_v4().simple()); + let s = PendingSession { + agent: INVITE_BROWSER_AGENT.to_string(), + created_ts: now_ms(), + provider: None, + provider_id: None, + redeem_invite: Some(token), + complete: None, + }; + state.pending_sessions.write().await.insert(session.clone(), s); + + let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + Redirect::temporary(&format!( + "{public_url}/auth/login?session={}", + urlencoding::encode(&session) + )) + .into_response() +} + pub async fn get_auth_login(Query(q): Query, State(state): State) -> impl IntoResponse { // Redirect to Google auth endpoint. let sessions = pending_sessions(&state); @@ -205,6 +290,7 @@ pub async fn get_auth_callback(Query(q): Query, State(state): s.provider = Some("google".to_string()); s.provider_id = Some(sub.clone()); if let Some(username) = existing { + let invite_tok = s.redeem_invite.clone(); let (bearer, token_event) = issue_token_for_user(&username); // append token event let ev = Event::TokenIssued(token_event); @@ -215,6 +301,11 @@ pub async fn get_auth_callback(Query(q): Query, State(state): let mut reduced = reduced_arc.write().await; reduced.apply_event(ev); } + if let Some(tok) = invite_tok { + if let Err(e) = apply_invite_redemption(&state, &tok, &username).await { + tracing::warn!(error = %e, "invite redemption skipped after oauth"); + } + } s.complete = Some((username, bearer)); return Redirect::temporary(&format!("{public_url}/auth/complete")).into_response(); } @@ -310,6 +401,16 @@ pub async fn post_choose_username( reduced.apply_event(ti_ev.clone()); } + // Redeem invite (if any) before marking the session complete. + if let Some(tok) = { + let sessions_read = sessions.read().await; + sessions_read.get(&form.session).and_then(|s| s.redeem_invite.clone()) + } { + if let Err(e) = apply_invite_redemption(&state, &tok, &canon_user).await { + tracing::warn!(error = %e, "invite redemption skipped after registration"); + } + } + // Mark complete for polling. { let mut sessions_write = sessions.write().await; @@ -339,6 +440,7 @@ pub async fn post_pending_session( created_ts: now_ms(), provider: None, provider_id: None, + redeem_invite: None, complete: None, }; let sessions = pending_sessions(&state); diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index a1ea432932cdca0e42dc7377f0a25075fd5644c0..cb031aecebad1107dfa2898a98fb6084b28ba6e9 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -4,6 +4,7 @@ mod rpc; mod validate; pub use auth::{ + get_join_invite, get_pending_session, get_whoami, post_pending_session, diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 11a76ed7a02aae588edadb7a87a43660f5669d9e..f6bbc3df71909a2da7403cd46fe4ea6ca130c692 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -13,12 +13,14 @@ use slug_types::*; use crate::{ canonical_path::{canonicalize_item, canonicalize_tag}, dsl, - events::{AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility}, + events::{ + AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility, + }, identity::{parse_agent, parse_username}, path_types::CanonicalItemUrl, ranking::{connected_components_from_voted_pairs, ranked_items_subset}, reducer::{scope_from_room_wire, ReducerState, ScopeId}, - state::AppState, + state::{AppState, InviteState}, }; use super::auth::verify_bearer_principal; @@ -142,6 +144,27 @@ fn gen_short_id() -> String { (0..7).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect() } +fn gen_invite_token() -> String { + use rand::Rng; + const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::thread_rng(); + let tail: String = (0..16).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect(); + format!("inv_{tail}") +} + +const INVITE_TTL_MS: i64 = 86_400_000; + +fn capability_wire(c: ThreadCapability) -> String { + match c { + ThreadCapability::View => "view", + ThreadCapability::Post => "post", + ThreadCapability::Vote => "vote", + ThreadCapability::AddItem => "add_item", + ThreadCapability::Manage => "manage", + } + .to_string() +} + fn build_rank_response_for_content( content: &crate::reducer::ContentState, parent: Option<&str>, @@ -512,7 +535,7 @@ fn rpc_forum_thread_detail( None => Err(("post not found".into(), None)), Some((idx, ing)) => Ok(ThreadDetailResponse { thread: format!("#{}", tag), - posts: vec![PostRow { + items: vec![ThreadItem::Post { id: ing.id.clone(), index: idx, ts: ing.ts, @@ -543,7 +566,7 @@ fn rpc_forum_thread_detail( let total = filtered.len(); const MAX_BODY: usize = 2000; - let posts: Vec = filtered + let items: Vec = filtered .into_iter() .skip(offset) .take(limit) @@ -553,7 +576,7 @@ fn rpc_forum_thread_detail( } else { (ing.raw.clone(), false) }; - PostRow { + ThreadItem::Post { id: ing.id.clone(), index: idx, ts: ing.ts, @@ -566,7 +589,7 @@ fn rpc_forum_thread_detail( Ok(ThreadDetailResponse { thread: format!("#{}", tag), - posts, + items, total, offset, }) @@ -1007,7 +1030,7 @@ pub async fn handle_rpc_batch( RpcCommand::RoomGrant { room, username, - capability, + capabilities, } => { let principal = { let reduced = state.reduced.read().await; @@ -1022,6 +1045,8 @@ pub async fn handle_rpc_batch( }; if !can_manage { line_err("requires Manage capability", None) + } else if capabilities.is_empty() { + line_err("capabilities must not be empty", None) } else { match parse_username(&username) { Err(msg) => line_err("invalid username", Some(msg)), @@ -1033,14 +1058,18 @@ pub async fn handle_rpc_batch( if !user_exists { line_err(format!("user @{target} not found"), None) } else { - match parse_capability(&capability) { + let caps: Result, String> = capabilities + .iter() + .map(|c| parse_capability(c.trim())) + .collect(); + match caps { Err(msg) => line_err(msg, None), - Ok(cap) => { + Ok(caps) => { let ga_ev = Event::GrantAdded(GrantAdded { ts: now_ms(), room_id: room, username: target, - capabilities: vec![cap], + capabilities: caps, granted_by: principal, }); if let Err(e) = state.event_log.append(&ga_ev).await { @@ -1059,6 +1088,114 @@ pub async fn handle_rpc_batch( } } } + RpcCommand::RoomMintInvite { + room, + capabilities, + max_uses, + } => { + let principal = { + let reduced = state.reduced.read().await; + verify_bearer_principal(&headers, &*reduced) + }; + match principal { + Err((_, m)) => line_err(m, None), + Ok(principal) => { + let can_manage = { + let reduced = state.reduced.read().await; + reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) + }; + if !can_manage { + line_err("requires Manage capability", None) + } else if capabilities.is_empty() { + line_err("capabilities must not be empty", None) + } else { + match capabilities + .iter() + .map(|c| parse_capability(c.trim())) + .collect::, String>>() + { + Err(msg) => line_err(msg, None), + Ok(caps) => { + let max_uses = max_uses.max(1).min(100_000); + let now = now_ms(); + let expires_at_ms = now + INVITE_TTL_MS; + let token = loop { + let t = gen_invite_token(); + let taken = { + let invites = state.invites.read().await; + invites.contains_key(&t) + }; + if !taken { + break t; + } + }; + let inv = InviteState { + room_id: room.clone(), + capabilities: caps, + expires_at_ms, + max_uses, + current_uses: 0, + inviter: principal, + }; + state.invites.write().await.insert(token.clone(), inv); + let public_url = std::env::var("SLUG_PUBLIC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let invite_url = format!("{public_url}/join/{token}"); + line_ok(RpcResult::RoomInviteMinted { + invite_url, + expires_at_ms: Some(expires_at_ms), + max_uses, + }) + } + } + } + } + } + } + RpcCommand::RoomAudit { room } => { + let principal = { + let reduced = state.reduced.read().await; + verify_bearer_principal(&headers, &*reduced) + }; + match principal { + Err((_, m)) => line_err(m, None), + Ok(principal) => { + let reduced = state.reduced.read().await; + if !reduced.rooms.contains_key(&room) { + line_err("unknown room", None) + } else { + let can_audit = reduced.user_has_cap(&room, &principal, ThreadCapability::View) + || reduced.user_has_cap(&room, &principal, ThreadCapability::Manage); + if !can_audit { + line_err("requires View or Manage capability", None) + } else { + let grants: Vec = reduced + .grants + .get(&room) + .map(|m| { + let mut v: Vec = m + .iter() + .map(|(username, caps)| { + let mut c: Vec = + caps.iter().copied().map(capability_wire).collect(); + c.sort(); + RoomAuditEntry { + username: username.clone(), + capabilities: c, + } + }) + .collect(); + v.sort_by(|a, b| a.username.cmp(&b.username)); + v + }) + .unwrap_or_default(); + line_ok(RpcResult::RoomAudit(RoomAuditResponse { room, grants })) + } + } + } + } + } + RpcCommand::RoomRevoke { .. } => line_err("RoomRevoke is not implemented yet", None), RpcCommand::GetGlobalRank { room, limit, diff --git a/server/src/events.rs b/server/src/events.rs index 125005d89ade7b22c9b6d997f0ed81781df5e7ec..9e60f2c218f5c871a93e855e415dd984e3a017c5 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -26,6 +26,8 @@ pub enum Event { RoomCreated(RoomCreated), GrantAdded(GrantAdded), GrantRevoked(GrantRevoked), + InviteMinted(InviteMinted), + InviteRedeemed(InviteRedeemed), /// Ingest of a DSL+prose body. Identity and routing live in event metadata. Ingest(Ingest), } @@ -82,6 +84,25 @@ pub struct GrantRevoked { pub revoked_by: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InviteMinted { + pub ts: i64, + pub token: String, + pub room_id: String, + pub capabilities: Vec, + pub inviter: String, + pub max_uses: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_ts_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InviteRedeemed { + pub ts: i64, + pub token: String, + pub username: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Ingest { /// Unix timestamp in milliseconds. diff --git a/server/src/lib.rs b/server/src/lib.rs index d00b56d909e1fabd1d122d054dd02785e13e0bd8..bf2f73d4d0f26a357961e88c52c3b4f72626af81 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -27,6 +27,7 @@ pub fn create_app(state: AppState) -> Router { Router::new() .route("/healthz", axum::routing::get(|| async { "ok" })) .route("/static/:filename", axum::routing::get(crate::html::serve_theme_css)) + .route("/join/:token", axum::routing::get(api::get_join_invite)) .route("/auth/login", axum::routing::get(api::get_auth_login)) .route("/auth/callback", axum::routing::get(api::get_auth_callback)) .route("/auth/complete", axum::routing::get(api::get_auth_complete)) diff --git a/server/src/reducer.rs b/server/src/reducer.rs index 118cb19f4df4a083df2878ca7a1424f3d63de851..5190d0a7ee3d545a786a68fef483322aafdce7f9 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use serde::{Deserialize, Serialize}; use crate::canonical_path::canonicalize_tag; -use crate::events::{Event, Ingest, ThreadCapability}; +use crate::events::{Event, Ingest, ThreadCapability, ThreadVisibility}; use crate::path_types::CanonicalItemUrl; #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] @@ -156,6 +156,41 @@ pub struct RoomState { pub visibility: crate::events::ThreadVisibility, } +/// Durable invite link state (from [`crate::events::InviteMinted`] / [`crate::events::InviteRedeemed`]). +#[derive(Debug, Clone)] +pub struct ActiveInviteState { + pub room_id: String, + pub capabilities: HashSet, + pub inviter: String, + pub uses_remaining: u32, + pub expires_ts_ms: Option, +} + +#[derive(Clone, Debug)] +pub enum RoomTimelineKind { + RoomCreated { + owner: String, + slug: String, + visibility: ThreadVisibility, + }, + GrantAdded { + username: String, + granted_by: String, + capabilities: Vec, + }, + GrantRevoked { + username: String, + revoked_by: String, + capabilities: Vec, + }, +} + +#[derive(Clone, Debug)] +pub struct RoomTimelineEntry { + pub ts: i64, + pub kind: RoomTimelineKind, +} + #[derive(Debug, Clone)] pub struct ForumThreadState { pub last_activity_ts: i64, @@ -210,6 +245,10 @@ pub struct ReducerState { pub ingests_ordered: Vec, /// room_id → username → capabilities pub grants: HashMap>>, + /// room_id → chronological room admin lines (for thread UI). + pub room_timeline: HashMap>, + /// Invite token → active invite (absent when fully consumed or never minted). + pub invites: HashMap, } impl ReducerState { @@ -225,6 +264,20 @@ impl ReducerState { .unwrap_or(false) } + /// Invite link is present, not expired, and has uses left. + pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> { + let inv = self.invites.get(token)?; + if inv.uses_remaining == 0 { + return None; + } + if let Some(exp) = inv.expires_ts_ms { + if now_ms > exp { + return None; + } + } + Some(inv) + } + pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState { self.content.entry(scope).or_default() } @@ -356,6 +409,17 @@ impl ReducerState { visibility: rc.visibility, }, ); + self.room_timeline + .entry(rc.room_id.clone()) + .or_default() + .push(RoomTimelineEntry { + ts: rc.ts, + kind: RoomTimelineKind::RoomCreated { + owner: rc.owner.clone(), + slug: rc.slug.clone(), + visibility: rc.visibility, + }, + }); } Event::Ingest(mut ing) => { ing.thread_tag = canonicalize_tag(&ing.thread_tag); @@ -525,18 +589,31 @@ impl ReducerState { nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts)); } Event::GrantAdded(ga) => { + let room_id = ga.room_id.clone(); let caps = self.grants .entry(ga.room_id) .or_default() - .entry(ga.username) + .entry(ga.username.clone()) .or_default(); - for cap in ga.capabilities { + for cap in ga.capabilities.iter().copied() { caps.insert(cap); } + self.room_timeline + .entry(room_id) + .or_default() + .push(RoomTimelineEntry { + ts: ga.ts, + kind: RoomTimelineKind::GrantAdded { + username: ga.username.clone(), + granted_by: ga.granted_by.clone(), + capabilities: ga.capabilities.clone(), + }, + }); } Event::GrantRevoked(gr) => { + let room_id = gr.room_id.clone(); if let Some(room_grants) = self.grants.get_mut(&gr.room_id) { - let username = gr.username; + let username = gr.username.clone(); if let Some(caps) = room_grants.get_mut(&username) { for cap in &gr.capabilities { caps.remove(cap); @@ -549,6 +626,37 @@ impl ReducerState { self.grants.remove(&gr.room_id); } } + self.room_timeline + .entry(room_id) + .or_default() + .push(RoomTimelineEntry { + ts: gr.ts, + kind: RoomTimelineKind::GrantRevoked { + username: gr.username.clone(), + revoked_by: gr.revoked_by.clone(), + capabilities: gr.capabilities.clone(), + }, + }); + } + Event::InviteMinted(im) => { + self.invites.insert( + im.token.clone(), + ActiveInviteState { + room_id: im.room_id.clone(), + capabilities: im.capabilities.iter().copied().collect(), + inviter: im.inviter.clone(), + uses_remaining: im.max_uses, + expires_ts_ms: im.expires_ts_ms, + }, + ); + } + Event::InviteRedeemed(ir) => { + if let Some(inv) = self.invites.get_mut(&ir.token) { + inv.uses_remaining = inv.uses_remaining.saturating_sub(1); + if inv.uses_remaining == 0 { + self.invites.remove(&ir.token); + } + } } } } @@ -570,6 +678,8 @@ impl Default for ReducerState { actor_last_post_ts: HashMap::new(), ingests_ordered: Vec::new(), grants: HashMap::new(), + room_timeline: HashMap::new(), + invites: HashMap::new(), } } } diff --git a/server/src/state.rs b/server/src/state.rs index b1ff2330903780cbe0cdf35964cb166eb2423d7d..628fd5921b34ba53ac0ca6b6fd33957ca09129b9 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -1,8 +1,20 @@ +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{broadcast, RwLock}; -use crate::{event_log::EventLog, reducer::ReducerState}; +use crate::{event_log::EventLog, events::ThreadCapability, reducer::ReducerState}; + +/// Ephemeral invite link (24h TTL, in-memory only; not written to the event log). +#[derive(Debug, Clone)] +pub struct InviteState { + pub room_id: String, + pub capabilities: Vec, + pub expires_at_ms: i64, + pub max_uses: usize, + pub current_uses: usize, + pub inviter: String, +} #[derive(Debug, Clone)] pub struct PendingSession { @@ -10,6 +22,8 @@ pub struct PendingSession { pub created_ts: i64, pub provider: Option, pub provider_id: Option, + /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`]. + pub redeem_invite: Option, pub complete: Option<(String /*username*/, String /*bearer*/ )>, } @@ -42,7 +56,9 @@ pub struct AppState { pub cfg: Arc, pub event_log: Arc, pub reduced: Arc>, - pub pending_sessions: Arc>>, + pub pending_sessions: Arc>>, + /// Ephemeral invite tokens (`inv_…`) until expiry or exhaustion. + pub invites: Arc>>, /// Broadcast channel for SSE live-streaming. Capacity = 64 events. pub stream_tx: broadcast::Sender, /// Broadcast channel for web SSE HTML fragments (poem pattern). Capacity = 64. @@ -58,7 +74,8 @@ impl AppState { cfg: Arc::new(cfg), event_log: Arc::new(event_log), reduced: Arc::new(RwLock::new(ReducerState::default())), - pending_sessions: Arc::new(RwLock::new(std::collections::HashMap::new())), + pending_sessions: Arc::new(RwLock::new(HashMap::new())), + invites: Arc::new(RwLock::new(HashMap::new())), stream_tx, html_tx, } diff --git a/server/src/timeline.rs b/server/src/timeline.rs new file mode 100644 index 0000000000000000000000000000000000000000..251158943ab36d3015268e35f6bdd01c3f42ab3b --- /dev/null +++ b/server/src/timeline.rs @@ -0,0 +1,153 @@ +//! Room admin lines merged into forum thread views. + +use crate::{ + canonical_path::canonicalize_tag, + reducer::{ReducerState, RoomTimelineEntry, RoomTimelineKind}, +}; + +fn cap_label(c: crate::events::ThreadCapability) -> &'static str { + use crate::events::ThreadCapability::*; + match c { + View => "view", + Post => "post", + Vote => "vote", + AddItem => "add_item", + Manage => "manage", + } +} + +fn caps_list(caps: &[crate::events::ThreadCapability]) -> String { + let mut v: Vec<_> = caps.iter().map(|c| cap_label(*c)).collect(); + v.sort(); + v.join(", ") +} + +/// Human-readable system line for the thread feed. +pub fn format_room_timeline_entry(e: &RoomTimelineEntry) -> String { + match &e.kind { + RoomTimelineKind::RoomCreated { + owner, + slug, + visibility, + } => { + let vis = match visibility { + crate::events::ThreadVisibility::Public => "public", + crate::events::ThreadVisibility::Private => "private", + }; + format!("@{owner} created room #{slug} ({vis})") + } + RoomTimelineKind::GrantAdded { + username, + granted_by, + capabilities, + } => { + format!( + "@{granted_by} granted @{} {}", + username, + caps_list(capabilities) + ) + } + RoomTimelineKind::GrantRevoked { + username, + revoked_by, + capabilities, + } => { + format!( + "@{revoked_by} revoked @{} {}", + username, + caps_list(capabilities) + ) + } + } +} + +#[derive(Clone, Debug)] +pub enum MergedThreadRow { + System { ts: i64, text: String }, + Post { + index: usize, + id: String, + ts: i64, + principal: String, + raw: String, + }, +} + +/// Merge room admin lines with thread ingests for one room + tag. Oldest first. +/// `actor_prefix` filters posts only (system lines always included). +pub fn merge_thread_rows( + reduced: &ReducerState, + room_wire: &str, + thread_tag: &str, + since: Option, + before: Option, + actor_prefix: &str, +) -> Vec { + let scope = crate::reducer::scope_from_room_wire(room_wire); + let tag = canonicalize_tag(thread_tag); + let key = (scope.clone(), tag.clone()); + + let mut rows: Vec = Vec::new(); + + if let Some(entries) = reduced.room_timeline.get(room_wire.trim()) { + for e in entries { + if since.map_or(true, |s| e.ts >= s) && before.map_or(true, |b| e.ts < b) { + rows.push(MergedThreadRow::System { + ts: e.ts, + text: format_room_timeline_entry(e), + }); + } + } + } + + let all_ids: Vec = reduced + .ingests_by_scope_thread + .get(&key) + .map(|q| q.iter().rev().cloned().collect()) + .unwrap_or_default(); + + for (idx, id) in all_ids.into_iter().enumerate() { + let Some(ing) = reduced.ingests_by_id.get(&id) else { + continue; + }; + if since.map_or(true, |s| ing.ts >= s) && before.map_or(true, |b| ing.ts < b) { + if !actor_prefix.is_empty() + && !ing + .principal + .to_lowercase() + .starts_with(actor_prefix) + { + continue; + } + rows.push(MergedThreadRow::Post { + index: idx, + id: ing.id.clone(), + ts: ing.ts, + principal: ing.principal.clone(), + raw: ing.raw.clone(), + }); + } + } + + rows.sort_by(|a, b| { + let ta = match a { + MergedThreadRow::System { ts, .. } | MergedThreadRow::Post { ts, .. } => *ts, + }; + let tb = match b { + MergedThreadRow::System { ts, .. } | MergedThreadRow::Post { ts, .. } => *ts, + }; + ta.cmp(&tb) + }); + rows +} + +/// Public forum thread (`room_wire == "public"`): same merge (timeline usually empty). +pub fn merge_public_thread_rows( + reduced: &ReducerState, + thread_tag: &str, + since: Option, + before: Option, + actor_prefix: &str, +) -> Vec { + merge_thread_rows(reduced, "public", thread_tag, since, before, actor_prefix) +} diff --git a/test/grants.bb b/test/grants.bb index 10ef0f08ce1c3945e5f1634314e6aebcf6810cd2..4ded5ce92576cdea653cce0ba8a751be4374731a 100644 --- a/test/grants.bb +++ b/test/grants.bb @@ -105,7 +105,7 @@ ;; Alice grants bob View only. (println "\nalice grants bob View only…") (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token - [{"RoomGrant" {"room" room-id "username" "bob" "capability" "view"}}]))) + [{"RoomGrant" {"room" room-id "username" "bob" "capabilities" ["view"]}}]))) "grant View RPC ok") (println "\nbob (View only) tries to post prose…") @@ -117,7 +117,7 @@ ;; Alice grants bob Post. (println "\nalice grants bob Post…") (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token - [{"RoomGrant" {"room" room-id "username" "bob" "capability" "post"}}]))) + [{"RoomGrant" {"room" room-id "username" "bob" "capabilities" ["post"]}}]))) "grant Post RPC ok") (println "\nbob (View + Post) posts prose…") @@ -143,7 +143,7 @@ ;; Alice grants bob Vote. (println "\nalice grants bob Vote…") (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token - [{"RoomGrant" {"room" room-id "username" "bob" "capability" "vote"}}]))) + [{"RoomGrant" {"room" room-id "username" "bob" "capabilities" ["vote"]}}]))) "grant Vote RPC ok") (println "\nbob (View + Post + Vote) votes…") diff --git a/test/invites.bb b/test/invites.bb new file mode 100644 index 0000000000000000000000000000000000000000..f72b1e630a6856174cdef11eb977e2f81b16e551 --- /dev/null +++ b/test/invites.bb @@ -0,0 +1,150 @@ +(ns test.invites + "Ephemeral invite links: mint via RPC, GET /join → pending session + OAuth, redemption → GrantAdded, + RoomAudit, post succeeds, second GET /join returns 404 when max_uses exhausted." + (:require [babashka.fs :as fs] + [cheshire.core :as json] + [clojure.string :as str] + [test.common :as common] + [test.oauth :as oauth])) + +(def ^:private counts (atom {:pass 0 :fail 0})) + +(defn- assert! [pred msg] + (common/test-assert! counts pred msg)) + +(defn- bearer [token] {"Authorization" (str "Bearer " token)}) + +(defn- rpc-batch! [base-url token cmds] + (let [resp (oauth/http-post-json (str base-url "/api/v0/rpc") cmds :headers (bearer token))] + {:status (:status resp) + :parsed (json/parse-string (:body resp) false)})) + +(defn- rpc-line-ok? [parsed] + (true? (get-in parsed ["results" 0 "ok"]))) + +(defn- session-from-login-location [loc] + (when loc + (let [qpart (if (str/includes? loc "?") + (-> loc (str/split #"\?" 2) second) + "") + qpart (if (str/includes? qpart "#") + (-> qpart (str/split #"#" 2) first) + qpart) + m (oauth/parse-query qpart)] + (some-> (get m :session) str)))) + +(defn- invite-token-from-url [invite-url] + (second (re-find #"/join/(inv_[^/?#]+)" (str invite-url)))) + +(defn- register-user! [base-url session-agent username] + (oauth/complete-registration! base-url + :agent session-agent + :username username + :assert! (fn [pred msg] (assert! pred msg)))) + +(defn- ingest! [base-url token room thread delegate text] + (rpc-batch! base-url token + [{"Post" {"room" room + "thread_tag" thread + "delegate" delegate + "text" text + "return_rank_diff" false}}])) + +(defn invites-test [& _args] + (println "\n━━━ ephemeral invite + audit integration check ━━━\n") + (reset! counts {:pass 0 :fail 0}) + + (println "building server binary…") + (common/letlocals + (bind build (common/run-cargo-build-release! ["slugsocial-server"])) + (assert! (zero? (:exit build)) "cargo build succeeds") + (bind server-bin "target/release/slugsocial-server") + + (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-invites-"}))) + (bind slug-port (common/pick-port)) + (bind google-port (common/pick-port)) + (bind base-url (str "http://127.0.0.1:" slug-port)) + (bind google-url (str "http://127.0.0.1:" google-port)) + + (bind !server (atom nil)) + (bind !google (atom nil)) + + (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port)) + (try + (println (str "starting mock google on :" google-port)) + (reset! !google (oauth/start-mock-google google-port + :google-users ["google-user-alice" "google-user-bob"])) + + (println (str "starting server on :" slug-port)) + (reset! !server (common/start-server server-bin server-env)) + (assert! (common/wait-for-server base-url 10000) "server responds to /healthz") + + (println "\nregistering alice…") + (let [alice-token (register-user! base-url + "00000000-0000-0000-0000-000000000001:test:local/dev" + "alice") + + _ (println "\nalice creates private room…") + create (rpc-batch! base-url alice-token + [{"RoomCreate" {"slug" "invite-demo" "visibility" "private"}}]) + _ (assert! (= 200 (:status create)) "room create HTTP 200") + _ (assert! (rpc-line-ok? (:parsed create)) "room create RPC ok") + room-id (get-in (:parsed create) ["results" 0 "result" "RoomCreated" "room_id"]) + _ (assert! (some? room-id) "room_id present") + + _ (println "\nalice mints invite (view,post,vote uses=1)…") + mint (rpc-batch! base-url alice-token + [{"RoomMintInvite" {"room" room-id + "capabilities" ["view" "post" "vote"] + "max_uses" 1}}]) + _ (assert! (= 200 (:status mint)) "mint HTTP 200") + _ (assert! (rpc-line-ok? (:parsed mint)) "mint RPC ok") + invite-url (get-in (:parsed mint) ["results" 0 "result" "RoomInviteMinted" "invite_url"]) + inv-tok (invite-token-from-url invite-url) + _ (assert! (some? inv-tok) "invite token parsed from URL") + + _ (println "\nGET /join/:token (expect redirect + session)…") + join-resp (oauth/http-get-no-redirect (str base-url "/join/" inv-tok)) + _ (assert! (contains? #{302 307} (:status join-resp)) + (str "join returns redirect (got status " (:status join-resp) ")")) + sess (session-from-login-location (:location join-resp)) + _ (assert! (and (some? sess) (str/starts-with? sess "p_")) "Location carries session=p_…") + + _ (println "\nbob completes OAuth via invite session…") + bob-token (oauth/complete-pending-session! base-url sess "bob" + :assert! (fn [pred msg] (assert! pred msg))) + + _ (println "\nalice runs RoomAudit…") + audit (rpc-batch! base-url alice-token [{"RoomAudit" {"room" room-id}}]) + _ (assert! (rpc-line-ok? (:parsed audit)) "audit RPC ok") + grants (get-in (:parsed audit) ["results" 0 "result" "RoomAudit" "grants"]) + bob-entry (first (filter #(= "bob" (get % "username")) grants)) + _ (assert! (some? bob-entry) "audit lists bob") + bob-caps (set (get bob-entry "capabilities")) + _ (assert! (= bob-caps #{"view" "post" "vote"}) "bob has view, post, vote") + + _ (println "\nbob posts prose to private room…") + _ (assert! (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id "main" + "00000000-0000-0000-0000-000000000002:test:local/dev" + "Hello via invite link."))) + "bob post succeeds") + + _ (println "\nsecond GET /join (invite exhausted → 404)…") + join2 (oauth/http-get-no-redirect (str base-url "/join/" inv-tok)) + _ (assert! (= 404 (:status join2)) "exhausted invite returns 404")] + + (println "\ninvite lifecycle OK.")) + + (finally + (when-some [s @!server] (common/kill-server s)) + (when-some [g @!google] ((:stop-fn g))) + (fs/delete-tree tmp-dir))) + + (bind {pass :pass fail :fail} @counts) + (if (zero? fail) + (println (str "\n" common/ansi-green "━━━ " pass " invite checks passed ━━━" common/ansi-reset "\n")) + (do (println (str "\n" common/ansi-red "━━━ " fail " invite checks FAILED ━━━" common/ansi-reset "\n")) + (System/exit 1))))) + +(when (= *file* (System/getProperty "babashka.file")) + (invites-test)) diff --git a/test/oauth.bb b/test/oauth.bb index b69459acd17c23c023170d8dd93fe45bb49c8d88..efb3f3e66346ffde81bf9622d75c3a6ec89f8db2 100644 --- a/test/oauth.bb +++ b/test/oauth.bb @@ -21,6 +21,21 @@ resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))] {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))}))) +(defn http-get-no-redirect + "GET without following redirects; returns `:location` from the first `Location` header when present." + [url & {:keys [headers]}] + (let [client (-> (java.net.http.HttpClient/newBuilder) + (.followRedirects java.net.http.HttpClient$Redirect/NEVER) + (.connectTimeout connect-timeout) + (.build)) + b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))] + (doseq [[k v] (or headers {})] + (.header b k v)) + (let [req (-> b (.timeout request-timeout) (.GET) (.build)) + resp (.send client req (java.net.http.HttpResponse$BodyHandlers/ofString)) + loc (first (get (.map (.headers resp)) "location"))] + {:status (.statusCode resp) :body (.body resp) :location loc}))) + (defn http-post-json [url data & {:keys [headers]}] (let [body (json/generate-string data) b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))] @@ -50,6 +65,35 @@ resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))] {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))}))) +(defn complete-pending-session! + "Finish OAuth for an existing pending session id (e.g. created by `GET /join/inv_…`). Returns bearer token." + [base-url session-id username & {:keys [assert!]}] + (let [check! (fn [pred msg resp] + (if assert! + (assert! pred msg) + (when-not pred + (throw (ex-info msg {:resp resp})))))] + (let [enc (java.net.URLEncoder/encode session-id "UTF-8") + login-url (str base-url "/auth/login?session=" enc) + login-get (http-get login-url)] + (check! (= 200 (:status login-get)) + (str "oauth redirect chain for session " session-id) + login-get) + (let [choose (http-post-form (str base-url "/auth/choose-username") + {:session session-id :username username})] + (check! (= 200 (:status choose)) + (str "choose-username for " username " returns 200") + choose) + (let [poll (http-get (str base-url "/api/v0/pending-session/" session-id))] + (check! (= 200 (:status poll)) + (str "pending-session poll returns 200") + poll) + (let [poll-json (json/parse-string (:body poll) true)] + (check! (:complete poll-json) + (str "pending session complete for " username) + poll) + (:token poll-json))))))) + (defn parse-query [s] (into {} (for [part (str/split (or s "") #"&") diff --git a/types/src/lib.rs b/types/src/lib.rs index a715fe5639f4a3a7bfedcc706b5fb312b98c9ac5..98c66a00fd27803ef6f75b5ac478ff2eb762d771 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -123,13 +123,32 @@ pub struct PathDetailResponse { #[derive(Debug, Serialize, Deserialize)] pub struct ThreadDetailResponse { pub thread: String, - pub posts: Vec, - /// Total posts in this thread. + /// Chronological page: prose posts and room system lines, oldest first within the window. + pub items: Vec, + /// Total rows (posts + system lines) in this thread after filters. pub total: usize, - /// Chronological offset of the first post in this page. + /// Offset into the merged chronological list. pub offset: usize, } +/// One row in a thread timeline: a normal post or a room system line. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ThreadItem { + Post { + id: String, + index: usize, + ts: i64, + actor: String, + body: String, + truncated: bool, + }, + System { + ts: i64, + text: String, + }, +} + /// One post in a thread. Full body, no snippet. #[derive(Debug, Serialize, Deserialize)] pub struct PostRow { @@ -224,6 +243,23 @@ pub struct FeedPost { // RPC batch API (`POST /api/v0/rpc`) // --------------------------------------------------------------------------- +fn default_invite_max_uses() -> usize { + 1 +} + +/// One principal's capabilities in a private room (from [`RpcCommand::RoomAudit`]). +#[derive(Debug, Serialize, Deserialize)] +pub struct RoomAuditEntry { + pub username: String, + pub capabilities: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoomAuditResponse { + pub room: String, + pub grants: Vec, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(transparent)] pub struct RpcBatch(pub Vec); @@ -287,10 +323,27 @@ pub enum RpcCommand { visibility: Option, }, RoomGrant { + room: String, + username: String, + /// Capability names: `view`, `post`, `vote`, `add_item`, `manage`. + capabilities: Vec, + }, + RoomRevoke { room: String, username: String, capability: String, }, + /// Mint a shareable invite link (24h TTL, stored in memory only until redeemed or expiry). + RoomMintInvite { + room: String, + capabilities: Vec, + #[serde(default = "default_invite_max_uses")] + max_uses: usize, + }, + /// List principals granted access in a room (requires View or Manage). + RoomAudit { + room: String, + }, GetGlobalRank { room: String, #[serde(default)] @@ -361,6 +414,13 @@ pub enum RpcResult { RoomCreated { room_id: String, }, + RoomInviteMinted { + invite_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + expires_at_ms: Option, + max_uses: usize, + }, + RoomAudit(RoomAuditResponse), GrantOk {}, GlobalRank(GlobalRankResponse), Pair(PairResponse), Side B — contributor: tommy-mor Side B — commit message: [06b48801] Implement extensible URL foundation (#145) * Implement extensible URL foundation Co-authored-by: tommy * Make query params non-identity by default Co-authored-by: tommy * Fix external href helper test scope Co-authored-by: tommy * Avoid broken external previews for blocked hosts Co-authored-by: tommy * Clarify external empty state copy Co-authored-by: tommy * Add on-demand GitHub external resolver Co-authored-by: tommy * Add fenced JSON bodies for GitHub resolver Co-authored-by: tommy --------- Co-authored-by: Cursor Agent Side B — unified diff (full patch): diff --git a/agents.md b/agents.md index 59bd2174f4d2f972a45123fe10d408aa5881ee93..c66f33789441eea193b4354fce4c03b7fffdd639 100644 --- a/agents.md +++ b/agents.md @@ -55,6 +55,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma | Ingests, grants, rooms, identity tokens, agent binds, redactions, etc. | **JSONL** | Appended in `server/src/api/rpc.rs`, `server/src/api/auth.rs` (and related paths) before updating `ReducerState` | | **`RoomMintInvite` links** | **RAM only** | `AppState.invites` — not appended as `InviteMinted` today; **lost on restart** (`server/src/state.rs`, `server/src/api/rpc.rs`). Event types `InviteMinted` / `InviteRedeemed` exist for replay and a possible future persisted mint (`server/src/reducer.rs`). | | **OAuth / pending sessions** | **RAM only** | `AppState.pending_sessions` (`server/src/state.rs`, `server/src/api/auth.rs`) | +| **External resolver cooldowns** | **RAM only** | `AppState.resolver_runs` — debounce/rate-limit guard for on-demand resolver buttons. Resolver results themselves are durable synthetic `Ingest` events in `events.jsonl`. | | **Reducer projection** | **Derived** | Rebuilt from log on startup; not separately persisted | If you add a new ephemeral map or start persisting something that was RAM-only, **update this table and the code comments** (`server/src/state.rs` is a good anchor). diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index 7cbda3876451687aa7a55547fc06bbe86ac9d260..cd501ba6d4d656eabad03afed4583efdf885695d 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -18,14 +18,15 @@ use crate::{ rpc::{rpc_post_redact, rpc_post_with_bearer, rpc_room_delete}, }, canonical_path::canonicalize_tag, + external_resolver::resolve_github_children, + html::vote_compare_post_success_js, html::{ - fragment_new_thread_slot, login_to_post_hint_markup, - parse_html_ui_from_form, room_members_section_markup, thread_feed_html, - thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post, - thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, - user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav, + fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form, + room_members_section_markup, thread_feed_html, thread_feed_html_for_room, + thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full, + thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room, + HtmlUiAction, JsBuilder, ThreadNav, }, - html::vote_compare_post_success_js, reducer::{scope_from_room_wire, ScopeId}, state::AppState, }; @@ -104,26 +105,35 @@ async fn dispatch_ui_action( ) .into_response(); } - match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await { - Ok(RpcResult::PostOk { .. }) => { - post_success_response( - state, - &room, - &thread_tag, - error_target.as_ref(), - form_id.as_ref(), - Some(session.username.as_str()), - ) - .await - .into_response() - } + match rpc_post_with_bearer( + state, + &session.bearer, + room.clone(), + thread_tag.clone(), + text, + ) + .await + { + Ok(RpcResult::PostOk { .. }) => post_success_response( + state, + &room, + &thread_tag, + error_target.as_ref(), + form_id.as_ref(), + Some(session.username.as_str()), + ) + .await + .into_response(), Ok(_) => form_js_error( error_target.as_ref(), "unexpected response", "Post did not return PostOk.", ) .into_response(), - Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(), + Err((msg, hint)) => { + form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")) + .into_response() + } } } HtmlUiAction::CheckIngest { @@ -150,9 +160,19 @@ async fn dispatch_ui_action( return js_clear_errors(&form_error_target(error_target.as_ref())).into_response(); } match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await { - Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(), - Ok(_) => form_js_error(error_target.as_ref(), "unexpected response", "Check did not return CheckOk.").into_response(), - Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(), + Ok(RpcResult::CheckOk { .. }) => { + js_clear_errors(&form_error_target(error_target.as_ref())).into_response() + } + Ok(_) => form_js_error( + error_target.as_ref(), + "unexpected response", + "Check did not return CheckOk.", + ) + .into_response(), + Err((msg, hint)) => { + form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")) + .into_response() + } } } HtmlUiAction::VoteComparePost { @@ -167,7 +187,11 @@ async fn dispatch_ui_action( form_action, } => { if form_action != "/ui" { - return (StatusCode::BAD_REQUEST, "invalid vote_compare_post form_action").into_response(); + return ( + StatusCode::BAD_REQUEST, + "invalid vote_compare_post form_action", + ) + .into_response(); } let Some(session) = session else { return js_redirect("/login").into_response(); @@ -195,23 +219,15 @@ async fn dispatch_ui_action( let left_id = match crate::path_types::ItemId::parse(left_item.trim()) { Some(i) => i.normalized_storage(), None => { - return form_js_error( - err_tgt.as_ref(), - "bad item", - "Invalid left item path.", - ) - .into_response(); + return form_js_error(err_tgt.as_ref(), "bad item", "Invalid left item path.") + .into_response(); } }; let right_id = match crate::path_types::ItemId::parse(right_item.trim()) { Some(i) => i.normalized_storage(), None => { - return form_js_error( - err_tgt.as_ref(), - "bad item", - "Invalid right item path.", - ) - .into_response(); + return form_js_error(err_tgt.as_ref(), "bad item", "Invalid right item path.") + .into_response(); } }; let mut rl = ratio_left.trim().parse::().unwrap_or(0).max(0); @@ -231,7 +247,15 @@ async fn dispatch_ui_action( right_id.as_str() ); - match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await { + match rpc_post_with_bearer( + state, + &session.bearer, + room.clone(), + thread_tag.clone(), + text, + ) + .await + { Ok(RpcResult::PostOk { post_id, post_index, @@ -277,7 +301,10 @@ async fn dispatch_ui_action( "Post did not return PostOk.", ) .into_response(), - Err((msg, hint)) => form_js_error(err_tgt.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(), + Err((msg, hint)) => { + form_js_error(err_tgt.as_ref(), &msg, hint.as_deref().unwrap_or("")) + .into_response() + } } } HtmlUiAction::SetGardenPin { @@ -288,7 +315,11 @@ async fn dispatch_ui_action( form_action, } => { if form_action != "/ui" { - return (StatusCode::BAD_REQUEST, "invalid set_garden_pin form_action").into_response(); + return ( + StatusCode::BAD_REQUEST, + "invalid set_garden_pin form_action", + ) + .into_response(); } let next_path = sanitize_garden_pin_next(&next); use crate::html::{encode_pin_cookie_value, GARDEN_PIN_COOKIE}; @@ -301,7 +332,11 @@ async fn dispatch_ui_action( if room.is_empty() { return (StatusCode::BAD_REQUEST, "missing room").into_response(); } - let Some(raw) = item_storage.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) else { + let Some(raw) = item_storage + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + else { return (StatusCode::BAD_REQUEST, "missing item").into_response(); }; let Some(item) = ItemId::parse(&raw) else { @@ -309,16 +344,65 @@ async fn dispatch_ui_action( }; let item = item.normalized_storage(); let val = encode_pin_cookie_value(&room, item.as_str()); - let cookie = format!("{GARDEN_PIN_COOKIE}={val}; Path=/; SameSite=Lax; Max-Age=7776000"); + let cookie = + format!("{GARDEN_PIN_COOKIE}={val}; Path=/; SameSite=Lax; Max-Age=7776000"); redirect_with_pin_cookie(&cookie, &next_path) } + HtmlUiAction::ResolveExternal { + room_wire, + item_storage, + mode, + next, + form_action, + } => { + if form_action != "/ui" { + return ( + StatusCode::BAD_REQUEST, + "invalid resolve_external form_action", + ) + .into_response(); + } + let Some(session) = session else { + return js_redirect("/login").into_response(); + }; + let room = room_wire.trim(); + if room.is_empty() { + return ui_js_warn("missing room").into_response(); + } + let reduced = state.reduced.read().await; + if matches!(scope_from_room_wire(room), ScopeId::Room(_)) { + if !user_can_post_room(&reduced, room, &session.username) { + drop(reduced); + return ui_js_warn("forbidden").into_response(); + } + } + drop(reduced); + + let Some(item) = crate::path_types::ItemId::parse(item_storage.trim()) else { + return ui_js_warn("bad item").into_response(); + }; + let target = if mode.trim() == "siblings" { + match item.parent() { + Some(parent) => parent.normalized_storage(), + None => return ui_js_warn("no parent to resolve siblings").into_response(), + } + } else { + item.normalized_storage() + }; + match resolve_github_children(state, room, &target).await { + Ok(_) => js_redirect(&sanitize_garden_pin_next(&next)).into_response(), + Err(msg) => ui_js_warn(&msg).into_response(), + } + } HtmlUiAction::RedactPost { post_id } => { let Some(session) = session else { return js_redirect("/login").into_response(); }; let h = headers_from_bearer(&session.bearer); match rpc_post_redact(state, &h, post_id).await { - Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(), + Ok(RpcResult::RedactPostOk {}) => { + redact_success_response(state).await.into_response() + } Ok(_) => (StatusCode::BAD_REQUEST, "unexpected response").into_response(), Err((msg, hint)) => { let detail = hint.as_deref().unwrap_or(""); @@ -326,7 +410,10 @@ async fn dispatch_ui_action( } } } - HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => { + HtmlUiAction::SetRoomMembersExpanded { + room_wire, + expanded, + } => { let room_wire = room_wire.trim().to_string(); if room_wire.is_empty() { return ui_js_warn("missing room").into_response(); @@ -365,7 +452,10 @@ async fn dispatch_ui_action( } } } - HtmlUiAction::SetNewThreadComposeExpanded { room_wire, expanded } => { + HtmlUiAction::SetNewThreadComposeExpanded { + room_wire, + expanded, + } => { let room_wire = room_wire.trim().to_string(); if room_wire.is_empty() { return ui_js_warn("missing room").into_response(); @@ -614,7 +704,9 @@ async fn post_success_response( }; builder }) - .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location)); + .if_current_path_not_matches(&thread_location, |builder| { + builder.redirect(&thread_location) + }); builder.into_response() } @@ -625,4 +717,3 @@ async fn redact_success_response(state: &AppState) -> Response { .morph_selector("#thread-feed", feed_markup) .into_response() } - diff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs index 700c3174b0e980d46ebf4aa94e8ffe2f8dedd723..3168956e7dce1cadf50c275904b550bf81500687 100644 --- a/server/src/api/write_actor.rs +++ b/server/src/api/write_actor.rs @@ -54,7 +54,11 @@ fn content_for_room<'a>(reduced: &'a ReducerState, room: &str) -> &'a crate::red } async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str) { - let feed_id = if room_key == "public" { "thread-feed" } else { "room-thread-feed" }; + let feed_id = if room_key == "public" { + "thread-feed" + } else { + "room-thread-feed" + }; let thread_url = if room_key == "public" { format!("/t/{thread_id}") } else if let Some(seg) = room_route_segment(room_key) { @@ -86,7 +90,10 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str "/".to_string() }]; path_prefixes.push(thread_url.clone()); - let _ = state.js_tx.send(crate::state::JsSnippet { code: js, path_prefixes }); + let _ = state.js_tx.send(crate::state::JsSnippet { + code: js, + path_prefixes, + }); } fn compute_scope_rank_changes( @@ -104,7 +111,13 @@ fn compute_scope_rank_changes( for comp in &rankings.component_rankings { let total = comp.ranked.len(); for (i, item) in comp.ranked.iter().enumerate() { - map.insert(item.item.clone(), Some(RankPosition { rank: i + 1, of: total })); + map.insert( + item.item.clone(), + Some(RankPosition { + rank: i + 1, + of: total, + }), + ); } } for item in &rankings.unranked_items { @@ -158,7 +171,11 @@ fn compute_scope_rank_changes( }) } -async fn redeem_invite_grant(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> { +async fn redeem_invite_grant( + state: &AppState, + invite_token: &str, + grantee_username: &str, +) -> Result<(), String> { let now = now_ms(); let ga = { let mut invites = state.invites.write().await; @@ -220,14 +237,14 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let out = async { let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; let delegate: Option = match delegate_opt { None => None, Some(ref s) if s.trim().is_empty() => None, Some(s) => Some( - parse_agent(&s).map_err(|msg| (format!("invalid delegate format"), Some(msg)))?, + parse_agent(&s) + .map_err(|msg| (format!("invalid delegate format"), Some(msg)))?, ), }; @@ -241,11 +258,12 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { )); } - let v = validate_ingest_document(&reduced, &text, &scope) - .map_err(|(st, m, h)| { + let v = validate_ingest_document(&reduced, &text, &scope).map_err( + |(st, m, h)| { let _ = st; (m, h) - })?; + }, + )?; if is_private { use crate::events::ThreadCapability; @@ -276,7 +294,10 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { if let Some(ref d) = delegate { match reduced.agent_bindings.get(d) { Some(u) if u != &principal => { - return Err(("delegate already bound to another user".into(), None)); + return Err(( + "delegate already bound to another user".into(), + None, + )); } _ => {} } @@ -427,6 +448,80 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let _ = reply.send(out); } + WriteCmd::SystemIngest { + room, + thread_tag, + text, + principal, + reply, + } => { + let out = async { + let (room_key, thread_id) = normalize_room_and_thread(&room, &thread_tag); + let scope = scope_from_room_wire(&room_key); + let is_private = !matches!(scope, ScopeId::Public); + let mut reduced = state.reduced.write().await; + if is_private && !reduced.rooms.contains(&room_key) { + return Err(( + "unknown room".into(), + Some(format!("room `{}` does not exist", room_key)), + )); + } + + let v = validate_ingest_document(&reduced, &text, &scope).map_err( + |(st, m, h)| { + let _ = st; + (m, h) + }, + )?; + + let new_post_id = uuid::Uuid::new_v4().to_string(); + let ingest_event = Event::Ingest(Ingest { + ts: v.ts, + id: new_post_id.clone(), + raw: v.raw_text.clone(), + principal: principal.clone(), + delegate: None, + room_id: room_key.clone(), + thread_tag: thread_id.clone(), + }); + + state + .event_log + .append(&ingest_event) + .await + .map_err(|e| (format!("{e}"), None))?; + reduced.apply_event(ingest_event); + drop(reduced); + + use crate::canonical_path::canonicalize_tag; + let thread_tag_canon = canonicalize_tag(&thread_id); + broadcast_web_refresh(&state, &room_key, &thread_tag_canon).await; + let post_index = { + let reduced = state.reduced.read().await; + reduced.try_thread_post_index_chronological( + &scope_from_room_wire(&room_key), + &thread_tag_canon, + &new_post_id, + ) + }; + + Ok(RpcResult::PostOk { + events_appended: 1, + post_id: Some(new_post_id), + post_index, + ranking_changes: None, + threads: vec![format!("#{}", thread_id)], + next: slug_types::NextMoves { + pair: "npx slugsocial public garden pair".to_string(), + rank: "npx slugsocial public garden rank".to_string(), + web: ForumThreadUrl::from_room_tag(&room_key, &thread_id), + }, + }) + } + .await; + let _ = reply.send(out); + } + WriteCmd::Redact { post_id, bearer, @@ -434,8 +529,7 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { } => { let out = async { let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; let post_id = post_id.trim().to_string(); let Some(ing) = reduced.ingests_by_id.get(&post_id).cloned() else { return Err(("post not found".into(), None)); @@ -450,7 +544,11 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let scope = scope_from_room_wire(&room_key); if matches!(scope, ScopeId::Room(_)) && (!reduced.rooms.contains(&room_key) - || !reduced.user_has_cap(&room_key, &principal, crate::events::ThreadCapability::View)) + || !reduced.user_has_cap( + &room_key, + &principal, + crate::events::ThreadCapability::View, + )) { return Err(("room not found".into(), None)); } @@ -484,14 +582,16 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { } => { let out = async { let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; let slug = slug.trim().to_lowercase(); if slug.is_empty() || slug.len() > 64 { return Err(("slug must be 1-64 characters".into(), None)); } if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { - return Err(("slug must be lowercase alphanumeric with hyphens".into(), None)); + return Err(( + "slug must be lowercase alphanumeric with hyphens".into(), + None, + )); } let short_id = loop { let id = gen_short_id(); @@ -539,13 +639,16 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let _ = reply.send(out); } - WriteCmd::RoomDelete { room, bearer, reply } => { + WriteCmd::RoomDelete { + room, + bearer, + reply, + } => { let out = async { use crate::events::ThreadCapability; let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; let room = room.trim().to_string(); if !reduced.rooms.contains(&room) { return Err(("unknown room".into(), None)); @@ -589,15 +692,15 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { use crate::identity::parse_username; let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) { return Err(("requires Manage capability".into(), None)); } if capabilities.is_empty() { return Err(("capabilities must not be empty".into(), None)); } - let target = parse_username(&username).map_err(|msg| ("invalid username".into(), Some(msg)))?; + let target = parse_username(&username) + .map_err(|msg| ("invalid username".into(), Some(msg)))?; if !reduced.users_by_provider.values().any(|u| u == &target) { return Err((format!("user @{target} not found"), None)); } @@ -637,12 +740,12 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { use crate::identity::parse_username; let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) { return Err(("requires Manage capability".into(), None)); } - let target = parse_username(&username).map_err(|msg| ("invalid username".into(), Some(msg)))?; + let target = parse_username(&username) + .map_err(|msg| ("invalid username".into(), Some(msg)))?; if !reduced.users_by_provider.values().any(|u| u == &target) { return Err((format!("user @{target} not found"), None)); } diff --git a/server/src/dsl.rs b/server/src/dsl.rs index def8b497c71567016a522648b9630d7038163e41..4203c2f59dd8825d7a91513c4023f3ccd37f1efc 100644 --- a/server/src/dsl.rs +++ b/server/src/dsl.rs @@ -450,8 +450,8 @@ fn parse_block_prefixed_statement( } fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Result { - let (item1, j) = - parse_item_name_at(stripped, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?; + let (item1, j) = parse_item_name_at(stripped, 0) + .ok_or_else(|| DslError::Parse("invalid item name".to_string()))?; let i = skip_ws(stripped, j); if i >= stripped.len() { @@ -632,6 +632,19 @@ mod tests { ); } + #[test] + fn parse_item_with_fenced_json_body_preserves_braces() { + let input = "~/item/in/url ```json\n{\"test\": true}\n```"; + let doc = parse_full(input).unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Item { + title: "~/item/in/url".to_string(), + body: Some("```json\n{\"test\": true}\n```".to_string()), + }] + ); + } + #[test] fn parse_vote_ratio_and_symbols() { let d1 = parse_full("{because}\n~/a 3:1 ~/b").unwrap(); diff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs index 13f06b922a685f110d269f7397da807cf8600fe1..8481f1fbff1c4265da182a225f539f97d6829002 100644 --- a/server/src/external_resolver.rs +++ b/server/src/external_resolver.rs @@ -1,6 +1,26 @@ use async_trait::async_trait; +use serde_json::Value; +use tokio::sync::oneshot; -use crate::path_types::ItemId; +use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd}; + +const GITHUB_SYSTEM_PRINCIPAL: &str = "system:github-resolver"; +const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000; + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedChild { + pub url: String, + pub title: String, + pub body: Option, +} #[async_trait] pub trait ExternalResolver: Send + Sync { @@ -14,7 +34,290 @@ pub trait ExternalResolver: Send + Sync { async fn fetch_body(&self, item: &ItemId) -> Result; } -/// Placeholder until domain-specific resolvers exist. +#[derive(Clone)] +pub struct GitHubResolver { + client: reqwest::Client, + api_base_url: String, + token: Option, +} + +impl GitHubResolver { + pub fn from_env() -> Self { + let api_base_url = std::env::var("SLUG_GITHUB_API_BASE_URL") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "https://api.github.com".to_string()); + let token = std::env::var("SLUG_GITHUB_TOKEN") + .ok() + .filter(|s| !s.trim().is_empty()); + Self { + client: reqwest::Client::new(), + api_base_url: api_base_url.trim_end_matches('/').to_string(), + token, + } + } + + pub fn can_resolve_children(&self, item: &ItemId) -> bool { + github_segments(item).is_some() + } + + pub async fn list_children(&self, item: &ItemId) -> Result, String> { + let segments = github_segments(item).ok_or_else(|| "not a GitHub URL".to_string())?; + match segments.as_slice() { + [] => Ok(vec![]), + [owner] => self.list_repos(owner).await, + [owner, repo] => Ok(github_repo_sections(owner, repo)), + [owner, repo, section] if section == "issues" => self.list_issues(owner, repo).await, + [owner, repo, section] if section == "pulls" => self.list_pulls(owner, repo).await, + _ => Ok(vec![]), + } + } + + async fn get_json(&self, path: &str) -> Result { + let url = format!("{}/{}", self.api_base_url, path.trim_start_matches('/')); + let mut req = self + .client + .get(url) + .header(reqwest::header::USER_AGENT, "slugsocial-github-resolver"); + if let Some(token) = &self.token { + req = req.bearer_auth(token); + } + let resp = req + .send() + .await + .map_err(|e| format!("GitHub request failed: {e}"))?; + let status = resp.status(); + if !status.is_success() { + return Err(format!("GitHub request returned {status}")); + } + resp.json::() + .await + .map_err(|e| format!("GitHub response JSON failed: {e}")) + } + + async fn list_repos(&self, owner: &str) -> Result, String> { + let value = self + .get_json(&format!( + "/users/{owner}/repos?per_page=100&sort=updated&type=owner" + )) + .await?; + let arr = value + .as_array() + .ok_or_else(|| "GitHub repos response was not an array".to_string())?; + let mut out = Vec::new(); + for repo in arr { + let name = repo + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if name.is_empty() { + continue; + } + let full_name = repo + .get("full_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_else(|| format!("{owner}/{name}").to_ascii_lowercase()); + out.push(ResolvedChild { + url: format!("https://github.com/{full_name}"), + title: full_name.clone(), + body: Some(github_json_body(repo)), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } + + async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> { + let value = self + .get_json(&format!( + "/repos/{owner}/{repo}/issues?state=open&per_page=100" + )) + .await?; + let arr = value + .as_array() + .ok_or_else(|| "GitHub issues response was not an array".to_string())?; + let mut out = Vec::new(); + for issue in arr { + if issue.get("pull_request").is_some() { + continue; + } + let Some(number) = issue.get("number").and_then(|v| v.as_i64()) else { + continue; + }; + let title = issue + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled issue"); + out.push(ResolvedChild { + url: format!("https://github.com/{owner}/{repo}/issues/{number}"), + title: format!("#{number} {title}"), + body: Some(github_json_body(issue)), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } + + async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> { + let value = self + .get_json(&format!( + "/repos/{owner}/{repo}/pulls?state=open&per_page=100" + )) + .await?; + let arr = value + .as_array() + .ok_or_else(|| "GitHub pulls response was not an array".to_string())?; + let mut out = Vec::new(); + for pull in arr { + let Some(number) = pull.get("number").and_then(|v| v.as_i64()) else { + continue; + }; + let title = pull + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled pull request"); + out.push(ResolvedChild { + url: format!("https://github.com/{owner}/{repo}/pulls/{number}"), + title: format!("#{number} {title}"), + body: Some(github_json_body(pull)), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } +} + +fn github_segments(item: &ItemId) -> Option> { + let url = url::Url::parse(item.as_str()).ok()?; + if url.host_str()?.eq_ignore_ascii_case("github.com") { + Some( + url.path_segments() + .map(|segments| { + segments + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()) + .collect::>() + }) + .unwrap_or_default(), + ) + } else { + None + } +} + +fn github_repo_sections(owner: &str, repo: &str) -> Vec { + [ + ("issues", "GitHub issues for this repository."), + ("pulls", "GitHub pull requests for this repository."), + ("commits", "GitHub commits for this repository."), + ("releases", "GitHub releases for this repository."), + ] + .into_iter() + .map(|(section, body)| ResolvedChild { + url: format!("https://github.com/{owner}/{repo}/{section}"), + title: section.to_string(), + body: Some(body.to_string()), + }) + .collect() +} + +fn resolver_thread_tag(item: &ItemId) -> String { + let tail = item + .display_path() + .trim_start_matches("-/") + .replace('/', ":") + .replace('?', ":"); + format!("import:{tail}") +} + +fn sanitize_body(s: &str) -> String { + s.replace('{', "(") + .replace('}', ")") + .replace("```", "` ` `") + .chars() + .take(4_000) + .collect() +} + +fn github_json_body(value: &Value) -> String { + let json = serde_json::to_string_pretty(value) + .unwrap_or_else(|_| value.to_string()) + .replace("```", "` ` `"); + format!("```json\n{json}\n```") +} + +fn children_to_dsl(children: &[ResolvedChild]) -> String { + let mut out = String::new(); + for child in children { + let body = child + .body + .as_deref() + .filter(|s| !s.trim().is_empty()) + .unwrap_or(child.title.as_str()); + if body.trim_start().starts_with("```") { + out.push_str(&format!("{} {}\n\n", child.url, body.trim())); + } else { + out.push_str(&format!( + "{} {{\n{}\n}}\n\n", + child.url, + sanitize_body(body) + )); + } + } + out +} + +pub async fn resolve_github_children( + state: &AppState, + room: &str, + item: &ItemId, +) -> Result { + if !state.github_resolver.can_resolve_children(item) { + return Err("no GitHub resolver for this item".to_string()); + } + + let key = format!("github:{}:{}", room.trim(), item.as_str()); + let now = now_ms(); + { + let mut runs = state.resolver_runs.write().await; + if let Some(last) = runs.get(&key) { + let remaining = GITHUB_RESOLVER_COOLDOWN_MS - (now - *last); + if remaining > 0 { + return Err(format!( + "GitHub resolver cooldown: try again in {}s", + (remaining + 999) / 1000 + )); + } + } + runs.insert(key, now); + } + + let children = state.github_resolver.list_children(item).await?; + if children.is_empty() { + return Ok(0); + } + let text = children_to_dsl(&children); + let thread_tag = resolver_thread_tag(item); + let (tx, rx) = oneshot::channel(); + state + .write_tx + .send(WriteCmd::SystemIngest { + room: room.to_string(), + thread_tag, + text, + principal: GITHUB_SYSTEM_PRINCIPAL.to_string(), + reply: tx, + }) + .await + .map_err(|_| "writer unavailable".to_string())?; + rx.await + .map_err(|_| "writer dropped".to_string())? + .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!("{msg}: {h}")))?; + Ok(children.len()) +} + +/// Placeholder until other domain-specific resolvers exist. pub struct DefaultExternalResolver; #[async_trait] @@ -31,3 +334,51 @@ impl ExternalResolver for DefaultExternalResolver { Err("external fetch not implemented".to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_segments_parse_normalized_url() { + let item = ItemId::parse("https://github.com/Sortersocial/Slug/issues").unwrap(); + assert_eq!( + github_segments(&item), + Some(vec![ + "sortersocial".to_string(), + "slug".to_string(), + "issues".to_string() + ]) + ); + } + + #[test] + fn repo_sections_are_direct_children() { + let sections = github_repo_sections("sortersocial", "slug"); + let urls: Vec = sections.into_iter().map(|c| c.url).collect(); + assert!(urls.contains(&"https://github.com/sortersocial/slug/issues".to_string())); + assert!(urls.contains(&"https://github.com/sortersocial/slug/pulls".to_string())); + } + + #[test] + fn children_to_dsl_contains_item_bodies() { + let dsl = children_to_dsl(&[ResolvedChild { + url: "https://github.com/o/r/issues/1".into(), + title: "#1 title".into(), + body: Some("body with {braces}".into()), + }]); + assert!(dsl.contains("https://github.com/o/r/issues/1")); + assert!(dsl.contains("body with (braces)")); + } + + #[test] + fn children_to_dsl_preserves_fenced_json_bodies() { + let dsl = children_to_dsl(&[ResolvedChild { + url: "https://github.com/o/r/issues/1".into(), + title: "#1 title".into(), + body: Some("```json\n{\"test\": true}\n```".into()), + }]); + assert!(dsl.contains("https://github.com/o/r/issues/1 ```json")); + assert!(dsl.contains("{\"test\": true}")); + } +} diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index 4c03640334a524babe39c02289989739b1ad0ae9..b28271e0316738eae245855b24aeb76486860554 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -9,18 +9,21 @@ use serde::Deserialize; use serde_json::json; use std::collections::HashSet; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE, Engine as _}; use crate::{ api::optional_principal, canonical_path::{canonicalize_item, canonicalize_tag}, - middleware::canonical_view_url, events::ThreadCapability, form_template::template_json_compact, - html::{JsBuilder, ui_action::UI_RPC_FIELD, user_can_post_room}, + html::{ui_action::UI_RPC_FIELD, user_can_post_room, HtmlUiAction, JsBuilder}, + middleware::canonical_view_url, path_types::ItemId, reducer::{ContentState, ReducerState, ScopeId}, - scope_rank::{ChildrenRankings, build_children_rankings}, + scope_rank::{ + build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive, + ChildrenRankings, + }, state::AppState, timeago, }; @@ -128,7 +131,11 @@ fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 { let l = ratio_left.max(0) as f64; let r = ratio_right.max(0) as f64; let sum = l + r; - if sum <= 0.0 { 0.5 } else { l / sum } + if sum <= 0.0 { + 0.5 + } else { + l / sum + } } /// Stronger preference for **`page_left` first**; ties **newer first**. @@ -864,6 +871,7 @@ struct ItemPageViewModel { /// False at the tilde ontology root (`~/`): sibling-rank footnote does not apply. item_has_parent: bool, child_rankings: ChildrenRankings, + child_depth: usize, rank_history: Vec, /// Forum threads that mention or vote on this item. threads: Vec, @@ -1018,13 +1026,20 @@ fn build_item_page_view_model( reduced: &crate::reducer::ReducerState, scope: &ScopeId, item: &str, + child_depth: usize, ) -> ItemPageViewModel { let content = content_for_garden_view(reduced, scope); let item_key = ItemId::parse(item) .unwrap_or_else(|| ItemId::parse("~/").unwrap()) .normalized_storage(); let item_has_parent = item_key.parent().is_some(); - let child_rankings = build_children_rankings(content, &item_key); + let child_depth = child_depth.clamp(1, 5); + let child_rankings = if child_depth > 1 { + let items = resolve_scope_recursive(content, &[item_key.as_str().to_string()], child_depth); + build_rankings_for_item_set(content, &items) + } else { + build_children_rankings(content, &item_key) + }; let sibling_nav = build_sibling_nav(reduced, scope, &item_key); let rank_history = build_rank_history(reduced, scope, item_key.as_str()); @@ -1046,11 +1061,111 @@ fn build_item_page_view_model( sibling_nav, item_has_parent, child_rankings, + child_depth, rank_history, threads, } } +fn child_depth_from_uri(uri: &Uri) -> usize { + uri.query() + .into_iter() + .flat_map(|q| q.split('&')) + .filter_map(|pair| pair.split_once('=')) + .find_map(|(k, v)| { + if k == "depth" { + v.parse::().ok() + } else { + None + } + }) + .unwrap_or(1) + .clamp(1, 5) +} + +fn external_source_href(item: &str) -> String { + let Ok(mut url) = url::Url::parse(item) else { + return item.to_string(); + }; + let is_youtube = url + .host_str() + .map(|h| h.eq_ignore_ascii_case("www.youtube.com")) + .unwrap_or(false); + if is_youtube { + let segments: Vec = url + .path_segments() + .map(|s| s.map(|seg| seg.to_string()).collect()) + .unwrap_or_default(); + if segments.len() == 3 && segments[0] == "watch" && segments[1] == "v" { + let id = segments[2].clone(); + url.set_path("/watch"); + url.set_query(None); + url.query_pairs_mut().append_pair("v", &id); + return url.to_string(); + } + } + item.to_string() +} + +fn external_frame_allowed(item: &str) -> bool { + let Ok(url) = url::Url::parse(item) else { + return false; + }; + let host = url.host_str().unwrap_or_default(); + !matches!(host, "github.com" | "www.github.com") +} + +fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str) -> Option { + let item_id = ItemId::parse(item)?.normalized_storage(); + let url = url::Url::parse(item_id.as_str()).ok()?; + if !url + .host_str() + .map(|h| h.eq_ignore_ascii_case("github.com")) + .unwrap_or(false) + { + return None; + } + let children_rpc = template_json_compact(&HtmlUiAction::ResolveExternal { + room_wire: nav.room_wire.clone(), + item_storage: item_id.as_str().to_string(), + mode: "children".to_string(), + next: next.to_string(), + form_action: "/ui".to_string(), + }) + .ok()?; + let siblings_rpc = item_id.parent().and_then(|_| { + template_json_compact(&HtmlUiAction::ResolveExternal { + room_wire: nav.room_wire.clone(), + item_storage: item_id.as_str().to_string(), + mode: "siblings".to_string(), + next: next.to_string(), + form_action: "/ui".to_string(), + }) + .ok() + }); + + Some(html! { + section id="external-resolver-panel" class="ont-tab-panel ont-external-resolver" { + h3 { "GitHub resolver" } + p class="muted" { + "Import GitHub neighbors on demand. Results are saved as system ingests." + } + div class="resolver-actions" { + form method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(children_rpc); + button type="submit" data-testid="github-resolve-children" { "Load children from GitHub" } + } + @if let Some(rpc) = siblings_rpc { + form method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(rpc); + button type="submit" data-testid="github-resolve-siblings" { "Load siblings from GitHub" } + } + } + } + } + }) +} + async fn render_scope_view( state: AppState, browse: GardenBrowsePath, @@ -1061,11 +1176,14 @@ async fn render_scope_view( let scope = nav.scope(); let pin_ref = pinned_item_from_jar(&jar); let reduced = state.reduced.read().await; - let model = build_item_page_view_model(&reduced, &scope, browse.item()); + let child_depth = child_depth_from_uri(&uri); + let model = build_item_page_view_model(&reduced, &scope, browse.item(), child_depth); let scope_content = content_for_garden_view(&reduced, &scope); let thread_href = |tag: &str| nav.thread_url(tag); let external_empty_body = browse.is_external() && model.body.is_none(); let cli_path_arg = item_display_path(&model.item); + let external_href = external_source_href(&model.item); + let external_can_embed = external_frame_allowed(&model.item); let (garden_room, garden_prefix) = garden_layout_meta(&nav); let next_for_pin = uri .path_and_query() @@ -1104,7 +1222,24 @@ async fn render_scope_view( div class="ont-item-content ont-external-empty" { p { "This is an external scope." } p class="muted" { - button type="button" disabled { "Kick off an Agent Run to import and rank items" } + "This scope has no imported body yet. Open the source page directly." + } + p { + a href=(external_href.as_str()) target="_blank" rel="noopener noreferrer" { + "Open external URL" + } + } + @if external_can_embed { + p class="muted" { "Best-effort embedded preview:" } + iframe + class="ont-external-frame" + src=(external_href.as_str()) + sandbox="" + loading="lazy" + referrerpolicy="no-referrer" + style="width:100%;height:360px;border:1px solid currentColor;" {} + } @else { + p class="muted" { "Embedded preview is unavailable for this host." } } } } @else { @@ -1112,6 +1247,10 @@ async fn render_scope_view( } } + @if let Some(markup) = github_resolver_controls(&model.item, &nav, &next_for_pin) { + (markup) + } + @if !model.rank_history.is_empty() { details class="ont-rank-history" { summary { @@ -1182,7 +1321,13 @@ async fn render_scope_view( } section class="ont-tab-panel ont-tab-panel-children" { - h3 { "ranked child groups" } + h3 { + "ranked child groups" + @if model.child_depth > 1 { + " " + span class="muted" { (format!("(depth {})", model.child_depth)) } + } + } @if model.child_rankings.component_rankings.is_empty() { p class="muted" { "no voted pairs yet in this scope" } } @else { @@ -1415,7 +1560,7 @@ async fn vote_compare_inner( #[cfg(test)] mod tests { - use super::build_item_page_view_model; + use super::{build_item_page_view_model, external_source_href}; use crate::{ events::{Event, Ingest}, reducer::{ReducerState, ScopeId}, @@ -1516,7 +1661,7 @@ mod tests { ~/topic/b {beta}\n", ); - let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a"); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); assert_eq!(model.body.as_deref(), Some("alpha")); assert!(model.sibling_nav.is_some()); assert!(model.child_rankings.component_rankings.is_empty()); @@ -1536,7 +1681,7 @@ mod tests { {a beats b}\n ~/topic/a 2:1 ~/topic/b\n", ); - let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a"); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); let nav = model.sibling_nav.expect("expected sibling nav"); assert_eq!(nav.groups.len(), 2); assert_eq!(nav.groups[0].links.len(), 2); @@ -1558,7 +1703,7 @@ mod tests { {a beats b}\n ~/topic/a 2:1 ~/topic/b\n", ); - let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a"); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); let nav = model.sibling_nav.expect("expected sibling nav"); assert_eq!(nav.groups.len(), 3); assert_eq!(nav.groups[0].links.len(), 2); @@ -1582,7 +1727,7 @@ mod tests { ~/topic/kid1/leaf {leaf}\n", ); - let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic"); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic", 1); assert_eq!(model.child_rankings.component_rankings.len(), 1); assert_eq!(model.child_rankings.component_rankings[0].pairs, 1); let names: Vec<&str> = model.child_rankings.component_rankings[0] @@ -1625,6 +1770,7 @@ mod tests { &reduced, &ScopeId::Room("9ab12cd/my-room".to_string()), root.as_str(), + 1, ); assert!(!model.item_has_parent); assert_eq!(model.child_rankings.unranked_items.len(), 2); @@ -1655,6 +1801,7 @@ mod tests { &reduced, &ScopeId::Room("9ab12cd/my-room".to_string()), root.as_str(), + 1, ); assert_eq!(model.child_rankings.component_rankings.len(), 1); assert_eq!(model.child_rankings.component_rankings[0].pairs, 1); @@ -1680,11 +1827,56 @@ mod tests { "@00000000-0000-0000-0000-000000000000:test:local/test\n~/x {x}\n", ); let model = - build_item_page_view_model(&reduced, &ScopeId::Public, "https://slug.social/~/"); + build_item_page_view_model(&reduced, &ScopeId::Public, "https://slug.social/~/", 1); assert_eq!(model.child_rankings.unranked_items.len(), 1); assert_eq!( model.child_rankings.unranked_items[0].as_str(), "https://slug.social/~/x" ); } + + #[test] + fn item_page_model_depth_includes_descendants() { + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "@00000000-0000-0000-0000-000000000000:test:local/test\n\ + ~/topic {root}\n\ + ~/topic/a {alpha}\n\ + ~/topic/a/leaf {leaf}\n\ + ~/topic/b {beta}\n", + ); + + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic", 2); + let items: std::collections::HashSet<&str> = model + .child_rankings + .unranked_items + .iter() + .map(|u| u.as_str()) + .collect(); + assert!(items.contains("https://slug.social/~/topic/a")); + assert!(items.contains("https://slug.social/~/topic/a/leaf")); + assert!(items.contains("https://slug.social/~/topic/b")); + } + + #[test] + fn external_source_href_maps_youtube_path_identity_back_to_watch_url() { + assert_eq!( + external_source_href("https://www.youtube.com/watch/v/dQw4w9WgXcQ"), + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + ); + assert_eq!( + external_source_href("https://github.com/sortersocial/slug"), + "https://github.com/sortersocial/slug" + ); + } + + #[test] + fn external_frame_allowed_skips_known_blocked_hosts() { + assert!(!super::external_frame_allowed( + "https://github.com/sortersocial/slug" + )); + assert!(super::external_frame_allowed("https://example.com/path")); + } } diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs index 874fe729865c8b6179c94745221d8b79d625f642..8ab6f2924c3c66f826f38c487c48d6140eee4671 100644 --- a/server/src/html/ui_action.rs +++ b/server/src/html/ui_action.rs @@ -66,10 +66,17 @@ pub enum HtmlUiAction { #[serde(default = "default_ui_form_action")] form_action: String, }, - /// Author redacts own post via `POST /ui`. - RedactPost { - post_id: String, + /// Resolve children or siblings for an external garden item. + ResolveExternal { + room_wire: String, + item_storage: String, + mode: String, + next: String, + #[serde(default = "default_ui_form_action")] + form_action: String, }, + /// Author redacts own post via `POST /ui`. + RedactPost { post_id: String }, /// Morph `#room-members-section` — members list open or collapsed (server-rendered). SetRoomMembersExpanded { room_wire: String, @@ -77,9 +84,7 @@ pub enum HtmlUiAction { expanded: bool, }, /// Delete the private room (Manage only); redirects to `/` on success. - DeleteRoom { - room: String, - }, + DeleteRoom { room: String }, /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home). SetNewThreadComposeExpanded { room_wire: String, @@ -117,13 +122,14 @@ pub enum HtmlUiParseError { } /// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize. -pub fn parse_html_ui_from_form(form: &HashMap) -> Result { - let template = form - .get(UI_RPC_FIELD) - .ok_or(HtmlUiParseError::MissingRpc)?; +pub fn parse_html_ui_from_form( + form: &HashMap, +) -> Result { + let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?; let mut hole_map = form.clone(); hole_map.remove(UI_RPC_FIELD); - let v: Value = fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?; + let v: Value = + fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?; serde_json::from_value(v).map_err(HtmlUiParseError::Action) } diff --git a/server/src/lib.rs b/server/src/lib.rs index b902094653cf9319efd14e67b91436e269362441..84e94bbec144eae77de68482385941cd2c5845eb 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,11 +3,11 @@ pub mod paths; pub mod api; pub mod canonical_path; pub mod dsl; +pub mod event_log; +pub mod events; pub mod external_resolver; pub mod form_template; pub mod html; -pub mod event_log; -pub mod events; pub mod identity; pub mod middleware; pub mod path_types; @@ -50,16 +50,18 @@ pub fn create_app_state(cfg: AppConfig) -> AppState { js_tx, write_tx, views, + resolver_runs: Arc::new(RwLock::new(HashMap::new())), + github_resolver: Arc::new(crate::external_resolver::GitHubResolver::from_env()), }; - tokio::spawn(crate::api::write_actor::writer_actor(write_rx, state.clone())); + tokio::spawn(crate::api::write_actor::writer_actor( + write_rx, + state.clone(), + )); state } /// Spawn the serialized writer. Used by integration tests after seeding reducer state in-process. -pub fn spawn_writer_actor_for_test( - state: AppState, - rx: tokio::sync::mpsc::Receiver, -) { +pub fn spawn_writer_actor_for_test(state: AppState, rx: tokio::sync::mpsc::Receiver) { tokio::spawn(crate::api::write_actor::writer_actor(rx, state)); } @@ -89,10 +91,7 @@ pub fn create_app(state: AppState) -> Router { .route("/-", get(crate::html::external_garden_index)) .route("/-/*path", get(crate::html::external_ontology_path)) .route("/r/:room_key/~", get(crate::html::room_garden_index)) - .route( - "/r/:room_key/~/*path", - get(crate::html::room_ontology_path), - ) + .route("/r/:room_key/~/*path", get(crate::html::room_ontology_path)) .route( "/r/:room_key/-", get(crate::html::room_external_garden_index), diff --git a/server/src/state.rs b/server/src/state.rs index 13831e868031edc19fda0f347376e0b17e8b99b7..8a80fed0a6bc7ae3516c76ab9d1e8592d7038ad1 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use tokio::sync::{broadcast, mpsc, RwLock}; -use crate::{event_log::EventLog, events::ThreadCapability, reducer::ReducerState, write_cmd::WriteCmd}; +use crate::{ + event_log::EventLog, events::ThreadCapability, external_resolver::GitHubResolver, + reducer::ReducerState, write_cmd::WriteCmd, +}; /// Ephemeral invite link (24h TTL, in-memory only; not written to the event log). #[derive(Debug, Clone)] @@ -24,7 +27,7 @@ pub struct PendingSession { pub provider_id: Option, /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`]. pub redeem_invite: Option, - pub complete: Option<(String /*username*/, String /*bearer*/ )>, + pub complete: Option<(String /*username*/, String /*bearer*/)>, } /// An SSE event broadcast to all live stream subscribers when an ingest occurs. @@ -68,6 +71,9 @@ pub struct AppState { /// All durable writes and reducer mutations are serialized through this channel. pub write_tx: mpsc::Sender, pub views: crate::views::ViewStore, + /// Ephemeral resolver cooldowns keyed by `room + item`: not persisted; durable results are JSONL ingests. + pub resolver_runs: Arc>>, + pub github_resolver: Arc, } impl AppState { @@ -94,7 +100,8 @@ impl AppState { js_tx, write_tx, views, + resolver_runs: Arc::new(RwLock::new(HashMap::new())), + github_resolver: Arc::new(GitHubResolver::from_env()), } } } - diff --git a/server/src/write_cmd.rs b/server/src/write_cmd.rs index 494aaeadf7bb919768788e101c3c1ffa12da8503..094a1b80b64caa327af523dc36c618ea04551cdc 100644 --- a/server/src/write_cmd.rs +++ b/server/src/write_cmd.rs @@ -14,6 +14,13 @@ pub enum WriteCmd { bearer: String, reply: oneshot::Sender, }, + SystemIngest { + room: String, + thread_tag: String, + text: String, + principal: String, + reply: oneshot::Sender, + }, Redact { post_id: String, bearer: String, diff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj new file mode 100644 index 0000000000000000000000000000000000000000..1d5b6f1c4575e67ec430e4ce7f02c1bca5a35775 --- /dev/null +++ b/test/browser_github_resolver.clj @@ -0,0 +1,133 @@ +(ns test.browser-github-resolver + "Playwright coverage for on-demand GitHub external resolver imports." + (:require [babashka.fs :as fs] + [cheshire.core :as json] + [clojure.string :as str] + [clojure.test :refer [deftest is]] + [com.blockether.spel.core :as core] + [com.blockether.spel.locator :as locator] + [com.blockether.spel.page :as page] + [org.httpkit.server :as http] + [test.common :as common] + [test.oauth :as oauth])) + +(defn- wait-for-text [pg selector expected timeout-ms] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [text (locator/text-content (page/locator pg selector))] + (if (and (string? text) (str/includes? text expected)) + true + (if (< (System/currentTimeMillis) deadline) + (do (Thread/sleep 200) (recur)) + false)))))) + +(defn- start-mock-github [port] + (let [!paths (atom []) + handler (fn [req] + (swap! !paths conj (:uri req)) + (case (:uri req) + "/users/octo/repos" + {:status 200 + :headers {"Content-Type" "application/json"} + :body (json/generate-string + [{:name "hello" + :full_name "octo/hello" + :description "Hello repo"} + {:name "other" + :full_name "octo/other" + :description "Other repo"}])} + + "/repos/octo/hello/issues" + {:status 200 + :headers {"Content-Type" "application/json"} + :body (json/generate-string + [{:number 42 + :title "Seeded issue" + :body "Already pasted"} + {:number 43 + :title "Imported sibling" + :body "Loaded from mock GitHub"} + {:number 99 + :title "PR should be filtered" + :pull_request {}}])} + + {:status 404 :body "not found"})) + stop-fn (http/run-server handler {:port port})] + {:stop-fn stop-fn :paths !paths})) + +(defn github-resolver-flow! [] + (println "\n━━━ browser GitHub resolver: on-demand children and siblings ━━━\n") + + (common/letlocals + (bind build (common/run-cargo-build-release! ["slugsocial-server"])) + (is (zero? (:exit build)) "cargo build succeeds") + (bind server-bin "target/release/slugsocial-server") + + (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-browser-github-resolver-"}))) + (bind slug-port (common/pick-port)) + (bind google-port (common/pick-port)) + (bind github-port (common/pick-port)) + (bind base-url (str "http://127.0.0.1:" slug-port)) + (bind google-url (str "http://127.0.0.1:" google-port)) + (bind github-url (str "http://127.0.0.1:" github-port)) + + (bind !server (atom nil)) + (bind !google (atom nil)) + (bind !github (atom nil)) + (bind server-env (assoc (common/slug-server-env tmp-dir base-url google-url slug-port) + "SLUG_GITHUB_API_BASE_URL" github-url)) + (try + (reset! !google (oauth/start-mock-google google-port + :google-users ["google-user-alice"])) + (reset! !github (start-mock-github github-port)) + (reset! !server (common/start-server server-bin server-env)) + (is (common/wait-for-server base-url 10000) "server responds to /healthz") + + (let [alice-token (oauth/fetch-bearer-token! base-url :username "alice") + seed-resp (oauth/http-post-json + (str base-url "/api/v0/rpc") + [{"Post" {"room" "public" + "thread_tag" "github-resolver-seed" + "text" "https://github.com/octo/hello/issues/42 {Seed pasted issue}" + "return_rank_diff" false}}] + :headers {"Authorization" (str "Bearer " alice-token)}) + seed-json (json/parse-string (:body seed-resp) false)] + (is (true? (get-in seed-json ["results" 0 "ok"])) "seed pasted GitHub issue") + + (core/with-playwright [pw] + (core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})] + (core/with-context [ctx (core/new-context browser)] + (core/with-page [pg (core/new-page-from-context ctx)] + (page/navigate pg (str base-url "/login")) + (is (wait-for-text pg "body" "@alice" 15000) "alice session after login") + + (page/navigate pg (str base-url "/-/github.com/octo")) + (is (wait-for-text pg "#external-resolver-panel" "GitHub resolver" 15000) + "user page shows resolver panel") + (locator/click (page/locator pg "[data-testid=\"github-resolve-children\"]")) + (is (wait-for-text pg "body" "-/github.com/octo/other" 15000) + "user resolver imports repos") + + (page/navigate pg (str base-url "/-/github.com/octo/hello")) + (locator/click (page/locator pg "[data-testid=\"github-resolve-children\"]")) + (is (wait-for-text pg "body" "-/github.com/octo/hello/pulls" 15000) + "repo resolver imports structural children") + + (page/navigate pg (str base-url "/-/github.com/octo/hello/issues/42")) + (locator/click (page/locator pg "[data-testid=\"github-resolve-siblings\"]")) + (page/navigate pg (str base-url "/-/github.com/octo/hello/issues")) + (is (wait-for-text pg "body" "-/github.com/octo/hello/issues/43" 15000) + "issue sibling resolver imports issue siblings"))))) + + (is (some #{"/users/octo/repos"} @(:paths @!github)) + "mock GitHub saw user repos request") + (is (some #{"/repos/octo/hello/issues"} @(:paths @!github)) + "mock GitHub saw repo issues request")) + + (finally + (when-some [s @!server] (common/kill-server s)) + (when-some [g @!google] ((:stop-fn g))) + (when-some [g @!github] ((:stop-fn g))))))) + +(deftest browser-github-resolver-test + (github-resolver-flow!)) diff --git a/test/browser_ui_morph.clj b/test/browser_ui_morph.clj index 1dad7eb142728e14f0fe1a77747c6f443192cd03..7740a44546de12731833f048a19c35af8c6f2d1e 100644 --- a/test/browser_ui_morph.clj +++ b/test/browser_ui_morph.clj @@ -73,8 +73,8 @@ (is (wait-for-text pg "nav.breadcrumb" "repo" 5000) "breadcrumb includes path segment repo") (is (wait-for-text pg ".ont-external-empty" "This is an external scope." 10000) "external item shows empty-state copy") - (is (wait-for-text pg ".ont-external-empty" "Kick off an Agent Run to import and rank items" 5000) - "external empty state shows disabled agent-run button label") + (is (wait-for-text pg ".ont-external-empty" "Open external URL" 5000) + "external empty state links to source URL") (page/navigate pg (str base-url "/t/" thread-tag)) (is (wait-for-text pg "#thread-feed-region" "[show full post]" 15000) "truncated card shows expand link") diff --git a/tests.edn b/tests.edn index 103d38a1769a8f63754f08fb1e42a24c80ee37dc..5e2b0433a66c51806d08ef38d691259cfccd1fe0 100644 --- a/tests.edn +++ b/tests.edn @@ -20,7 +20,8 @@ "^test\\.browser-public-garden$" "^test\\.browser-redact-thread-index$" "^test\\.browser-garden-pin$" - "^test\\.browser-vote-compare$"] + "^test\\.browser-vote-compare$" + "^test\\.browser-github-resolver$"] :kaocha.filter/skip-meta [:skip] :parallel? false}] :plugins [:kaocha.plugin/junit-xml] diff --git a/types/src/paths.rs b/types/src/paths.rs index 8669f6872b9fa71d72a3f5c346945427bc00ac5a..791a44a0850b54b2e6baf16b47726bde507194c6 100644 --- a/types/src/paths.rs +++ b/types/src/paths.rs @@ -361,13 +361,14 @@ mod tests { SLUG_TILDE_ONTOLOGY_ROOT.to_string() ); assert_eq!( - ItemId::parse("https://slug.social/~/") - .unwrap() - .as_str(), + ItemId::parse("https://slug.social/~/").unwrap().as_str(), SLUG_TILDE_ONTOLOGY_ROOT ); let legacy = ItemId::opaque("https://slug.social/~/".to_string()); - assert_eq!(legacy.normalized_storage().as_str(), SLUG_TILDE_ONTOLOGY_ROOT); + assert_eq!( + legacy.normalized_storage().as_str(), + SLUG_TILDE_ONTOLOGY_ROOT + ); } #[test] @@ -423,7 +424,8 @@ mod tests { #[test] fn garden_private_room_prefixes_ontology() { assert_eq!( - GardenItemUrl::from_storage_str("https://slug.social/~/topic/x", "9ab12cd/my-room").as_str(), + GardenItemUrl::from_storage_str("https://slug.social/~/topic/x", "9ab12cd/my-room") + .as_str(), "https://slug.social/r/9ab12cdmy-room/~/topic/x" ); } @@ -465,11 +467,11 @@ mod tests { fn canonicalize_youtube_short_links() { assert_eq!( canonicalize_item("https://youtu.be/dQw4w9WgXcQ"), - "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + "https://youtu.be/dQw4w9WgXcQ" ); assert_eq!( canonicalize_item("-/youtu.be/dQw4w9WgXcQ"), - "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + "https://youtu.be/dQw4w9WgXcQ" ); } @@ -487,12 +489,29 @@ mod tests { } #[test] - fn item_id_parent_external_strips_last_segment() { - let c = ItemId::parse("https://spotify.com/track/1").unwrap(); + fn canonicalize_external_url_strips_fragment_and_query_params() { + assert_eq!( + canonicalize_item("https://example.com/path?utm_source=newsletter&b=2#section"), + "https://example.com/path" + ); assert_eq!( - c.parent().unwrap().as_str(), - "https://spotify.com/track" + canonicalize_item("-/example.com/path?FbClId=abc&a=1"), + "https://example.com/path" ); + } + + #[test] + fn canonicalize_github_external_url_normalizes_repo_identity() { + assert_eq!( + canonicalize_item("https://github.com/ORG/REPO.git?tab=issues&q=is%3Aopen"), + "https://github.com/org/repo" + ); + } + + #[test] + fn item_id_parent_external_strips_last_segment() { + let c = ItemId::parse("https://spotify.com/track/1").unwrap(); + assert_eq!(c.parent().unwrap().as_str(), "https://spotify.com/track"); assert_eq!( ItemId::parse("https://github.com/iss/1") .unwrap() @@ -501,7 +520,10 @@ mod tests { .as_str(), "https://github.com/iss" ); - assert!(ItemId::parse("https://github.com").unwrap().parent().is_none()); + assert!(ItemId::parse("https://github.com") + .unwrap() + .parent() + .is_none()); } #[test] diff --git a/types/src/url_normalize.rs b/types/src/url_normalize.rs index 999baa4f3367b632763b44ddc77b894451207c50..b655f4d834dd5a482aa1b5adad25b33461dbb76e 100644 --- a/types/src/url_normalize.rs +++ b/types/src/url_normalize.rs @@ -1,7 +1,9 @@ //! Normalization for external `http(s)://` item identity (not slug tilde ontology). //! //! Policy (intentional, extend here as new domains need treatment): -//! - Query pairs sorted lexicographically by **lowercased** key, then value. +//! - Query pairs are navigation/filter state by default and are stripped from identity. +//! - Known query-primary resources may canonicalize to path-based short links (YouTube `youtu.be/:id`). +//! - Fragments are stripped from identity. //! - YouTube family → stable `www.youtube.com` shapes where possible. use url::Url; @@ -23,8 +25,10 @@ pub fn normalize_http_identity_url(s: &str) -> Option { if !matches!(u.scheme(), "http" | "https") { return None; } + u.set_fragment(None); rewrite_youtube(&mut u); - sort_query_pairs(&mut u); + normalize_github(&mut u); + u.set_query(None); Some(u.to_string()) } @@ -45,23 +49,18 @@ fn rewrite_youtube(u: &mut Url) { match base.as_str() { "youtu.be" => { - let id = path.trim_start_matches('/').split('/').next().unwrap_or("").to_string(); + let id = path + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string(); if id.is_empty() { return; } - let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - let Ok(mut out) = Url::parse(&format!("https://www.youtube.com/watch?v={id}")) else { + let Ok(out) = Url::parse(&format!("https://youtu.be/{id}")) else { return; }; - { - let mut q = out.query_pairs_mut(); - for (k, v) in saved { - if k.eq_ignore_ascii_case("v") { - continue; - } - q.append_pair(&k, &v); - } - } *u = out; } "youtube.com" | "m.youtube.com" => { @@ -80,20 +79,9 @@ fn rewrite_youtube(u: &mut Url) { if id.is_empty() { return; } - let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - let Ok(mut out) = Url::parse(&format!("https://www.youtube.com/watch?v={id}")) - else { + let Ok(out) = Url::parse(&format!("https://youtu.be/{id}")) else { return; }; - { - let mut q = out.query_pairs_mut(); - for (k, v) in saved { - if k.eq_ignore_ascii_case("v") { - continue; - } - q.append_pair(&k, &v); - } - } *u = out; return; } @@ -109,25 +97,21 @@ fn rewrite_youtube(u: &mut Url) { if id.is_empty() { return; } - let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - let Ok(mut out) = Url::parse(&format!("https://www.youtube.com/watch?v={id}")) - else { + let Ok(out) = Url::parse(&format!("https://youtu.be/{id}")) else { return; }; - { - let mut q = out.query_pairs_mut(); - for (k, v) in saved { - if k.eq_ignore_ascii_case("v") { - continue; - } - q.append_pair(&k, &v); - } - } *u = out; return; } if path.starts_with("/watch") { let _ = u.set_host(Some("www.youtube.com")); + if let Some((_, id)) = u.query_pairs().find(|(k, _)| k.eq_ignore_ascii_case("v")) { + if !id.is_empty() { + if let Ok(out) = Url::parse(&format!("https://youtu.be/{id}")) { + *u = out; + } + } + } return; } if path.starts_with("/shorts/") { @@ -142,17 +126,9 @@ fn rewrite_youtube(u: &mut Url) { if id.is_empty() { return; } - let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - let Ok(mut out) = Url::parse(&format!("https://www.youtube.com/shorts/{id}")) - else { + let Ok(out) = Url::parse(&format!("https://www.youtube.com/shorts/{id}")) else { return; }; - { - let mut q = out.query_pairs_mut(); - for (k, v) in saved { - q.append_pair(&k, &v); - } - } *u = out; return; } @@ -163,25 +139,32 @@ fn rewrite_youtube(u: &mut Url) { } } -fn sort_query_pairs(u: &mut Url) { - let pairs: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - if pairs.is_empty() { - u.set_query(None); +fn normalize_github(u: &mut Url) { + let Some(host_raw) = u.host_str() else { + return; + }; + if host_raw.to_ascii_lowercase() != "github.com" { return; } - let mut pairs = pairs; - pairs.sort_by(|a, b| { - a.0.to_ascii_lowercase() - .cmp(&b.0.to_ascii_lowercase()) - .then_with(|| a.1.cmp(&b.1)) - }); - u.set_query(None); - { - let mut q = u.query_pairs_mut(); - for (k, v) in pairs { - q.append_pair(&k, &v); + + let Some(segments) = u.path_segments() else { + return; + }; + let mut segments: Vec = segments + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()) + .collect(); + if let Some(repo) = segments.get_mut(1) { + if let Some(stripped) = repo.strip_suffix(".git") { + *repo = stripped.to_string(); } } + + if segments.is_empty() { + u.set_path("/"); + } else { + u.set_path(&format!("/{}", segments.join("/"))); + } } #[cfg(test)] @@ -192,19 +175,19 @@ mod tests { fn youtube_youtu_be_to_watch() { assert_eq!( normalize_http_identity_url("https://youtu.be/dQw4w9WgXcQ").as_deref(), - Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + Some("https://youtu.be/dQw4w9WgXcQ") ); } #[test] - fn youtube_watch_query_sorted() { + fn youtube_watch_v_query_becomes_path_child() { assert_eq!( normalize_http_identity_url("https://youtube.com/watch?v=Z&a=1&b=2").as_deref(), - Some("https://www.youtube.com/watch?a=1&b=2&v=Z") + Some("https://youtu.be/Z") ); assert_eq!( normalize_http_identity_url("https://youtube.com/watch?b=2&a=1&v=Z").as_deref(), - Some("https://www.youtube.com/watch?a=1&b=2&v=Z") + Some("https://youtu.be/Z") ); } @@ -212,7 +195,7 @@ mod tests { fn youtube_embed_to_watch() { assert_eq!( normalize_http_identity_url("https://www.youtube.com/embed/dQw4w9WgXcQ").as_deref(), - Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + Some("https://youtu.be/dQw4w9WgXcQ") ); } @@ -225,10 +208,56 @@ mod tests { } #[test] - fn arbitrary_query_sorted() { + fn arbitrary_query_is_stripped() { assert_eq!( normalize_http_identity_url("https://example.com/x?z=1&a=2").as_deref(), - Some("https://example.com/x?a=2&z=1") + Some("https://example.com/x") + ); + } + + #[test] + fn fragment_is_stripped_from_identity() { + assert_eq!( + normalize_http_identity_url("https://example.com/path?b=2#a-section").as_deref(), + Some("https://example.com/path") + ); + } + + #[test] + fn all_generic_query_params_are_stripped() { + assert_eq!( + normalize_http_identity_url( + "https://example.com/x?z=1&utm_source=newsletter&FbClId=abc&ref=share&a=2" + ) + .as_deref(), + Some("https://example.com/x") + ); + } + + #[test] + fn stripping_tracking_params_removes_empty_query() { + assert_eq!( + normalize_http_identity_url("https://example.com/x?utm_medium=email&si=share") + .as_deref(), + Some("https://example.com/x") + ); + } + + #[test] + fn youtube_identity_query_is_promoted_before_query_cleanup() { + assert_eq!( + normalize_http_identity_url("https://youtu.be/dQw4w9WgXcQ?si=share&v=ignored&t=12") + .as_deref(), + Some("https://youtu.be/dQw4w9WgXcQ") + ); + } + + #[test] + fn github_repo_suffix_and_noise_query_are_normalized() { + assert_eq!( + normalize_http_identity_url("https://github.com/ORG/REPO.git?tab=readme&q=is%3Aopen") + .as_deref(), + Some("https://github.com/org/repo") ); } } @@ -324,7 +353,10 @@ mod url_identity_tests { fn empty_path_vs_slash_only_path_may_differ() { let root = Url::parse("https://example.com").unwrap(); let slash = Url::parse("https://example.com/").unwrap(); - assert_eq!(root, slash, "root and trailing-slash-only merge for this parser"); + assert_eq!( + root, slash, + "root and trailing-slash-only merge for this parser" + ); } #[test] @@ -353,6 +385,9 @@ mod url_identity_tests { let plus = Url::parse("https://example.com/?q=a+b").unwrap(); let encoded = Url::parse("https://example.com/?q=a%20b").unwrap(); - assert_ne!(plus, encoded, "space as + vs %20 — different keys unless normalized"); + assert_ne!( + plus, encoded, + "space as + vs %20 — different keys unless normalized" + ); } }