diff --git a/Cargo.lock b/Cargo.lock index ad7e4fe6d4ba2f2b033916194c1ef1ed873f1d46..bf8153d9c723af97122c9ffdd4a7cfe82e853bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1805,6 +1805,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "url", "urlencoding", "uuid", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index 6d85b8cce5701482367d4be43287394e26f5896f..0d9cdcd21d94751babdf769e118d2c89d6a009d8 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,6 +28,7 @@ slug-types = { path = "../types" } async-trait = "0.1" base64 = "0.22" postcard = { version = "1", features = ["use-std"] } +url = "2" urlencoding = "2" [dev-dependencies] diff --git a/server/src/html/editor.rs b/server/src/html/editor.rs index a24e638802c8bb38699b39fdb0c53c22183a4a49..8ff58326eb75806eeec3937f088fa977c7f3886e 100644 --- a/server/src/html/editor.rs +++ b/server/src/html/editor.rs @@ -9,8 +9,9 @@ use maud::{html, Markup}; use serde::Deserialize; use crate::{ - api::{validate_ingest_document, resolve_item}, + api::{resolve_item, validate_ingest_document}, html::JsBuilder, + middleware::canonical_view_url, reducer::ScopeId, state::AppState, }; @@ -25,7 +26,10 @@ fn bc_try() -> Markup { } /// The interactive editor page — `/try`. -pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse { +pub async fn editor_page(State(state): State, jar: CookieJar, uri: Uri) -> impl IntoResponse { + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "try — slug.social", "view-thread", @@ -41,7 +45,7 @@ pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse { div id="editor-results" {} } }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs index 63f196e0df251678bd649da97107e35a741f97fa..70321dd2a75e563282f7612f2b2fa6d57b9175d9 100644 --- a/server/src/html/forum/feed.rs +++ b/server/src/html/forum/feed.rs @@ -8,6 +8,7 @@ use maud::{html, Markup}; use crate::api::optional_principal; use crate::canonical_path::canonicalize_tag; +use crate::middleware::canonical_view_url; use crate::reducer::{ReducerState, ScopeId}; use crate::state::AppState; use crate::timeago; @@ -218,6 +219,9 @@ pub async fn home( let strip = auth_strip(&headers, &jar, &reduced_read); drop(reduced_read); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "slug.social", "view-thread", @@ -251,7 +255,7 @@ pub async fn home( (render_thread_feed(Some(&nav), "thread-feed", &public_rows, now)) (cli_panel(&["npx slugsocial public forum list"])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs index 53809e94e3b107a1ddb937c2e45325fc6b8706c6..bd60703924eee5b0e5a7f69c0a2dc526442512a2 100644 --- a/server/src/html/forum/mod.rs +++ b/server/src/html/forum/mod.rs @@ -20,7 +20,6 @@ pub use profile::user_profile_page; pub use views::{room_page, room_thread_view, thread_view}; pub(crate) use access::{user_can_post_room, user_can_view_room}; -pub(crate) use ingest::ingest_entry_markup; pub(crate) use new_thread::{fragment_new_thread_slot, login_to_post_hint_markup}; pub(crate) use room_members::room_members_section_markup; pub(crate) use thread_morph::{ diff --git a/server/src/html/forum/post_single.rs b/server/src/html/forum/post_single.rs index 6e36e80d076ddd8ca521c1450cbf8cb7ba6a28fe..91866b2cc3dea98d0c3847f54379245eca67894c 100644 --- a/server/src/html/forum/post_single.rs +++ b/server/src/html/forum/post_single.rs @@ -8,6 +8,7 @@ use maud::html; use crate::api::optional_principal; use crate::canonical_path::canonicalize_tag; +use crate::middleware::canonical_view_url; use crate::reducer::ScopeId; use crate::state::AppState; @@ -70,6 +71,9 @@ async fn thread_post_view_inner( } }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( &format!("#{tag} / post #{index}"), "view-thread", @@ -87,7 +91,7 @@ async fn thread_post_view_inner( p class="muted" { "post not found" } } }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/forum/profile.rs b/server/src/html/forum/profile.rs index ebdc41fdd44e97a10a49ecd1ac5150ae390ec319..94701f8e9e293b64b889291c2145f97c1078c1c4 100644 --- a/server/src/html/forum/profile.rs +++ b/server/src/html/forum/profile.rs @@ -9,6 +9,7 @@ use maud::html; use crate::api::optional_principal; use crate::canonical_path::canonicalize_tag; use crate::identity::parse_username; +use crate::middleware::canonical_view_url; use crate::state::AppState; use super::ingest::{thread_nav_for_ingest, thread_post_index_in_scope}; @@ -74,6 +75,9 @@ pub async fn user_profile_page( }; let now = now_ms(); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( &format!("@{canon}"), "view-thread", @@ -108,7 +112,7 @@ pub async fn user_profile_page( } (cli_panel(&[format!("npx slugsocial public forum list")])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs index 34c47a2a6838a0ec99415e326cbd78f2cc988bd7..b363f0d7c8af3d941e4f402c1f1b87100bb0d394 100644 --- a/server/src/html/forum/views.rs +++ b/server/src/html/forum/views.rs @@ -11,6 +11,7 @@ use serde_json::json; use crate::api::optional_principal; use crate::canonical_path::canonicalize_tag; use crate::form_template::template_json_compact; +use crate::middleware::canonical_view_url; use crate::reducer::ScopeId; use crate::state::AppState; @@ -141,6 +142,9 @@ async fn thread_view_inner( ScopeId::Room(r) => format!("npx slugsocial private {r} forum show {tag}"), }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let body = html! { (strip) nav class="breadcrumb" { (bc) } @@ -166,7 +170,7 @@ async fn thread_view_inner( &format!("#{tag}"), "view-thread", body, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, @@ -276,6 +280,9 @@ pub async fn room_page( let audit_cli = format!("npx slugsocial private {room_id} audit"); drop(reduced); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let slug_display = room_id .split_once('/') .map(|(_, slug)| slug) @@ -296,7 +303,7 @@ pub async fn room_page( } (cli_panel(&[forum_cli, garden_cli, audit_cli])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index 2d5fcd903fe8f56800ad2c5564b704bd25f9b18d..9e08307e636ce7984766168db1187f84b8635201 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -14,11 +14,12 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE 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}, path_types::ItemId, - reducer::{ContentState, ReducerState, ScopeId, scope_from_room_wire}, + reducer::{ContentState, ReducerState, ScopeId}, scope_rank::{ChildrenRankings, build_children_rankings}, state::AppState, timeago, @@ -28,7 +29,7 @@ use super::{ bc_path, bc_path_external, bc_segment, breadcrumb_path::{ExternalOntologyPath, OntologyPath}, cli_panel, - forum::{ThreadNav, ingest_entry_markup}, + forum::ThreadNav, layout, layout_full_bleed_chromeless, now_ms, ratio_pct, render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri, }; @@ -224,39 +225,24 @@ fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemI } } -/// After a successful vote post: morph the new card into `#vote-compare-preview` and refresh edge history. +/// After a successful vote post: refresh edge history (no in-page preview card). pub(crate) async fn vote_compare_post_success_js( state: &AppState, nav: &ThreadNav, - room_wire: &str, - thread_tag: &str, + _room_wire: &str, + _thread_tag: &str, left: &ItemId, right: &ItemId, - post_id: &str, - post_idx: Option, + _post_id: &str, + _post_idx: Option, ) -> String { let reduced = state.reduced.read().await; - let scope = scope_from_room_wire(room_wire); - let Some(ing) = reduced.ingests_by_id.get(post_id).cloned() else { - drop(reduced); - return "console.warn('vote compare: new post not found');".to_string(); - }; - let idx = match post_idx { - Some(i) => i, - None => reduced - .try_thread_post_index_chronological(&scope, thread_tag, post_id) - .unwrap_or(0), - }; - let viewer = None::<&str>; - let now = now_ms(); let content = content_for_garden_view(&reduced, &nav.scope()); let edge_history = vote_edge_history_markup(content, left, right); - let card = ingest_entry_markup(nav, thread_tag, idx, &ing, viewer, now, &reduced); drop(reduced); - let mut b = JsBuilder::new(); - b = b.morph_inner_selector("#vote-compare-preview", card); - b = b.morph_inner_selector("#vote-edge-history-region", edge_history); - b.build() + JsBuilder::new() + .morph_inner_selector("#vote-edge-history-region", edge_history) + .build() } fn item_display_path(item: &str) -> String { @@ -515,6 +501,9 @@ pub async fn garden_index( build_children_rankings(reduced.public(), &ItemId::ontology_root()) }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "~/", "view-ontology view-ontology-light", @@ -556,7 +545,7 @@ pub async fn garden_index( } (cli_panel(&["npx slugsocial garden tree"])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), Some("public"), @@ -597,6 +586,9 @@ pub async fn external_garden_index( build_children_rankings(reduced.public(), &parent) }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "-/", "view-ontology view-ontology-light", @@ -637,7 +629,7 @@ pub async fn external_garden_index( } } }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), Some("public"), @@ -727,6 +719,9 @@ pub async fn room_external_garden_index( build_children_rankings(content_for_garden_view(&reduced, &nav.scope()), &parent); drop(reduced); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "-/", "view-ontology view-ontology-light", @@ -766,7 +761,7 @@ pub async fn room_external_garden_index( } } }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), Some(nav.room_wire.as_str()), @@ -1080,6 +1075,9 @@ async fn render_scope_view( .filter(|s| !s.is_empty()) .unwrap_or_else(|| "/".to_string()); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( &item_display_path(&model.item), "view-ontology view-ontology-light", @@ -1227,7 +1225,7 @@ async fn render_scope_view( }; (cli_panel(std::slice::from_ref(&cli))) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), Some(&garden_room), diff --git a/server/src/html/search.rs b/server/src/html/search.rs index 04fd6f27854138f668689df3b250d7240645d1ac..35d3604ab8ee50327af5b0acf1a86e646633cf1f 100644 --- a/server/src/html/search.rs +++ b/server/src/html/search.rs @@ -10,6 +10,7 @@ use serde::Deserialize; use crate::{ api::optional_principal, events::ThreadCapability, + middleware::canonical_view_url, reducer::{ReducerState, ScopeId}, state::AppState, timeago, @@ -407,6 +408,9 @@ pub async fn search_page( SearchResults { items: vec![], threads: vec![], posts: vec![] } }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "search \u{2014} slug.social", "view-thread", @@ -420,7 +424,7 @@ pub async fn search_page( (render_search_results(&results, &query)) (cli_panel(&["npx slugsocial search "])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/lib.rs b/server/src/lib.rs index fc95fc5a845fde9718195e275f2b228f68d913a6..b902094653cf9319efd14e67b91436e269362441 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -16,6 +16,7 @@ pub mod reducer; pub mod scope_rank; pub mod state; pub mod timeago; +pub mod views; pub mod write_cmd; use std::collections::HashMap; @@ -37,6 +38,8 @@ pub fn create_app_state(cfg: AppConfig) -> AppState { let (stream_tx, _) = broadcast::channel(64); let (js_tx, _) = broadcast::channel(64); let (write_tx, write_rx) = mpsc::channel::(256); + let views_path = format!("{}/views.json", cfg.data_dir); + let views = crate::views::ViewStore::new(&views_path); let state = AppState { cfg: Arc::new(cfg), event_log: Arc::new(event_log), @@ -46,6 +49,7 @@ pub fn create_app_state(cfg: AppConfig) -> AppState { stream_tx, js_tx, write_tx, + views, }; tokio::spawn(crate::api::write_actor::writer_actor(write_rx, state.clone())); state @@ -118,6 +122,10 @@ pub fn create_app(state: AppState) -> Router { .route("/api/v0/pending-session/:id", get(api::get_pending_session)) .route("/api/v0/whoami", get(api::get_whoami)) .route("/api/v0/rpc", post(api::handle_rpc_batch)) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + crate::middleware::view_count_middleware, + )) .with_state(state) .layer(TraceLayer::new_for_http()) } diff --git a/server/src/middleware.rs b/server/src/middleware.rs index 47751d0da2cf44df44b3e7c94e2a1063b29bd6d6..458bd65054bb1baa70691b06ec44ae71b72b47c5 100644 --- a/server/src/middleware.rs +++ b/server/src/middleware.rs @@ -1,12 +1,47 @@ use axum::{ - extract::Request, + extract::{Request, State}, middleware::Next, response::Response, }; +use crate::state::AppState; + +pub fn canonical_view_url(uri: &axum::http::Uri) -> String { + let path = uri.path(); + if let Some(query) = uri.query() { + let mut pairs: Vec<_> = url::form_urlencoded::parse(query.as_bytes()).into_owned().collect(); + if pairs.is_empty() { + return path.to_string(); + } + pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + + let new_query = url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs(pairs) + .finish(); + format!("{path}?{new_query}") + } else { + path.to_string() + } +} + pub async fn view_count_middleware( + State(state): State, req: Request, next: Next, ) -> Response { + if req.method() == axum::http::Method::GET { + let path = req.uri().path(); + + if !path.starts_with("/static") + && !path.starts_with("/api") + && !path.starts_with("/sse") + && !path.starts_with("/auth") + && path != "/healthz" + && path != "/ui" + { + let url_key = canonical_view_url(req.uri()); + state.views.increment(url_key); + } + } next.run(req).await } diff --git a/server/src/state.rs b/server/src/state.rs index 8fc39914d64ace53a544c5eb85bc61447db60305..13831e868031edc19fda0f347376e0b17e8b99b7 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -67,6 +67,7 @@ pub struct AppState { pub js_tx: broadcast::Sender, /// All durable writes and reducer mutations are serialized through this channel. pub write_tx: mpsc::Sender, + pub views: crate::views::ViewStore, } impl AppState { @@ -81,6 +82,8 @@ impl AppState { let event_log = EventLog::new(cfg.event_log_path.clone()); let (stream_tx, _) = broadcast::channel(64); let (js_tx, _) = broadcast::channel(64); + let views_path = format!("{}/views.json", cfg.data_dir); + let views = crate::views::ViewStore::new(&views_path); Self { cfg: Arc::new(cfg), event_log: Arc::new(event_log), @@ -90,6 +93,7 @@ impl AppState { stream_tx, js_tx, write_tx, + views, } } } diff --git a/server/tests/integration.rs b/server/tests/integration.rs index b34ba69a2115cad9875afce17743ad27f3838959..bd10fd2a6ef06ac1a23de790a54ad5aa0065a472 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -1,8 +1,10 @@ +use axum::http::Uri; use sha2::{Digest, Sha256}; -use slug_types::room_route_segment; +use slug_types::{room_route_segment, ItemId}; use slugsocial_server::{ event_log::EventLog, events::{Event, TokenIssued, UserRegistered}, + middleware::canonical_view_url, spawn_writer_actor_for_test, state::{AppConfig, AppState}, }; @@ -1368,7 +1370,69 @@ async fn test_search_page_and_results() { #[tokio::test] async fn test_view_counts_increment_and_display() { - // HTML view counters are offline during the auth-v3 refactor. + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + let client = reqwest::Client::new(); + let url = format!("http://{addr}/~"); + + let r1 = client.get(&url).send().await.unwrap(); + assert!(r1.status().is_success()); + let body1 = r1.text().await.unwrap(); + assert!( + body1.contains("1 views"), + "expected first GET to show 1 views, body snippet: {}", + &body1.chars().take(500).collect::() + ); + + let r2 = client.get(&url).send().await.unwrap(); + assert!(r2.status().is_success()); + let body2 = r2.text().await.unwrap(); + assert!( + body2.contains("2 views"), + "expected second GET to show 2 views" + ); + + // Vote compare uses permuted `left` / `right`; middleware canonicalizes query order. + let left = ItemId::parse("~/vc-l") + .unwrap() + .normalized_storage() + .to_storage_string(); + let right = ItemId::parse("~/vc-r") + .unwrap() + .normalized_storage() + .to_storage_string(); + let vote_q_right_first = format!( + "/vote/compare?right={}&left={}", + urlencoding::encode(&right), + urlencoding::encode(&left) + ); + let vote_q_left_first = format!( + "/vote/compare?left={}&right={}", + urlencoding::encode(&left), + urlencoding::encode(&right) + ); + let vote_key_uri: Uri = format!("http://127.0.0.1{vote_q_left_first}") + .parse() + .unwrap(); + let vote_key = canonical_view_url(&vote_key_uri); + + let v1 = client + .get(format!("http://{addr}{vote_q_right_first}")) + .send() + .await + .unwrap(); + assert!(v1.status().is_success(), "vote compare GET 1: {}", v1.status()); + let v2 = client + .get(format!("http://{addr}{vote_q_left_first}")) + .send() + .await + .unwrap(); + assert!(v2.status().is_success(), "vote compare GET 2: {}", v2.status()); + + assert_eq!( + state.views.get_views(&vote_key), + 2, + "permuted vote/compare URLs should share one ViewStore key ({vote_key:?})" + ); } #[tokio::test] diff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj index 78fc40c160c84478ab8ee0d953b9ed878b4c712f..839482d12963f130a2063f2212a6b922b2d229e1 100644 --- a/test/browser_vote_compare.clj +++ b/test/browser_vote_compare.clj @@ -71,13 +71,12 @@ (page/navigate pg (str base-url "/login")) (is (wait-for-text pg "body" "@alice" 15000) "alice session after login") (page/navigate pg cmp-url) - (is (wait-for-text pg ".vote-compare-shell" "compare" 15000) "vote compare shell") + ;; No .vote-compare-shell wrapper — wait on stable vote-compare UI instead. + (is (wait-for-text pg "body.view-vote-compare" "compare" 15000) "vote compare page") (is (wait-for-text pg "ul.vote-edge-history" "seed edge vote" 15000) "edge history lists canonical-order vote") (locator/fill (page/locator pg "#vote-explain") "because playwright says so") (locator/click (page/locator pg "#vote-compare-form button[type=submit]")) - (is (wait-for-text pg "#vote-compare-preview" "because playwright" 20000) - "preview region morphs new post card") (is (wait-for-text pg "ul.vote-edge-history" "because playwright" 20000) "new vote appears in edge history after morph"))))))