diff --git a/.env.example b/.env.example index cb503392b30..a6740f7a7d8 100644 --- a/.env.example +++ b/.env.example @@ -59,9 +59,15 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and +# authenticated desktop clients use this relay as the metadata/search proxy. +# Keep the real value in your deployment's secret manager; never commit it. +# BUZZ_KLIPY_API_KEY= + # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60 +# BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN=30 # BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300 # BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10 # BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120 diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index 8fd42c50fb9..9e64627404c 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -60,6 +60,8 @@ pub enum LimitType { Messages, /// HTTP REST API calls. ApiCalls, + /// Relay-proxied GIF metadata searches. + GifSearches, /// All WebSocket events (broader than `Messages`). WsEvents, /// Concurrent WebSocket connections from a single IP address. @@ -72,6 +74,7 @@ impl LimitType { match self { Self::Messages => "msg", Self::ApiCalls => "api", + Self::GifSearches => "gif", Self::WsEvents => "ws", Self::IpConnections => "conn", } @@ -87,6 +90,10 @@ pub struct RateLimitConfig { /// Maximum messages per minute for human users. Default: 60. #[serde(default = "default_human_msg")] pub human_messages_per_min: u64, + /// Maximum relay-proxied GIF searches per minute for each pubkey. + /// Default: 30. + #[serde(default = "default_gif_searches")] + pub gif_searches_per_min: u64, /// Maximum HTTP API calls per minute for human users. Default: 300. #[serde(default = "default_human_api")] pub human_api_calls_per_min: u64, @@ -110,6 +117,9 @@ pub struct RateLimitConfig { fn default_human_msg() -> u64 { 60 } +fn default_gif_searches() -> u64 { + 30 +} fn default_human_api() -> u64 { 300 } @@ -133,6 +143,7 @@ impl Default for RateLimitConfig { fn default() -> Self { Self { human_messages_per_min: default_human_msg(), + gif_searches_per_min: default_gif_searches(), human_api_calls_per_min: default_human_api(), human_ws_events_per_sec: default_human_ws(), agent_standard_messages_per_min: default_agent_std_msg(), @@ -272,6 +283,17 @@ mod tests { assert!(key.ends_with(":msg")); } + #[test] + fn gif_searches_have_an_independent_quota_key() { + let ctx = fixture_ctx("relay-a.example"); + let keys = Keys::generate(); + let gif_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::GifSearches); + let api_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::ApiCalls); + + assert!(gif_key.ends_with(":gif")); + assert_ne!(gif_key, api_key); + } + #[test] fn rate_limit_key_isolates_communities_for_same_pubkey() { // The S1 cross-community isolation fence at the rate-limit key layer: diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs new file mode 100644 index 00000000000..a8848af295a --- /dev/null +++ b/crates/buzz-relay/src/api/gifs.rs @@ -0,0 +1,613 @@ +//! Relay-owned KLIPY GIF metadata/search proxy. +//! +//! KLIPY requires a provider credential, but desktop applications cannot keep +//! build-time credentials secret. These narrow endpoints keep the key on the +//! operator's relay while returning only KLIPY-hosted media URLs and metadata; +//! GIF bytes are never downloaded, cached, or stored by Buzz. +//! +//! Search and share reporting are the only relay endpoints. Sending a selected +//! GIF is a normal message containing its CDN URL, and clients render that URL +//! through the existing image path. No GIF bytes transit the relay. + +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::Json, +}; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::Value; + +use crate::state::AppState; + +use buzz_auth::LimitType; + +use super::{api_error, bridge, internal_error, relay_members}; + +const KLIPY_API_ROOT: &str = "https://api.klipy.com/api/v1/"; +pub(crate) const SEARCH_PATH: &str = "/gifs/search"; +pub(crate) const SHARE_PATH: &str = "/gifs/share"; +const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_UPSTREAM_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +/// Build the dedicated KLIPY client. Redirects are disabled: the API key rides +/// in the request path, so following a provider 3xx could replay a key-bearing +/// URL to an attacker-chosen host. With no redirect policy, a 3xx comes back as +/// a non-success status that the handlers map to a generic `502`, and the +/// `Location` target is never read or forwarded. +pub fn build_gif_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(UPSTREAM_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("static GIF HTTP client configuration") +} + +#[derive(Debug, Deserialize)] +/// Client-owned search context forwarded to KLIPY by the relay. +pub struct SearchRequest { + /// Empty means trending; otherwise this is the user's search text. + query: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, + /// Desktop locale used to localize provider results. + locale: String, +} + +#[derive(Debug, Deserialize)] +/// Client-owned share context forwarded to KLIPY by the relay. +pub struct ShareRequest { + /// Provider slug for the selected GIF. + slug: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, +} + +fn validate_text( + name: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), (StatusCode, Json)> { + let count = value.chars().count(); + if (!allow_empty && value.trim().is_empty()) || count > max_chars { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "{name} must be {} through {max_chars} characters", + if allow_empty { 0 } else { 1 } + ), + )); + } + Ok(()) +} + +fn klipy_url( + api_key: &str, + path: &[&str], + query: &[(&str, &str)], +) -> Result)> { + let mut url = url::Url::parse(KLIPY_API_ROOT) + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + segments.pop_if_empty().push(api_key); + for segment in path { + segments.push(segment); + } + } + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + Ok(url) +} + +fn klipy_share_request( + client: &reqwest::Client, + api_key: &str, + request: &ShareRequest, +) -> Result)> { + let url = klipy_url(api_key, &["gifs", "share", request.slug.trim()], &[])?; + Ok(client + .post(url) + .json(&serde_json::json!({ "customer_id": request.customer_id }))) +} + +async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + "POST", + &expected_url, + Some(body), + true, + true, + )?; + bridge::enforce_http_admission(state, &tenant, &pubkey).await?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey.to_bytes(), + headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()), + ) + .await?; + + Ok((tenant, pubkey)) +} + +async fn send_upstream( + request: reqwest::RequestBuilder, +) -> Result)> { + request + .timeout(UPSTREAM_TIMEOUT) + .send() + .await + .map_err(|error| { + tracing::warn!( + timeout = error.is_timeout(), + "KLIPY upstream request failed" + ); + api_error(StatusCode::BAD_GATEWAY, "GIF provider is unavailable") + }) +} + +async fn enforce_search_admission( + state: &AppState, + tenant: &buzz_core::TenantContext, + pubkey: &nostr::PublicKey, +) -> Result<(), (StatusCode, Json)> { + let limit = state.auth.config().rate_limits.gif_searches_per_min; + match crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + tenant, + pubkey, + LimitType::GifSearches, + 60, + limit, + ) + .await + { + Ok(()) => Ok(()), + Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => { + metrics::counter!("buzz_gif_search_rejections_total", "reason" => "quota").increment(1); + Err(api_error( + StatusCode::TOO_MANY_REQUESTS, + &format!("rate-limited: GIF search quota exceeded; retry in {reset_in_secs}s"), + )) + } + Err(crate::admission::AdmissionError::Unavailable) => Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "rate-limited: GIF search admission unavailable", + )), + } +} + +async fn limited_json(response: reqwest::Response) -> Result)> { + if response + .content_length() + .is_some_and(|length| length > MAX_UPSTREAM_RESPONSE_BYTES as u64) + { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response could not be read", + ) + })?; + if body.len().saturating_add(chunk.len()) > MAX_UPSTREAM_RESPONSE_BYTES { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + body.extend_from_slice(&chunk); + } + + serde_json::from_slice(&body).map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider returned an invalid response", + ) + }) +} + +fn successful_search_payload(upstream: &Value) -> Result)> { + if upstream.get("result").and_then(Value::as_bool) != Some(true) { + tracing::warn!("KLIPY search returned an unsuccessful result"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + let data = upstream.get("data").cloned().unwrap_or(Value::Null); + Ok(serde_json::json!({ "result": true, "data": data })) +} + +/// Search or browse trending KLIPY GIF metadata for an authenticated member. +pub async fn search( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; + let request: SearchRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON"))?; + validate_text("query", &request.query, 200, true)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + validate_text("locale", &request.locale, 32, false)?; + enforce_search_admission(&state, &tenant, &pubkey).await?; + + let endpoint = if request.query.trim().is_empty() { + "trending" + } else { + "search" + }; + let mut query = vec![ + ("page", "1"), + ("per_page", "24"), + ("customer_id", request.customer_id.as_str()), + ("locale", request.locale.as_str()), + ]; + if !request.query.trim().is_empty() { + query.push(("q", request.query.trim())); + } + let url = klipy_url(config.api_key(), &["gifs", endpoint], &query)?; + let response = send_upstream(state.gif_http_client.get(url)).await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY search failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + + // Never forward the provider response wholesale. KLIPY may report an + // application-level failure with HTTP 200 and include request details in + // its error fields. Allowlist only successful result data so credentials + // and provider diagnostics cannot cross the relay boundary. + let upstream = limited_json(response).await?; + Ok(Json(successful_search_payload(&upstream)?)) +} + +/// Report a selected GIF to KLIPY so the provider can update Recents. +pub async fn share( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + authenticate(&state, &headers, SHARE_PATH, &body).await?; + let request: ShareRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON"))?; + validate_text("slug", &request.slug, 200, false)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + + let response = send_upstream(klipy_share_request( + &state.gif_http_client, + config.api_key(), + &request, + )?) + .await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY share failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the share request", + )); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt; + + async fn unconfigured_test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("test config"); + config.klipy = None; + config.redis_url = "redis://127.0.0.1:1".to_string(); + + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://buzz:buzz_dev@127.0.0.1:1/buzz") // sadscan:disable np.postgres.1 + .expect("lazy test database pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("lazy test Redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("test pubsub"), + ); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("test media storage config"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + None::, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + async fn search_route_returns_not_found_before_auth_when_unconfigured() { + let state = unconfigured_test_state().await; + let response = Router::new() + .route(SEARCH_PATH, axum::routing::post(search)) + .with_state(state) + .oneshot( + Request::post(SEARCH_PATH) + .body(Body::from("{}")) + .expect("search request"), + ) + .await + .expect("search response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn limited_json_rejects_oversized_streamed_bodies() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/oversized", + get(|| async { + ( + [(header::CONTENT_TYPE, "application/json")], + "x".repeat(MAX_UPSTREAM_RESPONSE_BYTES + 1), + ) + }), + ), + ) + .await + .expect("serve oversized response"); + }); + let response = reqwest::get(format!("http://{address}/oversized")) + .await + .expect("test upstream response"); + let (status, _) = limited_json(response) + .await + .expect_err("oversized body must be rejected"); + + server.abort(); + let _ = server.await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + } + + #[test] + fn klipy_url_encodes_credentials_as_a_path_segment() { + let url = klipy_url( + "key/with spaces", + &["gifs", "search"], + &[("customer_id", "customer")], + ) + .expect("static URL is valid"); + + assert_eq!( + url.as_str(), + "https://api.klipy.com/api/v1/key%2Fwith%20spaces/gifs/search?customer_id=customer" + ); + } + + #[test] + fn klipy_share_request_uses_slug_path_and_customer_body() { + let request = ShareRequest { + slug: " ship/it ".to_string(), + customer_id: "customer-123".to_string(), + }; + let built = klipy_share_request(&reqwest::Client::new(), "secret-key", &request) + .expect("share request builds") + .build() + .expect("share request is valid"); + + assert_eq!(built.method(), reqwest::Method::POST); + assert_eq!( + built.url().as_str(), + "https://api.klipy.com/api/v1/secret-key/gifs/share/ship%2Fit" + ); + assert_eq!( + built.body().and_then(reqwest::Body::as_bytes), + Some(br#"{"customer_id":"customer-123"}"#.as_slice()) + ); + } + + #[test] + fn validation_bounds_provider_control_fields() { + assert!(validate_text("query", "", 200, true).is_ok()); + assert!(validate_text("customer_id", "", 128, false).is_err()); + assert!(validate_text("query", &"x".repeat(201), 200, true).is_err()); + } + + #[test] + fn successful_payload_strips_provider_errors_and_unknown_fields() { + let payload = successful_search_payload(&serde_json::json!({ + "result": true, + "data": { "data": [] }, + "errors": { "message": ["request used secret-key"] }, + "debug": "secret-key" + })) + .expect("successful payload"); + + assert_eq!( + payload, + serde_json::json!({ "result": true, "data": { "data": [] } }) + ); + } + + #[test] + fn unsuccessful_payload_is_rejected_without_provider_details() { + let (status, body) = successful_search_payload(&serde_json::json!({ + "result": false, + "errors": { "message": ["request used secret-key"] } + })) + .expect_err("unsuccessful provider payload must be rejected"); + + assert_eq!(status, StatusCode::BAD_GATEWAY); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert!(!serialized.contains("secret-key")); + } + + /// A provider 3xx must never cause a second connection, and the error + /// surfaced past the shared send/reject path must leak neither the API key + /// (carried in the request path) nor the redirect target. + /// + /// Mutation check: swapping `build_gif_http_client`'s redirect policy back + /// to the default makes the client follow the 302, the redirect listener + /// records a request, and this test fails on the `redirect_hits` assertion. + #[tokio::test] + async fn gif_client_refuses_provider_redirects_without_leaking_secrets() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + const SECRET_KEY: &str = "super-secret-klipy-key"; + + // Second listener: the redirect target. It must never be reached. + let redirect_hits = Arc::new(AtomicUsize::new(0)); + let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind redirect target"); + let redirect_addr = redirect_listener.local_addr().expect("redirect address"); + let redirect_hits_server = redirect_hits.clone(); + let redirect_server = tokio::spawn(async move { + axum::serve( + redirect_listener, + Router::new().route( + "/leaked", + get(move || { + redirect_hits_server.fetch_add(1, Ordering::SeqCst); + async { "reached the redirect target" } + }), + ), + ) + .await + .expect("serve redirect target"); + }); + + // Fake upstream: answers the key-bearing path with a 302 whose Location + // points at the second listener, exactly the disclosure vector. + let redirect_location = format!("http://{redirect_addr}/leaked"); + let upstream_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake upstream"); + let upstream_addr = upstream_listener.local_addr().expect("upstream address"); + let location_header = redirect_location.clone(); + let upstream_server = tokio::spawn(async move { + axum::serve( + upstream_listener, + Router::new().route( + &format!("/{SECRET_KEY}/gifs/search"), + get(move || { + let location = location_header.clone(); + async move { + ( + StatusCode::FOUND, + [(header::LOCATION, location)], + "provider body naming the secret-key", + ) + } + }), + ), + ) + .await + .expect("serve fake upstream"); + }); + + let client = build_gif_http_client(); + let response = + send_upstream(client.get(format!("http://{upstream_addr}/{SECRET_KEY}/gifs/search"))) + .await + .expect("request completes without following the redirect"); + + // The redirect was not followed: the client surfaces the 3xx itself. + assert!(response.status().is_redirection()); + assert!(!response.status().is_success()); + assert_eq!(redirect_hits.load(Ordering::SeqCst), 0); + + // The shared reject path (both handlers gate on `!is_success`) returns a + // static generic error carrying no key and no redirect target. + let (status, body) = api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + ); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(!serialized.contains(SECRET_KEY)); + assert!(!serialized.contains(&redirect_location)); + + upstream_server.abort(); + redirect_server.abort(); + let _ = upstream_server.await; + let _ = redirect_server.await; + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 2a942bc8039..204ec360c3f 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod bridge; pub mod events; +pub mod gifs; pub mod git; pub mod invites; pub mod media; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3d..9fb96b7a198 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -46,6 +46,30 @@ pub struct JoinPolicyConfig { pub version: String, } +/// Optional KLIPY GIF-search integration owned by the relay operator. +/// +/// The API key deliberately stays private and its [`Debug`] implementation is +/// redacted so dumping [`Config`] cannot disclose it. +#[derive(Clone)] +pub struct KlipyConfig { + api_key: String, +} + +impl KlipyConfig { + /// Return the key only to the outbound KLIPY client. + pub(crate) fn api_key(&self) -> &str { + &self.api_key + } +} + +impl std::fmt::Debug for KlipyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KlipyConfig") + .field("api_key", &"[REDACTED]") + .finish() + } +} + /// Maximum configured jitter, leaving ten seconds of the hard-drain budget for /// WebSocket close-frame delivery after the final delayed cancellation. pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; @@ -218,6 +242,10 @@ pub struct Config { /// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`. pub allow_nip_oa_auth: bool, + /// Relay-owned KLIPY integration. Unset means GIF search is not advertised + /// and its proxy routes return 404. + pub klipy: Option, + /// Media storage configuration (S3/MinIO). pub media: buzz_media::MediaConfig, /// Maximum concurrent media uploads handled by one relay process. @@ -319,6 +347,10 @@ fn rate_limit_config_from_env() -> Result, + /// Relay-owned GIF search integration. The descriptor is public and + /// provider-agnostic; provider credentials remain server-side. + #[serde(skip_serializing_if = "Option::is_none")] + pub gif: Option, /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, } +/// Public capability descriptor for relay-proxied GIF search. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GifDescriptor { + /// Provider identifier understood by Buzz clients. + pub provider: String, + /// Relay-relative authenticated metadata search endpoint. + pub search: String, + /// Relay-relative authenticated share-reporting endpoint. + pub share: String, +} + /// Protocol and resource limits advertised in the NIP-11 document. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayLimitation { @@ -138,12 +153,18 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. + /// + /// `gif_provider` is a config-derived provider identifier. When present, + /// `build` advertises the provider-agnostic `buzz-gif` extension and the + /// relay-relative metadata search endpoint. It must never contain a + /// provider credential. pub fn build( relay_self: Option<&str>, icon: Option<&str>, advertise_nip43: bool, max_message_length: usize, pairing_relay_url: Option<&str>, + gif_provider: Option<&str>, ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), @@ -155,6 +176,16 @@ impl RelayInfo { supported_nips.push(NIP_RELAY_MEMBERSHIP); } + let mut supported_extensions = vec!["nip-er".to_string()]; + let gif = gif_provider.map(|provider| { + supported_extensions.push("buzz-gif".to_string()); + GifDescriptor { + provider: provider.to_string(), + search: crate::api::gifs::SEARCH_PATH.to_string(), + share: crate::api::gifs::SHARE_PATH.to_string(), + } + }); + Self { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), @@ -162,12 +193,13 @@ impl RelayInfo { pubkey: None, contact: None, supported_nips, - supported_extensions: Some(vec!["nip-er".to_string()]), + supported_extensions: Some(supported_extensions), push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation(max_message_length)), pairing_relay_url: pairing_relay_url.map(str::to_string), + gif, relay_self: relay_self.map(|s| s.to_string()), } } @@ -236,7 +268,8 @@ fn push_descriptor( /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. Every input to `RelayInfo::build` /// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the -/// host-scoped workspace icon. +/// host-scoped workspace icon. Optional provider capabilities are passed as +/// config-derived scalar identifiers; no provider credential enters NIP-11. pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo { let (relay_self, advertise_nip43) = nip11_facts(state); let icon = workspace_icon_for_host(state, raw_host).await; @@ -246,6 +279,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st advertise_nip43, state.config.max_frame_bytes, state.config.pairing_relay_url.as_deref(), + state.config.klipy.as_ref().map(|_| "klipy"), ); let tenant_host = if state.config.push_gateway_delivery_url.is_some() { crate::tenant::bind_community(&state.db, raw_host) @@ -337,6 +371,7 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( bool, usize, Option<&str>, + Option<&str>, ) -> RelayInfo = RelayInfo::build; #[cfg(test)] @@ -391,7 +426,7 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.software, "https://github.com/block/buzz"); } @@ -403,6 +438,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, Some("wss://pairing.buzz.xyz"), + None, ); let json = serde_json::to_value(&info).expect("serialize"); assert_eq!( @@ -411,11 +447,40 @@ mod tests { Some("wss://pairing.buzz.xyz") ); - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); let json = serde_json::to_value(&info).expect("serialize"); assert!(json.get("pairing_relay_url").is_none()); } + #[test] + fn gif_descriptor_and_extension_are_config_gated_and_credential_free() { + let info = RelayInfo::build( + None, + None, + false, + DEFAULT_MAX_FRAME_BYTES, + None, + Some("klipy"), + ); + + let json = serde_json::to_value(&info).expect("serialize"); + assert_eq!(json["gif"]["provider"], "klipy"); + assert_eq!(json["gif"]["search"], "/gifs/search"); + assert_eq!(json["gif"]["share"], "/gifs/share"); + assert!(json["supported_extensions"] + .as_array() + .expect("extensions") + .contains(&serde_json::json!("buzz-gif"))); + assert!(!json.to_string().contains("api_key")); + + let unconfigured = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); + assert!(unconfigured.gif.is_none()); + assert!(!unconfigured + .supported_extensions + .expect("extensions") + .contains(&"buzz-gif".to_string())); + } + /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard /// `icon` field; no icon (or a cleared, empty icon) omits the field /// entirely so the JSON matches pre-icon documents byte-for-byte. @@ -427,6 +492,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, None, + None, ); assert_eq!( info.icon.as_deref(), @@ -439,7 +505,7 @@ mod tests { ); for icon in [None, Some("")] { - let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.icon.is_none()); let json = serde_json::to_value(&info).expect("serialize"); assert!( @@ -459,7 +525,7 @@ mod tests { #[test] fn max_message_length_uses_configured_frame_limit() { - let info = RelayInfo::build(None, None, false, 262_144, None); + let info = RelayInfo::build(None, None, false, 262_144, None, None); let limitation = info.limitation.expect("limitation"); assert_eq!(limitation.max_message_length, Some(262_144)); } @@ -490,7 +556,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -503,7 +569,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -512,7 +578,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -523,6 +589,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None); + let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 653e21f0936..fc5396407c1 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -72,6 +72,9 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + // Relay-owned third-party GIF metadata proxy (NIP-98 auth). + .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) + .route(api::gifs::SHARE_PATH, post(api::gifs::share)) .route( "/workflows/{workflow_id}/runs", get(api::workflows::workflow_runs), diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2f544e188c0..ab3f1d8c7eb 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -722,6 +722,9 @@ pub struct AppState { /// replace this with process-local caching; replay freshness must survive /// cross-pod routing. pub nip98_replay: Arc, + /// Shared HTTP client for relay-proxied GIF provider requests. Reusing the + /// connection pool avoids a fresh TLS handshake for every search/share. + pub gif_http_client: reqwest::Client, /// Shared Redis-backed admission limits for ordinary HTTP and WebSocket work. pub admission_rate_limiter: Arc, @@ -852,6 +855,7 @@ impl AppState { ); let nip98_replay: Arc = Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone())); + let gif_http_client = crate::api::gifs::build_gif_http_client(); let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone())); let audit_enabled = audit_arc.is_some(); let state = Self { @@ -912,6 +916,7 @@ impl AppState { shutting_down: Arc::new(AtomicBool::new(false)), started_at: Instant::now(), nip98_replay, + gif_http_client, admission_rate_limiter, observer_rate_limiter: Arc::new(DashMap::new()), media_upload_rate_limiter: Arc::new(DashMap::new()), diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index e0645ef6fb9..8a4b0c6d665 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -44,6 +44,12 @@ The chart is designed for ArgoCD and Flux. Both render charts with `helm templat Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless. +To enable relay-proxied KLIPY search, add `BUZZ_KLIPY_API_KEY` to that Secret. +The key stays in the relay pod; clients discover the public `buzz-gif` +extension and `gif` descriptor in NIP-11, then receive KLIPY-hosted media URLs. +See [`docs/gif-search.md`](../../../docs/gif-search.md) for the protocol and +security boundaries. + See: - [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application diff --git a/deploy/charts/buzz/examples/secret-sample.yaml b/deploy/charts/buzz/examples/secret-sample.yaml index 42d3254d486..c615a0de316 100644 --- a/deploy/charts/buzz/examples/secret-sample.yaml +++ b/deploy/charts/buzz/examples/secret-sample.yaml @@ -12,6 +12,7 @@ # REDIS_URL — redis://... (required when replicaCount > 1) # BUZZ_S3_ACCESS_KEY # BUZZ_S3_SECRET_KEY +# BUZZ_KLIPY_API_KEY — omit to disable relay-proxied GIF search apiVersion: v1 kind: Secret metadata: @@ -25,3 +26,4 @@ stringData: REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379" BUZZ_S3_ACCESS_KEY: "REPLACE" BUZZ_S3_SECRET_KEY: "REPLACE" + BUZZ_KLIPY_API_KEY: "REPLACE" diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 451ebb1cded..319ec7f1594 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -215,6 +215,12 @@ spec: name: {{ include "buzz.envSecretName" . }} key: BUZZ_S3_SECRET_KEY optional: true + - name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_KLIPY_API_KEY + optional: true - name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: {{ include "buzz.huddleAudioAvailable" . | quote }} diff --git a/deploy/charts/buzz/tests/secrets_test.yaml b/deploy/charts/buzz/tests/secrets_test.yaml index dca83ce27ff..d313caf4d4a 100644 --- a/deploy/charts/buzz/tests/secrets_test.yaml +++ b/deploy/charts/buzz/tests/secrets_test.yaml @@ -80,6 +80,27 @@ tests: optional: true template: templates/deployment.yaml + - it: Deployment env points BUZZ_KLIPY_API_KEY at existingSecret as optional + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + secrets.existingSecret: "buzz-secrets" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: buzz-secrets + key: BUZZ_KLIPY_API_KEY + optional: true + template: templates/deployment.yaml + - it: READ_DATABASE_URL stays optional against the chart-managed Secret set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 738c50eec31..6c57a5c8ac9 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -94,6 +94,7 @@ ownerPubkey: "" # REDIS_URL — full Redis URL with auth # BUZZ_S3_ACCESS_KEY — S3 access key # BUZZ_S3_SECRET_KEY — S3 secret key +# BUZZ_KLIPY_API_KEY — KLIPY GIF search key; omit to disable GIF search secrets: existingSecret: "" # Inline overrides (NOT recommended for production; they land in values). diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index 92d640afa9f..ea7136db427 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -82,11 +82,14 @@ type EmojiPickerProps = { autoFocus?: boolean; /** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */ onSelect: (emoji: string) => void; + /** Number of emoji columns. Defaults to the compact picker used elsewhere. */ + perLine?: number; }; export const EmojiPicker = React.memo(function EmojiPicker({ autoFocus = false, onSelect, + perLine = 8, }: EmojiPickerProps) { const customEmoji = useCustomEmoji(); const custom = React.useMemo( @@ -116,7 +119,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({ onSelect(value); } }} - perLine={8} + perLine={perLine} previewPosition="bottom" set="native" skinTonePosition="search" diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index d79cce9c35a..961716aaf48 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -610,6 +610,7 @@ export function ForumComposer({ ) : undefined } formattingDisabled={Boolean(disabled || isSubmissionPending)} + gifMediaController={media} isEmojiPickerOpen={isEmojiPickerOpen} isFormattingOpen={isFormattingOpen} isSending={Boolean(isSending || isSubmissionPending)} diff --git a/desktop/src/features/gifs/api.test.mjs b/desktop/src/features/gifs/api.test.mjs new file mode 100644 index 00000000000..60d44b29de0 --- /dev/null +++ b/desktop/src/features/gifs/api.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + klipyGifAttachment, + klipyGifFilename, + normalizeKlipyGifs, + relayKlipyCapability, +} from "./api.ts"; + +const GIF_ASSET = { + url: "https://static.klipy.com/example.gif", + width: 640, + height: 360, + size: 42, +}; + +test("normalizeKlipyGifs selects a compact preview and medium GIF", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 7, + title: " Ship it ", + slug: "ship-it", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + webp: { + url: "https://static.klipy.com/preview.webp", + width: 220, + height: 124, + size: 12, + }, + }, + }, + }, + ]); + + assert.equal(gif.title, "Ship it"); + assert.equal(gif.original.url, GIF_ASSET.url); + assert.equal(gif.preview.url, "https://static.klipy.com/preview.webp"); + assert.equal(gif.poster, null); +}); + +test("normalizeKlipyGifs carries a static jpg poster when present", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 8, + title: "Static poster", + slug: "static-poster", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + jpg: { + url: "https://static.klipy.com/poster.jpg", + width: 220, + height: 124, + size: 8, + }, + }, + }, + }, + ]); + + assert.equal(gif.poster?.url, "https://static.klipy.com/poster.jpg"); +}); + +test("normalizeKlipyGifs omits ads and malformed file records", () => { + const gifs = normalizeKlipyGifs([ + { id: 1, slug: "ad", type: "ad" }, + { id: 2, slug: "missing", type: "gif", file: {} }, + ]); + + assert.deepEqual(gifs, []); +}); + +test("relayKlipyCapability requires safe search and share endpoints", () => { + assert.deepEqual( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["nip-er", "buzz-gif"], + }), + { searchPath: "/gifs/search", sharePath: "/gifs/share" }, + ); + assert.equal(relayKlipyCapability({}), null); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "another", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { provider: "klipy", search: "/gifs/search" }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + for (const path of [ + "https://attacker.example/search", + "//attacker.example/search", + "/\\attacker.example/search", + "/%5c%5cattacker.example/search", + "/gifs/../admin", + "/gifs/%2e%2e/admin", + "/gifs/search?redirect=https://attacker.example", + "/gifs/search#fragment", + ]) { + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: path, + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: path, + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + } +}); + +test("klipyGifFilename sanitizes provider slugs", () => { + const gif = { + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: " That's a wrap! ", + title: "That's a wrap", + }; + + const filename = klipyGifFilename(gif); + + assert.equal(filename, "that-s-a-wrap.gif"); +}); + +test("klipyGifAttachment references KLIPY media without an uploaded copy", () => { + const attachment = klipyGifAttachment({ + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: "ship-it", + title: "Ship it", + }); + + assert.deepEqual(attachment, { + dim: "640x360", + displayLabel: "Ship it", + filename: "ship-it.gif", + sha256: "", + size: 42, + type: "image/gif", + uploaded: 0, + url: GIF_ASSET.url, + }); +}); diff --git a/desktop/src/features/gifs/api.ts b/desktop/src/features/gifs/api.ts new file mode 100644 index 00000000000..1408eaeb3cf --- /dev/null +++ b/desktop/src/features/gifs/api.ts @@ -0,0 +1,185 @@ +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; + +type KlipyAsset = { + height?: number; + size?: number; + url?: string; + width?: number; +}; + +type KlipyFileSet = { + gif?: KlipyAsset; + jpg?: KlipyAsset; + webp?: KlipyAsset; +}; + +type KlipyRawGif = { + file?: { + hd?: KlipyFileSet; + md?: KlipyFileSet; + sm?: KlipyFileSet; + xs?: KlipyFileSet; + }; + id?: number; + slug?: string; + title?: string; + type?: string; +}; + +export type KlipyResponse = { + data?: { + data?: KlipyRawGif[]; + }; + result?: boolean; +}; + +export type KlipyGif = { + id: number | null; + original: Required; + /** + * Static (non-animated) poster for the GIF, when KLIPY exposes a `jpg` + * asset. Rendered in place of the animated preview under + * `prefers-reduced-motion: reduce`. + */ + poster: Required | null; + preview: Required; + slug: string; + title: string; +}; + +export type RelayGifSearchInfo = { + gif?: { + provider?: string; + search?: string; + share?: string; + }; + supported_extensions?: string[]; +}; + +export type RelayKlipyCapability = { + searchPath: string; + sharePath: string; +}; + +function safeRelayPath(path: unknown): path is string { + return ( + typeof path === "string" && + path.startsWith("/") && + !path.startsWith("//") && + !path.includes("\\") && + !path.includes("%") && + !path.includes("?") && + !path.includes("#") && + !path.split("/").some((segment) => segment === "." || segment === "..") + ); +} + +/** The safe relay-relative KLIPY endpoints advertised by NIP-11, if any. */ +export function relayKlipyCapability( + info: RelayGifSearchInfo, +): RelayKlipyCapability | null { + const searchPath = info.gif?.search; + const sharePath = info.gif?.share; + if ( + info.supported_extensions?.includes("buzz-gif") === true && + info.gif?.provider === "klipy" && + safeRelayPath(searchPath) && + safeRelayPath(sharePath) + ) { + return { searchPath, sharePath }; + } + return null; +} + +function isCompleteAsset( + asset: KlipyAsset | undefined, +): asset is Required { + return ( + typeof asset?.url === "string" && + asset.url.length > 0 && + typeof asset.width === "number" && + typeof asset.height === "number" && + typeof asset.size === "number" + ); +} + +function firstCompleteAsset( + ...assets: Array +): Required | null { + return assets.find(isCompleteAsset) ?? null; +} + +/** + * Normalize KLIPY's mixed media response to GIF-only results. The API can + * interleave ad/content records without a file payload; those are intentionally + * omitted until Buzz has an explicit third-party ad surface. + */ +export function normalizeKlipyGifs(items: KlipyRawGif[]): KlipyGif[] { + const gifs: KlipyGif[] = []; + + for (const item of items) { + if (item.type !== "gif" || !item.file || !item.slug) continue; + + const original = firstCompleteAsset( + item.file.md?.gif, + item.file.hd?.gif, + item.file.sm?.gif, + item.file.xs?.gif, + ); + const preview = firstCompleteAsset( + item.file.sm?.webp, + item.file.sm?.gif, + item.file.xs?.webp, + item.file.xs?.gif, + item.file.md?.webp, + original ?? undefined, + ); + if (!original || !preview) continue; + + const poster = firstCompleteAsset( + item.file.sm?.jpg, + item.file.xs?.jpg, + item.file.md?.jpg, + item.file.hd?.jpg, + ); + + gifs.push({ + id: item.id ?? null, + original, + poster, + preview, + slug: item.slug, + title: item.title?.trim() || "GIF", + }); + } + + return gifs; +} + +export function klipyGifFilename(gif: KlipyGif): string { + const safeSlug = gif.slug + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${safeSlug || "klipy-gif"}.gif`; +} + +/** + * Represent a selected KLIPY GIF as externally hosted media. The empty hash + * marks it as content-only media: the outgoing builder appends the image URL + * to the message body but deliberately omits an imeta tag, since Buzz relays + * only accept verified local `/media/` entries in imeta. + */ +export function klipyGifAttachment(gif: KlipyGif): ImetaMedia { + return { + dim: `${gif.original.width}x${gif.original.height}`, + displayLabel: gif.title, + filename: klipyGifFilename(gif), + sha256: "", + size: gif.original.size, + type: "image/gif", + uploaded: 0, + url: gif.original.url, + }; +} diff --git a/desktop/src/features/gifs/relay.ts b/desktop/src/features/gifs/relay.ts new file mode 100644 index 00000000000..bdcffd4f9f6 --- /dev/null +++ b/desktop/src/features/gifs/relay.ts @@ -0,0 +1,142 @@ +import { + type KlipyGif, + type KlipyResponse, + normalizeKlipyGifs, + relayKlipyCapability, + type RelayKlipyCapability, + type RelayGifSearchInfo, +} from "@/features/gifs/api"; +import { relayHttpFromWs } from "@/shared/api/inviteHelpers"; +import { signRelayEvent } from "@/shared/api/tauri"; + +const KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX = "buzz:klipy-customer-id:v1:"; +const NIP98_KIND = 27235; + +function customerId(relayUrl: string): string { + if (typeof window === "undefined") return globalThis.crypto.randomUUID(); + + try { + const storageKey = `${KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX}${relayUrl}`; + const existing = window.localStorage.getItem(storageKey); + if (existing) return existing; + + const created = globalThis.crypto.randomUUID(); + window.localStorage.setItem(storageKey, created); + return created; + } catch { + // Storage can be unavailable in hardened webviews. Prefer an ephemeral ID + // over a process-wide fallback that would correlate unrelated relays. + return globalThis.crypto.randomUUID(); + } +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +async function nip98PostHeader(url: string, body: string): Promise { + const authEvent = await signRelayEvent({ + kind: NIP98_KIND, + content: "", + tags: [ + ["u", url], + ["method", "POST"], + ["payload", await sha256Hex(body)], + ["nonce", crypto.randomUUID()], + ], + }); + return `Nostr ${btoa(JSON.stringify(authEvent))}`; +} + +const FRIENDLY_GIF_ERRORS: Record = { + relay_membership_required: "Join this community to search GIFs.", +}; + +function gifErrorMessage(error: string | undefined, status: number): string { + if (error && FRIENDLY_GIF_ERRORS[error]) return FRIENDLY_GIF_ERRORS[error]; + return error || `GIF request failed (${status})`; +} + +async function relayPost( + relayUrl: string, + path: string, + payload: Record, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}${path}`; + const body = JSON.stringify(payload); + const response = await fetch(url, { + body, + headers: { + Authorization: await nip98PostHeader(url, body), + "Content-Type": "application/json", + }, + method: "POST", + signal, + }); + if (!response.ok) { + const json = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(gifErrorMessage(json.error, response.status)); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +/** The selected relay's advertised KLIPY endpoints, when supported. */ +export async function relayKlipyEndpoints( + relayUrl: string, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}/info`; + const response = await fetch(url, { + headers: { Accept: "application/nostr+json" }, + signal, + }); + if (!response.ok) + throw new Error(`Could not read relay capabilities (${response.status})`); + const info = (await response.json()) as RelayGifSearchInfo; + return relayKlipyCapability(info); +} + +/** Search KLIPY through the selected relay without exposing its provider key. */ +export async function fetchKlipyGifs( + relayUrl: string, + searchPath: string, + query: string, + signal?: AbortSignal, +): Promise { + const response = await relayPost( + relayUrl, + searchPath, + { + customer_id: customerId(relayUrl), + locale: navigator.language || "en-US", + query: query.trim(), + }, + signal, + ); + if (response.result === false) { + throw new Error("GIF search failed"); + } + return normalizeKlipyGifs(response.data?.data ?? []); +} + +/** Report a selected GIF so KLIPY can update the anonymous user's Recents. */ +export async function reportKlipyShare( + relayUrl: string, + sharePath: string, + slug: string, +): Promise { + await relayPost(relayUrl, sharePath, { + customer_id: customerId(relayUrl), + slug, + }); +} diff --git a/desktop/src/features/gifs/ui/KlipyGifPicker.tsx b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx new file mode 100644 index 00000000000..6558cb24b8c --- /dev/null +++ b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx @@ -0,0 +1,161 @@ +import { useQuery } from "@tanstack/react-query"; +import { LoaderCircle, Search } from "lucide-react"; +import { useReducedMotion } from "motion/react"; +import * as React from "react"; + +import type { KlipyGif } from "@/features/gifs/api"; +import { fetchKlipyGifs } from "@/features/gifs/relay"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type KlipyGifPickerProps = { + onSelect: (gif: KlipyGif) => void; + relayUrl: string; + searchPath: string; +}; + +const LOADING_SKELETONS = [ + "tall-a", + "short-a", + "short-b", + "tall-b", + "short-c", + "short-d", + "tall-c", + "short-e", + "short-f", + "tall-d", +] as const; + +export const KlipyGifPicker = React.memo(function KlipyGifPicker({ + onSelect, + relayUrl, + searchPath, +}: KlipyGifPickerProps) { + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + const prefersReducedMotion = useReducedMotion() ?? false; + + React.useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedSearch(search.trim()), + 500, + ); + return () => window.clearTimeout(timeout); + }, [search]); + + const gifsQuery = useQuery({ + queryFn: ({ signal }) => + fetchKlipyGifs(relayUrl, searchPath, debouncedSearch, signal), + queryKey: ["klipy-gifs", relayUrl, searchPath, debouncedSearch], + retry: false, + staleTime: 5 * 60 * 1_000, + }); + + return ( +
+
+
+ + setSearch(event.target.value)} + placeholder="Search KLIPY" + type="search" + value={search} + /> + {gifsQuery.isFetching ? ( + + ) : null} +
+
+ +
+ {gifsQuery.isPending ? ( +
+ Loading GIFs + {LOADING_SKELETONS.map((id) => ( + + ))} +
+ ) : gifsQuery.isError ? ( +
+

+ {gifsQuery.error.message} +

+ +
+ ) : gifsQuery.data.length === 0 ? ( +
+ No GIFs found. +
+ ) : ( +
+ {gifsQuery.data.map((gif) => { + const staticPoster = prefersReducedMotion ? gif.poster : null; + const showAnimated = !prefersReducedMotion; + return ( + + ); + })} +
+ )} +
+ +
+ Powered by KLIPY +
+
+ ); +}); diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 79c193fb19b..e1af5be6bcd 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -597,22 +597,31 @@ test("imetaMediaFromTags: entry without size leaves size 0", () => { assert.equal(out[0].size, 0); }); -test("buildImetaTags: omits x line when sha256 is empty", () => { +test("buildImetaTags: omits hashless external media entirely", () => { const tags = buildImetaTags([ { - url: "https://b/a.png", - type: "image/png", + url: "https://static.klipy.com/a.gif", + type: "image/gif", sha256: "", size: 1, uploaded: 0, }, ]); - assert.equal(tags.length, 1); - // No element starts with "x " or "x\t" — no empty x line emitted. - assert.ok( - !tags[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(tags[0])}`, - ); + assert.deepEqual(tags, []); +}); + +test("buildOutgoingMessage: hashless external media is content-only", () => { + const out = buildOutgoingMessage("", [ + { + url: "https://static.klipy.com/a.gif", + type: "image/gif", + sha256: "", + size: 1, + uploaded: 0, + }, + ]); + assert.equal(out.content, "\n![image](https://static.klipy.com/a.gif)"); + assert.equal(out.mediaTags, undefined); }); test("buildImetaTags: omits size line when size is 0", () => { @@ -632,31 +641,14 @@ test("buildImetaTags: omits size line when size is 0", () => { ); }); -test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", () => { - // Legacy / cross-client entry: only url + m. No x, no size. +test("round-trip: sparse legacy imeta is not re-emitted without a hash", () => { const legacyTags = [["imeta", "url https://b/legacy.png", "m image/png"]]; const projected = imetaMediaFromTags(legacyTags); assert.equal(projected.length, 1); assert.equal(projected[0].sha256, ""); assert.equal(projected[0].size, 0); - const rebuilt = buildImetaTags(projected); - assert.equal(rebuilt.length, 1); - // Neither "x " nor "size 0" leaked into the rebuilt tag. - assert.ok( - !rebuilt[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(rebuilt[0])}`, - ); - assert.ok( - !rebuilt[0].some((part) => /^size[\s\t]/.test(part)), - `expected no size line, got ${JSON.stringify(rebuilt[0])}`, - ); - // url and m survived. - assert.deepEqual(rebuilt[0], [ - "imeta", - "url https://b/legacy.png", - "m image/png", - ]); + assert.deepEqual(buildImetaTags(projected), []); }); const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"]; diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index fde84922897..f02b9906ca2 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -79,29 +79,31 @@ export function imetaMediaFromTags( * Shared by the send path (initial post) and the edit path (full new tag set * on the edit event), so the two stay perfectly symmetric. * - * `url` and `m` are always emitted (NIP-92's only de-facto required fields; - * `m` carries a fallback in `imetaMediaFromTags`). All other fields are - * conditional — including `x` and `size` — because legacy and cross-client - * imeta entries can land without a sha256 or size, and our relay validator - * rejects literal `"x "` / `"size 0"` empties. NIP-92 itself treats every - * field except `url` as optional, so dropping them is spec-clean. + * `url`, `m`, and `x` are emitted for verified relay-hosted media. Entries + * without a hash represent external content (for example KLIPY GIFs); their + * markdown URL remains in the message body, but they are omitted from imeta + * because Buzz's relay validator requires a hash-backed local `/media/` path. + * Other fields remain conditional so legacy entries do not emit invalid + * literal `"size 0"` values. */ export function buildImetaTags( imetaMedia: ReadonlyArray, ): string[][] { - return imetaMedia.map((d) => [ - "imeta", - `url ${d.url}`, - `m ${d.type}`, - ...(d.sha256 ? [`x ${d.sha256}`] : []), - ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), - ...(d.dim ? [`dim ${d.dim}`] : []), - ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), - ...(d.thumb ? [`thumb ${d.thumb}`] : []), - ...(d.duration != null ? [`duration ${d.duration}`] : []), - ...(d.image ? [`image ${d.image}`] : []), - ...(d.filename ? [`filename ${d.filename}`] : []), - ]); + return imetaMedia + .filter((d) => d.sha256.length > 0) + .map((d) => [ + "imeta", + `url ${d.url}`, + `m ${d.type}`, + `x ${d.sha256}`, + ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), + ...(d.dim ? [`dim ${d.dim}`] : []), + ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), + ...(d.thumb ? [`thumb ${d.thumb}`] : []), + ...(d.duration != null ? [`duration ${d.duration}`] : []), + ...(d.image ? [`image ${d.image}`] : []), + ...(d.filename ? [`filename ${d.filename}`] : []), + ]); } const MEDIA_LINE_RE = @@ -329,9 +331,11 @@ export function buildOutgoingMessage( spoiler: spoileredMediaUrls.has(d.url), }); } - const mediaTags = - pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined; - return { content, mediaTags }; + const mediaTags = buildImetaTags(pendingImeta); + return { + content, + mediaTags: mediaTags.length > 0 ? mediaTags : undefined, + }; } /** diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index f46336b3e90..be0db496496 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -13,7 +13,6 @@ import { X, } from "lucide-react"; -import type { BlobDescriptor } from "@/shared/api/tauri"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { @@ -226,7 +225,7 @@ function composerMediaStyle(): React.CSSProperties { } type MediaAttachmentItemProps = { - attachment: BlobDescriptor; + attachment: ImetaMedia; isSpoilered: boolean; onEditSave?: (url: string, bytes: Uint8Array) => Promise; onRemove: (url: string) => void; @@ -266,6 +265,18 @@ const MediaAttachmentItem = React.forwardRef< const hash = shortHash(attachment.sha256); const isVideo = attachment.type.startsWith("video/"); + // One accessible name for every control/label in this item. Provider media + // (e.g. KLIPY GIFs) carries a `displayLabel` but no content hash; ordinary + // uploads keep their historical type-aware `Attachment ` / + // `Video attachment ` name; only genuinely hashless non-provider media + // falls back to a filename. + const mediaLabel = + attachment.displayLabel?.trim() || + (attachment.sha256 + ? isVideo + ? `Video attachment ${hash}` + : `Attachment ${hash}` + : attachment.filename?.trim() || `Attachment ${hash}`); const thumbUrl = attachment.thumb ? rewriteRelayUrl(attachment.thumb) : rewriteRelayUrl(attachment.url); @@ -275,7 +286,11 @@ const MediaAttachmentItem = React.forwardRef< ? rewriteRelayUrl(attachment.thumb) : undefined; - const canEdit = !isVideo && onEditSave !== undefined; + // Only Buzz-hosted uploads have a content hash. URL-only provider media + // must remain externally hosted instead of being copied into storage by the + // image editor's save path. + const canEdit = + !isVideo && onEditSave !== undefined && attachment.sha256.length === 64; const canRevert = !isVideo && onRevert !== undefined && originalUrl !== undefined; @@ -338,40 +353,41 @@ const MediaAttachmentItem = React.forwardRef< style={composerMediaStyle()} > - -
- {isVideo ? ( -
- {videoPosterUrl ? ( - {`Video - ) : ( -
- )} -
-
- -
-
- ) : ( - {`Attachment - )} - {isSpoilered ? ( -
- + + {isVideo ? ( +
+ {videoPosterUrl ? ( + + ) : ( +
+ )} +
+
+
- ) : null} -
+
+ ) : ( + + )} + {isSpoilered ? ( +
+ +
+ ) : null} - Attachment {hash} preview + {mediaLabel} preview Full-size attachment preview. Press Escape or click outside to @@ -401,7 +417,7 @@ const MediaAttachmentItem = React.forwardRef< ) : null} {mode === "edit" && !isVideo ? ( ) : ( {`Attachment