mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
feat(relay): add configurable relay banners
Signed-off-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -437,6 +437,9 @@ pub const KIND_THREAD_SUMMARY: u32 = 39005;
|
||||
/// content = `{has_more, next_cursor}`. The only authority on exhaustion —
|
||||
/// clients must not infer `has_more` from row counts.
|
||||
pub const KIND_WINDOW_BOUNDS: u32 = 39006;
|
||||
/// Deployment banner overlay: relay-signed active operator banner for this viewer.
|
||||
/// Content is `{id, severity, text, maxDisplays}` and `d` tag is the banner public id.
|
||||
pub const KIND_RELAY_BANNER: u32 = 13536;
|
||||
|
||||
/// Workflow definition (parameterized replaceable, d=workflow_uuid).
|
||||
pub const KIND_WORKFLOW_DEF: u32 = 30620;
|
||||
@@ -694,6 +697,7 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_NIP29_GROUP_ROLES,
|
||||
KIND_THREAD_SUMMARY,
|
||||
KIND_WINDOW_BOUNDS,
|
||||
KIND_RELAY_BANNER,
|
||||
KIND_PRESENCE_UPDATE,
|
||||
KIND_TYPING_INDICATOR,
|
||||
KIND_HUDDLE_REACTION,
|
||||
@@ -839,6 +843,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool {
|
||||
| KIND_DM_VISIBILITY
|
||||
| KIND_THREAD_SUMMARY
|
||||
| KIND_WINDOW_BOUNDS
|
||||
| KIND_RELAY_BANNER
|
||||
)
|
||||
}
|
||||
|
||||
@@ -867,6 +872,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 3062
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999
|
||||
const _: () = assert!(is_replaceable(KIND_RELAY_BANNER)); // 13536 ∈ 10000–19999
|
||||
|
||||
// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range.
|
||||
const _: () = assert!(
|
||||
@@ -936,6 +942,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_banner_is_replaceable_relay_only_not_parameterized() {
|
||||
assert_eq!(KIND_RELAY_BANNER, 13536);
|
||||
assert!(is_replaceable(KIND_RELAY_BANNER));
|
||||
assert!(!is_parameterized_replaceable(KIND_RELAY_BANNER));
|
||||
assert!(is_relay_only_kind(KIND_RELAY_BANNER));
|
||||
}
|
||||
|
||||
// ── event_is_shared / is_unshared_gated_event ────────────────────────
|
||||
|
||||
fn make_event_of_kind(kind: u32, tags: &[&[&str]]) -> nostr::Event {
|
||||
|
||||
@@ -64,8 +64,8 @@ pub(crate) use runtime::{
|
||||
pub use store::{
|
||||
admin_moderation, allowlist, api_token, archived_identities, channel, channel_members,
|
||||
community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push,
|
||||
reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder,
|
||||
replaceable, storage_accounting, thread, usage, user, workflow,
|
||||
reaction, relay_admin_actions, relay_banners, relay_invite, relay_members, relay_operators,
|
||||
reminder, replaceable, storage_accounting, thread, usage, user, workflow,
|
||||
};
|
||||
|
||||
pub use allowlist::AllowlistEntry;
|
||||
@@ -78,6 +78,10 @@ pub use community::{
|
||||
pub use error::{DbError, Result};
|
||||
pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT};
|
||||
pub use reaction::ReactionEventInsertOutcome;
|
||||
pub use relay_banners::{
|
||||
RelayBannerDismissOutcome, RelayBannerRecord, RelayBannerScope, RelayBannerSeverity,
|
||||
RelayBannerTargetScope, RelayBannerUpsert, RelayBannerViewOutcome, MAX_BANNER_MESSAGE_CHARS,
|
||||
};
|
||||
pub use reminder::DueReminder;
|
||||
pub use usage::UsageMetricsLeader;
|
||||
|
||||
|
||||
@@ -490,6 +490,9 @@ mod postgres_tests {
|
||||
"relay_admin_outbox",
|
||||
"relay_operator_audit",
|
||||
"storage_accounting_snapshots",
|
||||
"relay_banners",
|
||||
"relay_banner_communities",
|
||||
"relay_banner_user_state",
|
||||
] {
|
||||
if normalized[insert_pos..].contains(&format!("'{value}'")) {
|
||||
globals.insert(value.to_owned());
|
||||
@@ -1828,6 +1831,8 @@ mod postgres_tests {
|
||||
let mut expected_fences = migration.fence_attachments.clone();
|
||||
expected_fences.remove("product_feedback");
|
||||
expected_fences.remove("rate_limit_violations");
|
||||
expected_fences.insert("relay_banner_communities".to_owned());
|
||||
expected_fences.insert("relay_banner_user_state".to_owned());
|
||||
assert_eq!(
|
||||
expected_fences, schema.fence_attachments,
|
||||
"write-fence attachment targets differ after recovery policy"
|
||||
@@ -2267,6 +2272,9 @@ mod postgres_tests {
|
||||
"relay_admin_actions",
|
||||
"relay_admin_outbox",
|
||||
"relay_operator_audit",
|
||||
"relay_banners",
|
||||
"relay_banner_communities",
|
||||
"relay_banner_user_state",
|
||||
] {
|
||||
assert_eq!(
|
||||
columns(&desired, table).await,
|
||||
|
||||
@@ -36,6 +36,8 @@ pub mod push;
|
||||
pub mod reaction;
|
||||
/// HTTP report-resolution enforcement state machine persistence.
|
||||
pub mod relay_admin_actions;
|
||||
/// Deployment-global relay banner persistence.
|
||||
pub mod relay_banners;
|
||||
/// Use-limited relay invite persistence (v2 opaque tokens).
|
||||
pub mod relay_invite;
|
||||
/// Relay-level membership persistence (NIP-43).
|
||||
|
||||
868
crates/buzz-db/src/store/relay_banners.rs
Normal file
868
crates/buzz-db/src/store/relay_banners.rs
Normal file
@@ -0,0 +1,868 @@
|
||||
//! Deployment-global relay banner persistence and per-user state.
|
||||
//!
|
||||
//! A deployment has at most one active banner. Delivery is community-scoped at
|
||||
//! read time: an active banner either targets every community, including future
|
||||
//! communities, or an explicit non-empty set of community ids.
|
||||
|
||||
use buzz_core::CommunityId;
|
||||
use buzz_datastore_tracing::datastore_span;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Postgres, Row as _, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Db, Result};
|
||||
|
||||
/// Maximum relay banner message length in Unicode scalar count.
|
||||
pub const MAX_BANNER_MESSAGE_CHARS: usize = 2_000;
|
||||
|
||||
/// Relay banner severity values accepted by the backend and emitted to clients.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RelayBannerSeverity {
|
||||
/// Informational banner.
|
||||
Info,
|
||||
/// Warning banner.
|
||||
Warning,
|
||||
/// Urgent banner.
|
||||
Urgent,
|
||||
}
|
||||
|
||||
impl RelayBannerSeverity {
|
||||
/// Returns the wire/database representation.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "info",
|
||||
Self::Warning => "warning",
|
||||
Self::Urgent => "urgent",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(value: String) -> Result<Self> {
|
||||
match value.as_str() {
|
||||
"info" => Ok(Self::Info),
|
||||
"warning" => Ok(Self::Warning),
|
||||
"urgent" => Ok(Self::Urgent),
|
||||
other => Err(crate::DbError::InvalidData(format!(
|
||||
"invalid relay banner severity: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public targeting scope value used by the admin/client banner contracts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RelayBannerTargetScope {
|
||||
/// Banner targets every community.
|
||||
All,
|
||||
/// Banner targets selected communities only.
|
||||
Communities,
|
||||
}
|
||||
|
||||
impl RelayBannerTargetScope {
|
||||
/// Returns the wire representation.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "all",
|
||||
Self::Communities => "communities",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Targeting scope for an operator-configured relay banner.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RelayBannerScope {
|
||||
/// Banner applies to every community, including communities created later.
|
||||
AllCommunities,
|
||||
/// Banner applies only to this non-empty community set.
|
||||
Communities(Vec<CommunityId>),
|
||||
}
|
||||
|
||||
/// Input for replacing the deployment's active banner.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RelayBannerUpsert {
|
||||
/// Severity/type of the banner.
|
||||
pub severity: RelayBannerSeverity,
|
||||
/// Plain-text message, 1..=2000 characters.
|
||||
pub message: String,
|
||||
/// Number of successful views each user may receive before suppression.
|
||||
pub max_displays: i32,
|
||||
/// Targeting scope.
|
||||
pub scope: RelayBannerScope,
|
||||
/// Authenticated operator pubkey performing the write.
|
||||
pub actor_pubkey: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Stored relay banner plus targeting metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RelayBannerRecord {
|
||||
/// Monotonic internal banner id.
|
||||
pub id: i64,
|
||||
/// Stable public banner id exposed on the wire.
|
||||
pub public_id: Uuid,
|
||||
/// Severity/type of the banner.
|
||||
pub severity: RelayBannerSeverity,
|
||||
/// Plain-text message.
|
||||
pub message: String,
|
||||
/// Number of successful views each user may receive before suppression.
|
||||
pub max_displays: i32,
|
||||
/// Whether the banner targets every community.
|
||||
pub target_all_communities: bool,
|
||||
/// Explicit community targets when not targeting all communities.
|
||||
pub community_ids: Vec<CommunityId>,
|
||||
/// Row creation timestamp.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Row update timestamp.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Operator pubkey that created the row.
|
||||
pub created_by: Vec<u8>,
|
||||
/// Disable timestamp, if disabled.
|
||||
pub disabled_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl RelayBannerRecord {
|
||||
/// Returns the public target-scope enum for this banner.
|
||||
pub const fn target_scope(&self) -> RelayBannerTargetScope {
|
||||
if self.target_all_communities {
|
||||
RelayBannerTargetScope::All
|
||||
} else {
|
||||
RelayBannerTargetScope::Communities
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of recording a banner view acknowledgement.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RelayBannerViewOutcome {
|
||||
/// The display was accepted and consumed one remaining view.
|
||||
Accepted {
|
||||
/// Display count after accepting this view.
|
||||
display_count: i32,
|
||||
},
|
||||
/// The banner was already permanently dismissed by this user.
|
||||
Dismissed,
|
||||
/// The user had already exhausted `max_displays`.
|
||||
Exhausted {
|
||||
/// Display count already consumed by this user.
|
||||
display_count: i32,
|
||||
},
|
||||
/// The banner is not active or does not target the request community.
|
||||
NotEligible,
|
||||
}
|
||||
|
||||
/// Result of recording a banner dismiss acknowledgement.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RelayBannerDismissOutcome {
|
||||
/// Dismiss state was present after the call. True means this call wrote it.
|
||||
Dismissed {
|
||||
/// Whether this call changed durable state from not-dismissed to dismissed.
|
||||
changed: bool,
|
||||
},
|
||||
/// The banner is not active or does not target the request community.
|
||||
NotEligible,
|
||||
}
|
||||
|
||||
impl RelayBannerUpsert {
|
||||
fn validate(&self) -> Result<()> {
|
||||
let trimmed_empty = self.message.trim().is_empty();
|
||||
let char_count = self.message.chars().count();
|
||||
if trimmed_empty || char_count > MAX_BANNER_MESSAGE_CHARS {
|
||||
return Err(crate::DbError::InvalidData(format!(
|
||||
"relay banner message must be 1..={MAX_BANNER_MESSAGE_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
if self.max_displays < 1 {
|
||||
return Err(crate::DbError::InvalidData(
|
||||
"relay banner max_displays must be >= 1".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.actor_pubkey.len() != 32 {
|
||||
return Err(crate::DbError::InvalidData(
|
||||
"relay banner actor pubkey must be 32 bytes".to_owned(),
|
||||
));
|
||||
}
|
||||
if let RelayBannerScope::Communities(ids) = &self.scope {
|
||||
if ids.is_empty() {
|
||||
return Err(crate::DbError::InvalidData(
|
||||
"relay banner community scope must be non-empty".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn acquire_banner_lock(tx: &mut Transaction<'_, Postgres>) -> Result<()> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended('relay_banner_active', 0))")
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn banner_internal_id_for_public_id(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
public_id: Uuid,
|
||||
) -> Result<Option<i64>> {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM relay_banners
|
||||
WHERE public_id = $1
|
||||
AND disabled_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(public_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn banner_targets_community(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
banner_id: i64,
|
||||
community_id: CommunityId,
|
||||
) -> Result<bool> {
|
||||
let eligible = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM relay_banners b
|
||||
WHERE b.id = $1
|
||||
AND b.disabled_at IS NULL
|
||||
AND (
|
||||
b.target_all_communities
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM relay_banner_communities bc
|
||||
WHERE bc.banner_id = b.id
|
||||
AND bc.community_id = $2
|
||||
)
|
||||
)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(banner_id)
|
||||
.bind(community_id.as_uuid())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
Ok(eligible)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Lists active communities for the admin banner community picker.
|
||||
#[datastore_span(name = "admin_list_banner_communities", system = "postgresql")]
|
||||
pub async fn admin_list_banner_communities(&self) -> Result<Vec<crate::CommunityRecord>> {
|
||||
let mut connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, host
|
||||
FROM communities
|
||||
WHERE archived_at IS NULL
|
||||
AND deleted_at IS NULL
|
||||
AND deletion_state = 'active'
|
||||
ORDER BY lower(host), id
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&mut *connection)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(crate::CommunityRecord {
|
||||
id: CommunityId::from_uuid(row.try_get("id")?),
|
||||
host: row.try_get("host")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lists relay banners, newest first, retaining disabled history.
|
||||
#[datastore_span(name = "admin_list_relay_banners", system = "postgresql")]
|
||||
pub async fn admin_list_relay_banners(&self, limit: i64) -> Result<Vec<RelayBannerRecord>> {
|
||||
let limit = limit.clamp(1, 200);
|
||||
let mut connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
b.id,
|
||||
b.public_id,
|
||||
b.severity,
|
||||
b.message,
|
||||
b.max_displays,
|
||||
b.target_all_communities,
|
||||
b.created_by,
|
||||
b.created_at,
|
||||
b.updated_at,
|
||||
b.disabled_at,
|
||||
COALESCE(array_agg(bc.community_id ORDER BY bc.community_id)
|
||||
FILTER (WHERE bc.community_id IS NOT NULL), ARRAY[]::uuid[]) AS community_ids
|
||||
FROM relay_banners b
|
||||
LEFT JOIN relay_banner_communities bc ON bc.banner_id = b.id
|
||||
GROUP BY b.id
|
||||
ORDER BY b.updated_at DESC, b.id DESC
|
||||
LIMIT $1
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&mut *connection)
|
||||
.await?;
|
||||
rows.into_iter().map(row_to_banner).collect()
|
||||
}
|
||||
|
||||
/// Returns the currently active deployment banner, if any.
|
||||
#[datastore_span(name = "admin_get_active_relay_banner", system = "postgresql")]
|
||||
pub async fn admin_get_active_relay_banner(&self) -> Result<Option<RelayBannerRecord>> {
|
||||
let mut items = self.admin_list_active_relay_banners(1).await?;
|
||||
Ok(items.pop())
|
||||
}
|
||||
|
||||
async fn admin_list_active_relay_banners(&self, limit: i64) -> Result<Vec<RelayBannerRecord>> {
|
||||
let mut connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
b.id,
|
||||
b.public_id,
|
||||
b.severity,
|
||||
b.message,
|
||||
b.max_displays,
|
||||
b.target_all_communities,
|
||||
b.created_by,
|
||||
b.created_at,
|
||||
b.updated_at,
|
||||
b.disabled_at,
|
||||
COALESCE(array_agg(bc.community_id ORDER BY bc.community_id)
|
||||
FILTER (WHERE bc.community_id IS NOT NULL), ARRAY[]::uuid[]) AS community_ids
|
||||
FROM relay_banners b
|
||||
LEFT JOIN relay_banner_communities bc ON bc.banner_id = b.id
|
||||
WHERE b.disabled_at IS NULL
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC
|
||||
LIMIT $1
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&mut *connection)
|
||||
.await?;
|
||||
rows.into_iter().map(row_to_banner).collect()
|
||||
}
|
||||
|
||||
/// Replaces the single active deployment banner and returns the new row.
|
||||
#[datastore_span(name = "admin_upsert_relay_banner", system = "postgresql")]
|
||||
pub async fn admin_upsert_relay_banner(
|
||||
&self,
|
||||
input: RelayBannerUpsert,
|
||||
) -> Result<RelayBannerRecord> {
|
||||
input.validate()?;
|
||||
let connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let mut tx = sqlx::Transaction::begin(connection, None).await?;
|
||||
acquire_banner_lock(&mut tx).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE relay_banners
|
||||
SET disabled_at = now(), disabled_by = $1, updated_at = now(), updated_by = $1
|
||||
WHERE disabled_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(&input.actor_pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let target_all = matches!(input.scope, RelayBannerScope::AllCommunities);
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO relay_banners
|
||||
(severity, message, max_displays, target_all_communities, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)
|
||||
RETURNING id, public_id, severity, message, max_displays, target_all_communities,
|
||||
created_by, created_at, updated_at, disabled_at
|
||||
"#,
|
||||
)
|
||||
.bind(input.severity.as_str())
|
||||
.bind(&input.message)
|
||||
.bind(input.max_displays)
|
||||
.bind(target_all)
|
||||
.bind(&input.actor_pubkey)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let banner_id: i64 = row.try_get("id")?;
|
||||
|
||||
let community_ids = match input.scope {
|
||||
RelayBannerScope::AllCommunities => Vec::new(),
|
||||
RelayBannerScope::Communities(ids) => {
|
||||
for community_id in &ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_banner_communities (banner_id, community_id) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(banner_id)
|
||||
.bind(community_id.as_uuid())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
ids
|
||||
}
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(RelayBannerRecord {
|
||||
id: banner_id,
|
||||
public_id: row.try_get("public_id")?,
|
||||
severity: RelayBannerSeverity::parse(row.try_get("severity")?)?,
|
||||
message: row.try_get("message")?,
|
||||
max_displays: row.try_get("max_displays")?,
|
||||
target_all_communities: row.try_get("target_all_communities")?,
|
||||
community_ids,
|
||||
created_by: row.try_get("created_by")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
disabled_at: row.try_get("disabled_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Disables the currently active deployment banner, preserving history and state.
|
||||
#[datastore_span(name = "admin_disable_active_relay_banner", system = "postgresql")]
|
||||
pub async fn admin_disable_active_relay_banner(&self, actor_pubkey: &[u8]) -> Result<bool> {
|
||||
let connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let mut tx = sqlx::Transaction::begin(connection, None).await?;
|
||||
acquire_banner_lock(&mut tx).await?;
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE relay_banners
|
||||
SET disabled_at = now(), disabled_by = $1, updated_at = now(), updated_by = $1
|
||||
WHERE disabled_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(actor_pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Resolves the active banner eligible for this user in this community.
|
||||
#[datastore_span(name = "active_relay_banner_for_user", system = "postgresql")]
|
||||
pub async fn active_relay_banner_for_user(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Option<RelayBannerRecord>> {
|
||||
let mut connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
b.id,
|
||||
b.public_id,
|
||||
b.severity,
|
||||
b.message,
|
||||
b.max_displays,
|
||||
b.target_all_communities,
|
||||
b.created_by,
|
||||
b.created_at,
|
||||
b.updated_at,
|
||||
b.disabled_at,
|
||||
COALESCE(array_agg(bc_all.community_id ORDER BY bc_all.community_id)
|
||||
FILTER (WHERE bc_all.community_id IS NOT NULL), ARRAY[]::uuid[]) AS community_ids
|
||||
FROM relay_banners b
|
||||
LEFT JOIN relay_banner_communities bc_all ON bc_all.banner_id = b.id
|
||||
LEFT JOIN relay_banner_user_state us
|
||||
ON us.banner_id = b.id
|
||||
AND us.community_id = $1
|
||||
AND us.pubkey = $2
|
||||
WHERE b.disabled_at IS NULL
|
||||
AND (b.target_all_communities OR EXISTS (
|
||||
SELECT 1
|
||||
FROM relay_banner_communities bc
|
||||
WHERE bc.banner_id = b.id
|
||||
AND bc.community_id = $1
|
||||
))
|
||||
AND us.dismissed_at IS NULL
|
||||
AND COALESCE(us.display_count, 0) < b.max_displays
|
||||
GROUP BY b.id, us.display_count, us.dismissed_at
|
||||
ORDER BY b.id DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await?;
|
||||
row.map(row_to_banner).transpose()
|
||||
}
|
||||
|
||||
/// Records a successful client render/view acknowledgement.
|
||||
#[datastore_span(name = "ack_relay_banner_view", system = "postgresql")]
|
||||
pub async fn ack_relay_banner_view(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
public_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
) -> Result<RelayBannerViewOutcome> {
|
||||
let connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let mut tx = sqlx::Transaction::begin(connection, None).await?;
|
||||
let Some(banner_id) = banner_internal_id_for_public_id(&mut tx, public_id).await? else {
|
||||
tx.rollback().await?;
|
||||
return Ok(RelayBannerViewOutcome::NotEligible);
|
||||
};
|
||||
if !banner_targets_community(&mut tx, banner_id, community_id).await? {
|
||||
tx.rollback().await?;
|
||||
return Ok(RelayBannerViewOutcome::NotEligible);
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO relay_banner_user_state
|
||||
(banner_id, community_id, pubkey, display_count, first_viewed_at, last_viewed_at)
|
||||
SELECT b.id, $2, $3, 1, now(), now()
|
||||
FROM relay_banners b
|
||||
WHERE b.id = $1
|
||||
AND b.disabled_at IS NULL
|
||||
AND b.max_displays >= 1
|
||||
ON CONFLICT (banner_id, community_id, pubkey) DO UPDATE SET
|
||||
display_count = relay_banner_user_state.display_count + 1,
|
||||
first_viewed_at = COALESCE(relay_banner_user_state.first_viewed_at, now()),
|
||||
last_viewed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE relay_banner_user_state.dismissed_at IS NULL
|
||||
AND relay_banner_user_state.display_count < (
|
||||
SELECT max_displays FROM relay_banners WHERE id = $1 AND disabled_at IS NULL
|
||||
)
|
||||
RETURNING display_count
|
||||
"#,
|
||||
)
|
||||
.bind(banner_id)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let outcome = if let Some(row) = row {
|
||||
RelayBannerViewOutcome::Accepted {
|
||||
display_count: row.try_get("display_count")?,
|
||||
}
|
||||
} else {
|
||||
let state = sqlx::query(
|
||||
r#"
|
||||
SELECT us.display_count, us.dismissed_at IS NOT NULL AS dismissed
|
||||
FROM relay_banner_user_state us
|
||||
WHERE us.banner_id = $1 AND us.community_id = $2 AND us.pubkey = $3
|
||||
"#,
|
||||
)
|
||||
.bind(banner_id)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
match state {
|
||||
Some(row) if row.try_get::<bool, _>("dismissed")? => {
|
||||
RelayBannerViewOutcome::Dismissed
|
||||
}
|
||||
Some(row) => RelayBannerViewOutcome::Exhausted {
|
||||
display_count: row.try_get("display_count")?,
|
||||
},
|
||||
None => RelayBannerViewOutcome::NotEligible,
|
||||
}
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Permanently dismisses a banner for a user in one community.
|
||||
#[datastore_span(name = "ack_relay_banner_dismiss", system = "postgresql")]
|
||||
pub async fn ack_relay_banner_dismiss(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
public_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
) -> Result<RelayBannerDismissOutcome> {
|
||||
let connection = crate::observability::acquire_writer(
|
||||
&self.pool,
|
||||
crate::observability::WriterOperation::Authorization,
|
||||
)
|
||||
.await?;
|
||||
let mut tx = sqlx::Transaction::begin(connection, None).await?;
|
||||
let Some(banner_id) = banner_internal_id_for_public_id(&mut tx, public_id).await? else {
|
||||
tx.rollback().await?;
|
||||
return Ok(RelayBannerDismissOutcome::NotEligible);
|
||||
};
|
||||
if !banner_targets_community(&mut tx, banner_id, community_id).await? {
|
||||
tx.rollback().await?;
|
||||
return Ok(RelayBannerDismissOutcome::NotEligible);
|
||||
}
|
||||
|
||||
let changed = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
WITH inserted AS (
|
||||
INSERT INTO relay_banner_user_state
|
||||
(banner_id, community_id, pubkey, dismissed_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (banner_id, community_id, pubkey) DO NOTHING
|
||||
RETURNING TRUE AS changed
|
||||
), updated AS (
|
||||
UPDATE relay_banner_user_state
|
||||
SET dismissed_at = now(), updated_at = now()
|
||||
WHERE banner_id = $1
|
||||
AND community_id = $2
|
||||
AND pubkey = $3
|
||||
AND dismissed_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM inserted)
|
||||
RETURNING TRUE AS changed
|
||||
)
|
||||
SELECT COALESCE(
|
||||
(SELECT changed FROM inserted),
|
||||
(SELECT changed FROM updated),
|
||||
FALSE
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(banner_id)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(RelayBannerDismissOutcome::Dismissed { changed })
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_banner(row: sqlx::postgres::PgRow) -> Result<RelayBannerRecord> {
|
||||
let community_uuids: Vec<Uuid> = row.try_get("community_ids")?;
|
||||
Ok(RelayBannerRecord {
|
||||
id: row.try_get("id")?,
|
||||
public_id: row.try_get("public_id")?,
|
||||
severity: RelayBannerSeverity::parse(row.try_get("severity")?)?,
|
||||
message: row.try_get("message")?,
|
||||
max_displays: row.try_get("max_displays")?,
|
||||
target_all_communities: row.try_get("target_all_communities")?,
|
||||
community_ids: community_uuids
|
||||
.into_iter()
|
||||
.map(CommunityId::from_uuid)
|
||||
.collect(),
|
||||
created_by: row.try_get("created_by")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
disabled_at: row.try_get("disabled_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn setup_db() -> Db {
|
||||
let pool = PgPool::connect(&crate::test_support::database_url())
|
||||
.await
|
||||
.expect("connect to test DB");
|
||||
Db::from_pool(pool)
|
||||
}
|
||||
|
||||
async fn make_community(pool: &PgPool) -> CommunityId {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(format!("banner-{}.example", id.simple()))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert community");
|
||||
CommunityId::from_uuid(id)
|
||||
}
|
||||
|
||||
fn actor(byte: u8) -> Vec<u8> {
|
||||
vec![byte; 32]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn upsert_replaces_single_active_banner_and_retains_history() {
|
||||
let db = setup_db().await;
|
||||
let first = db
|
||||
.admin_upsert_relay_banner(RelayBannerUpsert {
|
||||
severity: RelayBannerSeverity::Info,
|
||||
message: "first".to_owned(),
|
||||
max_displays: 1,
|
||||
scope: RelayBannerScope::AllCommunities,
|
||||
actor_pubkey: actor(1),
|
||||
})
|
||||
.await
|
||||
.expect("insert first banner");
|
||||
let second = db
|
||||
.admin_upsert_relay_banner(RelayBannerUpsert {
|
||||
severity: RelayBannerSeverity::Warning,
|
||||
message: "second".to_owned(),
|
||||
max_displays: 2,
|
||||
scope: RelayBannerScope::AllCommunities,
|
||||
actor_pubkey: actor(2),
|
||||
})
|
||||
.await
|
||||
.expect("replace banner");
|
||||
|
||||
let active = db
|
||||
.admin_get_active_relay_banner()
|
||||
.await
|
||||
.expect("get active")
|
||||
.expect("active banner");
|
||||
assert_eq!(active.id, second.id);
|
||||
let all = db.admin_list_relay_banners(10).await.expect("list banners");
|
||||
assert_eq!(all.len(), 2);
|
||||
assert!(all
|
||||
.iter()
|
||||
.any(|b| b.id == first.id && b.disabled_at.is_some()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn community_scope_filters_delivery() {
|
||||
let db = setup_db().await;
|
||||
let included = make_community(&db.pool).await;
|
||||
let excluded = make_community(&db.pool).await;
|
||||
let user = actor(3);
|
||||
let banner = db
|
||||
.admin_upsert_relay_banner(RelayBannerUpsert {
|
||||
severity: RelayBannerSeverity::Urgent,
|
||||
message: "scoped".to_owned(),
|
||||
max_displays: 1,
|
||||
scope: RelayBannerScope::Communities(vec![included]),
|
||||
actor_pubkey: actor(4),
|
||||
})
|
||||
.await
|
||||
.expect("insert scoped banner");
|
||||
|
||||
assert_eq!(banner.community_ids, vec![included]);
|
||||
assert!(db
|
||||
.active_relay_banner_for_user(included, &user)
|
||||
.await
|
||||
.expect("included lookup")
|
||||
.is_some());
|
||||
assert!(db
|
||||
.active_relay_banner_for_user(excluded, &user)
|
||||
.await
|
||||
.expect("excluded lookup")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn view_ack_consumes_display_and_then_exhausts() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let user = actor(5);
|
||||
let banner = db
|
||||
.admin_upsert_relay_banner(RelayBannerUpsert {
|
||||
severity: RelayBannerSeverity::Info,
|
||||
message: "limited".to_owned(),
|
||||
max_displays: 1,
|
||||
scope: RelayBannerScope::AllCommunities,
|
||||
actor_pubkey: actor(6),
|
||||
})
|
||||
.await
|
||||
.expect("insert banner");
|
||||
|
||||
assert_eq!(
|
||||
db.active_relay_banner_for_user(community, &user)
|
||||
.await
|
||||
.expect("lookup before view")
|
||||
.as_ref()
|
||||
.map(|active| active.id),
|
||||
Some(banner.id),
|
||||
"delivery lookup must not consume max_displays before client view ack"
|
||||
);
|
||||
assert_eq!(
|
||||
db.active_relay_banner_for_user(community, &user)
|
||||
.await
|
||||
.expect("second lookup before view")
|
||||
.as_ref()
|
||||
.map(|active| active.id),
|
||||
Some(banner.id),
|
||||
"repeated delivery lookups without view ack must not exhaust max_displays"
|
||||
);
|
||||
assert_eq!(
|
||||
db.ack_relay_banner_view(community, banner.public_id, &user)
|
||||
.await
|
||||
.expect("first view"),
|
||||
RelayBannerViewOutcome::Accepted { display_count: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
db.ack_relay_banner_view(community, banner.public_id, &user)
|
||||
.await
|
||||
.expect("second view"),
|
||||
RelayBannerViewOutcome::Exhausted { display_count: 1 }
|
||||
);
|
||||
assert!(db
|
||||
.active_relay_banner_for_user(community, &user)
|
||||
.await
|
||||
.expect("post-exhaust lookup")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn dismiss_permanently_suppresses_banner() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let user = actor(7);
|
||||
let banner = db
|
||||
.admin_upsert_relay_banner(RelayBannerUpsert {
|
||||
severity: RelayBannerSeverity::Info,
|
||||
message: "dismiss me".to_owned(),
|
||||
max_displays: 3,
|
||||
scope: RelayBannerScope::AllCommunities,
|
||||
actor_pubkey: actor(8),
|
||||
})
|
||||
.await
|
||||
.expect("insert banner");
|
||||
|
||||
assert_eq!(
|
||||
db.ack_relay_banner_dismiss(community, banner.public_id, &user)
|
||||
.await
|
||||
.expect("dismiss"),
|
||||
RelayBannerDismissOutcome::Dismissed { changed: true }
|
||||
);
|
||||
assert_eq!(
|
||||
db.ack_relay_banner_dismiss(community, banner.public_id, &user)
|
||||
.await
|
||||
.expect("second dismiss"),
|
||||
RelayBannerDismissOutcome::Dismissed { changed: false }
|
||||
);
|
||||
assert_eq!(
|
||||
db.ack_relay_banner_view(community, banner.public_id, &user)
|
||||
.await
|
||||
.expect("view after dismiss"),
|
||||
RelayBannerViewOutcome::Dismissed
|
||||
);
|
||||
assert!(db
|
||||
.active_relay_banner_for_user(community, &user)
|
||||
.await
|
||||
.expect("lookup after dismiss")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,13 @@ pub fn router(state: Arc<crate::state::AppState>) -> Router {
|
||||
.route("/operators", get(list_operators))
|
||||
.route("/operators/{pubkey}", put(upsert_operator))
|
||||
.route("/operators/{pubkey}", delete(delete_operator))
|
||||
.route("/communities", get(list_banner_communities))
|
||||
.route("/banners", get(banners))
|
||||
.route("/banners/current", put(upsert_banner))
|
||||
.route("/banners/current", delete(disable_banner))
|
||||
.layer(middleware::from_fn(security_headers))
|
||||
// Mutation routes carry a JSON body (max ~4 KB); read-only routes have no body.
|
||||
.layer(RequestBodyLimitLayer::new(4096))
|
||||
// Mutation routes carry a JSON body; banner text may be up to 2,000 chars.
|
||||
.layer(RequestBodyLimitLayer::new(8192))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -406,6 +410,223 @@ async fn feedback_attachment(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BannerCommunityResponse {
|
||||
id: Uuid,
|
||||
host: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AdminBannerResponse {
|
||||
id: String,
|
||||
severity: &'static str,
|
||||
text: String,
|
||||
max_displays: i32,
|
||||
target_scope: &'static str,
|
||||
community_ids: Vec<Uuid>,
|
||||
created_by: String,
|
||||
created_at: DateTime<Utc>,
|
||||
disabled_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BannersResponse {
|
||||
active: Option<AdminBannerResponse>,
|
||||
history: Vec<AdminBannerResponse>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct BannerQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct UpsertBannerBody {
|
||||
severity: buzz_db::RelayBannerSeverity,
|
||||
text: String,
|
||||
max_displays: i32,
|
||||
target_scope: buzz_db::RelayBannerTargetScope,
|
||||
community_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
impl From<buzz_db::RelayBannerRecord> for AdminBannerResponse {
|
||||
fn from(value: buzz_db::RelayBannerRecord) -> Self {
|
||||
let target_scope = value.target_scope().as_str();
|
||||
Self {
|
||||
id: value.public_id.to_string(),
|
||||
severity: value.severity.as_str(),
|
||||
text: value.message,
|
||||
max_displays: value.max_displays,
|
||||
target_scope,
|
||||
community_ids: value
|
||||
.community_ids
|
||||
.into_iter()
|
||||
.map(|id| *id.as_uuid())
|
||||
.collect(),
|
||||
created_by: hex::encode(value.created_by),
|
||||
created_at: value.created_at,
|
||||
disabled_at: value.disabled_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_banner_communities(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Vec<BannerCommunityResponse>>, ApiError> {
|
||||
let principal = authorize(
|
||||
&state,
|
||||
&headers,
|
||||
uri.path_and_query()
|
||||
.map_or_else(|| uri.path(), |pq| pq.as_str()),
|
||||
"GET",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let principal = require_mutation_principal(principal)?;
|
||||
require_operator(&principal)?;
|
||||
let communities = state.db.admin_list_banner_communities().await?;
|
||||
Ok(Json(
|
||||
communities
|
||||
.into_iter()
|
||||
.map(|community| BannerCommunityResponse {
|
||||
id: *community.id.as_uuid(),
|
||||
host: community.host,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn banners(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<BannerQuery>,
|
||||
) -> Result<Json<BannersResponse>, ApiError> {
|
||||
let principal = authorize(
|
||||
&state,
|
||||
&headers,
|
||||
uri.path_and_query()
|
||||
.map_or_else(|| uri.path(), |pq| pq.as_str()),
|
||||
"GET",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let principal = require_mutation_principal(principal)?;
|
||||
require_operator(&principal)?;
|
||||
let history = state
|
||||
.db
|
||||
.admin_list_relay_banners(limit(query.limit)?)
|
||||
.await?;
|
||||
let active = history
|
||||
.iter()
|
||||
.find(|banner| banner.disabled_at.is_none())
|
||||
.cloned()
|
||||
.map(AdminBannerResponse::from);
|
||||
Ok(Json(BannersResponse {
|
||||
active,
|
||||
history: history.into_iter().map(AdminBannerResponse::from).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upsert_banner(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body_bytes: Bytes,
|
||||
) -> Result<Json<AdminBannerResponse>, ApiError> {
|
||||
let principal = require_mutation_principal(
|
||||
authorize(
|
||||
&state,
|
||||
&headers,
|
||||
uri.path_and_query()
|
||||
.map_or_else(|| uri.path(), |pq| pq.as_str()),
|
||||
"PUT",
|
||||
Some(&body_bytes),
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
require_operator(&principal)?;
|
||||
let body: UpsertBannerBody = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
|
||||
let scope = if body.target_scope == buzz_db::RelayBannerTargetScope::All {
|
||||
if body
|
||||
.community_ids
|
||||
.as_ref()
|
||||
.is_some_and(|ids| !ids.is_empty())
|
||||
{
|
||||
return Err(ApiError::bad_request(
|
||||
"invalid_scope",
|
||||
"communityIds must be empty when targetScope is all",
|
||||
));
|
||||
}
|
||||
buzz_db::RelayBannerScope::AllCommunities
|
||||
} else {
|
||||
let ids = body.community_ids.unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"invalid_scope",
|
||||
"communityIds must be non-empty when targetScope is communities",
|
||||
));
|
||||
}
|
||||
buzz_db::RelayBannerScope::Communities(
|
||||
ids.into_iter()
|
||||
.map(buzz_core::CommunityId::from_uuid)
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
let banner = state
|
||||
.db
|
||||
.admin_upsert_relay_banner(buzz_db::RelayBannerUpsert {
|
||||
severity: body.severity,
|
||||
message: body.text,
|
||||
max_displays: body.max_displays,
|
||||
scope,
|
||||
actor_pubkey: principal.pubkey.to_vec(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_banner_db_error)?;
|
||||
Ok(Json(AdminBannerResponse::from(banner)))
|
||||
}
|
||||
|
||||
async fn disable_banner(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let principal = require_mutation_principal(
|
||||
authorize(
|
||||
&state,
|
||||
&headers,
|
||||
uri.path_and_query()
|
||||
.map_or_else(|| uri.path(), |pq| pq.as_str()),
|
||||
"DELETE",
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
require_operator(&principal)?;
|
||||
let disabled = state
|
||||
.db
|
||||
.admin_disable_active_relay_banner(&principal.pubkey)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({ "disabled": disabled })))
|
||||
}
|
||||
|
||||
fn map_banner_db_error(error: buzz_db::DbError) -> ApiError {
|
||||
match error {
|
||||
buzz_db::DbError::InvalidData(message) => ApiError::bad_request("invalid_banner", &message),
|
||||
_ => ApiError::internal(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Report resolution ────────────────────────────────────────────────
|
||||
|
||||
/// Request body for POST /reports/{id}/resolve.
|
||||
|
||||
373
crates/buzz-relay/src/api/banners.rs
Normal file
373
crates/buzz-relay/src/api/banners.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! Client-facing relay banner API.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
response::Json,
|
||||
};
|
||||
use buzz_core::TenantContext;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::api::{api_error, bridge, internal_error, relay_members};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Client route for retrieving the active banner for the authenticated user.
|
||||
pub(crate) const BANNER_ACTIVE_PATH: &str = "/api/banners/current";
|
||||
/// Client route for acknowledging a successful banner render/view.
|
||||
pub(crate) const BANNER_VIEW_ROUTE: &str = "/api/banners/{banner_id}/view";
|
||||
/// Client route for dismissing a banner.
|
||||
pub(crate) const BANNER_DISMISS_ROUTE: &str = "/api/banners/{banner_id}/dismiss";
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BannerResponse {
|
||||
/// Public banner id.
|
||||
pub id: String,
|
||||
/// Banner event kind clients may also query over Nostr.
|
||||
pub kind: u32,
|
||||
/// Banner severity/type.
|
||||
pub severity: &'static str,
|
||||
/// Plain-text banner text.
|
||||
pub text: String,
|
||||
/// Number of successful views each user may receive before suppression.
|
||||
pub max_displays: i32,
|
||||
/// Every v1 severity is dismissible.
|
||||
pub dismissible: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BannerAckResponse {
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
impl From<buzz_db::RelayBannerRecord> for BannerResponse {
|
||||
fn from(value: buzz_db::RelayBannerRecord) -> Self {
|
||||
Self {
|
||||
id: value.public_id.to_string(),
|
||||
kind: buzz_core::kind::KIND_RELAY_BANNER,
|
||||
severity: value.severity.as_str(),
|
||||
text: value.message,
|
||||
max_displays: value.max_displays,
|
||||
dismissible: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn banner_event(
|
||||
relay_keypair: &nostr::Keys,
|
||||
banner: &buzz_db::RelayBannerRecord,
|
||||
) -> Result<nostr::Event, String> {
|
||||
let id = banner.public_id.to_string();
|
||||
let scope = if banner.target_all_communities {
|
||||
"all"
|
||||
} else {
|
||||
"communities"
|
||||
};
|
||||
let content = serde_json::json!({
|
||||
"id": id,
|
||||
"severity": banner.severity.as_str(),
|
||||
"text": banner.message,
|
||||
"maxDisplays": banner.max_displays,
|
||||
"dismissible": true,
|
||||
})
|
||||
.to_string();
|
||||
let tags = vec![
|
||||
nostr::Tag::parse(["d", id.as_str()]).map_err(|e| e.to_string())?,
|
||||
nostr::Tag::parse(["scope", scope]).map_err(|e| e.to_string())?,
|
||||
];
|
||||
nostr::EventBuilder::new(
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_RELAY_BANNER as u16),
|
||||
content,
|
||||
)
|
||||
.tags(tags)
|
||||
.sign_with_keys(relay_keypair)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn active_banner_event_for_user(
|
||||
state: &AppState,
|
||||
tenant: &TenantContext,
|
||||
pubkey_bytes: &[u8],
|
||||
) -> Result<Option<nostr::Event>, String> {
|
||||
let Some(banner) = state
|
||||
.db
|
||||
.active_relay_banner_for_user(tenant.community(), pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
banner_event(&state.relay_keypair, &banner).map(Some)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_active_banner(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Option<BannerResponse>>, (axum::http::StatusCode, Json<Value>)> {
|
||||
let (tenant, pubkey, event_id, signed_created_at) =
|
||||
authenticate_client_request(&state, &headers, "GET", BANNER_ACTIVE_PATH, None, false)
|
||||
.await?;
|
||||
bridge::check_nip98_replay(&state, &tenant, event_id).await?;
|
||||
enforce_member(&state, &tenant, &headers, &pubkey, signed_created_at).await?;
|
||||
|
||||
let banner = state
|
||||
.db
|
||||
.active_relay_banner_for_user(tenant.community(), pubkey.as_bytes())
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("banner lookup: {e}")))?
|
||||
.map(BannerResponse::from);
|
||||
Ok(Json(banner))
|
||||
}
|
||||
|
||||
pub(crate) async fn ack_banner_view(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(banner_id): Path<uuid::Uuid>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<BannerAckResponse>, (axum::http::StatusCode, Json<Value>)> {
|
||||
let (tenant, pubkey, event_id, signed_created_at) = authenticate_client_request(
|
||||
&state,
|
||||
&headers,
|
||||
"POST",
|
||||
&format!("/api/banners/{banner_id}/view"),
|
||||
Some(&body),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
bridge::check_nip98_replay(&state, &tenant, event_id).await?;
|
||||
enforce_member(&state, &tenant, &headers, &pubkey, signed_created_at).await?;
|
||||
if !body.is_empty() {
|
||||
return Err(non_empty_ack_body_error());
|
||||
}
|
||||
match state
|
||||
.db
|
||||
.ack_relay_banner_view(tenant.community(), banner_id, pubkey.as_bytes())
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("banner view ack: {e}")))?
|
||||
{
|
||||
buzz_db::RelayBannerViewOutcome::Accepted { .. } => {
|
||||
metrics::counter!(
|
||||
"buzz_relay_banner_views_total",
|
||||
"community" => tenant.host().to_owned()
|
||||
)
|
||||
.increment(1);
|
||||
Ok(Json(BannerAckResponse { status: "accepted" }))
|
||||
}
|
||||
buzz_db::RelayBannerViewOutcome::Dismissed => Ok(Json(BannerAckResponse {
|
||||
status: "dismissed",
|
||||
})),
|
||||
buzz_db::RelayBannerViewOutcome::Exhausted { .. } => Ok(Json(BannerAckResponse {
|
||||
status: "exhausted",
|
||||
})),
|
||||
buzz_db::RelayBannerViewOutcome::NotEligible => Err(api_error(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"banner not eligible",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ack_banner_dismiss(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(banner_id): Path<uuid::Uuid>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<BannerAckResponse>, (axum::http::StatusCode, Json<Value>)> {
|
||||
let (tenant, pubkey, event_id, signed_created_at) = authenticate_client_request(
|
||||
&state,
|
||||
&headers,
|
||||
"POST",
|
||||
&format!("/api/banners/{banner_id}/dismiss"),
|
||||
Some(&body),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
bridge::check_nip98_replay(&state, &tenant, event_id).await?;
|
||||
enforce_member(&state, &tenant, &headers, &pubkey, signed_created_at).await?;
|
||||
if !body.is_empty() {
|
||||
return Err(non_empty_ack_body_error());
|
||||
}
|
||||
match state
|
||||
.db
|
||||
.ack_relay_banner_dismiss(tenant.community(), banner_id, pubkey.as_bytes())
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("banner dismiss ack: {e}")))?
|
||||
{
|
||||
buzz_db::RelayBannerDismissOutcome::Dismissed { changed } => {
|
||||
if changed {
|
||||
metrics::counter!(
|
||||
"buzz_relay_banner_dismissals_total",
|
||||
"community" => tenant.host().to_owned()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
Ok(Json(BannerAckResponse {
|
||||
status: "dismissed",
|
||||
}))
|
||||
}
|
||||
buzz_db::RelayBannerDismissOutcome::NotEligible => Err(api_error(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"banner not eligible",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate_client_request(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<&[u8]>,
|
||||
require_payload: bool,
|
||||
) -> Result<
|
||||
(TenantContext, nostr::PublicKey, [u8; 32], Option<u64>),
|
||||
(axum::http::StatusCode, Json<Value>),
|
||||
> {
|
||||
let raw_host = headers
|
||||
.get(axum::http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let tenant = crate::tenant::bind_community(&state.db, raw_host)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
api_error(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"relay: no community is configured for this host",
|
||||
)
|
||||
})?;
|
||||
let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path);
|
||||
let verified = bridge::verify_bridge_auth_with_options(
|
||||
headers,
|
||||
method,
|
||||
&url,
|
||||
body,
|
||||
state.config.require_auth_token,
|
||||
require_payload,
|
||||
)?;
|
||||
bridge::enforce_http_admission(state, &tenant, &verified.pubkey).await?;
|
||||
Ok((
|
||||
tenant,
|
||||
verified.pubkey,
|
||||
verified.event_id_bytes,
|
||||
verified.signed_created_at,
|
||||
))
|
||||
}
|
||||
|
||||
fn non_empty_ack_body_error() -> (axum::http::StatusCode, Json<Value>) {
|
||||
api_error(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"banner acknowledgement body must be empty",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn banner_record(target_all_communities: bool) -> buzz_db::RelayBannerRecord {
|
||||
buzz_db::RelayBannerRecord {
|
||||
id: 42,
|
||||
public_id: uuid::Uuid::from_u128(0x12345678123456781234567812345678),
|
||||
severity: buzz_db::RelayBannerSeverity::Warning,
|
||||
message: "scheduled maintenance".to_owned(),
|
||||
max_displays: 3,
|
||||
target_all_communities,
|
||||
community_ids: Vec::new(),
|
||||
created_at: chrono::DateTime::UNIX_EPOCH,
|
||||
updated_at: chrono::DateTime::UNIX_EPOCH,
|
||||
created_by: vec![7; 32],
|
||||
disabled_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_event_uses_published_contract() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let banner = banner_record(false);
|
||||
let event = banner_event(&keys, &banner).expect("banner event");
|
||||
|
||||
assert_eq!(
|
||||
event.kind.as_u16() as u32,
|
||||
buzz_core::kind::KIND_RELAY_BANNER
|
||||
);
|
||||
assert_eq!(buzz_core::kind::KIND_RELAY_BANNER, 13536);
|
||||
assert_eq!(
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind().to_string() == "d")
|
||||
.and_then(|tag| tag.content()),
|
||||
Some(banner.public_id.to_string().as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind().to_string() == "scope")
|
||||
.and_then(|tag| tag.content()),
|
||||
Some("communities")
|
||||
);
|
||||
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(&event.content).expect("banner JSON content");
|
||||
assert_eq!(content["id"], banner.public_id.to_string());
|
||||
assert_eq!(content["severity"], "warning");
|
||||
assert_eq!(content["text"], "scheduled maintenance");
|
||||
assert_eq!(content["maxDisplays"], 3);
|
||||
assert_eq!(content["dismissible"], true);
|
||||
assert!(content.get("message").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_community_banner_event_has_all_scope() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let banner = banner_record(true);
|
||||
let event = banner_event(&keys, &banner).expect("banner event");
|
||||
|
||||
assert_eq!(
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind().to_string() == "scope")
|
||||
.and_then(|tag| tag.content()),
|
||||
Some("all")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_ack_routes_are_uuid_path_routes() {
|
||||
assert_eq!(BANNER_VIEW_ROUTE, "/api/banners/{banner_id}/view");
|
||||
assert_eq!(BANNER_DISMISS_ROUTE, "/api/banners/{banner_id}/dismiss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_empty_ack_body_is_rejected() {
|
||||
let (status, body) = non_empty_ack_body_error();
|
||||
|
||||
assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"], "banner acknowledgement body must be empty");
|
||||
}
|
||||
}
|
||||
|
||||
async fn enforce_member(
|
||||
state: &AppState,
|
||||
tenant: &TenantContext,
|
||||
headers: &HeaderMap,
|
||||
pubkey: &nostr::PublicKey,
|
||||
signed_created_at: Option<u64>,
|
||||
) -> Result<(), (axum::http::StatusCode, Json<Value>)> {
|
||||
let auth_tag = relay_members::extract_auth_tag_header(headers);
|
||||
relay_members::enforce_relay_membership(
|
||||
state,
|
||||
tenant.community(),
|
||||
pubkey.as_bytes(),
|
||||
auth_tag,
|
||||
signed_created_at,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
@@ -1182,6 +1182,27 @@ async fn query_events_authed(
|
||||
return presence_result.map(|events| Json(Value::Array(events)));
|
||||
}
|
||||
|
||||
if filters_are_relay_banner_only(&filters) {
|
||||
let mut events = Vec::new();
|
||||
if let Some(event) =
|
||||
crate::api::banners::active_banner_event_for_user(state, tenant, &pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("banner lookup: {e}")))?
|
||||
{
|
||||
let stored =
|
||||
buzz_core::StoredEvent::with_received_at(event, chrono::Utc::now(), None, true);
|
||||
if filters.iter().any(|filter| {
|
||||
buzz_core::filter::filters_match(std::slice::from_ref(filter), &stored)
|
||||
}) {
|
||||
events.push(
|
||||
serde_json::to_value(&stored.event)
|
||||
.map_err(|e| internal_error(&format!("banner serialize: {e}")))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(Json(Value::Array(events)));
|
||||
}
|
||||
|
||||
let mut events: Vec<Value> = Vec::new();
|
||||
let mut handled: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
|
||||
@@ -2329,6 +2350,18 @@ async fn synthesize_presence(
|
||||
Some(Ok(events))
|
||||
}
|
||||
|
||||
fn filters_are_relay_banner_only(filters: &[nostr::Filter]) -> bool {
|
||||
!filters.is_empty()
|
||||
&& filters.iter().all(|filter| {
|
||||
filter.kinds.as_ref().is_some_and(|kinds| {
|
||||
kinds.len() == 1
|
||||
&& kinds
|
||||
.iter()
|
||||
.all(|kind| kind.as_u16() as u32 == buzz_core::kind::KIND_RELAY_BANNER)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Moderation queue reads (L6 — Quinn) ───────────────────────────────────────
|
||||
//
|
||||
// Mod-only structured rows (`moderation_reports`/`moderation_actions`/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge.
|
||||
|
||||
pub mod admin;
|
||||
pub mod banners;
|
||||
pub mod bridge;
|
||||
pub mod events;
|
||||
pub mod gifs;
|
||||
|
||||
@@ -8,7 +8,7 @@ use tracing::{debug, warn};
|
||||
use buzz_core::filter::filters_match;
|
||||
use buzz_core::kind::{
|
||||
is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC,
|
||||
KIND_DM_VISIBILITY, KIND_HUDDLE_LIVENESS, P_GATED_KINDS, RESULT_GATED_KINDS,
|
||||
KIND_DM_VISIBILITY, KIND_HUDDLE_LIVENESS, KIND_RELAY_BANNER, P_GATED_KINDS, RESULT_GATED_KINDS,
|
||||
SHARED_GATED_KINDS,
|
||||
};
|
||||
use buzz_core::tenant::TenantContext;
|
||||
@@ -221,6 +221,30 @@ pub async fn handle_req(
|
||||
return;
|
||||
}
|
||||
|
||||
if filters_are_relay_banner_only(&filters) {
|
||||
match crate::api::banners::active_banner_event_for_user(&state, &conn.tenant, &pubkey_bytes)
|
||||
.await
|
||||
{
|
||||
Ok(Some(event)) => {
|
||||
let stored =
|
||||
buzz_core::StoredEvent::with_received_at(event, chrono::Utc::now(), None, true);
|
||||
if filters_match(&filters, &stored)
|
||||
&& !conn.send(RelayMessage::event(&sub_id, &stored.event))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
warn!(conn_id = %conn_id, sub_id = %sub_id, "Relay banner lookup failed: {error}");
|
||||
conn.send(RelayMessage::closed(&sub_id, "error: database error"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
conn.send(RelayMessage::eose(&sub_id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Applied BEFORE the NIP-50 search branch so that an authenticated member
|
||||
// cannot use `{"search":"...","kinds":[30174]}` (or similar for p-gated
|
||||
// kinds) to harvest indexed-but-globally-stored sensitive events. Search
|
||||
@@ -1159,6 +1183,18 @@ fn filters_are_huddle_liveness_only(filters: &[Filter]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn filters_are_relay_banner_only(filters: &[Filter]) -> bool {
|
||||
!filters.is_empty()
|
||||
&& filters.iter().all(|filter| {
|
||||
filter.kinds.as_ref().is_some_and(|kinds| {
|
||||
kinds.len() == 1
|
||||
&& kinds
|
||||
.iter()
|
||||
.all(|kind| kind.as_u16() as u32 == KIND_RELAY_BANNER)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn huddle_liveness_session_ids(filters: &[Filter]) -> Vec<uuid::Uuid> {
|
||||
let d_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::D);
|
||||
let mut session_ids = Vec::new();
|
||||
|
||||
@@ -73,6 +73,18 @@ 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))
|
||||
.route(
|
||||
api::banners::BANNER_ACTIVE_PATH,
|
||||
get(api::banners::get_active_banner),
|
||||
)
|
||||
.route(
|
||||
api::banners::BANNER_VIEW_ROUTE,
|
||||
post(api::banners::ack_banner_view),
|
||||
)
|
||||
.route(
|
||||
api::banners::BANNER_DISMISS_ROUTE,
|
||||
post(api::banners::ack_banner_dismiss),
|
||||
)
|
||||
// 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))
|
||||
|
||||
62
migrations/0046_relay_banners.sql
Normal file
62
migrations/0046_relay_banners.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- Operator-configured deployment banners.
|
||||
-- Exactly one active banner exists deployment-wide. Scope is either all
|
||||
-- communities (target_all_communities=true) or an explicit non-empty set in
|
||||
-- relay_banner_communities.
|
||||
|
||||
CREATE TABLE relay_banners (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'urgent')),
|
||||
message TEXT NOT NULL CHECK (char_length(message) BETWEEN 1 AND 2000),
|
||||
max_displays INTEGER NOT NULL CHECK (max_displays >= 1),
|
||||
target_all_communities BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
disabled_at TIMESTAMPTZ,
|
||||
created_by BYTEA NOT NULL CHECK (length(created_by) = 32),
|
||||
updated_by BYTEA NOT NULL CHECK (length(updated_by) = 32),
|
||||
disabled_by BYTEA CHECK (disabled_by IS NULL OR length(disabled_by) = 32)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_relay_banners_public_id
|
||||
ON relay_banners (public_id);
|
||||
|
||||
CREATE UNIQUE INDEX idx_relay_banners_one_active
|
||||
ON relay_banners ((true))
|
||||
WHERE disabled_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_relay_banners_updated_at
|
||||
ON relay_banners (updated_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE relay_banner_communities (
|
||||
banner_id BIGINT NOT NULL REFERENCES relay_banners(id) ON DELETE CASCADE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (banner_id, community_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_banner_communities_community
|
||||
ON relay_banner_communities (community_id, banner_id);
|
||||
|
||||
CREATE TABLE relay_banner_user_state (
|
||||
banner_id BIGINT NOT NULL REFERENCES relay_banners(id) ON DELETE CASCADE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
|
||||
pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32),
|
||||
display_count INTEGER NOT NULL DEFAULT 0 CHECK (display_count >= 0),
|
||||
dismissed_at TIMESTAMPTZ,
|
||||
first_viewed_at TIMESTAMPTZ,
|
||||
last_viewed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (banner_id, community_id, pubkey),
|
||||
CHECK (first_viewed_at IS NULL OR last_viewed_at IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_banner_user_state_pubkey
|
||||
ON relay_banner_user_state (community_id, pubkey, banner_id);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_banners', 'deployment-global operator-configured banner; no community_id intentionally'),
|
||||
('relay_banner_communities', 'deployment-global banner targeting allowlist; community_id is target provenance only'),
|
||||
('relay_banner_user_state', 'deployment-global per-user banner display state; community_id scopes the targeted impression only');
|
||||
|
||||
SELECT attach_community_write_fence('relay_banner_communities');
|
||||
SELECT attach_community_write_fence('relay_banner_user_state');
|
||||
@@ -1700,6 +1700,69 @@ BEGIN
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
|
||||
-- Operator-configured deployment banners.
|
||||
-- Exactly one active banner exists deployment-wide. Scope is either all
|
||||
-- communities (target_all_communities=true) or an explicit non-empty set in
|
||||
-- relay_banner_communities.
|
||||
|
||||
CREATE TABLE relay_banners (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'urgent')),
|
||||
message TEXT NOT NULL CHECK (char_length(message) BETWEEN 1 AND 2000),
|
||||
max_displays INTEGER NOT NULL CHECK (max_displays >= 1),
|
||||
target_all_communities BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
disabled_at TIMESTAMPTZ,
|
||||
created_by BYTEA NOT NULL CHECK (length(created_by) = 32),
|
||||
updated_by BYTEA NOT NULL CHECK (length(updated_by) = 32),
|
||||
disabled_by BYTEA CHECK (disabled_by IS NULL OR length(disabled_by) = 32)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_relay_banners_public_id
|
||||
ON relay_banners (public_id);
|
||||
|
||||
CREATE UNIQUE INDEX idx_relay_banners_one_active
|
||||
ON relay_banners ((true))
|
||||
WHERE disabled_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_relay_banners_updated_at
|
||||
ON relay_banners (updated_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE relay_banner_communities (
|
||||
banner_id BIGINT NOT NULL REFERENCES relay_banners(id) ON DELETE CASCADE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (banner_id, community_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_banner_communities_community
|
||||
ON relay_banner_communities (community_id, banner_id);
|
||||
|
||||
CREATE TABLE relay_banner_user_state (
|
||||
banner_id BIGINT NOT NULL REFERENCES relay_banners(id) ON DELETE CASCADE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
|
||||
pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32),
|
||||
display_count INTEGER NOT NULL DEFAULT 0 CHECK (display_count >= 0),
|
||||
dismissed_at TIMESTAMPTZ,
|
||||
first_viewed_at TIMESTAMPTZ,
|
||||
last_viewed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (banner_id, community_id, pubkey),
|
||||
CHECK (first_viewed_at IS NULL OR last_viewed_at IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_banner_user_state_pubkey
|
||||
ON relay_banner_user_state (community_id, pubkey, banner_id);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_banners', 'deployment-global operator-configured banner; no community_id intentionally'),
|
||||
('relay_banner_communities', 'deployment-global banner targeting allowlist; community_id is target provenance only'),
|
||||
('relay_banner_user_state', 'deployment-global per-user banner display state; community_id scopes the targeted impression only');
|
||||
|
||||
|
||||
-- Attach the universal fence to every existing table carrying community_id,
|
||||
-- including deployment-private sidecars whose community_id is provenance.
|
||||
DO $$
|
||||
@@ -1749,6 +1812,8 @@ SELECT attach_community_write_fence('push_wake_outbox');
|
||||
SELECT attach_community_write_fence('reactions');
|
||||
SELECT attach_community_write_fence('relay_invites');
|
||||
SELECT attach_community_write_fence('relay_members');
|
||||
SELECT attach_community_write_fence('relay_banner_communities');
|
||||
SELECT attach_community_write_fence('relay_banner_user_state');
|
||||
SELECT attach_community_write_fence('scheduled_workflow_fires');
|
||||
SELECT attach_community_write_fence('subscriptions');
|
||||
SELECT attach_community_write_fence('thread_metadata');
|
||||
@@ -1761,7 +1826,6 @@ SELECT attach_community_write_fence('workflows');
|
||||
-- Deployment-level principals staffed via the admin API. Config-backed operators
|
||||
-- (RELAY_OPERATOR_PUBKEYS, RELAY_OWNER_PUBKEY owner-fallback) are NOT seeded here;
|
||||
-- they are authoritative in config and outrank any DB row.
|
||||
|
||||
CREATE TABLE relay_operators (
|
||||
pubkey BYTEA NOT NULL PRIMARY KEY CHECK (length(pubkey) = 32),
|
||||
role TEXT NOT NULL CHECK (role IN ('operator', 'moderator')),
|
||||
|
||||
Reference in New Issue
Block a user