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: [08565bea] Tokenize prose refs for garden URL links (#147) * Tokenize prose refs for garden URL links Co-authored-by: tommy * Stop prose URLs at line boundaries Co-authored-by: tommy * Require braced DSL item bodies Co-authored-by: tommy --------- Co-authored-by: Cursor Agent Side B — unified diff (full patch): diff --git a/server/src/dsl.rs b/server/src/dsl.rs index 4203c2f59dd8825d7a91513c4023f3ccd37f1efc..b3c785674560a0b42a7f4c3aa13d80cd350f485b 100644 --- a/server/src/dsl.rs +++ b/server/src/dsl.rs @@ -1,7 +1,5 @@ use std::collections::HashMap; -use rand::Rng; - /// Parsed DSL document. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Document { @@ -39,31 +37,57 @@ pub enum DslError { /// Matches the legacy Python parser behavior: /// - Supports toggle markers (open == close), e.g. ```...``` /// - Supports nested markers (open != close), e.g. { ... { ... } ... } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockKind { + CodeFence, + DoubleBrace, + Brace, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MaskedBlock { + kind: BlockKind, +} + #[derive(Debug, Default, Clone)] pub struct BlockMasker { pub replacements: HashMap, + blocks: HashMap, + next_id: u32, } impl BlockMasker { pub fn new() -> Self { Self { replacements: HashMap::new(), + blocks: HashMap::new(), + next_id: 0, } } - fn new_token(&mut self) -> String { - let mut rng = rand::thread_rng(); - let n: u32 = rng.gen(); - let token = format!("__BLOCK_{:08x}__", n); - // Extremely unlikely collision; if it happens, regenerate. - if self.replacements.contains_key(&token) { - return self.new_token(); + fn new_token(&mut self, haystack: &str) -> String { + loop { + let token = format!("__BLOCK_{:08x}__", self.next_id); + self.next_id = self.next_id.wrapping_add(1); + if !self.replacements.contains_key(&token) && !haystack.contains(&token) { + return token; + } } - token } /// Replace outermost balanced blocks with tokens. pub fn mask(&mut self, text: &str, open_marker: &str, close_marker: &str) -> String { + self.mask_kind(text, open_marker, close_marker, BlockKind::Brace) + } + + /// Replace outermost balanced blocks with typed deterministic tokens. + pub fn mask_kind( + &mut self, + text: &str, + open_marker: &str, + close_marker: &str, + kind: BlockKind, + ) -> String { if text.is_empty() { return text.to_string(); } @@ -97,9 +121,10 @@ impl BlockMasker { // Found end of outermost block let s = start_idx.max(0) as usize; let original_block = &text[s..i]; - let token = self.new_token(); + let token = self.new_token(text); self.replacements .insert(token.clone(), original_block.to_string()); + self.blocks.insert(token.clone(), MaskedBlock { kind }); result_parts.push(token); current_idx = i; } @@ -176,13 +201,22 @@ impl BlockMasker { } token.to_string() } + + pub fn block_kind(&self, token: &str) -> Option { + self.blocks.get(token).map(|b| b.kind) + } } fn mask_all(mut masker: BlockMasker, text: &str) -> (BlockMasker, String) { // Mask hierarchy: Code -> Double Brace -> Single Brace. - let t = masker.mask(text, "```", "```"); - let t = masker.mask(&t, "{{", "}}"); - let t = masker.mask(&t, "{", "}"); + let t = masker.mask_kind(text, "```", "```", BlockKind::CodeFence); + let t = masker.mask_kind(&t, "{{", "}}", BlockKind::DoubleBrace); + let t = masker.mask_kind(&t, "{", "}", BlockKind::Brace); + (masker, t) +} + +fn mask_code_fences(mut masker: BlockMasker, text: &str) -> (BlockMasker, String) { + let t = masker.mask_kind(text, "```", "```", BlockKind::CodeFence); (masker, t) } @@ -253,7 +287,34 @@ fn skip_ws(s: &str, mut i: usize) -> usize { i } -fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> { +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProseToken { + Text(String), + ItemRef(String), +} + +fn trim_prose_item_ref_end(s: &str, mut end: usize) -> usize { + while end > 0 { + let Some((idx, c)) = s[..end].char_indices().next_back() else { + break; + }; + if matches!( + c, + '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' + ) { + end = idx; + } else { + break; + } + } + end +} + +fn parse_item_name_at_with_mode( + s: &str, + i: usize, + trim_trailing_punctuation: bool, +) -> Option<(String, usize)> { let bytes = s.as_bytes(); if i >= bytes.len() { return None; @@ -263,6 +324,9 @@ fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> { if s[i..].starts_with("https://") || s[i..].starts_with("http://") { let mut j = i; while j < bytes.len() { + if trim_trailing_punctuation && bytes[j] == b'\n' { + break; + } if bytes[j..].starts_with(b"__BLOCK_") || is_ws_byte(bytes[j]) { break; } @@ -271,6 +335,12 @@ fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> { if j <= i { return None; } + if trim_trailing_punctuation { + j = trim_prose_item_ref_end(s, j); + if j <= i { + return None; + } + } return Some((s[i..j].to_string(), j)); } @@ -296,6 +366,12 @@ fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> { if j <= i + 2 { return None; } + if trim_trailing_punctuation { + j = trim_prose_item_ref_end(s, j); + if j <= i + 2 { + return None; + } + } let raw = &s[i..j]; if !is_item_name(raw) { return None; @@ -336,6 +412,46 @@ fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> { Some((format!("~/{}", name), j)) } +fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> { + parse_item_name_at_with_mode(s, i, false) +} + +pub fn parse_prose_item_ref_at(s: &str, i: usize) -> Option<(String, usize)> { + parse_item_name_at_with_mode(s, i, true) +} + +pub fn tokenize_prose_item_refs(text: &str) -> Vec { + if text.is_empty() { + return Vec::new(); + } + let (masker, masked) = mask_code_fences(BlockMasker::new(), text); + let mut tokens = Vec::new(); + let mut text_start = 0usize; + let mut i = 0usize; + + while i < masked.len() { + if let Some((raw, end)) = parse_prose_item_ref_at(&masked, i) { + if text_start < i { + tokens.push(ProseToken::Text(masker.unmask(&masked[text_start..i]))); + } + tokens.push(ProseToken::ItemRef(masker.unmask(&raw))); + i = end; + text_start = i; + continue; + } + + let Some((_, c)) = masked[i..].char_indices().next() else { + break; + }; + i += c.len_utf8(); + } + + if text_start < masked.len() { + tokens.push(ProseToken::Text(masker.unmask(&masked[text_start..]))); + } + tokens +} + fn parse_block_token_at(s: &str, i: usize) -> Option<(String, usize)> { let bytes = s.as_bytes(); if i >= bytes.len() { @@ -401,6 +517,12 @@ fn parse_block_prefixed_statement( tail: &str, masker: &BlockMasker, ) -> Result { + if masker.block_kind(block_token) == Some(BlockKind::CodeFence) { + return Err(DslError::Parse( + "vote explanations must use `{ ... }`; code fences belong inside body blocks" + .to_string(), + )); + } // vote: block item_ref comparison item_ref let s = tail.trim_start(); if s.is_empty() { @@ -462,6 +584,11 @@ fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Resu } if let Some((tok, end)) = parse_block_token_at(stripped, i) { + if masker.block_kind(&tok) == Some(BlockKind::CodeFence) { + return Err(DslError::Parse( + "item bodies must use `{ ... }`; code fences belong inside body blocks".to_string(), + )); + } let body = masker.extract_body(&tok); let tail = stripped[end..].trim(); if !tail.is_empty() { @@ -572,6 +699,10 @@ pub fn parse_full(text: &str) -> Result { if let Some((tok, end)) = parse_block_token_at(stripped, 0) { if stripped[end..].trim().is_empty() { + if masker.block_kind(&tok) == Some(BlockKind::CodeFence) { + prose_buffer.push(line); + continue; + } pending_block = Some(tok); continue; } @@ -619,6 +750,70 @@ mod tests { assert_eq!(roundtrip, input); } + #[test] + fn blockmasker_tokens_are_deterministic_and_typed() { + let input = "x ```code``` y {body}"; + let (masker, masked) = mask_all(BlockMasker::new(), input); + assert!(masked.contains("__BLOCK_00000000__")); + assert!(masked.contains("__BLOCK_00000001__")); + assert_eq!( + masker.block_kind("__BLOCK_00000000__"), + Some(BlockKind::CodeFence) + ); + assert_eq!( + masker.block_kind("__BLOCK_00000001__"), + Some(BlockKind::Brace) + ); + assert_eq!(masker.unmask(&masked), input); + } + + #[test] + fn prose_tokenizer_finds_tilde_dash_and_raw_url_refs() { + let tokens = + tokenize_prose_item_refs("see ~/a/b then -/example.com/x and https://Example.com/A/B."); + assert_eq!( + tokens, + vec![ + ProseToken::Text("see ".to_string()), + ProseToken::ItemRef("~/a/b".to_string()), + ProseToken::Text(" then ".to_string()), + ProseToken::ItemRef("-/example.com/x".to_string()), + ProseToken::Text(" and ".to_string()), + ProseToken::ItemRef("https://Example.com/A/B".to_string()), + ProseToken::Text(".".to_string()), + ] + ); + } + + #[test] + fn prose_tokenizer_stops_raw_urls_at_newlines() { + let tokens = tokenize_prose_item_refs("https://example.com/a/b.\n-/example.com/a/b"); + assert_eq!( + tokens, + vec![ + ProseToken::ItemRef("https://example.com/a/b".to_string()), + ProseToken::Text(".\n".to_string()), + ProseToken::ItemRef("-/example.com/a/b".to_string()), + ] + ); + } + + #[test] + fn prose_tokenizer_does_not_linkify_inside_code_fences() { + let tokens = tokenize_prose_item_refs( + "before ```json\n{\"url\":\"https://example.com\"}\n``` after ~/x", + ); + assert_eq!( + tokens, + vec![ + ProseToken::Text( + "before ```json\n{\"url\":\"https://example.com\"}\n``` after ".to_string() + ), + ProseToken::ItemRef("~/x".to_string()), + ] + ); + } + #[test] fn parse_item_with_body_strips_outer_braces() { let input = "~/rust { Systems language }"; @@ -633,8 +828,8 @@ mod tests { } #[test] - fn parse_item_with_fenced_json_body_preserves_braces() { - let input = "~/item/in/url ```json\n{\"test\": true}\n```"; + fn parse_item_with_braced_fenced_json_body_preserves_braces() { + let input = "~/item/in/url {\n```json\n{\"test\": true}\n```\n}"; let doc = parse_full(input).unwrap(); assert_eq!( doc.statements, @@ -645,6 +840,51 @@ mod tests { ); } + #[test] + fn parse_rejects_singleton_fenced_json_item_body() { + let input = "~/item/in/url ```json\n{\"test\": true}\n```"; + let err = parse_full(input).unwrap_err().to_string(); + assert!( + err.contains("item bodies must use"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_keeps_standalone_code_fence_as_prose() { + let input = "```json\n{\"test\": true}\n```"; + let doc = parse_full(input).unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Prose { + text: input.to_string(), + }] + ); + } + + #[test] + fn parse_rejects_code_fence_vote_explanation() { + let input = "```json\n{\"why\": true}\n```\n~/a 2:1 ~/b"; + let err = parse_full(input).unwrap_err().to_string(); + assert!( + err.contains("vote explanations must start"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_raw_url_item_with_braced_fenced_json_body() { + let input = "https://example.com/itembody/slug {\n```json\n{\"test\": true}\n```\n}"; + let doc = parse_full(input).unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Item { + title: "https://example.com/itembody/slug".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 8481f1fbff1c4265da182a225f539f97d6829002..04bc3de32b6df1aefe197d942db67de2cbdbde4f 100644 --- a/server/src/external_resolver.rs +++ b/server/src/external_resolver.rs @@ -256,7 +256,7 @@ fn children_to_dsl(children: &[ResolvedChild]) -> String { .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())); + out.push_str(&format!("{} {{\n{}\n}}\n\n", child.url, body.trim())); } else { out.push_str(&format!( "{} {{\n{}\n}}\n\n", @@ -378,7 +378,8 @@ mod tests { 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("https://github.com/o/r/issues/1 {\n```json")); assert!(dsl.contains("{\"test\": true}")); + assert!(dsl.contains("```\n}\n")); } } diff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs index 3e949ce2cb70de3f024f46af773a93f3f8852260..e48612d7d3c4df52c161499a11865d4caca607aa 100644 --- a/server/src/html/breadcrumb_path.rs +++ b/server/src/html/breadcrumb_path.rs @@ -88,16 +88,12 @@ impl ExternalOntologyPath { .filter(|x| !x.is_empty()) .map(|x| x.to_string()) .collect(); - let segments = if segments == ["."] { - vec![] - } else { - segments - }; + let segments = if segments == ["."] { vec![] } else { segments }; Self { item, segments } } pub(super) fn is_root(&self) -> bool { - self.segments.len() <= 1 + self.segments.is_empty() } pub(super) fn segments(&self) -> &[String] { @@ -108,3 +104,28 @@ impl ExternalOntologyPath { self.item.as_str() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_root_and_host_paths_are_distinct() { + let root = ExternalOntologyPath::from_input(""); + assert!(root.is_root()); + assert!(root.segments().is_empty()); + + let host = ExternalOntologyPath::from_input("example.com"); + assert!(!host.is_root()); + assert_eq!(host.segments(), &["example.com".to_string()]); + } + + #[test] + fn external_path_keeps_each_url_segment_for_breadcrumbs() { + let path = ExternalOntologyPath::from_input("https://example.com/a/b"); + assert_eq!( + path.segments(), + &["example.com".to_string(), "a".to_string(), "b".to_string()] + ); + } +} diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 8876a1a00a16ee3bf7a371f7a5ca27b5862b62cc..d5877eeb3632ed5d770f23a04cb4d1881209bc74 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -20,27 +20,30 @@ mod search; pub mod ui_action; use breadcrumb_path::{ExternalOntologyPath, OntologyPath}; -pub use auth::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page}; +pub use auth::{ + auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, + choose_username_page, +}; pub use editor::{editor_check, editor_page}; pub use forum::{ home, room_page, room_thread_post_view, room_thread_view, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup, thread_post_view, thread_view, ThreadNav, }; +pub use forum::user_profile_page; pub(crate) use forum::{ fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post, user_can_post_room, user_can_view_room, }; +pub(crate) use garden::{encode_pin_cookie_value, vote_compare_post_success_js, GARDEN_PIN_COOKIE}; pub use garden::{ external_garden_index, external_ontology_path, garden_index, ontology_path, - room_external_garden_index, room_external_ontology_path, room_garden_index, - room_ontology_path, room_vote_compare_page, vote_compare_page, + room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path, + room_vote_compare_page, vote_compare_page, }; -pub(crate) use garden::{encode_pin_cookie_value, vote_compare_post_success_js, GARDEN_PIN_COOKIE}; pub use routing::RouteContext; pub use search::{search_page, search_results_fragment}; -pub use forum::user_profile_page; pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD}; /// Public profile URL path for a stored username (no `@`). @@ -107,8 +110,8 @@ pub struct ThemeForm { pub async fn post_theme(Form(form): Form) -> impl IntoResponse { let theme = normalize_theme(&form.theme); let next = sanitize_theme_next(form.next.as_deref()); - let loc = HeaderValue::try_from(next.as_str()) - .unwrap_or_else(|_| HeaderValue::from_static("/")); + let loc = + HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static("/")); Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, loc) @@ -179,7 +182,9 @@ pub(crate) struct JsQueryBuilder { impl JsBuilder { pub(crate) fn new() -> Self { - Self { snippets: Vec::new() } + Self { + snippets: Vec::new(), + } } pub(crate) fn morph_selector(self, selector: &str, markup: Markup) -> Self { @@ -195,7 +200,12 @@ impl JsBuilder { self.qs(selector).morph_inner(markup) } - pub(crate) fn morph_expr(mut self, expr: &str, markup: Markup, morph_style: Option<&str>) -> Self { + pub(crate) fn morph_expr( + mut self, + expr: &str, + markup: Markup, + morph_style: Option<&str>, + ) -> Self { let html = js_string_literal(&markup.into_string()); let opts = morph_style .map(|style| format!(", {{morphStyle: {}}}", js_string_literal(style))) @@ -217,7 +227,11 @@ impl JsBuilder { self.qs(&format!("#{id}")) } - pub(crate) fn if_current_path_matches(mut self, path: &str, f: impl FnOnce(JsBuilder) -> JsBuilder) -> Self { + pub(crate) fn if_current_path_matches( + mut self, + path: &str, + f: impl FnOnce(JsBuilder) -> JsBuilder, + ) -> Self { let inner = f(JsBuilder::new()).build(); self.snippets.push(format!( "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0) {{ {inner} }}", @@ -226,7 +240,11 @@ impl JsBuilder { self } - pub(crate) fn if_current_path_not_matches(mut self, path: &str, f: impl FnOnce(JsBuilder) -> JsBuilder) -> Self { + pub(crate) fn if_current_path_not_matches( + mut self, + path: &str, + f: impl FnOnce(JsBuilder) -> JsBuilder, + ) -> Self { let inner = f(JsBuilder::new()).build(); self.snippets.push(format!( "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (!(__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0)) {{ {inner} }}", @@ -301,7 +319,17 @@ pub(super) fn layout( garden_room_wire: Option<&str>, garden_path_prefix: Option<&str>, ) -> Markup { - layout_embed_controls(title, view, body, views, theme, theme_next, garden_room_wire, garden_path_prefix, true) + layout_embed_controls( + title, + view, + body, + views, + theme, + theme_next, + garden_room_wire, + garden_path_prefix, + true, + ) } /// Minimal document shell: no bottom controls, no garden HUD data attributes (`data-garden-room` / @@ -315,15 +343,7 @@ pub(super) fn layout_full_bleed_chromeless( theme_next: &str, ) -> Markup { layout_embed_controls( - title, - view, - body, - views, - theme, - theme_next, - None, - None, - false, + title, view, body, views, theme, theme_next, None, None, false, ) } @@ -545,7 +565,58 @@ fn item_body_title_snippet(body: &str) -> Option { Some(format!("{truncated}{ellipsis}")) } -/// Replace ~/path slugs in raw text with clickable links. +fn garden_href_for_item_ref( + raw_ref: &str, + garden_prefix: &str, +) -> Option<(crate::path_types::ItemId, String)> { + let key = slug_types::canonicalize_item(raw_ref); + let id = crate::path_types::ItemId::parse(&key)?; + let href = if let Some(tail) = id.tilde_tail() { + if tail.is_empty() { + garden_prefix.trim_end_matches('/').to_string() + } else { + format!("{}/{}", garden_prefix.trim_end_matches('/'), tail) + } + } else if id.as_str().starts_with("https://") || id.as_str().starts_with("http://") { + let display = id.display_path(); + let rest = display.strip_prefix("-/").unwrap_or(display.as_str()); + let ext_prefix = format!("{}-", garden_prefix.trim_end_matches('~')); + format!("{}/{}", ext_prefix, rest) + } else { + return None; + }; + Some((id, href)) +} + +fn push_item_ref_anchor( + out: &mut String, + raw_ref: &str, + garden_prefix: &str, + item_bodies: Option<&HashMap>, +) -> bool { + let Some((id, href)) = garden_href_for_item_ref(raw_ref, garden_prefix) else { + return false; + }; + out.push_str(r#"'); + out.push_str(&escape_html(raw_ref)); + out.push_str(""); + true +} + +/// Replace item refs in raw prose with clickable garden links. /// /// When `item_bodies` is set, matching ontology items get a `title` attribute with a truncated /// body preview for native browser tooltips (forum posts, item pages). @@ -554,53 +625,16 @@ pub(super) fn linkify_slugs_with_prefix( garden_prefix: &str, item_bodies: Option<&HashMap>, ) -> String { - let escaped = escape_html(raw); - let mut out = String::with_capacity(escaped.len() + 64); - let mut i = 0; - let s = escaped.as_str(); - while i < s.len() { - let rest = &s[i..]; - if let Some(after_tilde) = rest.strip_prefix("~/") { - let path_len = after_tilde - .chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '/') - .map(|c| c.len_utf8()) - .sum::(); - if path_len > 0 { - let path = &after_tilde[..path_len]; - out.push_str(r#" out.push_str(&escape_html(&text)), + crate::dsl::ProseToken::ItemRef(raw_ref) => { + if !push_item_ref_anchor(&mut out, &raw_ref, garden_prefix, item_bodies) { + out.push_str(&escape_html(&raw_ref)); } - out.push('>'); - out.push_str("~/"); - out.push_str(path); - out.push_str(""); - i += 2 + path_len; - continue; } } - if let Some((j, c)) = rest.char_indices().next() { - out.push(c); - i += j + c.len_utf8(); - } else { - break; - } } out } @@ -638,7 +672,13 @@ fn spotify_embed_src(url: &str) -> Option { if !(host == "open.spotify.com" || host == "www.open.spotify.com") { return None; } - let path = tail.split('#').next().unwrap_or(tail).split('?').next().unwrap_or(tail); + let path = tail + .split('#') + .next() + .unwrap_or(tail) + .split('?') + .next() + .unwrap_or(tail); let mut segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if segs.first().is_some_and(|s| s.starts_with("intl-")) { segs.remove(0); @@ -670,8 +710,16 @@ fn youtube_embed_src(url: &str) -> Option { let host = host.to_lowercase(); let video_id = if host == "youtu.be" || host == "www.youtu.be" { - clean_media_id(tail.split(['?', '#']).next().unwrap_or(tail).trim_matches('/')) - } else if matches!(host.as_str(), "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com") { + clean_media_id( + tail.split(['?', '#']) + .next() + .unwrap_or(tail) + .trim_matches('/'), + ) + } else if matches!( + host.as_str(), + "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" + ) { let path = format!("/{}", tail.split('#').next().unwrap_or(tail)); if path.starts_with("/watch") { clean_media_id(&query_param(url, "v")?) @@ -745,10 +793,7 @@ pub(super) fn render_linkified_with_embeds_in_scope( /// CLI strings are embedded in a single-quoted JS literal; they must never need escaping. fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) { assert!( - !s.contains('\\') - && !s.contains('\'') - && !s.contains('\n') - && !s.contains('\r'), + !s.contains('\\') && !s.contains('\'') && !s.contains('\n') && !s.contains('\r'), "cli_panel cmd must not contain `\\`, `'`, or newlines (got {s:?})" ); } @@ -805,15 +850,40 @@ mod linkify_title_tests { let mut bodies = HashMap::new(); let key = ItemId::parse(&slug_types::canonicalize_item("~/foo/bar")).unwrap(); bodies.insert(key, "Hello world\nline".to_string()); - let html = linkify_slugs_with_prefix( - "see ~/foo/bar ok", - "/r/x/~", - Some(&bodies), - ); + let html = linkify_slugs_with_prefix("see ~/foo/bar ok", "/r/x/~", Some(&bodies)); assert!(html.contains("title=\"Hello world line\"")); assert!(html.contains("href=\"/r/x/~/foo/bar\"")); } + #[test] + fn raw_url_links_to_public_external_garden_page() { + let html = linkify_slugs_with_prefix("see https://example.com/z.", "/~", None); + assert!(html + .contains(r#"https://example.com/z."#)); + } + + #[test] + fn dash_ref_links_to_room_external_garden_page_with_title() { + let mut bodies = HashMap::new(); + let key = ItemId::parse(&slug_types::canonicalize_item("-/example.com/z")).unwrap(); + bodies.insert(key, "External body\npreview".to_string()); + let html = + linkify_slugs_with_prefix("see -/example.com/z", "/r/9ab12cdroom/~", Some(&bodies)); + assert!(html.contains(r#"href="/r/9ab12cdroom/-/example.com/z""#)); + assert!(html.contains(r#"title="External body preview""#)); + } + + #[test] + fn code_fence_urls_are_not_linkified() { + let html = linkify_slugs_with_prefix( + "```json\n{\"url\":\"https://example.com/z\"}\n```\nthen https://example.com/a", + "/~", + None, + ); + assert!(!html.contains(r#"href="/-/example.com/z""#)); + assert!(html.contains(r#"href="/-/example.com/a""#)); + } + #[test] fn no_title_when_body_missing_or_empty() { let html = linkify_slugs_with_prefix("x ~/a/b y", "/~", Some(&HashMap::new()));