mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: soft-delete for events/channels, enriched API responses, NIP-29 group management (#17)
This commit is contained in:
@@ -218,6 +218,7 @@ Sprout is designed as a complete platform, not a collection of independent micro
|
||||
| ✅ | Nostr proxy (`sprout-proxy`) — guest client compatibility |
|
||||
| ✅ | Huddle (`sprout-huddle`) — LiveKit integration |
|
||||
| ✅ | Admin CLI (`sprout-admin`) |
|
||||
| ✅ | Channel features — messaging, threads, DMs, reactions, NIP-29 group management, soft-delete |
|
||||
| 🚧 | Web client (Tauri) — Stream, Forum, DM, Search |
|
||||
| ✅ | Workflow engine (`sprout-workflow`) — YAML-as-code, 4 trigger types, 7 action types, approval gates, execution traces |
|
||||
| ✅ | Home Feed (`/api/feed`) — @mentions, needs-action, channel activity, agent activity |
|
||||
|
||||
@@ -511,8 +511,9 @@ pub async fn remove_member(
|
||||
pub async fn is_member(pool: &MySqlPool, channel_id: Uuid, pubkey: &[u8]) -> Result<bool> {
|
||||
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
let row = sqlx::query(
|
||||
"SELECT COUNT(*) as cnt FROM channel_members \
|
||||
WHERE channel_id = ? AND pubkey = ? AND removed_at IS NULL",
|
||||
"SELECT COUNT(*) as cnt FROM channel_members cm \
|
||||
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \
|
||||
WHERE cm.channel_id = ? AND cm.pubkey = ? AND cm.removed_at IS NULL",
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(pubkey)
|
||||
@@ -523,14 +524,17 @@ pub async fn is_member(pool: &MySqlPool, channel_id: Uuid, pubkey: &[u8]) -> Res
|
||||
}
|
||||
|
||||
/// Returns all active members of the given channel.
|
||||
///
|
||||
/// Returns an empty list if the channel has been soft-deleted.
|
||||
pub async fn get_members(pool: &MySqlPool, channel_id: Uuid) -> Result<Vec<MemberRecord>> {
|
||||
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT channel_id, pubkey, role, joined_at, invited_by, removed_at
|
||||
FROM channel_members
|
||||
WHERE channel_id = ? AND removed_at IS NULL
|
||||
ORDER BY joined_at ASC
|
||||
SELECT cm.channel_id, cm.pubkey, cm.role, cm.joined_at, cm.invited_by, cm.removed_at
|
||||
FROM channel_members cm
|
||||
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL
|
||||
WHERE cm.channel_id = ? AND cm.removed_at IS NULL
|
||||
ORDER BY cm.joined_at ASC
|
||||
LIMIT 1000
|
||||
"#,
|
||||
)
|
||||
@@ -547,9 +551,10 @@ pub async fn get_members(pool: &MySqlPool, channel_id: Uuid) -> Result<Vec<Membe
|
||||
pub async fn get_accessible_channel_ids(pool: &MySqlPool, pubkey: &[u8]) -> Result<Vec<Uuid>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT channel_id
|
||||
FROM channel_members
|
||||
WHERE pubkey = ? AND removed_at IS NULL
|
||||
SELECT cm.channel_id
|
||||
FROM channel_members cm
|
||||
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL
|
||||
WHERE cm.pubkey = ? AND cm.removed_at IS NULL
|
||||
UNION
|
||||
SELECT id AS channel_id
|
||||
FROM channels
|
||||
@@ -1008,6 +1013,21 @@ pub async fn unarchive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft-delete a channel by setting `deleted_at = NOW(6)`.
|
||||
///
|
||||
/// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already
|
||||
/// deleted or not found.
|
||||
pub async fn soft_delete_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<bool> {
|
||||
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
let result =
|
||||
sqlx::query("UPDATE channels SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
|
||||
.bind(&id_bytes)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Returns the count of active (non-removed) members in a channel.
|
||||
pub async fn get_member_count(pool: &MySqlPool, channel_id: Uuid) -> Result<i64> {
|
||||
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
@@ -1020,6 +1040,42 @@ pub async fn get_member_count(pool: &MySqlPool, channel_id: Uuid) -> Result<i64>
|
||||
Ok(row.try_get("cnt")?)
|
||||
}
|
||||
|
||||
/// Bulk-fetch member counts for a set of channel IDs.
|
||||
///
|
||||
/// Returns a map of `channel_id → count`. Channels with zero members are omitted.
|
||||
/// Single query regardless of input size.
|
||||
pub async fn get_member_counts_bulk(
|
||||
pool: &MySqlPool,
|
||||
channel_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashMap<Uuid, i64>> {
|
||||
use crate::event::uuid_from_bytes;
|
||||
|
||||
if channel_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let mut qb: sqlx::QueryBuilder<sqlx::MySql> = sqlx::QueryBuilder::new(
|
||||
"SELECT channel_id, COUNT(*) as cnt FROM channel_members \
|
||||
WHERE removed_at IS NULL AND channel_id IN (",
|
||||
);
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in channel_ids {
|
||||
sep.push_bind(id.as_bytes().to_vec());
|
||||
}
|
||||
qb.push(") GROUP BY channel_id");
|
||||
|
||||
let rows = qb.build().fetch_all(pool).await?;
|
||||
|
||||
let mut map = std::collections::HashMap::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id_bytes: Vec<u8> = row.try_get("channel_id")?;
|
||||
let id = uuid_from_bytes(&id_bytes)?;
|
||||
let cnt: i64 = row.try_get("cnt")?;
|
||||
map.insert(id, cnt);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Get the active role of a pubkey in a channel.
|
||||
///
|
||||
/// Returns `None` if the pubkey is not an active member.
|
||||
@@ -1030,7 +1086,9 @@ pub async fn get_member_role(
|
||||
) -> Result<Option<String>> {
|
||||
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
let row = sqlx::query(
|
||||
"SELECT role FROM channel_members WHERE channel_id = ? AND pubkey = ? AND removed_at IS NULL",
|
||||
"SELECT cm.role FROM channel_members cm \
|
||||
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \
|
||||
WHERE cm.channel_id = ? AND cm.pubkey = ? AND cm.removed_at IS NULL",
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(pubkey)
|
||||
|
||||
@@ -99,7 +99,7 @@ pub async fn query_events(pool: &MySqlPool, q: &EventQuery) -> Result<Vec<Stored
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
|
||||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||||
FROM events WHERE 1=1",
|
||||
FROM events WHERE deleted_at IS NULL",
|
||||
);
|
||||
|
||||
if let Some(ch) = q.channel_id {
|
||||
@@ -185,8 +185,154 @@ pub(crate) fn row_to_stored_event(row: sqlx::mysql::MySqlRow) -> Result<Option<S
|
||||
)))
|
||||
}
|
||||
|
||||
/// Fetches a single event by its raw 32-byte ID. Returns `None` if not found.
|
||||
/// Soft-delete an event by setting `deleted_at = NOW(6)`.
|
||||
///
|
||||
/// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted
|
||||
/// or not found. Callers are responsible for decrementing thread reply counts
|
||||
/// when the deleted event is a thread reply.
|
||||
pub async fn soft_delete_event(pool: &MySqlPool, event_id: &[u8]) -> Result<bool> {
|
||||
let result =
|
||||
sqlx::query("UPDATE events SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
|
||||
.bind(event_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Atomically soft-delete an event and decrement thread reply counters.
|
||||
///
|
||||
/// Wraps the delete + counter update in a single transaction so a crash between
|
||||
/// them cannot leave counters permanently inflated. Returns `Ok(true)` if the
|
||||
/// event was deleted this call.
|
||||
pub async fn soft_delete_event_and_update_thread(
|
||||
pool: &MySqlPool,
|
||||
event_id: &[u8],
|
||||
parent_event_id: Option<&[u8]>,
|
||||
root_event_id: Option<&[u8]>,
|
||||
) -> Result<bool> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let result =
|
||||
sqlx::query("UPDATE events SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
|
||||
.bind(event_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let deleted = result.rows_affected() > 0;
|
||||
|
||||
if deleted {
|
||||
if let Some(pid) = parent_event_id {
|
||||
sqlx::query(
|
||||
"UPDATE thread_metadata \
|
||||
SET reply_count = GREATEST(reply_count - 1, 0) \
|
||||
WHERE event_id = ?",
|
||||
)
|
||||
.bind(pid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(root_id) = root_event_id {
|
||||
sqlx::query(
|
||||
"UPDATE thread_metadata \
|
||||
SET descendant_count = GREATEST(descendant_count - 1, 0) \
|
||||
WHERE event_id = ?",
|
||||
)
|
||||
.bind(root_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Returns the `created_at` timestamp of the most recent non-deleted event in a channel.
|
||||
pub async fn get_last_message_at(
|
||||
pool: &MySqlPool,
|
||||
channel_id: uuid::Uuid,
|
||||
) -> Result<Option<DateTime<Utc>>> {
|
||||
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
let row = sqlx::query(
|
||||
"SELECT created_at FROM events \
|
||||
WHERE channel_id = ? AND deleted_at IS NULL \
|
||||
ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(r.try_get("created_at")?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk-fetch the most recent `created_at` for a set of channel IDs.
|
||||
///
|
||||
/// Returns a map of `channel_id → last_message_at`. Channels with no events are omitted.
|
||||
/// Single query regardless of input size.
|
||||
pub async fn get_last_message_at_bulk(
|
||||
pool: &MySqlPool,
|
||||
channel_ids: &[uuid::Uuid],
|
||||
) -> Result<std::collections::HashMap<uuid::Uuid, DateTime<Utc>>> {
|
||||
if channel_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
|
||||
"SELECT channel_id, MAX(created_at) as last_at FROM events \
|
||||
WHERE deleted_at IS NULL AND channel_id IN (",
|
||||
);
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in channel_ids {
|
||||
sep.push_bind(id.as_bytes().to_vec());
|
||||
}
|
||||
qb.push(") GROUP BY channel_id");
|
||||
|
||||
let rows = qb.build().fetch_all(pool).await?;
|
||||
|
||||
let mut map = std::collections::HashMap::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id_bytes: Vec<u8> = row.try_get("channel_id")?;
|
||||
let id = uuid_from_bytes(&id_bytes)?;
|
||||
let last_at: DateTime<Utc> = row.try_get("last_at")?;
|
||||
map.insert(id, last_at);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Fetches a single non-deleted event by its raw 32-byte ID.
|
||||
///
|
||||
/// Returns `None` if the event does not exist or has been soft-deleted.
|
||||
/// Use [`get_event_by_id_including_deleted`] when you need to inspect
|
||||
/// tombstoned rows (e.g. audit, undelete).
|
||||
pub async fn get_event_by_id(pool: &MySqlPool, id_bytes: &[u8]) -> Result<Option<StoredEvent>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||||
FROM events WHERE id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(id_bytes)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => row_to_stored_event(r),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a single event by its raw 32-byte ID, **including soft-deleted rows**.
|
||||
///
|
||||
/// Most callers should use [`get_event_by_id`] instead. This variant is needed
|
||||
/// when the caller must distinguish "never existed" from "was deleted" (e.g.
|
||||
/// audit trails, compliance queries).
|
||||
pub async fn get_event_by_id_including_deleted(
|
||||
pool: &MySqlPool,
|
||||
id_bytes: &[u8],
|
||||
) -> Result<Option<StoredEvent>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||||
FROM events WHERE id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
@@ -201,6 +347,154 @@ pub async fn get_event_by_id(pool: &MySqlPool, id_bytes: &[u8]) -> Result<Option
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [`insert_event_with_thread_metadata`].
|
||||
#[derive(Debug)]
|
||||
pub struct ThreadMetadataParams<'a> {
|
||||
/// The Nostr event ID of this message.
|
||||
pub event_id: &'a [u8],
|
||||
/// When the event was created.
|
||||
pub event_created_at: DateTime<Utc>,
|
||||
/// The channel this event belongs to.
|
||||
pub channel_id: Uuid,
|
||||
/// Event ID of the direct parent, if this is a reply.
|
||||
pub parent_event_id: Option<&'a [u8]>,
|
||||
/// When the parent event was created.
|
||||
pub parent_event_created_at: Option<DateTime<Utc>>,
|
||||
/// Event ID of the thread root, if this is a nested reply.
|
||||
pub root_event_id: Option<&'a [u8]>,
|
||||
/// When the root event was created.
|
||||
pub root_event_created_at: Option<DateTime<Utc>>,
|
||||
/// Nesting depth (root = 0).
|
||||
pub depth: i32,
|
||||
/// Whether this reply is broadcast to the channel timeline.
|
||||
pub broadcast: bool,
|
||||
}
|
||||
|
||||
/// Atomically insert an event AND its thread metadata in a single transaction.
|
||||
///
|
||||
/// This prevents the race condition where a concurrent delete between separate
|
||||
/// `insert_event` and `insert_thread_metadata` calls could leave reply counters
|
||||
/// permanently inflated (the metadata insert increments counters for an event
|
||||
/// that was already soft-deleted).
|
||||
///
|
||||
/// Returns `(StoredEvent, was_inserted)`.
|
||||
pub async fn insert_event_with_thread_metadata(
|
||||
pool: &MySqlPool,
|
||||
event: &Event,
|
||||
channel_id: Option<Uuid>,
|
||||
thread_meta: Option<ThreadMetadataParams<'_>>,
|
||||
) -> Result<(StoredEvent, bool)> {
|
||||
let kind_u16 = event.kind.as_u16();
|
||||
let kind_u32 = u32::from(kind_u16);
|
||||
|
||||
if kind_u32 == KIND_AUTH {
|
||||
return Err(DbError::AuthEventRejected);
|
||||
}
|
||||
if is_ephemeral(kind_u32) {
|
||||
return Err(DbError::EphemeralEventRejected(kind_u16));
|
||||
}
|
||||
|
||||
let id_bytes = event.id.as_bytes();
|
||||
let pubkey_bytes = event.pubkey.to_bytes();
|
||||
let sig_bytes = event.sig.serialize();
|
||||
let tags_json = serde_json::to_value(&event.tags)?;
|
||||
let kind_i32 = event_kind_i32(event);
|
||||
let created_at_secs = event.created_at.as_u64() as i64;
|
||||
let created_at = DateTime::from_timestamp(created_at_secs, 0)
|
||||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||||
let received_at = Utc::now();
|
||||
let channel_id_bytes: Option<[u8; 16]> = channel_id.map(|u| *u.as_bytes());
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// ── Insert event ──────────────────────────────────────────────────────────
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT IGNORE INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(id_bytes.as_slice())
|
||||
.bind(pubkey_bytes.as_slice())
|
||||
.bind(created_at)
|
||||
.bind(kind_i32)
|
||||
.bind(&tags_json)
|
||||
.bind(&event.content)
|
||||
.bind(sig_bytes.as_slice())
|
||||
.bind(received_at)
|
||||
.bind(channel_id_bytes.as_ref().map(|b| b.as_slice()))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let was_inserted = result.rows_affected() > 0;
|
||||
|
||||
// ── Insert thread metadata (if provided and event was actually inserted) ──
|
||||
if was_inserted {
|
||||
if let Some(ref meta) = thread_meta {
|
||||
let ch_bytes = meta.channel_id.as_bytes().as_slice().to_vec();
|
||||
let broadcast_val: i8 = if meta.broadcast { 1 } else { 0 };
|
||||
|
||||
let tm_result = sqlx::query(
|
||||
r#"
|
||||
INSERT IGNORE INTO thread_metadata
|
||||
(event_created_at, event_id, channel_id,
|
||||
parent_event_id, parent_event_created_at,
|
||||
root_event_id, root_event_created_at,
|
||||
depth, broadcast)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(meta.event_created_at)
|
||||
.bind(meta.event_id)
|
||||
.bind(ch_bytes.as_slice())
|
||||
.bind(meta.parent_event_id)
|
||||
.bind(meta.parent_event_created_at)
|
||||
.bind(meta.root_event_id)
|
||||
.bind(meta.root_event_created_at)
|
||||
.bind(meta.depth)
|
||||
.bind(broadcast_val)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Only bump reply counts if the metadata row was actually inserted.
|
||||
if tm_result.rows_affected() > 0 {
|
||||
if let Some(pid) = meta.parent_event_id {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE thread_metadata
|
||||
SET reply_count = reply_count + 1, last_reply_at = NOW(6)
|
||||
WHERE event_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(root_id) = meta.root_event_id {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE thread_metadata
|
||||
SET descendant_count = descendant_count + 1
|
||||
WHERE event_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(root_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((
|
||||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
|
||||
was_inserted,
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert raw BINARY(16) bytes to a [`Uuid`].
|
||||
pub(crate) fn uuid_from_bytes(bytes: &[u8]) -> Result<Uuid> {
|
||||
Uuid::from_slice(bytes).map_err(|e| DbError::InvalidData(format!("invalid UUID: {e}")))
|
||||
|
||||
@@ -139,11 +139,70 @@ impl Db {
|
||||
event::query_events(&self.pool, q).await
|
||||
}
|
||||
|
||||
/// Fetches a single event by its raw ID bytes. Returns `None` if not found.
|
||||
/// Fetches a single non-deleted event by its raw ID bytes.
|
||||
///
|
||||
/// Returns `None` if the event does not exist or has been soft-deleted.
|
||||
pub async fn get_event_by_id(&self, id_bytes: &[u8]) -> Result<Option<StoredEvent>> {
|
||||
event::get_event_by_id(&self.pool, id_bytes).await
|
||||
}
|
||||
|
||||
/// Fetches a single event by ID, **including soft-deleted rows**.
|
||||
///
|
||||
/// Most callers should use [`get_event_by_id`] instead.
|
||||
pub async fn get_event_by_id_including_deleted(
|
||||
&self,
|
||||
id_bytes: &[u8],
|
||||
) -> Result<Option<StoredEvent>> {
|
||||
event::get_event_by_id_including_deleted(&self.pool, id_bytes).await
|
||||
}
|
||||
|
||||
/// Atomically insert an event and its thread metadata in one transaction.
|
||||
///
|
||||
/// Prevents the race where a concurrent delete between separate insert calls
|
||||
/// could leave reply counters permanently inflated.
|
||||
pub async fn insert_event_with_thread_metadata(
|
||||
&self,
|
||||
ev: &nostr::Event,
|
||||
channel_id: Option<Uuid>,
|
||||
thread_meta: Option<event::ThreadMetadataParams<'_>>,
|
||||
) -> Result<(StoredEvent, bool)> {
|
||||
event::insert_event_with_thread_metadata(&self.pool, ev, channel_id, thread_meta).await
|
||||
}
|
||||
|
||||
/// Soft-delete an event. Returns `true` if the event was deleted.
|
||||
pub async fn soft_delete_event(&self, event_id: &[u8]) -> Result<bool> {
|
||||
event::soft_delete_event(&self.pool, event_id).await
|
||||
}
|
||||
|
||||
/// Atomically soft-delete an event and decrement thread counters in one transaction.
|
||||
pub async fn soft_delete_event_and_update_thread(
|
||||
&self,
|
||||
event_id: &[u8],
|
||||
parent_event_id: Option<&[u8]>,
|
||||
root_event_id: Option<&[u8]>,
|
||||
) -> Result<bool> {
|
||||
event::soft_delete_event_and_update_thread(
|
||||
&self.pool,
|
||||
event_id,
|
||||
parent_event_id,
|
||||
root_event_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns the timestamp of the most recent non-deleted event in a channel.
|
||||
pub async fn get_last_message_at(&self, channel_id: Uuid) -> Result<Option<DateTime<Utc>>> {
|
||||
event::get_last_message_at(&self.pool, channel_id).await
|
||||
}
|
||||
|
||||
/// Bulk-fetch last message timestamps for multiple channels in one query.
|
||||
pub async fn get_last_message_at_bulk(
|
||||
&self,
|
||||
channel_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashMap<Uuid, DateTime<Utc>>> {
|
||||
event::get_last_message_at_bulk(&self.pool, channel_ids).await
|
||||
}
|
||||
|
||||
// ── Feed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns events that mention `pubkey` in the given channels.
|
||||
@@ -309,11 +368,24 @@ impl Db {
|
||||
channel::unarchive_channel(&self.pool, channel_id).await
|
||||
}
|
||||
|
||||
/// Soft-delete a channel. Returns `true` if the channel was deleted.
|
||||
pub async fn soft_delete_channel(&self, channel_id: Uuid) -> Result<bool> {
|
||||
channel::soft_delete_channel(&self.pool, channel_id).await
|
||||
}
|
||||
|
||||
/// Returns the count of active members in a channel.
|
||||
pub async fn get_member_count(&self, channel_id: Uuid) -> Result<i64> {
|
||||
channel::get_member_count(&self.pool, channel_id).await
|
||||
}
|
||||
|
||||
/// Bulk-fetch member counts for multiple channels in one query.
|
||||
pub async fn get_member_counts_bulk(
|
||||
&self,
|
||||
channel_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashMap<Uuid, i64>> {
|
||||
channel::get_member_counts_bulk(&self.pool, channel_ids).await
|
||||
}
|
||||
|
||||
/// Returns the active role of a pubkey in a channel.
|
||||
pub async fn get_member_role(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<Option<String>> {
|
||||
channel::get_member_role(&self.pool, channel_id, pubkey).await
|
||||
@@ -379,6 +451,18 @@ impl Db {
|
||||
thread::get_channel_messages_top_level(&self.pool, channel_id, limit, before).await
|
||||
}
|
||||
|
||||
/// Decrement reply counts when a thread reply is deleted.
|
||||
///
|
||||
/// Decrements `reply_count` on the parent and `descendant_count` on the root
|
||||
/// (floor at 0). Mirrors [`thread::increment_reply_count`] exactly.
|
||||
pub async fn decrement_reply_count(
|
||||
&self,
|
||||
parent_event_id: &[u8],
|
||||
root_event_id: Option<&[u8]>,
|
||||
) -> Result<()> {
|
||||
thread::decrement_reply_count(&self.pool, parent_event_id, root_event_id).await
|
||||
}
|
||||
|
||||
/// Fetch a raw thread_metadata row by event ID.
|
||||
pub async fn get_thread_metadata_by_event(
|
||||
&self,
|
||||
|
||||
@@ -186,6 +186,11 @@ pub async fn insert_thread_metadata(
|
||||
/// root — even when root == parent (direct reply to root). This is correct
|
||||
/// because `reply_count` tracks direct children only, while `descendant_count`
|
||||
/// tracks ALL descendants at every nesting level.
|
||||
///
|
||||
/// NOTE: The primary increment path is inlined inside [`insert_thread_metadata`]'s
|
||||
/// transaction. This standalone version exists for future use cases where
|
||||
/// incrementing outside of insert is needed (e.g., event re-parenting).
|
||||
#[allow(dead_code)]
|
||||
pub async fn increment_reply_count(
|
||||
pool: &MySqlPool,
|
||||
parent_event_id: &[u8],
|
||||
|
||||
@@ -36,6 +36,20 @@ pub async fn channels_handler(
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
// Bulk-fetch member counts and last-message timestamps in two queries
|
||||
// instead of 2N queries (one per channel per metric).
|
||||
let channel_ids: Vec<uuid::Uuid> = channels.iter().map(|ch| ch.id).collect();
|
||||
let member_counts = state
|
||||
.db
|
||||
.get_member_counts_bulk(&channel_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let last_messages = state
|
||||
.db
|
||||
.get_last_message_at_bulk(&channel_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut result = Vec::with_capacity(channels.len());
|
||||
|
||||
for ch in &channels {
|
||||
@@ -45,10 +59,15 @@ pub async fn channels_handler(
|
||||
(vec![], vec![])
|
||||
};
|
||||
|
||||
let member_count = member_counts.get(&ch.id).copied().unwrap_or(0);
|
||||
let last_message_at = last_messages.get(&ch.id).copied();
|
||||
|
||||
result.push(channel_record_to_json(
|
||||
ch,
|
||||
participants,
|
||||
participant_pubkeys,
|
||||
member_count,
|
||||
last_message_at,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -120,7 +139,7 @@ pub async fn create_channel(
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(channel_record_to_json(&channel, vec![], vec![])),
|
||||
Json(channel_record_to_json(&channel, vec![], vec![], 1, None)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -128,12 +147,23 @@ fn channel_record_to_json(
|
||||
channel: &ChannelRecord,
|
||||
participants: Vec<String>,
|
||||
participant_pubkeys: Vec<String>,
|
||||
member_count: i64,
|
||||
last_message_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": channel.id.to_string(),
|
||||
"name": &channel.name,
|
||||
"channel_type": &channel.channel_type,
|
||||
"visibility": &channel.visibility,
|
||||
"description": channel.description.clone().unwrap_or_default(),
|
||||
"topic": channel.topic,
|
||||
"purpose": channel.purpose,
|
||||
"created_by": nostr_hex::encode(&channel.created_by),
|
||||
"created_at": channel.created_at.to_rfc3339(),
|
||||
"updated_at": channel.updated_at.to_rfc3339(),
|
||||
"archived_at": channel.archived_at.map(|t| t.to_rfc3339()),
|
||||
"member_count": member_count,
|
||||
"last_message_at": last_message_at.map(|t| t.to_rfc3339()),
|
||||
"participants": participants,
|
||||
"participant_pubkeys": participant_pubkeys,
|
||||
})
|
||||
|
||||
@@ -428,3 +428,54 @@ pub async fn unarchive_channel_handler(
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// DELETE /api/channels/{channel_id} — Soft-delete a channel.
|
||||
///
|
||||
/// Requires owner role. Sets `deleted_at` on the channel record; the channel
|
||||
/// becomes invisible to all queries that filter on `deleted_at IS NULL`.
|
||||
pub async fn delete_channel_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(channel_id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
let channel_id = parse_channel_id(&channel_id_str)?;
|
||||
|
||||
// Only channel owners may delete.
|
||||
let role = state
|
||||
.db
|
||||
.get_member_role(channel_id, &pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
match role.as_deref() {
|
||||
Some("owner") => {}
|
||||
Some(_) => return Err(forbidden("only channel owner can delete")),
|
||||
None => return Err(forbidden("not a member of this channel")),
|
||||
}
|
||||
|
||||
let deleted = state
|
||||
.db
|
||||
.soft_delete_channel(channel_id)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
if !deleted {
|
||||
return Err(api_error(StatusCode::CONFLICT, "channel already deleted"));
|
||||
}
|
||||
|
||||
let actor_hex = nostr_hex::encode(&pubkey_bytes);
|
||||
if let Err(e) = emit_system_message(
|
||||
&state,
|
||||
channel_id,
|
||||
serde_json::json!({
|
||||
"type": "channel_deleted",
|
||||
"actor": actor_hex,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to emit system message: {e}");
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "deleted": true })))
|
||||
}
|
||||
|
||||
@@ -29,7 +29,44 @@ use serde::Deserialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
|
||||
use super::{
|
||||
api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
|
||||
};
|
||||
|
||||
/// Extract the effective message author from a stored event.
|
||||
///
|
||||
/// REST-created messages are signed by the relay keypair and attribute the real
|
||||
/// sender via a `p` tag. For user-signed events (WebSocket), `event.pubkey` is
|
||||
/// the author. This helper returns the correct author bytes in both cases.
|
||||
fn effective_author(event: &nostr::Event, relay_pubkey: &nostr::PublicKey) -> Vec<u8> {
|
||||
if event.pubkey == *relay_pubkey {
|
||||
// Relay-signed: real author is in the first p tag.
|
||||
for tag in event.tags.iter() {
|
||||
if tag.kind().to_string() == "p" {
|
||||
if let Some(hex) = tag.content() {
|
||||
if let Ok(bytes) = nostr_hex::decode(hex) {
|
||||
if bytes.len() == 32 {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// User-signed or no p tag found: pubkey is the author.
|
||||
event.pubkey.serialize().to_vec()
|
||||
}
|
||||
|
||||
/// Serialize a slice of reaction summaries to JSON.
|
||||
fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serde_json::Value {
|
||||
serde_json::json!(reactions
|
||||
.iter()
|
||||
.map(|r| serde_json::json!({
|
||||
"emoji": r.emoji,
|
||||
"count": r.count,
|
||||
}))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
// ── POST /api/channels/:channel_id/messages ───────────────────────────────────
|
||||
|
||||
@@ -215,32 +252,26 @@ pub async fn send_message(
|
||||
chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
|
||||
};
|
||||
|
||||
// ── Persist event ─────────────────────────────────────────────────────────
|
||||
// ── Persist event + thread metadata atomically ──────────────────────────
|
||||
|
||||
let thread_meta = Some(sprout_db::event::ThreadMetadataParams {
|
||||
event_id: &event_id_bytes,
|
||||
event_created_at,
|
||||
channel_id,
|
||||
parent_event_id: parent_id_bytes.as_deref(),
|
||||
parent_event_created_at: parent_created_at,
|
||||
root_event_id: root_id_bytes.as_deref(),
|
||||
root_event_created_at: root_created_at,
|
||||
depth,
|
||||
broadcast: body.broadcast_to_channel,
|
||||
});
|
||||
|
||||
state
|
||||
.db
|
||||
.insert_event(&event, Some(channel_id))
|
||||
.insert_event_with_thread_metadata(&event, Some(channel_id), thread_meta)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
// ── Persist thread metadata ───────────────────────────────────────────────
|
||||
|
||||
state
|
||||
.db
|
||||
.insert_thread_metadata(
|
||||
&event_id_bytes,
|
||||
event_created_at,
|
||||
channel_id,
|
||||
parent_id_bytes.as_deref(),
|
||||
parent_created_at,
|
||||
root_id_bytes.as_deref(),
|
||||
root_created_at,
|
||||
depth,
|
||||
body.broadcast_to_channel,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("thread metadata error: {e}")))?;
|
||||
|
||||
// ── Response ──────────────────────────────────────────────────────────────
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
@@ -252,6 +283,82 @@ pub async fn send_message(
|
||||
})))
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/:event_id ─────────────────────────────────────────────
|
||||
|
||||
/// Soft-delete a message by event ID.
|
||||
///
|
||||
/// Authorization: the caller must be the message author, or an owner/admin of
|
||||
/// the channel the message belongs to. If the deleted event is a thread reply,
|
||||
/// parent/root reply counts are decremented.
|
||||
pub async fn delete_message(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(event_id_hex): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
|
||||
let event_id_bytes = nostr_hex::decode(&event_id_hex)
|
||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?;
|
||||
|
||||
// Look up the event to check ownership and channel.
|
||||
let stored = state
|
||||
.db
|
||||
.get_event_by_id(&event_id_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?
|
||||
.ok_or_else(|| not_found("event not found"))?;
|
||||
|
||||
let channel_id = stored
|
||||
.channel_id
|
||||
.ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?;
|
||||
|
||||
// Auth: must be the message author OR an owner/admin of the channel.
|
||||
// For relay-signed REST messages, the real author is in the p tag.
|
||||
let author_bytes = effective_author(&stored.event, &state.relay_keypair.public_key());
|
||||
let is_author = author_bytes == pubkey_bytes;
|
||||
|
||||
if !is_author {
|
||||
let role = state
|
||||
.db
|
||||
.get_member_role(channel_id, &pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
match role.as_deref() {
|
||||
Some("owner") | Some("admin") => {} // authorized
|
||||
_ => return Err(forbidden("must be message author or channel owner/admin")),
|
||||
}
|
||||
}
|
||||
|
||||
// Look up thread metadata before deleting so we can pass parent/root IDs
|
||||
// to the transactional delete function. Fail hard on DB errors to avoid
|
||||
// deleting the event without decrementing counters.
|
||||
let meta = state
|
||||
.db
|
||||
.get_thread_metadata_by_event(&event_id_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
let parent_id = meta.as_ref().and_then(|m| m.parent_event_id.clone());
|
||||
let root_id = meta.as_ref().and_then(|m| m.root_event_id.clone());
|
||||
|
||||
// Atomically soft-delete the event and decrement thread counters in one transaction.
|
||||
let deleted = state
|
||||
.db
|
||||
.soft_delete_event_and_update_thread(
|
||||
&event_id_bytes,
|
||||
parent_id.as_deref(),
|
||||
root_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
if !deleted {
|
||||
return Err(api_error(StatusCode::CONFLICT, "event already deleted"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "deleted": true })))
|
||||
}
|
||||
|
||||
// ── GET /api/channels/:channel_id/messages ────────────────────────────────────
|
||||
|
||||
/// Query parameters for listing top-level channel messages.
|
||||
@@ -297,15 +404,33 @@ pub async fn list_messages(
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
// Optionally enrich with thread summaries.
|
||||
if params.with_threads {
|
||||
for msg in &mut messages {
|
||||
if let Ok(summary) = state.db.get_thread_summary(&msg.event_id).await {
|
||||
msg.thread_summary = summary;
|
||||
}
|
||||
// Always enrich with thread summaries for messages that have replies.
|
||||
// The `with_threads` param is kept for backward compatibility but summaries
|
||||
// are now included by default.
|
||||
for msg in &mut messages {
|
||||
if let Ok(summary) = state.db.get_thread_summary(&msg.event_id).await {
|
||||
msg.thread_summary = summary;
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk-fetch reaction counts for all messages in this page.
|
||||
let event_pairs: Vec<(&[u8], chrono::DateTime<Utc>)> = messages
|
||||
.iter()
|
||||
.map(|m| (m.event_id.as_slice(), m.created_at))
|
||||
.collect();
|
||||
let bulk_reactions = state
|
||||
.db
|
||||
.get_reactions_bulk(&event_pairs)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Index reactions by event_id for O(1) lookup during serialization.
|
||||
let reaction_map: std::collections::HashMap<Vec<u8>, &[sprout_db::reaction::ReactionSummary]> =
|
||||
bulk_reactions
|
||||
.iter()
|
||||
.map(|entry| (entry.event_id.clone(), entry.reactions.as_slice()))
|
||||
.collect();
|
||||
|
||||
// Determine next_cursor from the oldest message in this page.
|
||||
let next_cursor = messages.last().map(|m| m.created_at.timestamp());
|
||||
|
||||
@@ -332,6 +457,11 @@ pub async fn list_messages(
|
||||
});
|
||||
}
|
||||
|
||||
// Embed reaction counts if any exist for this message.
|
||||
if let Some(reactions) = reaction_map.get(&m.event_id) {
|
||||
obj["reactions"] = reactions_to_json(reactions);
|
||||
}
|
||||
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
@@ -443,7 +573,7 @@ pub async fn get_thread(
|
||||
|
||||
// Serialize root event.
|
||||
let root_created_at = root_event.event.created_at.as_u64() as i64;
|
||||
let root_obj = serde_json::json!({
|
||||
let mut root_obj = serde_json::json!({
|
||||
"event_id": root_event.event.id.to_hex(),
|
||||
"pubkey": root_event.event.pubkey.to_hex(),
|
||||
"content": root_event.event.content,
|
||||
@@ -460,11 +590,37 @@ pub async fn get_thread(
|
||||
})),
|
||||
});
|
||||
|
||||
// Bulk-fetch reaction counts for root + all replies.
|
||||
let root_created_at_dt =
|
||||
chrono::DateTime::from_timestamp(root_created_at, 0).unwrap_or_else(Utc::now);
|
||||
let mut thread_event_pairs: Vec<(&[u8], chrono::DateTime<Utc>)> =
|
||||
vec![(root_id_bytes.as_slice(), root_created_at_dt)];
|
||||
for r in &replies {
|
||||
thread_event_pairs.push((r.event_id.as_slice(), r.created_at));
|
||||
}
|
||||
let thread_bulk_reactions = state
|
||||
.db
|
||||
.get_reactions_bulk(&thread_event_pairs)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let thread_reaction_map: std::collections::HashMap<
|
||||
Vec<u8>,
|
||||
&[sprout_db::reaction::ReactionSummary],
|
||||
> = thread_bulk_reactions
|
||||
.iter()
|
||||
.map(|entry| (entry.event_id.clone(), entry.reactions.as_slice()))
|
||||
.collect();
|
||||
|
||||
// Attach reactions to root event.
|
||||
if let Some(reactions) = thread_reaction_map.get(&root_id_bytes) {
|
||||
root_obj["reactions"] = reactions_to_json(reactions);
|
||||
}
|
||||
|
||||
// Serialize replies.
|
||||
let reply_objs: Vec<serde_json::Value> = replies
|
||||
.iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
let mut obj = serde_json::json!({
|
||||
"event_id": nostr_hex::encode(&r.event_id),
|
||||
"parent_event_id": r.parent_event_id.as_ref().map(nostr_hex::encode),
|
||||
"root_event_id": r.root_event_id.as_ref().map(nostr_hex::encode),
|
||||
@@ -475,7 +631,13 @@ pub async fn get_thread(
|
||||
"depth": r.depth,
|
||||
"created_at": r.created_at.timestamp(),
|
||||
"broadcast": r.broadcast,
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(reactions) = thread_reaction_map.get(&r.event_id) {
|
||||
obj["reactions"] = reactions_to_json(reactions);
|
||||
}
|
||||
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -43,13 +43,13 @@ pub use agents::agents_handler;
|
||||
pub use approvals::{deny_approval, grant_approval};
|
||||
pub use channels::{channels_handler, create_channel};
|
||||
pub use channels_metadata::{
|
||||
archive_channel_handler, get_channel_handler, set_purpose_handler, set_topic_handler,
|
||||
unarchive_channel_handler, update_channel_handler,
|
||||
archive_channel_handler, delete_channel_handler, get_channel_handler, set_purpose_handler,
|
||||
set_topic_handler, unarchive_channel_handler, update_channel_handler,
|
||||
};
|
||||
pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler};
|
||||
pub use feed::feed_handler;
|
||||
pub use members::{add_members, join_channel, leave_channel, list_members, remove_member};
|
||||
pub use messages::{get_thread, list_messages, send_message};
|
||||
pub use messages::{delete_message, get_thread, list_messages, send_message};
|
||||
pub use presence::presence_handler;
|
||||
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
|
||||
pub use search::search_handler;
|
||||
|
||||
@@ -145,13 +145,55 @@ pub async fn validate_admin_event(
|
||||
}
|
||||
}
|
||||
9005 => {
|
||||
// DELETE_EVENT: owner/admin or event author
|
||||
// For now, just check membership
|
||||
let is_member = state.db.is_member(channel_id, &actor_bytes).await?;
|
||||
if is_member {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("not a member"))
|
||||
// DELETE_EVENT: event author OR channel owner/admin.
|
||||
// Extract target event from e tag to check authorship.
|
||||
let target_id = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| {
|
||||
if tag.kind().to_string() == "e" {
|
||||
tag.content().and_then(|v| hex::decode(v).ok())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("missing e tag for target event"))?;
|
||||
|
||||
// Verify the target event belongs to the h-tag channel BEFORE storage.
|
||||
// Without this, an admin of channel A could craft h=A, e=<event-in-B>
|
||||
// and the relay would store the invalid admin event.
|
||||
if let Ok(Some(target_event)) = state.db.get_event_by_id(&target_id).await {
|
||||
match target_event.channel_id {
|
||||
Some(target_ch) if target_ch != channel_id => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"target event belongs to a different channel"
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Err(anyhow::anyhow!("target event has no channel"));
|
||||
}
|
||||
_ => {} // Same channel — OK
|
||||
}
|
||||
|
||||
// Check if actor is the event author.
|
||||
// For relay-signed REST messages, the real author is in the p tag.
|
||||
let author = effective_message_author(
|
||||
&target_event.event,
|
||||
&state.relay_keypair.public_key(),
|
||||
);
|
||||
if author == actor_bytes {
|
||||
return Ok(()); // Author can always delete their own messages
|
||||
}
|
||||
}
|
||||
|
||||
// Not the author — must be owner/admin.
|
||||
let members = state.db.get_members(channel_id).await?;
|
||||
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
|
||||
match actor_member {
|
||||
Some(m) if m.role == "owner" || m.role == "admin" => Ok(()),
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"must be event author or channel owner/admin"
|
||||
)),
|
||||
}
|
||||
}
|
||||
9008 => {
|
||||
@@ -378,8 +420,50 @@ async fn handle_delete_event_side_effect(
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("missing e tag for target event"))?;
|
||||
|
||||
// TODO: Add soft_delete_event to Db for full implementation
|
||||
tracing::info!(target_event = %hex::encode(&target_id), "Would soft-delete event");
|
||||
// Verify the target event belongs to the same channel as the h-tag.
|
||||
// Without this check, an admin of channel A could delete events in channel B
|
||||
// by sending h=A, e=<event-in-B>.
|
||||
if let Some(target_event) = state
|
||||
.db
|
||||
.get_event_by_id_including_deleted(&target_id)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("get_event_by_id failed: {e}"))?
|
||||
{
|
||||
match target_event.channel_id {
|
||||
Some(target_ch) if target_ch != channel_id => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"target event belongs to a different channel"
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Err(anyhow::anyhow!("target event has no channel"));
|
||||
}
|
||||
_ => {} // Same channel — OK
|
||||
}
|
||||
}
|
||||
|
||||
// Look up thread metadata so we can pass parent/root IDs to the
|
||||
// transactional delete function.
|
||||
let meta = state
|
||||
.db
|
||||
.get_thread_metadata_by_event(&target_id)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("get_thread_metadata failed: {e}"))?;
|
||||
|
||||
let parent_id = meta.as_ref().and_then(|m| m.parent_event_id.clone());
|
||||
let root_id = meta.as_ref().and_then(|m| m.root_event_id.clone());
|
||||
|
||||
// Atomically soft-delete the event and decrement thread counters in one transaction.
|
||||
let deleted = state
|
||||
.db
|
||||
.soft_delete_event_and_update_thread(&target_id, parent_id.as_deref(), root_id.as_deref())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("soft_delete_event failed: {e}"))?;
|
||||
|
||||
if !deleted {
|
||||
warn!(target_event = %hex::encode(&target_id), "event already deleted or not found");
|
||||
return Ok(()); // No-op: skip system message to avoid false audit records.
|
||||
}
|
||||
|
||||
let actor_hex = nostr::util::hex::encode(event.pubkey.serialize());
|
||||
emit_system_message(
|
||||
@@ -393,6 +477,7 @@ async fn handle_delete_event_side_effect(
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(target_event = %hex::encode(&target_id), "NIP-29 DELETE_EVENT processed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -436,7 +521,17 @@ async fn handle_delete_group(event: &Event, state: &Arc<AppState>) -> anyhow::Re
|
||||
extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?;
|
||||
let actor_bytes = event.pubkey.serialize().to_vec();
|
||||
|
||||
// TODO: Add soft_delete_channel to Db for full implementation
|
||||
// Soft-delete the channel.
|
||||
let deleted = state
|
||||
.db
|
||||
.soft_delete_channel(channel_id)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("soft_delete_channel failed: {e}"))?;
|
||||
|
||||
if !deleted {
|
||||
warn!(channel = %channel_id, "channel already deleted or not found");
|
||||
}
|
||||
|
||||
let actor_hex = nostr::util::hex::encode(&actor_bytes);
|
||||
emit_system_message(
|
||||
state,
|
||||
@@ -447,6 +542,7 @@ async fn handle_delete_group(event: &Event, state: &Arc<AppState>) -> anyhow::Re
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(channel = %channel_id, "NIP-29 DELETE_GROUP processed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -581,6 +677,28 @@ fn extract_p_tag(event: &Event) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the effective message author from a stored event.
|
||||
///
|
||||
/// REST-created messages are signed by the relay keypair and attribute the real
|
||||
/// sender via a `p` tag. For user-signed events (WebSocket), `event.pubkey` is
|
||||
/// the author. Returns the correct author bytes in both cases.
|
||||
fn effective_message_author(event: &Event, relay_pubkey: &nostr::PublicKey) -> Vec<u8> {
|
||||
if event.pubkey == *relay_pubkey {
|
||||
for tag in event.tags.iter() {
|
||||
if tag.kind().to_string() == "p" {
|
||||
if let Some(hex) = tag.content() {
|
||||
if let Ok(bytes) = hex::decode(hex) {
|
||||
if bytes.len() == 32 {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
event.pubkey.serialize().to_vec()
|
||||
}
|
||||
|
||||
/// Extract value of a named tag.
|
||||
fn extract_tag_value(event: &Event, tag_name: &str) -> Option<String> {
|
||||
for tag in event.tags.iter() {
|
||||
|
||||
@@ -56,7 +56,7 @@ impl RelayInfo {
|
||||
description: "Sprout — private team communication relay".to_string(),
|
||||
pubkey: None,
|
||||
contact: None,
|
||||
supported_nips: vec![1, 11, 42],
|
||||
supported_nips: vec![1, 11, 25, 29, 42],
|
||||
software: "https://github.com/sprout-rs/sprout".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
limitation: Some(RelayLimitation {
|
||||
|
||||
@@ -62,7 +62,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
// Channel detail + metadata routes
|
||||
.route(
|
||||
"/api/channels/{channel_id}",
|
||||
get(api::get_channel_handler).put(api::update_channel_handler),
|
||||
get(api::get_channel_handler)
|
||||
.put(api::update_channel_handler)
|
||||
.delete(api::delete_channel_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/channels/{channel_id}/topic",
|
||||
@@ -98,6 +100,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
"/api/dms/{channel_id}/members",
|
||||
post(api::add_dm_member_handler),
|
||||
)
|
||||
// Message delete route
|
||||
.route("/api/messages/{event_id}", delete(api::delete_message))
|
||||
// Reaction routes
|
||||
.route(
|
||||
"/api/messages/{event_id}/reactions",
|
||||
|
||||
Reference in New Issue
Block a user