mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): add KLIPY GIF search to composers (#5554)
## Summary - Supersedes #1913 with a KLIPY-hosted URL implementation. - Adds KLIPY GIF search and trending results to desktop message and forum composers. - Keeps selected GIFs hosted by KLIPY; Buzz stores only the external URL and media metadata (no imeta tag, since relays only accept hash-backed local `/media/` entries). - Aligns the Emoji/GIF picker with Buzz's standard segmented control, theme surfaces, and motion behavior. ## Relay-to-provider boundary - The relay proxies KLIPY search/share so the `BUZZ_KLIPY_API_KEY` never reaches the desktop; the key stays server-side behind a redacted `Debug` impl. - The dedicated GIF `reqwest` client sets `redirect::Policy::none()`. Because the API key rides in the request path, following a provider `3xx` could replay a key-bearing URL to an attacker-chosen host (an SSRF/key-disclosure primitive). With redirects disabled, a `3xx` returns as a non-success status that the handlers map to a generic `502`; the `Location` target is never read or forwarded. - Admission reuses the established NIP-98, membership, replay, and per-pubkey rate-limit gates, with an upstream response-size cap and allowlisting so KLIPY error bodies never cross the relay boundary. ## Accessibility - Under `prefers-reduced-motion: reduce`, the picker grid renders a static provider poster (a normalized `jpg` asset) instead of the animated preview, or a named static placeholder when no poster is available. It reacts to preference changes while mounted. `no-preference` keeps the animated preview. - Selected GIFs carry their title through `ImetaMedia`'s `displayLabel`, so the composer thumbnail, preview dialog, editor, lightbox, and remove control all derive one non-empty accessible name instead of an empty `Attachment ` label. Ordinary hashed uploads keep their existing hash-derived names. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
613
crates/buzz-relay/src/api/gifs.rs
Normal file
613
crates/buzz-relay/src/api/gifs.rs
Normal file
@@ -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<Value>)> {
|
||||
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<url::Url, (StatusCode, Json<Value>)> {
|
||||
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<reqwest::RequestBuilder, (StatusCode, Json<Value>)> {
|
||||
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<AppState>,
|
||||
headers: &HeaderMap,
|
||||
path: &str,
|
||||
body: &[u8],
|
||||
) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json<Value>)> {
|
||||
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<reqwest::Response, (StatusCode, Json<Value>)> {
|
||||
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<Value>)> {
|
||||
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<Value, (StatusCode, Json<Value>)> {
|
||||
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<Value, (StatusCode, Json<Value>)> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<StatusCode, (StatusCode, Json<Value>)> {
|
||||
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<AppState> {
|
||||
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::<buzz_audit::AuditService>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<KlipyConfig>,
|
||||
|
||||
/// 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<buzz_auth::RateLimitConfig, ConfigErro
|
||||
"BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN",
|
||||
defaults.human_messages_per_min,
|
||||
)?,
|
||||
gif_searches_per_min: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN",
|
||||
defaults.gif_searches_per_min,
|
||||
)?,
|
||||
human_api_calls_per_min: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN",
|
||||
defaults.human_api_calls_per_min,
|
||||
@@ -628,6 +660,12 @@ impl Config {
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
let klipy = std::env::var("BUZZ_KLIPY_API_KEY")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|api_key| KlipyConfig { api_key });
|
||||
|
||||
// Note: intentionally not prefixed with BUZZ_ — this is a relay-identity
|
||||
// config that may be shared across multiple services (e.g., ACP agent).
|
||||
let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY")
|
||||
@@ -1019,6 +1057,7 @@ impl Config {
|
||||
relay_operator_api_origin,
|
||||
relay_operator_pubkeys,
|
||||
allow_nip_oa_auth,
|
||||
klipy,
|
||||
media,
|
||||
media_max_concurrent_uploads,
|
||||
media_max_concurrent_uploads_per_pubkey,
|
||||
@@ -1049,6 +1088,17 @@ impl Config {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn klipy_config_debug_redacts_the_api_key() {
|
||||
let config = KlipyConfig {
|
||||
api_key: "private-klipy-key".to_string(),
|
||||
};
|
||||
|
||||
let debug = format!("{config:?}");
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
assert!(!debug.contains("private-klipy-key"));
|
||||
}
|
||||
|
||||
// Mutex to serialize tests that mutate environment variables.
|
||||
// Parallel env-var mutation causes `defaults_are_valid` to see the invalid
|
||||
// value set by `invalid_bind_addr_returns_error`, causing a flaky failure.
|
||||
@@ -1482,15 +1532,18 @@ mod tests {
|
||||
fn rate_limits_can_be_overridden() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN", "1001");
|
||||
std::env::set_var("BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN", "1004");
|
||||
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN", "1002");
|
||||
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "1003");
|
||||
|
||||
let config = Config::from_env().expect("config");
|
||||
|
||||
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN");
|
||||
std::env::remove_var("BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN");
|
||||
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN");
|
||||
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
|
||||
assert_eq!(config.auth.rate_limits.human_messages_per_min, 1001);
|
||||
assert_eq!(config.auth.rate_limits.gif_searches_per_min, 1004);
|
||||
assert_eq!(config.auth.rate_limits.human_api_calls_per_min, 1002);
|
||||
assert_eq!(config.auth.rate_limits.human_ws_events_per_sec, 1003);
|
||||
}
|
||||
|
||||
@@ -1563,6 +1563,7 @@ mod tests {
|
||||
false,
|
||||
crate::config::DEFAULT_MAX_FRAME_BYTES,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.limitation
|
||||
.expect("limitation")
|
||||
|
||||
@@ -52,11 +52,26 @@ pub struct RelayInfo {
|
||||
/// Public WebSocket URL of the dedicated NIP-AB device-pairing relay.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pairing_relay_url: Option<String>,
|
||||
/// 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<GifDescriptor>,
|
||||
/// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
|
||||
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
|
||||
pub relay_self: Option<String>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ pub fn build_router(state: Arc<AppState>) -> 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),
|
||||
|
||||
@@ -722,6 +722,9 @@ pub struct AppState {
|
||||
/// replace this with process-local caching; replay freshness must survive
|
||||
/// cross-pod routing.
|
||||
pub nip98_replay: Arc<dyn Nip98ReplayGuard>,
|
||||
/// 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<RedisRateLimiter>,
|
||||
|
||||
@@ -852,6 +855,7 @@ impl AppState {
|
||||
);
|
||||
let nip98_replay: Arc<dyn Nip98ReplayGuard> =
|
||||
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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -610,6 +610,7 @@ export function ForumComposer({
|
||||
) : undefined
|
||||
}
|
||||
formattingDisabled={Boolean(disabled || isSubmissionPending)}
|
||||
gifMediaController={media}
|
||||
isEmojiPickerOpen={isEmojiPickerOpen}
|
||||
isFormattingOpen={isFormattingOpen}
|
||||
isSending={Boolean(isSending || isSubmissionPending)}
|
||||
|
||||
179
desktop/src/features/gifs/api.test.mjs
Normal file
179
desktop/src/features/gifs/api.test.mjs
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
185
desktop/src/features/gifs/api.ts
Normal file
185
desktop/src/features/gifs/api.ts
Normal file
@@ -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<KlipyAsset>;
|
||||
/**
|
||||
* 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<KlipyAsset> | null;
|
||||
preview: Required<KlipyAsset>;
|
||||
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<KlipyAsset> {
|
||||
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<KlipyAsset | undefined>
|
||||
): Required<KlipyAsset> | 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,
|
||||
};
|
||||
}
|
||||
142
desktop/src/features/gifs/relay.ts
Normal file
142
desktop/src/features/gifs/relay.ts
Normal file
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string, string> = {
|
||||
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<T>(
|
||||
relayUrl: string,
|
||||
path: string,
|
||||
payload: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
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<RelayKlipyCapability | null> {
|
||||
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<KlipyGif[]> {
|
||||
const response = await relayPost<KlipyResponse>(
|
||||
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<void> {
|
||||
await relayPost<void>(relayUrl, sharePath, {
|
||||
customer_id: customerId(relayUrl),
|
||||
slug,
|
||||
});
|
||||
}
|
||||
161
desktop/src/features/gifs/ui/KlipyGifPicker.tsx
Normal file
161
desktop/src/features/gifs/ui/KlipyGifPicker.tsx
Normal file
@@ -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 (
|
||||
<div className="flex h-[435px] w-[352px] flex-col bg-muted text-foreground">
|
||||
<div className="p-2.5">
|
||||
<div className="relative">
|
||||
<Search
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
aria-label="Search KLIPY"
|
||||
autoFocus
|
||||
className="h-9 bg-background/75 pl-8 pr-8 hover:bg-background/85 focus-visible:bg-background focus-visible:ring-ring/40"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search KLIPY"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
{gifsQuery.isFetching ? (
|
||||
<LoaderCircle
|
||||
aria-hidden
|
||||
className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{gifsQuery.isPending ? (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<span className="sr-only">Loading GIFs</span>
|
||||
{LOADING_SKELETONS.map((id) => (
|
||||
<Skeleton
|
||||
className={id.startsWith("tall") ? "h-28" : "h-20"}
|
||||
key={id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : gifsQuery.isError ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{gifsQuery.error.message}
|
||||
</p>
|
||||
<button
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
onClick={() => void gifsQuery.refetch()}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : gifsQuery.data.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
|
||||
No GIFs found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="columns-2 gap-1.5" data-testid="klipy-gif-grid">
|
||||
{gifsQuery.data.map((gif) => {
|
||||
const staticPoster = prefersReducedMotion ? gif.poster : null;
|
||||
const showAnimated = !prefersReducedMotion;
|
||||
return (
|
||||
<button
|
||||
aria-label={`Choose ${gif.title}`}
|
||||
className="mb-1.5 block w-full break-inside-avoid overflow-hidden rounded-lg bg-muted outline-hidden ring-offset-background transition-[filter,transform] hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-[0.98]"
|
||||
key={gif.slug}
|
||||
onClick={() => onSelect(gif)}
|
||||
title={gif.title}
|
||||
type="button"
|
||||
>
|
||||
{showAnimated ? (
|
||||
<img
|
||||
alt={gif.title}
|
||||
className="block h-auto w-full"
|
||||
height={gif.preview.height}
|
||||
loading="lazy"
|
||||
src={gif.preview.url}
|
||||
width={gif.preview.width}
|
||||
/>
|
||||
) : staticPoster ? (
|
||||
<img
|
||||
alt={gif.title}
|
||||
className="block h-auto w-full"
|
||||
height={staticPoster.height}
|
||||
loading="lazy"
|
||||
src={staticPoster.url}
|
||||
width={staticPoster.width}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex aspect-video w-full items-center justify-center bg-muted px-2 text-center text-xs text-muted-foreground"
|
||||
data-testid="klipy-gif-static-placeholder"
|
||||
>
|
||||
{gif.title}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 px-3 py-2 text-center text-2xs text-muted-foreground">
|
||||
Powered by KLIPY
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
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"];
|
||||
|
||||
@@ -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<ImetaMedia>,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void>;
|
||||
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 <hash>` /
|
||||
// `Video attachment <hash>` 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()}
|
||||
>
|
||||
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogPrimitive.Trigger asChild>
|
||||
<div className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70">
|
||||
{isVideo ? (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
|
||||
{videoPosterUrl ? (
|
||||
<img
|
||||
src={videoPosterUrl}
|
||||
alt={`Video attachment ${hash}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-muted/80" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/15" />
|
||||
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
|
||||
<Play className="h-4 w-4 fill-white text-white" />
|
||||
</div>
|
||||
<DialogPrimitive.Trigger
|
||||
aria-label={mediaLabel}
|
||||
className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70"
|
||||
>
|
||||
{isVideo ? (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
|
||||
{videoPosterUrl ? (
|
||||
<img
|
||||
src={videoPosterUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-muted/80" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/15" />
|
||||
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
|
||||
<Play className="h-4 w-4 fill-white text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={`Attachment ${hash}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{isSpoilered ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
|
||||
data-composer-media-spoiler=""
|
||||
>
|
||||
<HatGlasses className="h-4 w-4" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{isSpoilered ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
|
||||
data-composer-media-spoiler=""
|
||||
>
|
||||
<HatGlasses className="h-4 w-4" />
|
||||
</div>
|
||||
) : null}
|
||||
</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
@@ -387,7 +403,7 @@ const MediaAttachmentItem = React.forwardRef<
|
||||
onEscapeKeyDown={handleEscapeKeyDown}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">
|
||||
Attachment {hash} preview
|
||||
{mediaLabel} preview
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Full-size attachment preview. Press Escape or click outside to
|
||||
@@ -401,7 +417,7 @@ const MediaAttachmentItem = React.forwardRef<
|
||||
) : null}
|
||||
{mode === "edit" && !isVideo ? (
|
||||
<ComposerImageEditor
|
||||
alt={`Attachment ${hash}`}
|
||||
alt={mediaLabel}
|
||||
src={rewriteRelayUrl(attachment.url)}
|
||||
sourceUrl={attachment.url}
|
||||
sourceType={attachment.type}
|
||||
@@ -421,7 +437,7 @@ const MediaAttachmentItem = React.forwardRef<
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
alt={`Attachment ${hash}`}
|
||||
alt=""
|
||||
className={cn(
|
||||
"relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain",
|
||||
isSpoilered && "blur-2xl brightness-75",
|
||||
@@ -515,7 +531,7 @@ const MediaAttachmentItem = React.forwardRef<
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Remove attachment"
|
||||
aria-label={`Remove ${mediaLabel}`}
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.url)}
|
||||
className={COMPOSER_MEDIA_REMOVE_CLASS}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { SmilePlus } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Images, Smile, SmilePlus } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
||||
import { klipyGifAttachment, type KlipyGif } from "@/features/gifs/api";
|
||||
import { relayKlipyEndpoints, reportKlipyShare } from "@/features/gifs/relay";
|
||||
import { KlipyGifPicker } from "@/features/gifs/ui/KlipyGifPicker";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
type ComposerEmojiPickerProps = {
|
||||
disabled?: boolean;
|
||||
gifMediaController: Pick<MediaUploadController, "setPendingImeta">;
|
||||
/** Called when the popover closes without an emoji selection (Escape,
|
||||
* click-outside). Use this to restore focus to the editor. */
|
||||
onClose?: () => void;
|
||||
@@ -17,21 +26,64 @@ type ComposerEmojiPickerProps = {
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
type ComposerPickerTab = "emoji" | "gifs";
|
||||
|
||||
const PICKER_TAB_ORDER: ComposerPickerTab[] = ["emoji", "gifs"];
|
||||
const ACTIVE_TAB_OUTER_CORNER_RADIUS = "calc(var(--radius-2xl) - 0.375rem)";
|
||||
|
||||
export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
|
||||
disabled = false,
|
||||
gifMediaController,
|
||||
onClose,
|
||||
onEmojiSelect,
|
||||
onOpenChange,
|
||||
onTriggerMouseDown,
|
||||
open,
|
||||
}: ComposerEmojiPickerProps) {
|
||||
const [pickerTab, setPickerTab] = React.useState<ComposerPickerTab>("emoji");
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { activeCommunity } = useCommunities();
|
||||
const relayUrl = activeCommunity?.relayUrl ?? "";
|
||||
const klipyCapabilityQuery = useQuery({
|
||||
enabled: relayUrl.length > 0,
|
||||
queryFn: ({ signal }) => relayKlipyEndpoints(relayUrl, signal),
|
||||
queryKey: ["relay-capability", relayUrl, "klipy"],
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1_000,
|
||||
});
|
||||
const gifSearchPath = klipyCapabilityQuery.data?.searchPath ?? "";
|
||||
const gifSharePath = klipyCapabilityQuery.data?.sharePath ?? "";
|
||||
const gifsAvailable = gifSearchPath.length > 0 && gifSharePath.length > 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) setPickerTab("emoji");
|
||||
}, [open]);
|
||||
|
||||
const handleGifSelect = React.useCallback(
|
||||
(gif: KlipyGif) => {
|
||||
if (!relayUrl) return;
|
||||
onOpenChange(false);
|
||||
gifMediaController.setPendingImeta((current) => [
|
||||
...current,
|
||||
klipyGifAttachment(gif),
|
||||
]);
|
||||
void reportKlipyShare(relayUrl, gifSharePath, gif.slug).catch(() => {
|
||||
// Sharing the GIF remains successful if best-effort Recents reporting
|
||||
// fails; the authenticated relay endpoint keeps provider details hidden.
|
||||
});
|
||||
},
|
||||
[gifMediaController.setPendingImeta, gifSharePath, onOpenChange, relayUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={onOpenChange} open={open}>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Insert emoji"
|
||||
aria-label={
|
||||
gifsAvailable ? "Insert emoji or GIF" : "Insert emoji"
|
||||
}
|
||||
data-testid="composer-emoji-button"
|
||||
disabled={disabled}
|
||||
onMouseDown={onTriggerMouseDown}
|
||||
@@ -43,7 +95,9 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Insert emoji</TooltipContent>
|
||||
<TooltipContent>
|
||||
{gifsAvailable ? "Emoji and GIFs" : "Emoji"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
@@ -64,7 +118,69 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
>
|
||||
<EmojiPicker autoFocus onSelect={onEmojiSelect} />
|
||||
{gifsAvailable ? (
|
||||
<Tabs
|
||||
className="w-[352px] overflow-hidden rounded-2xl bg-muted"
|
||||
onValueChange={(value) => setPickerTab(value as ComposerPickerTab)}
|
||||
value={pickerTab}
|
||||
>
|
||||
<TabsList className="relative isolate grid h-11 w-full grid-cols-2 overflow-hidden rounded-none border-b border-border/60 bg-muted p-1.5 text-muted-foreground">
|
||||
<motion.div
|
||||
animate={{
|
||||
transform: `translateX(${PICKER_TAB_ORDER.indexOf(pickerTab) * 100}%)`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-1.5 left-1.5 top-1.5 z-0 rounded-md bg-background shadow"
|
||||
initial={false}
|
||||
style={{
|
||||
borderTopLeftRadius:
|
||||
pickerTab === "emoji"
|
||||
? ACTIVE_TAB_OUTER_CORNER_RADIUS
|
||||
: undefined,
|
||||
borderTopRightRadius:
|
||||
pickerTab === "gifs"
|
||||
? ACTIVE_TAB_OUTER_CORNER_RADIUS
|
||||
: undefined,
|
||||
width: "calc((100% - 12px) / 2)",
|
||||
}}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 0.18,
|
||||
ease: [0.77, 0, 0.175, 1],
|
||||
}
|
||||
}
|
||||
/>
|
||||
<TabsTrigger
|
||||
className="relative z-10 h-8 gap-1.5 bg-transparent shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
value="emoji"
|
||||
>
|
||||
<Smile aria-hidden className="h-4 w-4" />
|
||||
Emoji
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="relative z-10 h-8 gap-1.5 bg-transparent shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
value="gifs"
|
||||
>
|
||||
<Images aria-hidden className="h-4 w-4" />
|
||||
GIFs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="m-0" value="emoji">
|
||||
<EmojiPicker autoFocus onSelect={onEmojiSelect} perLine={9} />
|
||||
</TabsContent>
|
||||
<TabsContent className="m-0" value="gifs">
|
||||
<KlipyGifPicker
|
||||
onSelect={handleGifSelect}
|
||||
relayUrl={relayUrl}
|
||||
searchPath={gifSearchPath}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<EmojiPicker autoFocus onSelect={onEmojiSelect} perLine={9} />
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -170,33 +170,32 @@ function MessageComposerImpl({
|
||||
media.queuedAttachmentsRef.current.length === 0;
|
||||
const ownsDropZone = mediaController === undefined;
|
||||
const backgroundUpload = useBackgroundMediaUpload();
|
||||
const { trackAuthoredContent: trackDraftAuthoredContent } =
|
||||
useDraftPersistLifecycle({
|
||||
effectiveDraftKey,
|
||||
channelId,
|
||||
loadDraft: drafts.loadDraft,
|
||||
persistDraft: drafts.persistDraft,
|
||||
getMentionRefs: mentions.getDraftMentionRefs,
|
||||
restoreMentionRefs: mentions.restoreDraftMentionRefs,
|
||||
livePendingImeta: media.pendingImeta,
|
||||
setPendingImeta: media.setPendingImeta,
|
||||
getQueuedAttachments: () => media.queuedAttachmentsRef.current,
|
||||
saveQueuedAttachmentsForDraft,
|
||||
clearQueuedAttachments: media.clearQueuedAttachments,
|
||||
restoreQueuedAttachments: media.restoreQueuedAttachments,
|
||||
takeQueuedAttachmentsForDraft,
|
||||
setContent: (content) => {
|
||||
setComposerContent(content);
|
||||
richText.setContent(content);
|
||||
},
|
||||
clearContent: () => {
|
||||
setComposerContent("");
|
||||
richText.clearContent();
|
||||
},
|
||||
setSpoileredAttachmentUrls,
|
||||
spoileredAttachmentUrlsRef,
|
||||
syncComposerContentFromEditor,
|
||||
});
|
||||
const { trackAuthoredContent } = useDraftPersistLifecycle({
|
||||
effectiveDraftKey,
|
||||
channelId,
|
||||
loadDraft: drafts.loadDraft,
|
||||
persistDraft: drafts.persistDraft,
|
||||
getMentionRefs: mentions.getDraftMentionRefs,
|
||||
restoreMentionRefs: mentions.restoreDraftMentionRefs,
|
||||
livePendingImeta: media.pendingImeta,
|
||||
setPendingImeta: media.setPendingImeta,
|
||||
getQueuedAttachments: () => media.queuedAttachmentsRef.current,
|
||||
saveQueuedAttachmentsForDraft,
|
||||
clearQueuedAttachments: media.clearQueuedAttachments,
|
||||
restoreQueuedAttachments: media.restoreQueuedAttachments,
|
||||
takeQueuedAttachmentsForDraft,
|
||||
setContent: (content) => {
|
||||
setComposerContent(content);
|
||||
richText.setContent(content);
|
||||
},
|
||||
clearContent: () => {
|
||||
setComposerContent("");
|
||||
richText.clearContent();
|
||||
},
|
||||
setSpoileredAttachmentUrls,
|
||||
spoileredAttachmentUrlsRef,
|
||||
syncComposerContentFromEditor,
|
||||
});
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
|
||||
React.useEffect(() => {
|
||||
media.setUploadState({ status: "idle" });
|
||||
@@ -273,7 +272,7 @@ function MessageComposerImpl({
|
||||
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
|
||||
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
|
||||
onUpdate: ({ cursor, linkPreviewContent, text }) => {
|
||||
trackDraftAuthoredContent(text);
|
||||
trackAuthoredContent(text);
|
||||
contentRef.current = text;
|
||||
setComposerContentFromText(text);
|
||||
setPreviewContent(linkPreviewContent);
|
||||
@@ -969,6 +968,7 @@ function MessageComposerImpl({
|
||||
editor={richText.editor}
|
||||
extraActions={toolbarExtraActions}
|
||||
formattingDisabled={composerDisabled}
|
||||
gifMediaController={media}
|
||||
isEmojiPickerOpen={isEmojiPickerOpen}
|
||||
isFormattingOpen={isFormattingOpen}
|
||||
isSending={isSending || mentionSendFlow.isPreparingMentionSend}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ALargeSmall, Paperclip, X } from "lucide-react";
|
||||
|
||||
import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
editor,
|
||||
extraActions,
|
||||
formattingDisabled,
|
||||
gifMediaController,
|
||||
isEmojiPickerOpen,
|
||||
isFormattingOpen,
|
||||
isSending,
|
||||
@@ -55,6 +57,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
editor: Editor | null;
|
||||
extraActions?: React.ReactNode;
|
||||
formattingDisabled: boolean;
|
||||
gifMediaController: Pick<MediaUploadController, "setPendingImeta">;
|
||||
isEmojiPickerOpen: boolean;
|
||||
isFormattingOpen: boolean;
|
||||
isSending: boolean;
|
||||
@@ -210,6 +213,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
</Tooltip>
|
||||
<ComposerEmojiPicker
|
||||
disabled={composerDisabled}
|
||||
gifMediaController={gifMediaController}
|
||||
onClose={() => editor?.commands.focus()}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
onOpenChange={onEmojiPickerOpenChange}
|
||||
|
||||
@@ -1133,6 +1133,15 @@ test("drops an expanded DM after agent startup fails", async ({ page }) => {
|
||||
|
||||
await input.fill(retryMessage);
|
||||
const retryBaseline = commandsAfterFailure.length;
|
||||
// The first send left the cursor parked over the bottom-right error toast,
|
||||
// which overlaps the send button. Sonner pauses its dismiss timer while the
|
||||
// toaster is hovered, so move the cursor away and let the transient toast
|
||||
// clear before retrying — otherwise the retry click is intercepted for the
|
||||
// full timeout.
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
|
||||
|
||||
@@ -75,7 +75,9 @@ test("image annotation overlay and editor controls", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Attachment aaaa", exact: true }),
|
||||
).toBeVisible();
|
||||
await composer.getByTestId("composer-media-attachment").hover();
|
||||
await expect(page.getByTestId("composer-attachment-annotate")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
@@ -104,7 +106,9 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as
|
||||
// Attach the original image via the mocked paperclip flow.
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Attachment aaaa", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Open the composer lightbox.
|
||||
await composer.getByTestId("composer-media-attachment").hover();
|
||||
@@ -146,7 +150,9 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as
|
||||
// Saving closes the lightbox; the composer thumbnail now shows the
|
||||
// annotated image.
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Attachment bbbb", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// The annotated PNG went through the real upload command.
|
||||
const uploadCommandCount = await page.evaluate(
|
||||
@@ -174,7 +180,9 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as
|
||||
// Closing the dialog shows the (restored) original thumbnail.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Attachment aaaa", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("spoiler marking survives drawing on the attachment", async ({ page }) => {
|
||||
@@ -184,7 +192,9 @@ test("spoiler marking survives drawing on the attachment", async ({ page }) => {
|
||||
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Attachment aaaa", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Spoiler the attachment from its lightbox (media spoilers are
|
||||
// per-attachment; the text spoiler control no longer affects media),
|
||||
@@ -215,6 +225,8 @@ test("spoiler marking survives drawing on the attachment", async ({ page }) => {
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
|
||||
// The annotated replacement is still marked as a spoiler.
|
||||
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Attachment bbbb", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -102,6 +102,6 @@ test("emoji picker tooltip dismisses when cursor leaves the trigger", async ({
|
||||
await expectTooltipDismissesOnLeave(
|
||||
page,
|
||||
page.getByTestId("composer-emoji-button"),
|
||||
"Insert emoji",
|
||||
"Emoji",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1879,6 +1879,394 @@ test("emoji picker inserts emoji into the draft and keeps focus in the composer"
|
||||
await expect(input).toHaveText("Ship🚀 now");
|
||||
});
|
||||
|
||||
test("relay GIF capability gates the composer picker", async ({ page }) => {
|
||||
await page.route("http://localhost:3000/info", (route) =>
|
||||
route.fulfill({
|
||||
body: JSON.stringify({ supported_extensions: [] }),
|
||||
contentType: "application/nostr+json",
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const pickerButton = page.getByTestId("composer-emoji-button");
|
||||
await expect(pickerButton).toHaveAccessibleName("Insert emoji");
|
||||
await pickerButton.click();
|
||||
await expect(page.getByRole("tab", { name: "GIFs" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("relay GIF search selects content-only media and reports the share", async ({
|
||||
page,
|
||||
}) => {
|
||||
const searchBodies: Array<{
|
||||
customer_id: string;
|
||||
locale: string;
|
||||
query: string;
|
||||
}> = [];
|
||||
const shareBodies: Array<{ customer_id: string; slug: string }> = [];
|
||||
const gifUrl = "https://static.klipy.com/e2e-ship-it.gif";
|
||||
const previewUrl = "https://static.klipy.com/e2e-ship-it.webp";
|
||||
|
||||
await page.route("http://localhost:3000/info", (route) =>
|
||||
route.fulfill({
|
||||
body: JSON.stringify({
|
||||
gif: {
|
||||
provider: "klipy",
|
||||
search: "/gifs/search",
|
||||
share: "/gifs/share",
|
||||
},
|
||||
supported_extensions: ["buzz-gif"],
|
||||
}),
|
||||
contentType: "application/nostr+json",
|
||||
}),
|
||||
);
|
||||
await page.route("http://localhost:3000/gifs/search", async (route) => {
|
||||
searchBodies.push(JSON.parse(route.request().postData() ?? "{}"));
|
||||
await route.fulfill({
|
||||
body: JSON.stringify({
|
||||
result: true,
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: null,
|
||||
file: {
|
||||
md: {
|
||||
gif: { height: 180, size: 42, url: gifUrl, width: 320 },
|
||||
},
|
||||
sm: {
|
||||
webp: {
|
||||
height: 90,
|
||||
size: 12,
|
||||
url: previewUrl,
|
||||
width: 160,
|
||||
},
|
||||
},
|
||||
},
|
||||
slug: "e2e-ship-it",
|
||||
title: "Ship it",
|
||||
type: "gif",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
await page.route("http://localhost:3000/gifs/share", async (route) => {
|
||||
shareBodies.push(JSON.parse(route.request().postData() ?? "{}"));
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
await page.route("https://static.klipy.com/e2e-ship-it.*", (route) =>
|
||||
route.fulfill({
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="#7c3aed"/></svg>',
|
||||
contentType: "image/svg+xml",
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const pickerButton = page.getByTestId("composer-emoji-button");
|
||||
await expect(pickerButton).toHaveAccessibleName("Insert emoji or GIF");
|
||||
await pickerButton.click();
|
||||
await page.getByRole("tab", { name: "GIFs" }).click();
|
||||
|
||||
await expect.poll(() => searchBodies.map(({ query }) => query)).toContain("");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Choose Ship it" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("searchbox", { name: "Search KLIPY" }).fill("celebrate");
|
||||
await expect
|
||||
.poll(() => searchBodies.map(({ query }) => query))
|
||||
.toContain("celebrate");
|
||||
await page.getByRole("button", { name: "Choose Ship it" }).click();
|
||||
|
||||
await expect(page.getByTestId("composer-media-attachment")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => shareBodies)
|
||||
.toEqual([
|
||||
{
|
||||
customer_id: searchBodies.at(-1)?.customer_id,
|
||||
slug: "e2e-ship-it",
|
||||
},
|
||||
]);
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(page.getByTestId("composer-media-attachment")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((expectedUrl) => {
|
||||
return Boolean(
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
||||
content?: string;
|
||||
kind?: number;
|
||||
}>;
|
||||
}
|
||||
).__BUZZ_E2E_SIGNED_EVENTS__?.some(
|
||||
(event) => event.kind === 9 && event.content?.includes(expectedUrl),
|
||||
),
|
||||
);
|
||||
}, gifUrl),
|
||||
)
|
||||
.toBe(true);
|
||||
const matchingEvent = await page.evaluate((expectedUrl) => {
|
||||
return (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
||||
content?: string;
|
||||
kind?: number;
|
||||
tags?: string[][];
|
||||
}>;
|
||||
}
|
||||
).__BUZZ_E2E_SIGNED_EVENTS__?.find(
|
||||
(event) => event.kind === 9 && event.content?.includes(expectedUrl),
|
||||
);
|
||||
}, gifUrl);
|
||||
expect(matchingEvent?.content).toBe(``);
|
||||
expect(matchingEvent?.tags?.some((tag) => tag[0] === "imeta")).toBe(false);
|
||||
});
|
||||
|
||||
async function routeGifMocks(page: import("@playwright/test").Page) {
|
||||
const gifs = [
|
||||
{
|
||||
animated: "https://static.klipy.com/e2e-party.gif",
|
||||
poster: "https://static.klipy.com/e2e-party.jpg",
|
||||
slug: "e2e-party",
|
||||
title: "Party parrot",
|
||||
},
|
||||
{
|
||||
animated: "https://static.klipy.com/e2e-thumbs-up.gif",
|
||||
poster: "https://static.klipy.com/e2e-thumbs-up.jpg",
|
||||
slug: "e2e-thumbs-up",
|
||||
title: "Thumbs up",
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("http://localhost:3000/info", (route) =>
|
||||
route.fulfill({
|
||||
body: JSON.stringify({
|
||||
gif: {
|
||||
provider: "klipy",
|
||||
search: "/gifs/search",
|
||||
share: "/gifs/share",
|
||||
},
|
||||
supported_extensions: ["buzz-gif"],
|
||||
}),
|
||||
contentType: "application/nostr+json",
|
||||
}),
|
||||
);
|
||||
await page.route("http://localhost:3000/gifs/search", (route) =>
|
||||
route.fulfill({
|
||||
body: JSON.stringify({
|
||||
result: true,
|
||||
data: {
|
||||
data: gifs.map((gif) => ({
|
||||
id: null,
|
||||
file: {
|
||||
md: {
|
||||
gif: { height: 180, size: 42, url: gif.animated, width: 320 },
|
||||
},
|
||||
sm: {
|
||||
jpg: { height: 90, size: 8, url: gif.poster, width: 160 },
|
||||
webp: {
|
||||
height: 90,
|
||||
size: 12,
|
||||
url: `${gif.animated.replace(/\.gif$/, ".webp")}`,
|
||||
width: 160,
|
||||
},
|
||||
},
|
||||
},
|
||||
slug: gif.slug,
|
||||
title: gif.title,
|
||||
type: "gif",
|
||||
})),
|
||||
},
|
||||
}),
|
||||
contentType: "application/json",
|
||||
}),
|
||||
);
|
||||
await page.route("http://localhost:3000/gifs/share", (route) =>
|
||||
route.fulfill({ status: 204 }),
|
||||
);
|
||||
await page.route("https://static.klipy.com/e2e-*", (route) =>
|
||||
route.fulfill({
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="#7c3aed"/></svg>',
|
||||
contentType: "image/svg+xml",
|
||||
}),
|
||||
);
|
||||
|
||||
return gifs;
|
||||
}
|
||||
|
||||
async function openGifGrid(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("composer-emoji-button").click();
|
||||
await page.getByRole("tab", { name: "GIFs" }).click();
|
||||
await expect(page.getByTestId("klipy-gif-grid")).toBeVisible();
|
||||
}
|
||||
|
||||
test("reduced-motion GIF grid renders static posters, not animated previews", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const gifs = await routeGifMocks(page);
|
||||
await openGifGrid(page);
|
||||
|
||||
const grid = page.getByTestId("klipy-gif-grid");
|
||||
// Each GIF stays identifiable and selectable by its accessible name.
|
||||
for (const gif of gifs) {
|
||||
await expect(
|
||||
grid.getByRole("button", { name: `Choose ${gif.title}` }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
const sources = await grid
|
||||
.locator("img")
|
||||
.evaluateAll((imgs) =>
|
||||
imgs.map((img) => (img as HTMLImageElement).getAttribute("src")),
|
||||
);
|
||||
// The static poster is shown; no animated preview URL enters the DOM.
|
||||
expect(sources).toEqual(gifs.map((gif) => gif.poster));
|
||||
for (const gif of gifs) {
|
||||
expect(sources).not.toContain(gif.animated);
|
||||
expect(sources.some((src) => src?.endsWith(".webp"))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("reduced-motion GIF grid falls back to a named static placeholder", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.route("http://localhost:3000/info", (route) =>
|
||||
route.fulfill({
|
||||
body: JSON.stringify({
|
||||
gif: {
|
||||
provider: "klipy",
|
||||
search: "/gifs/search",
|
||||
share: "/gifs/share",
|
||||
},
|
||||
supported_extensions: ["buzz-gif"],
|
||||
}),
|
||||
contentType: "application/nostr+json",
|
||||
}),
|
||||
);
|
||||
// A GIF with no jpg asset: reduced motion must not fall back to animation.
|
||||
await page.route("http://localhost:3000/gifs/search", (route) =>
|
||||
route.fulfill({
|
||||
body: JSON.stringify({
|
||||
result: true,
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: null,
|
||||
file: {
|
||||
md: {
|
||||
gif: {
|
||||
height: 180,
|
||||
size: 42,
|
||||
url: "https://static.klipy.com/e2e-no-poster.gif",
|
||||
width: 320,
|
||||
},
|
||||
},
|
||||
sm: {
|
||||
webp: {
|
||||
height: 90,
|
||||
size: 12,
|
||||
url: "https://static.klipy.com/e2e-no-poster.webp",
|
||||
width: 160,
|
||||
},
|
||||
},
|
||||
},
|
||||
slug: "e2e-no-poster",
|
||||
title: "No poster clip",
|
||||
type: "gif",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
contentType: "application/json",
|
||||
}),
|
||||
);
|
||||
await page.route("http://localhost:3000/gifs/share", (route) =>
|
||||
route.fulfill({ status: 204 }),
|
||||
);
|
||||
|
||||
await openGifGrid(page);
|
||||
|
||||
const grid = page.getByTestId("klipy-gif-grid");
|
||||
await expect(
|
||||
grid.getByRole("button", { name: "Choose No poster clip" }),
|
||||
).toBeVisible();
|
||||
await expect(grid.getByTestId("klipy-gif-static-placeholder")).toHaveText(
|
||||
"No poster clip",
|
||||
);
|
||||
await expect(grid.locator("img")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("normal-motion GIF grid renders animated previews", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
const gifs = await routeGifMocks(page);
|
||||
await openGifGrid(page);
|
||||
|
||||
const sources = await page
|
||||
.getByTestId("klipy-gif-grid")
|
||||
.locator("img")
|
||||
.evaluateAll((imgs) =>
|
||||
imgs.map((img) => (img as HTMLImageElement).getAttribute("src")),
|
||||
);
|
||||
// Animated `.webp` previews are used; static jpg posters stay out of the DOM.
|
||||
expect(sources.every((src) => src?.endsWith(".webp"))).toBe(true);
|
||||
for (const gif of gifs) {
|
||||
expect(sources).not.toContain(gif.poster);
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GIFs keep distinct accessible names in the composer and lightbox", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gifs = await routeGifMocks(page);
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
// Select two differently titled GIFs.
|
||||
for (const gif of gifs) {
|
||||
await page.getByTestId("composer-emoji-button").click();
|
||||
await page.getByRole("tab", { name: "GIFs" }).click();
|
||||
await page
|
||||
.getByTestId("klipy-gif-grid")
|
||||
.getByRole("button", { name: `Choose ${gif.title}` })
|
||||
.click();
|
||||
}
|
||||
|
||||
const thumbnails = page.getByTestId("composer-media-attachment");
|
||||
await expect(thumbnails).toHaveCount(2);
|
||||
|
||||
// Composer thumbnails carry distinct accessible names from the GIF titles.
|
||||
for (const gif of gifs) {
|
||||
await expect(
|
||||
thumbnails.getByRole("button", { name: gif.title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Remove ${gif.title}` }),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
// Each lightbox dialog is titled by the same GIF name.
|
||||
for (const gif of gifs) {
|
||||
await thumbnails
|
||||
.getByRole("button", { name: gif.title, exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: `${gif.title} preview` }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("empty message cannot be sent", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
@@ -99,7 +99,9 @@ test("image attachments can be marked and sent as hidden spoilers", async ({
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await expect(composer.getByAltText("Attachment cccc")).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Attachment cccc", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Media spoilers are toggled per-attachment from the lightbox.
|
||||
await composer.getByTestId("composer-media-attachment").hover();
|
||||
@@ -155,7 +157,9 @@ test("text spoiler stays usable while attachment upload is pending", async ({
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByTestId("message-composer").getByAltText("Attachment cccc"),
|
||||
page
|
||||
.getByTestId("message-composer")
|
||||
.getByRole("button", { name: "Attachment cccc", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -233,7 +233,9 @@ async function openReviewWithPostedTimecode(
|
||||
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
await expect(
|
||||
page.getByTestId("message-composer").getByAltText("Video attachment bbbb"),
|
||||
page
|
||||
.getByTestId("message-composer")
|
||||
.getByRole("button", { name: "Video attachment bbbb", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(page.getByText("Sending")).toHaveCount(0);
|
||||
@@ -289,8 +291,12 @@ test("video upload previews use poster frames and inline videos open review mode
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
|
||||
const composer = page.getByTestId("message-composer");
|
||||
const composerPoster = composer.getByAltText("Video attachment bbbb");
|
||||
await expect(composerPoster).toBeVisible();
|
||||
const composerTrigger = composer.getByRole("button", {
|
||||
name: "Video attachment bbbb",
|
||||
exact: true,
|
||||
});
|
||||
await expect(composerTrigger).toBeVisible();
|
||||
const composerPoster = composerTrigger.locator("img");
|
||||
await expect(composerPoster).toHaveAttribute("src", POSTER_DATA_URL);
|
||||
|
||||
const box = await composerPoster.boundingBox();
|
||||
|
||||
41
docs/gif-search.md
Normal file
41
docs/gif-search.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Relay-proxied GIF search
|
||||
|
||||
Buzz relays can optionally provide GIF search without distributing a provider
|
||||
credential to desktop clients. An operator configures `BUZZ_KLIPY_API_KEY` in
|
||||
the relay's secret store. When present, the relay advertises this NIP-11 shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"supported_extensions": ["buzz-gif"],
|
||||
"gif": {
|
||||
"provider": "klipy",
|
||||
"search": "/gifs/search",
|
||||
"share": "/gifs/share"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The descriptor is provider-agnostic so a relay can advertise another provider
|
||||
or paths later. Clients must require `buzz-gif`, recognize the provider, and use
|
||||
only safe relay-relative search and share paths.
|
||||
|
||||
`POST /gifs/search` requires NIP-98 authentication and relay membership. Its
|
||||
per-pubkey, per-community Redis admission limit defaults to 30 requests per
|
||||
minute and can be tuned with `BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN`. The relay
|
||||
sends the provider credential upstream and returns only allowlisted successful
|
||||
result data. Provider error bodies are never returned to clients or written to
|
||||
logs.
|
||||
|
||||
`POST /gifs/share` uses the same authentication and membership boundary. It
|
||||
accepts a bounded GIF slug plus anonymous customer ID and forwards KLIPY's share
|
||||
signal so selected media can enter the user's provider-backed Recents. The
|
||||
endpoint returns no provider body.
|
||||
|
||||
## Message and rendering boundary
|
||||
|
||||
Selecting a GIF sends a normal Buzz message containing a KLIPY CDN image URL.
|
||||
Because Buzz's imeta validator permits only hash-verified local `/media/` paths,
|
||||
external GIFs are deliberately content-only and carry no imeta tag. Buzz does
|
||||
not download, cache, or store the GIF bytes. Existing image URL rendering
|
||||
handles the message, including pasted GIF URLs on relays that do not advertise
|
||||
`buzz-gif`. Only the picker is capability-gated.
|
||||
@@ -40,7 +40,7 @@ Conformance obligations:
|
||||
| Surface | Today's observable behavior | Tenant source | Community-global vs operator-global | Required DB/index/RLS scope | Auth/fan-out/search effects | Single-community compatibility check | Open decision/test |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Row zero: host binding | A user connects to one relay URL and all state they can observe belongs to that relay. | `resolve_host(connection.host)` before handler entry. | Community-global selector; operator only manages the host map. | `communities(host, id, signing_key, …)`; every scoped table references immutable `community_id`. | All auth, event, REST, media, git, search, pub/sub, and workflow paths consume `TenantContext`; host/token mismatch rejects generically. | One host maps to the default community; no client-visible protocol field changes. | Add model/prose gate that `ctx.community` is derived from host, not supplied by the client. |
|
||||
| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs, a stable relay signing pubkey when configured, and the community's workspace icon (NIP-WP). | Host-derived community for community-specific facts; the workspace `icon` is pre-fetched as a scalar via `bind_community` (fail-open to absent on unmapped host); no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own `icon`, no field varies by community, and an unmapped host's document carries no `icon`. NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped scalars (like `icon`) are passed in pre-derived. |
|
||||
| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs, a stable relay signing pubkey when configured, the community's workspace icon (NIP-WP), and the config-derived `buzz-gif` descriptor when GIF search is configured. | Host-derived community for community-specific facts; the workspace `icon` is pre-fetched as a scalar via `bind_community` (fail-open to absent on unmapped host); GIF provider availability is passed as a credential-free config scalar; no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version and GIF provider configuration may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own `icon`, no field varies by community. The GIF descriptor reveals provider availability but never its credential; NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today and the opt-in GIF descriptor. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped or config-derived scalars (like `icon`, pairing URL, and GIF provider) are passed in pre-derived. |
|
||||
| API tokens and NIP-98 replay | API/NIP-98 clients authenticate REST/media/git; API tokens may carry scopes and channel IDs; NIP-98 replay uses an in-process seen-set today. | Host-derived community plus token's stamped community; stamps must agree. | Community-global token namespace; operator-global only for deployment health/secrets. | `api_tokens` gains `community_id`; token hash uniqueness and lookup are `(community_id, token_hash)` or the token cryptographically embeds community and lookup verifies both. Channel claims must reference channels in the same community. | Replay seen-set key is `(community_id, event_id)` in shared HA storage or equivalent sticky routing; NIP-98 `u` URL host must match `req.community`. | Existing single-community tokens continue to authorize the same scopes/channels after backfill to default community. | HA gate: Redis/shared seen-set with atomic insert-if-absent and TTL ≥ replay window, or documented single-replica/sticky alternative. |
|
||||
| Relay membership, pubkey allowlist, archived identities | `relay_members`, `pubkey_allowlist`, and `archived_identities` are relay-global gates over pubkeys. | Host-derived community for tenant access; operator context only for platform administration. | Community-global membership/allowlist/archive by default. Operator-global only for explicit platform ops tables that are never tenant-observable. | Add `community_id` to these tables; primary/unique keys become `(community_id, pubkey)` and indexes include `community_id`. | Membership errors remain generic. NIP-OA owner checks test owner membership in the same community. Identity archive requests cannot hide/archive a key in another community. | One default community preserves today's closed/open relay behavior and admin CLI semantics after commands target the default community. | Decide any future operator-global super-admin surface separately; do not reuse tenant membership tables for it. |
|
||||
| Users, profiles, NIP-05, and user search | Kind:0 updates sync a `users` row; NIP-05 handles are unique; `/api/users/search` searches display name/NIP-05/pubkey. | Channel-less events use `req.community`; NIP-05 domain is the connected community host. | Community-global. Same pubkey can have one profile per community; users repost kind:0 in each community they join. | `users` gains `community_id`; keys/uniques are `(community_id, pubkey)`, `(community_id, lower(nip05_handle))`, and `(community_id, okta_user_id)` where applicable. Profile event replacement is scoped by `(community_id, pubkey)`. | Search and batch profile reads include `community_id`; NIP-05 lookup only resolves handles for the requested host/community. No cross-community profile inheritance. | Existing users backfill into the default community; profile APIs and CLI output stay unchanged. | Add tests for same pubkey with different profile/NIP-05 in two communities and for NIP-05 same local part on two hosts. |
|
||||
|
||||
Reference in New Issue
Block a user