[READ-ONLY] Mirror of https://github.com/colibri-social/appview. The AppView (backend server) for Colibri api.colibri.social
2

Configure Feed

Select the types of activity you want to include in your feed.

feat: community moderation, invitations, notifications, and cursor pagination

Adds 14 new XRPC endpoints across community moderation, invitations, channel
reads, and a notification system, plus fixes the pagination TODO in
list_atproto_records to use rkey-based opaque cursors matching
com.atproto.repo.listRecords semantics.

Highlights:

- Cursor pagination: rkey-based, atproto-style. listRecords now returns
cursors only when a page is full.
- Read endpoints: listCategories, listChannels, listMembers,
channel.listMessages, channel.getReadCursor, channel.listReactions.
- Community moderation: blockUser, unblockUser, blockMessage,
listBlockedUsers. Backed by a new social.colibri.moderation record type
(single lexicon with action field, ozone-style audit log).
- Role/permission system: new social.colibri.role + social.colibri.member
lexicons; ActorAuthz with owner shortcut, channel overrides
(allow/deny), Discord-style hierarchy enforcement on ban+kick.
- Invitations: createInvitation, getInvitation, listInvitations,
deleteInvitation backed by a new off-protocol community_invitations
table.
- Notifications: indexer parses social.colibri.richtext.facet#mention and
detects parent-author replies; rows persisted once with a unique index
for dedupe; new listNotifications (paginated, embeds message body),
getUnreadCount, updateSeen endpoints. Real-time delivery over the
existing subscribeEvents WS as notification_event.
- Lexicons: social.colibri.role, social.colibri.member, and
social.colibri.moderation added to the website's lexicons.ts (separate
repo, separate PR).

Tests: 128 passing, up from 56 before this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Louis Escher (May 14, 2026, 1:20 PM +0200) 83a005a8 47abfcea

+5295 -81
+1
Cargo.lock
··· 821 821 "migration", 822 822 "p256", 823 823 "pretty_env_logger", 824 + "rand 0.8.5", 824 825 "regex", 825 826 "reqwest", 826 827 "rocket",
+1
Cargo.toml
··· 34 34 async-stream = "0.3.6" 35 35 rocket_cors = "0.6.0" 36 36 regex = "1.12.3" 37 + rand = "0.8"
+4
migration/src/lib.rs
··· 2 2 3 3 mod m20260426_145024_create_user_states; 4 4 mod m20260504_071943_complete_user_stats; 5 + mod m20260513_120000_create_community_invitations; 6 + mod m20260514_090000_create_notifications; 5 7 6 8 pub struct Migrator; 7 9 ··· 11 13 vec![ 12 14 Box::new(m20260426_145024_create_user_states::Migration), 13 15 Box::new(m20260504_071943_complete_user_stats::Migration), 16 + Box::new(m20260513_120000_create_community_invitations::Migration), 17 + Box::new(m20260514_090000_create_notifications::Migration), 14 18 ] 15 19 } 16 20 }
+39
migration/src/m20260513_120000_create_community_invitations.rs
··· 1 + use sea_orm_migration::{prelude::*, schema::*}; 2 + 3 + #[derive(DeriveMigrationName)] 4 + pub struct Migration; 5 + 6 + #[async_trait::async_trait] 7 + impl MigrationTrait for Migration { 8 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 + manager 10 + .create_table( 11 + Table::create() 12 + .table("community_invitations") 13 + .if_not_exists() 14 + .col(string("code").primary_key()) 15 + .col(string("community_uri")) 16 + .col(string("created_by")) 17 + .col(boolean("active").default(true)) 18 + .col(string("created_at")) 19 + .to_owned(), 20 + ) 21 + .await?; 22 + 23 + manager 24 + .create_index( 25 + Index::create() 26 + .name("idx_community_invitations_community_uri") 27 + .table("community_invitations") 28 + .col("community_uri") 29 + .to_owned(), 30 + ) 31 + .await 32 + } 33 + 34 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 35 + manager 36 + .drop_table(Table::drop().table("community_invitations").to_owned()) 37 + .await 38 + } 39 + }
+56
migration/src/m20260514_090000_create_notifications.rs
··· 1 + use sea_orm_migration::{prelude::*, schema::*}; 2 + 3 + #[derive(DeriveMigrationName)] 4 + pub struct Migration; 5 + 6 + #[async_trait::async_trait] 7 + impl MigrationTrait for Migration { 8 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 + manager 10 + .create_table( 11 + Table::create() 12 + .table("notifications") 13 + .if_not_exists() 14 + .col(big_integer("id").primary_key().auto_increment()) 15 + .col(string("recipient_did")) 16 + .col(string("kind")) 17 + .col(string("message_uri")) 18 + .col(string("author_did")) 19 + .col(string("channel_rkey")) 20 + .col(string("indexed_at")) 21 + .col(string_null("seen_at")) 22 + .to_owned(), 23 + ) 24 + .await?; 25 + 26 + manager 27 + .create_index( 28 + Index::create() 29 + .name("idx_notifications_recipient_did_id") 30 + .table("notifications") 31 + .col("recipient_did") 32 + .col("id") 33 + .to_owned(), 34 + ) 35 + .await?; 36 + 37 + manager 38 + .create_index( 39 + Index::create() 40 + .name("idx_notifications_dedupe") 41 + .table("notifications") 42 + .col("recipient_did") 43 + .col("message_uri") 44 + .col("kind") 45 + .unique() 46 + .to_owned(), 47 + ) 48 + .await 49 + } 50 + 51 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 52 + manager 53 + .drop_table(Table::drop().table("notifications").to_owned()) 54 + .await 55 + } 56 + }
+65
src/lib/at_uri.rs
··· 1 + /// A parsed AT URI of the form `at://<authority>/<collection>/<rkey>`. 2 + #[derive(Debug, Clone, PartialEq, Eq)] 3 + pub struct AtUri { 4 + pub authority: String, 5 + pub collection: String, 6 + pub rkey: String, 7 + } 8 + 9 + impl AtUri { 10 + /// Parses a fully qualified AT URI. Returns `None` if the URI does not have 11 + /// the `at://<authority>/<collection>/<rkey>` shape. 12 + pub fn parse(uri: &str) -> Option<Self> { 13 + let rest = uri.strip_prefix("at://")?; 14 + let mut parts = rest.splitn(3, '/'); 15 + let authority = parts.next()?; 16 + let collection = parts.next()?; 17 + let rkey = parts.next()?; 18 + 19 + if authority.is_empty() || collection.is_empty() || rkey.is_empty() { 20 + return None; 21 + } 22 + 23 + Some(AtUri { 24 + authority: authority.to_string(), 25 + collection: collection.to_string(), 26 + rkey: rkey.to_string(), 27 + }) 28 + } 29 + } 30 + 31 + #[cfg(test)] 32 + mod tests { 33 + use super::*; 34 + 35 + #[test] 36 + fn parses_well_formed_uri() { 37 + let uri = AtUri::parse("at://did:plc:abc/social.colibri.community/r1").unwrap(); 38 + assert_eq!(uri.authority, "did:plc:abc"); 39 + assert_eq!(uri.collection, "social.colibri.community"); 40 + assert_eq!(uri.rkey, "r1"); 41 + } 42 + 43 + #[test] 44 + fn rejects_missing_scheme() { 45 + assert!(AtUri::parse("did:plc:abc/social.colibri.community/r1").is_none()); 46 + } 47 + 48 + #[test] 49 + fn rejects_missing_rkey() { 50 + assert!(AtUri::parse("at://did:plc:abc/social.colibri.community").is_none()); 51 + } 52 + 53 + #[test] 54 + fn rejects_empty_segments() { 55 + assert!(AtUri::parse("at:///social.colibri.community/r1").is_none()); 56 + assert!(AtUri::parse("at://did:plc:abc//r1").is_none()); 57 + assert!(AtUri::parse("at://did:plc:abc/social.colibri.community/").is_none()); 58 + } 59 + 60 + #[test] 61 + fn keeps_extra_path_segments_with_rkey() { 62 + let uri = AtUri::parse("at://did:plc:abc/social.colibri.community/r1/extra").unwrap(); 63 + assert_eq!(uri.rkey, "r1/extra"); 64 + } 65 + }
+104
src/lib/colibri.rs
··· 184 184 /// Format: datetime 185 185 pub created_at: String, 186 186 } 187 + 188 + #[derive(Serialize, Deserialize, Debug, Clone)] 189 + #[serde(rename_all = "camelCase")] 190 + pub struct ColibriRoleChannelOverride { 191 + /// The channel rkey this override applies to. 192 + pub channel: String, 193 + /// Permissions explicitly granted in this channel. 194 + #[serde(default)] 195 + pub allow: Vec<String>, 196 + /// Permissions explicitly denied in this channel. 197 + #[serde(default)] 198 + pub deny: Vec<String>, 199 + } 200 + 201 + #[derive(Serialize, Deserialize, Debug, Clone)] 202 + #[serde(rename_all = "camelCase")] 203 + pub struct ColibriRole { 204 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 205 + pub record_type: Option<String>, 206 + 207 + /// Display name of the role. 1-32 characters. 208 + pub name: String, 209 + 210 + /// Optional hex color (`#rrggbb`). 211 + #[serde(skip_serializing_if = "Option::is_none")] 212 + pub color: Option<String>, 213 + 214 + /// Granted permissions. See `permissions::Permission` for the enumerated set. 215 + #[serde(default)] 216 + pub permissions: Vec<String>, 217 + 218 + /// Hierarchy position. Higher values sit higher in the hierarchy. 219 + pub position: i64, 220 + 221 + /// Whether members of this role are displayed separately in the member list. 222 + #[serde(default, skip_serializing_if = "Option::is_none")] 223 + pub hoisted: Option<bool>, 224 + 225 + /// Whether `@role` style mentions resolve to this role. 226 + #[serde(default, skip_serializing_if = "Option::is_none")] 227 + pub mentionable: Option<bool>, 228 + 229 + /// Per-channel permission overrides for this role. 230 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 231 + pub channel_overrides: Vec<ColibriRoleChannelOverride>, 232 + } 233 + 234 + #[derive(Serialize, Deserialize, Debug, Clone)] 235 + #[serde(rename_all = "camelCase")] 236 + pub struct ColibriMember { 237 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 238 + pub record_type: Option<String>, 239 + 240 + /// The member's DID. 241 + pub subject: String, 242 + 243 + /// Role rkeys assigned to this member, stored on the same community repo. 244 + #[serde(default)] 245 + pub roles: Vec<String>, 246 + 247 + /// Format: datetime 248 + pub joined_at: String, 249 + 250 + /// Optional per-community display-name override. 251 + #[serde(skip_serializing_if = "Option::is_none")] 252 + pub nickname: Option<String>, 253 + 254 + /// Optional AT-URI of the user-side `social.colibri.membership` declaration 255 + /// that this member record was admitted from. Audit trail. 256 + #[serde(skip_serializing_if = "Option::is_none")] 257 + pub from_membership: Option<String>, 258 + } 259 + 260 + #[derive(Serialize, Deserialize, Debug, Clone)] 261 + #[serde(rename_all = "camelCase")] 262 + pub struct ColibriModerationSubject { 263 + /// DID of the subject for user-targeted actions. 264 + #[serde(skip_serializing_if = "Option::is_none")] 265 + pub did: Option<String>, 266 + /// AT-URI of the subject for content-targeted actions. 267 + #[serde(skip_serializing_if = "Option::is_none")] 268 + pub uri: Option<String>, 269 + } 270 + 271 + #[derive(Serialize, Deserialize, Debug, Clone)] 272 + #[serde(rename_all = "camelCase")] 273 + pub struct ColibriModeration { 274 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 275 + pub record_type: Option<String>, 276 + 277 + /// `ban` | `unban` | `hideMessage` | `unhideMessage` | `kick` 278 + pub action: String, 279 + 280 + pub subject: ColibriModerationSubject, 281 + 282 + #[serde(skip_serializing_if = "Option::is_none")] 283 + pub reason: Option<String>, 284 + 285 + /// DID of the issuer. 286 + pub created_by: String, 287 + 288 + /// Format: datetime 289 + pub created_at: String, 290 + }
+299
src/lib/community_authz.rs
··· 1 + use sea_orm::{ColumnTrait, Condition, DatabaseConnection, DbErr, EntityTrait, QueryFilter}; 2 + 3 + use crate::lib::at_uri::AtUri; 4 + use crate::lib::colibri::{ColibriMember, ColibriRole}; 5 + use crate::lib::permissions::Permission; 6 + use crate::models::record_data; 7 + 8 + const MEMBER_NSID: &str = "social.colibri.member"; 9 + const ROLE_NSID: &str = "social.colibri.role"; 10 + 11 + /// Aggregated authz state for a single (actor, community) pair. 12 + #[derive(Debug, Clone)] 13 + pub struct ActorAuthz { 14 + pub is_owner: bool, 15 + pub member: Option<ColibriMember>, 16 + pub roles: Vec<ColibriRole>, 17 + } 18 + 19 + impl ActorAuthz { 20 + /// Highest role position held by the actor, or `None` if the actor has no 21 + /// roles. The owner is considered to outrank every role and is represented 22 + /// as `Some(i64::MAX)`. 23 + pub fn highest_position(&self) -> Option<i64> { 24 + if self.is_owner { 25 + return Some(i64::MAX); 26 + } 27 + self.roles.iter().map(|r| r.position).max() 28 + } 29 + 30 + /// Whether the actor holds the given permission, optionally in the context 31 + /// of a specific channel (so per-channel overrides apply). 32 + /// 33 + /// Evaluation order, matching Discord: 34 + /// 1. Owner short-circuits to allow. 35 + /// 2. Channel-override `deny` wins over any allow. 36 + /// 3. Channel-override `allow` grants without needing a base permission. 37 + /// 4. Otherwise the base `permissions` list of any role is consulted. 38 + pub fn has(&self, permission: Permission, channel_rkey: Option<&str>) -> bool { 39 + if self.is_owner { 40 + return true; 41 + } 42 + 43 + let needle = permission.as_str(); 44 + let mut has_base = false; 45 + let mut has_override_allow = false; 46 + let mut has_override_deny = false; 47 + 48 + for role in &self.roles { 49 + if role.permissions.iter().any(|p| p == needle) { 50 + has_base = true; 51 + } 52 + if let Some(channel) = channel_rkey { 53 + for override_entry in role.channel_overrides.iter() { 54 + if override_entry.channel != channel { 55 + continue; 56 + } 57 + if override_entry.deny.iter().any(|p| p == needle) { 58 + has_override_deny = true; 59 + } 60 + if override_entry.allow.iter().any(|p| p == needle) { 61 + has_override_allow = true; 62 + } 63 + } 64 + } 65 + } 66 + 67 + if has_override_deny { 68 + return false; 69 + } 70 + has_override_allow || has_base 71 + } 72 + 73 + /// Hierarchy guard for actions targeting another user. The acting party 74 + /// must outrank the target (strictly greater highest-role position). The 75 + /// owner outranks everyone. 76 + pub fn outranks(&self, target: &ActorAuthz) -> bool { 77 + if self.is_owner { 78 + return true; 79 + } 80 + if target.is_owner { 81 + return false; 82 + } 83 + match (self.highest_position(), target.highest_position()) { 84 + (Some(a), Some(b)) => a > b, 85 + (Some(_), None) => true, 86 + (None, _) => false, 87 + } 88 + } 89 + } 90 + 91 + /// Loads the authz state for `actor_did` in the community identified by 92 + /// `community_uri`. Returns the assembled state, doing per-table queries. 93 + pub async fn load_actor_authz( 94 + db: &DatabaseConnection, 95 + community_uri: &str, 96 + actor_did: &str, 97 + ) -> Result<ActorAuthz, DbErr> { 98 + let community = AtUri::parse(community_uri).ok_or_else(|| { 99 + DbErr::Custom(format!("invalid community AT-URI: {community_uri}")) 100 + })?; 101 + 102 + let is_owner = community.authority == actor_did; 103 + 104 + let member_record = record_data::Entity::find() 105 + .filter(record_data::Column::Did.eq(&community.authority)) 106 + .filter(record_data::Column::Nsid.eq(MEMBER_NSID)) 107 + .filter(sea_orm::prelude::Expr::cust_with_values( 108 + r#""record_data"."data"->>'subject' = $1"#, 109 + vec![sea_orm::Value::from(actor_did.to_string())], 110 + )) 111 + .one(db) 112 + .await?; 113 + 114 + let member = member_record 115 + .and_then(|m| serde_json::from_value::<ColibriMember>(m.data).ok()); 116 + 117 + let role_rkeys: Vec<String> = member.as_ref().map(|m| m.roles.clone()).unwrap_or_default(); 118 + 119 + let roles = if role_rkeys.is_empty() { 120 + Vec::new() 121 + } else { 122 + let role_records = record_data::Entity::find() 123 + .filter( 124 + Condition::all() 125 + .add(record_data::Column::Did.eq(&community.authority)) 126 + .add(record_data::Column::Nsid.eq(ROLE_NSID)) 127 + .add(record_data::Column::Rkey.is_in(role_rkeys.clone())), 128 + ) 129 + .all(db) 130 + .await?; 131 + role_records 132 + .into_iter() 133 + .filter_map(|r| serde_json::from_value::<ColibriRole>(r.data).ok()) 134 + .collect() 135 + }; 136 + 137 + Ok(ActorAuthz { 138 + is_owner, 139 + member, 140 + roles, 141 + }) 142 + } 143 + 144 + #[cfg(test)] 145 + mod tests { 146 + use super::*; 147 + use crate::lib::colibri::{ColibriMember, ColibriRole, ColibriRoleChannelOverride}; 148 + 149 + fn role(name: &str, position: i64, permissions: Vec<Permission>) -> ColibriRole { 150 + ColibriRole { 151 + record_type: None, 152 + name: name.to_string(), 153 + color: None, 154 + permissions: permissions.into_iter().map(|p| p.as_str().to_string()).collect(), 155 + position, 156 + hoisted: None, 157 + mentionable: None, 158 + channel_overrides: vec![], 159 + } 160 + } 161 + 162 + fn role_with_override( 163 + name: &str, 164 + position: i64, 165 + permissions: Vec<Permission>, 166 + channel: &str, 167 + allow: Vec<Permission>, 168 + deny: Vec<Permission>, 169 + ) -> ColibriRole { 170 + let mut r = role(name, position, permissions); 171 + r.channel_overrides.push(ColibriRoleChannelOverride { 172 + channel: channel.to_string(), 173 + allow: allow.into_iter().map(|p| p.as_str().to_string()).collect(), 174 + deny: deny.into_iter().map(|p| p.as_str().to_string()).collect(), 175 + }); 176 + r 177 + } 178 + 179 + fn member(subject: &str, roles: Vec<&str>) -> ColibriMember { 180 + ColibriMember { 181 + record_type: None, 182 + subject: subject.to_string(), 183 + roles: roles.into_iter().map(String::from).collect(), 184 + joined_at: String::from("2026-05-13T00:00:00Z"), 185 + nickname: None, 186 + from_membership: None, 187 + } 188 + } 189 + 190 + #[test] 191 + fn owner_has_every_permission() { 192 + let authz = ActorAuthz { 193 + is_owner: true, 194 + member: None, 195 + roles: vec![], 196 + }; 197 + assert!(authz.has(Permission::MemberBan, None)); 198 + assert!(authz.has(Permission::MessageDelete, Some("chan-a"))); 199 + assert_eq!(authz.highest_position(), Some(i64::MAX)); 200 + } 201 + 202 + #[test] 203 + fn non_member_has_no_permissions() { 204 + let authz = ActorAuthz { 205 + is_owner: false, 206 + member: None, 207 + roles: vec![], 208 + }; 209 + assert!(!authz.has(Permission::MemberBan, None)); 210 + assert!(authz.highest_position().is_none()); 211 + } 212 + 213 + #[test] 214 + fn role_permission_grants_action() { 215 + let authz = ActorAuthz { 216 + is_owner: false, 217 + member: Some(member("did:plc:alice", vec!["mod"])), 218 + roles: vec![role("Moderator", 10, vec![Permission::MemberBan])], 219 + }; 220 + assert!(authz.has(Permission::MemberBan, None)); 221 + assert!(!authz.has(Permission::CommunityDelete, None)); 222 + } 223 + 224 + #[test] 225 + fn channel_override_deny_beats_base_permission() { 226 + let authz = ActorAuthz { 227 + is_owner: false, 228 + member: Some(member("did:plc:alice", vec!["mod"])), 229 + roles: vec![role_with_override( 230 + "Moderator", 231 + 10, 232 + vec![Permission::MessageDelete], 233 + "chan-a", 234 + vec![], 235 + vec![Permission::MessageDelete], 236 + )], 237 + }; 238 + assert!(authz.has(Permission::MessageDelete, None)); 239 + assert!(authz.has(Permission::MessageDelete, Some("chan-b"))); 240 + assert!(!authz.has(Permission::MessageDelete, Some("chan-a"))); 241 + } 242 + 243 + #[test] 244 + fn channel_override_allow_grants_without_base() { 245 + let authz = ActorAuthz { 246 + is_owner: false, 247 + member: Some(member("did:plc:alice", vec!["mod"])), 248 + roles: vec![role_with_override( 249 + "Helper", 250 + 5, 251 + vec![], 252 + "chan-a", 253 + vec![Permission::MessageDelete], 254 + vec![], 255 + )], 256 + }; 257 + assert!(authz.has(Permission::MessageDelete, Some("chan-a"))); 258 + assert!(!authz.has(Permission::MessageDelete, Some("chan-b"))); 259 + assert!(!authz.has(Permission::MessageDelete, None)); 260 + } 261 + 262 + #[test] 263 + fn hierarchy_owner_outranks_everyone() { 264 + let owner = ActorAuthz { 265 + is_owner: true, 266 + member: None, 267 + roles: vec![], 268 + }; 269 + let mod_user = ActorAuthz { 270 + is_owner: false, 271 + member: Some(member("did:plc:alice", vec!["mod"])), 272 + roles: vec![role("Moderator", 10, vec![])], 273 + }; 274 + assert!(owner.outranks(&mod_user)); 275 + assert!(!mod_user.outranks(&owner)); 276 + } 277 + 278 + #[test] 279 + fn hierarchy_requires_strictly_higher_position() { 280 + let high = ActorAuthz { 281 + is_owner: false, 282 + member: Some(member("did:plc:a", vec!["r1"])), 283 + roles: vec![role("High", 20, vec![])], 284 + }; 285 + let mid = ActorAuthz { 286 + is_owner: false, 287 + member: Some(member("did:plc:b", vec!["r2"])), 288 + roles: vec![role("Mid", 10, vec![])], 289 + }; 290 + let other_mid = ActorAuthz { 291 + is_owner: false, 292 + member: Some(member("did:plc:c", vec!["r3"])), 293 + roles: vec![role("Other Mid", 10, vec![])], 294 + }; 295 + assert!(high.outranks(&mid)); 296 + assert!(!mid.outranks(&other_mid)); 297 + assert!(!mid.outranks(&high)); 298 + } 299 + }
+31
src/lib/events.rs
··· 117 117 } 118 118 119 119 #[derive(Serialize, Deserialize, Debug)] 120 + pub struct NotificationEventMessage { 121 + pub text: String, 122 + #[serde(default)] 123 + pub facets: Vec<Value>, 124 + #[serde(rename = "createdAt")] 125 + pub created_at: String, 126 + #[serde(skip_serializing_if = "Option::is_none")] 127 + pub parent: Option<String>, 128 + #[serde(default)] 129 + pub attachments: Vec<Value>, 130 + #[serde(default, skip_serializing_if = "Option::is_none")] 131 + pub edited: Option<bool>, 132 + } 133 + 134 + #[derive(Serialize, Deserialize, Debug)] 135 + pub struct NotificationEventData { 136 + pub id: i64, 137 + pub kind: String, 138 + #[serde(rename = "messageUri")] 139 + pub message_uri: String, 140 + #[serde(rename = "authorDid")] 141 + pub author_did: String, 142 + #[serde(rename = "channelRkey")] 143 + pub channel_rkey: String, 144 + #[serde(rename = "indexedAt")] 145 + pub indexed_at: String, 146 + pub message: NotificationEventMessage, 147 + } 148 + 149 + #[derive(Serialize, Deserialize, Debug)] 120 150 #[serde(untagged)] 121 151 pub enum ColibriServerEventData { 122 152 Community(CommunityEventData), ··· 127 157 Reaction(ReactionEventData), 128 158 User(UserEventData), 129 159 Typing(TypingEventData), 160 + Notification(NotificationEventData), 130 161 } 131 162 132 163 #[derive(Serialize, Deserialize, Debug)]
+136 -62
src/lib/list_atproto_records.rs
··· 1 1 use sea_orm::{ 2 - ColumnTrait, Condition, DatabaseConnection, DbErr, EntityTrait, QueryFilter, QuerySelect, 2 + ColumnTrait, Condition, DatabaseConnection, DbErr, EntityTrait, QueryFilter, QueryOrder, 3 + QuerySelect, 3 4 }; 4 5 5 6 use crate::models::record_data::{self, Entity as RecordData, Model}; 6 7 8 + const DEFAULT_LIMIT: u64 = 100; 9 + 10 + pub struct PagedRecords<T> { 11 + pub records: Vec<T>, 12 + pub cursor: Option<String>, 13 + } 14 + 15 + /// Lists records for a given (did, nsid) with rkey-based cursor pagination. 16 + /// 17 + /// The cursor is the rkey of the last record returned. Default order is 18 + /// descending by rkey (newest TID-encoded records first); when `reverse` is 19 + /// true, ascending order is used. The returned cursor is only set when the 20 + /// page is full, matching `com.atproto.repo.listRecords` semantics. 7 21 pub async fn list_atproto_records<T: for<'de> serde::Deserialize<'de>>( 8 22 did: String, 9 23 nsid: String, 10 24 limit: Option<u64>, 11 - _cursor: Option<&str>, // TODO: Cursor support 25 + cursor: Option<&str>, 12 26 reverse: Option<bool>, 13 27 db: &DatabaseConnection, 14 - ) -> Result<Vec<T>, DbErr> { 15 - let default_limit = 100; 16 - let records = RecordData::find() 17 - .filter( 18 - Condition::all() 19 - .add(record_data::Column::Did.eq(&did)) 20 - .add(record_data::Column::Nsid.eq(&nsid)), 21 - ) 22 - .limit(limit.unwrap_or(default_limit).to_owned()); 28 + ) -> Result<PagedRecords<T>, DbErr> { 29 + let effective_limit = limit.unwrap_or(DEFAULT_LIMIT); 30 + let ascending = reverse.is_some_and(|v| v); 31 + 32 + let mut condition = Condition::all() 33 + .add(record_data::Column::Did.eq(&did)) 34 + .add(record_data::Column::Nsid.eq(&nsid)); 35 + 36 + if let Some(c) = cursor { 37 + condition = if ascending { 38 + condition.add(record_data::Column::Rkey.gt(c)) 39 + } else { 40 + condition.add(record_data::Column::Rkey.lt(c)) 41 + }; 42 + } 43 + 44 + let query = RecordData::find().filter(condition).limit(effective_limit); 45 + 46 + let result: Vec<Model> = if ascending { 47 + query 48 + .order_by_asc(record_data::Column::Rkey) 49 + .all(db) 50 + .await? 51 + } else { 52 + query 53 + .order_by_desc(record_data::Column::Rkey) 54 + .all(db) 55 + .await? 56 + }; 23 57 24 - let result: Vec<Model> = if reverse.is_some_and(|v| v) { 25 - records.order_by_id_asc().all(db).await? 58 + let next_cursor = if (result.len() as u64) == effective_limit { 59 + result.last().map(|r| r.rkey.clone()) 26 60 } else { 27 - records.order_by_id_desc().all(db).await? 61 + None 28 62 }; 29 63 30 - let mut json_records: Vec<T> = result 31 - .iter() 32 - .map(|r| serde_json::from_value::<T>(r.to_owned().data).unwrap()) 64 + let records: Vec<T> = result 65 + .into_iter() 66 + .map(|r| serde_json::from_value::<T>(r.data).unwrap()) 33 67 .collect(); 34 68 35 - if reverse.is_some_and(|v| v) { 36 - json_records.reverse(); 37 - } 38 - 39 - Ok(json_records) 69 + Ok(PagedRecords { 70 + records, 71 + cursor: next_cursor, 72 + }) 40 73 } 41 74 42 75 #[cfg(test)] ··· 49 82 #[derive(Deserialize)] 50 83 struct SampleRecord { 51 84 text: String, 85 + } 86 + 87 + fn model(id: i64, rkey: &str, text: &str) -> crate::models::record_data::Model { 88 + crate::models::record_data::Model { 89 + id, 90 + did: String::from("did:plc:abc"), 91 + nsid: String::from("social.colibri.message"), 92 + rkey: String::from(rkey), 93 + data: serde_json::json!({ "text": text }), 94 + } 52 95 } 53 96 54 97 #[tokio::test] 55 - async fn returns_records_in_default_order() { 98 + async fn returns_records_in_descending_rkey_order_by_default() { 56 99 let db = MockDatabase::new(DatabaseBackend::Postgres) 57 - .append_query_results([vec![ 58 - crate::models::record_data::Model { 59 - id: 2, 60 - did: String::from("did:plc:abc"), 61 - nsid: String::from("social.colibri.message"), 62 - rkey: String::from("r2"), 63 - data: serde_json::json!({ "text": "second" }), 64 - }, 65 - crate::models::record_data::Model { 66 - id: 1, 67 - did: String::from("did:plc:abc"), 68 - nsid: String::from("social.colibri.message"), 69 - rkey: String::from("r1"), 70 - data: serde_json::json!({ "text": "first" }), 71 - }, 72 - ]]) 100 + .append_query_results([vec![model(2, "r2", "second"), model(1, "r1", "first")]]) 73 101 .into_connection(); 74 102 75 - let records = list_atproto_records::<SampleRecord>( 103 + let result = list_atproto_records::<SampleRecord>( 76 104 String::from("did:plc:abc"), 77 105 String::from("social.colibri.message"), 78 106 Some(10), ··· 83 111 .await 84 112 .unwrap(); 85 113 86 - assert_eq!(records[0].text, "second"); 87 - assert_eq!(records[1].text, "first"); 114 + assert_eq!(result.records[0].text, "second"); 115 + assert_eq!(result.records[1].text, "first"); 88 116 } 89 117 90 118 #[tokio::test] 91 - async fn reverses_records_when_reverse_flag_is_true() { 119 + async fn returns_records_in_ascending_rkey_order_when_reverse_is_true() { 92 120 let db = MockDatabase::new(DatabaseBackend::Postgres) 93 - .append_query_results([vec![ 94 - crate::models::record_data::Model { 95 - id: 1, 96 - did: String::from("did:plc:abc"), 97 - nsid: String::from("social.colibri.message"), 98 - rkey: String::from("r1"), 99 - data: serde_json::json!({ "text": "first" }), 100 - }, 101 - crate::models::record_data::Model { 102 - id: 2, 103 - did: String::from("did:plc:abc"), 104 - nsid: String::from("social.colibri.message"), 105 - rkey: String::from("r2"), 106 - data: serde_json::json!({ "text": "second" }), 107 - }, 108 - ]]) 121 + .append_query_results([vec![model(1, "r1", "first"), model(2, "r2", "second")]]) 109 122 .into_connection(); 110 123 111 - let records = list_atproto_records::<SampleRecord>( 124 + let result = list_atproto_records::<SampleRecord>( 112 125 String::from("did:plc:abc"), 113 126 String::from("social.colibri.message"), 114 127 Some(10), ··· 119 132 .await 120 133 .unwrap(); 121 134 122 - assert_eq!(records[0].text, "second"); 123 - assert_eq!(records[1].text, "first"); 135 + assert_eq!(result.records[0].text, "first"); 136 + assert_eq!(result.records[1].text, "second"); 137 + } 138 + 139 + #[tokio::test] 140 + async fn returns_cursor_when_page_is_full() { 141 + let db = MockDatabase::new(DatabaseBackend::Postgres) 142 + .append_query_results([vec![model(2, "r2", "second"), model(1, "r1", "first")]]) 143 + .into_connection(); 144 + 145 + let result = list_atproto_records::<SampleRecord>( 146 + String::from("did:plc:abc"), 147 + String::from("social.colibri.message"), 148 + Some(2), 149 + None, 150 + None, 151 + &db, 152 + ) 153 + .await 154 + .unwrap(); 155 + 156 + assert_eq!(result.cursor.as_deref(), Some("r1")); 157 + } 158 + 159 + #[tokio::test] 160 + async fn omits_cursor_when_page_is_not_full() { 161 + let db = MockDatabase::new(DatabaseBackend::Postgres) 162 + .append_query_results([vec![model(1, "r1", "first")]]) 163 + .into_connection(); 164 + 165 + let result = list_atproto_records::<SampleRecord>( 166 + String::from("did:plc:abc"), 167 + String::from("social.colibri.message"), 168 + Some(10), 169 + None, 170 + None, 171 + &db, 172 + ) 173 + .await 174 + .unwrap(); 175 + 176 + assert!(result.cursor.is_none()); 177 + } 178 + 179 + #[tokio::test] 180 + async fn returns_no_cursor_when_no_records_match() { 181 + let db = MockDatabase::new(DatabaseBackend::Postgres) 182 + .append_query_results([Vec::<crate::models::record_data::Model>::new()]) 183 + .into_connection(); 184 + 185 + let result = list_atproto_records::<SampleRecord>( 186 + String::from("did:plc:abc"), 187 + String::from("social.colibri.message"), 188 + Some(10), 189 + Some("r9"), 190 + None, 191 + &db, 192 + ) 193 + .await 194 + .unwrap(); 195 + 196 + assert!(result.records.is_empty()); 197 + assert!(result.cursor.is_none()); 124 198 } 125 199 }
+7
src/lib/mod.rs
··· 1 + pub mod at_uri; 1 2 pub mod bsky; 2 3 pub mod colibri; 4 + pub mod community_authz; 3 5 pub mod db; 4 6 pub mod did_document; 5 7 pub mod events; ··· 7 9 pub mod get_state; 8 10 pub mod list_atproto_records; 9 11 pub mod map_tap_event; 12 + pub mod moderation; 13 + pub mod notifications; 14 + pub mod permissions; 15 + pub mod reactions; 10 16 pub mod responses; 11 17 pub mod sentry; 12 18 pub mod service_auth; 13 19 pub mod state; 14 20 pub mod tap; 21 + pub mod time; 15 22 pub mod validate_state;
+199
src/lib/moderation.rs
··· 1 + use std::collections::HashSet; 2 + 3 + use sea_orm::{ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter, QueryOrder}; 4 + 5 + use crate::lib::at_uri::AtUri; 6 + use crate::lib::colibri::{ColibriModeration, ColibriModerationSubject}; 7 + use crate::models::record_data; 8 + 9 + pub const MODERATION_NSID: &str = "social.colibri.moderation"; 10 + 11 + pub const ACTION_BAN: &str = "ban"; 12 + pub const ACTION_UNBAN: &str = "unban"; 13 + pub const ACTION_HIDE_MESSAGE: &str = "hideMessage"; 14 + pub const ACTION_UNHIDE_MESSAGE: &str = "unhideMessage"; 15 + pub const ACTION_KICK: &str = "kick"; 16 + 17 + /// Generates a TID-like rkey (13 base32-sortable chars) based on the current 18 + /// system time. Sufficient for cursor ordering; the AppView is the only writer. 19 + pub fn generate_tid() -> String { 20 + use std::time::{SystemTime, UNIX_EPOCH}; 21 + 22 + const ALPHABET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz"; 23 + let micros = SystemTime::now() 24 + .duration_since(UNIX_EPOCH) 25 + .unwrap_or_default() 26 + .as_micros() as u64; 27 + // Pack microseconds into 13 base32 chars (65 bits, we drop the high bit). 28 + let mut out = [0u8; 13]; 29 + let mut value = micros & ((1u64 << 53) - 1); 30 + for slot in out.iter_mut().rev() { 31 + *slot = ALPHABET[(value & 31) as usize]; 32 + value >>= 5; 33 + } 34 + String::from_utf8(out.to_vec()).expect("ASCII characters") 35 + } 36 + 37 + /// Writes a `social.colibri.moderation` record onto the community repo. 38 + pub async fn write_moderation_record( 39 + db: &DatabaseConnection, 40 + community: &AtUri, 41 + record: &ColibriModeration, 42 + ) -> Result<record_data::Model, DbErr> { 43 + let rkey = generate_tid(); 44 + let data = serde_json::to_value(record).map_err(|e| DbErr::Custom(e.to_string()))?; 45 + 46 + let active = record_data::ActiveModel { 47 + did: sea_orm::ActiveValue::Set(community.authority.clone()), 48 + nsid: sea_orm::ActiveValue::Set(MODERATION_NSID.to_string()), 49 + rkey: sea_orm::ActiveValue::Set(rkey.clone()), 50 + data: sea_orm::ActiveValue::Set(data), 51 + ..Default::default() 52 + }; 53 + 54 + record_data::Entity::insert(active).exec(db).await?; 55 + 56 + let written = record_data::Entity::find() 57 + .filter(record_data::Column::Did.eq(&community.authority)) 58 + .filter(record_data::Column::Nsid.eq(MODERATION_NSID)) 59 + .filter(record_data::Column::Rkey.eq(&rkey)) 60 + .one(db) 61 + .await? 62 + .ok_or_else(|| DbErr::Custom(format!("inserted moderation record missing: {rkey}")))?; 63 + 64 + Ok(written) 65 + } 66 + 67 + /// Computes the set of DIDs currently banned in a community by replaying every 68 + /// `ban`/`unban` moderation event in createdAt order. 69 + pub async fn currently_banned_dids( 70 + db: &DatabaseConnection, 71 + community: &AtUri, 72 + ) -> Result<Vec<String>, DbErr> { 73 + let records = record_data::Entity::find() 74 + .filter(record_data::Column::Did.eq(&community.authority)) 75 + .filter(record_data::Column::Nsid.eq(MODERATION_NSID)) 76 + .order_by_asc(record_data::Column::Rkey) 77 + .all(db) 78 + .await?; 79 + 80 + let mut banned: HashSet<String> = HashSet::new(); 81 + for record in records { 82 + let Ok(mod_record) = serde_json::from_value::<ColibriModeration>(record.data) else { 83 + continue; 84 + }; 85 + let Some(did) = mod_record.subject.did else { 86 + continue; 87 + }; 88 + match mod_record.action.as_str() { 89 + ACTION_BAN => { 90 + banned.insert(did); 91 + } 92 + ACTION_UNBAN => { 93 + banned.remove(&did); 94 + } 95 + _ => {} 96 + } 97 + } 98 + let mut result: Vec<String> = banned.into_iter().collect(); 99 + result.sort(); 100 + Ok(result) 101 + } 102 + 103 + /// Returns true if the given user is currently banned in the community. 104 + pub async fn is_user_banned( 105 + db: &DatabaseConnection, 106 + community: &AtUri, 107 + did: &str, 108 + ) -> Result<bool, DbErr> { 109 + let banned = currently_banned_dids(db, community).await?; 110 + Ok(banned.iter().any(|b| b == did)) 111 + } 112 + 113 + /// Convenience constructor for building a moderation record payload. 114 + pub fn moderation_record( 115 + action: &str, 116 + subject: ColibriModerationSubject, 117 + created_by: String, 118 + created_at: String, 119 + reason: Option<String>, 120 + ) -> ColibriModeration { 121 + ColibriModeration { 122 + record_type: Some(MODERATION_NSID.to_string()), 123 + action: action.to_string(), 124 + subject, 125 + reason, 126 + created_by, 127 + created_at, 128 + } 129 + } 130 + 131 + /// Computes the latest moderation action targeting a given (subject) DID. Used 132 + /// for tests and finer-grained checks beyond the boolean `is_user_banned`. 133 + #[cfg(test)] 134 + pub fn latest_action_for_did(records: &[ColibriModeration], did: &str) -> Option<String> { 135 + records 136 + .iter() 137 + .filter(|r| { 138 + r.subject 139 + .did 140 + .as_ref() 141 + .map(|d| d == did) 142 + .unwrap_or(false) 143 + }) 144 + .map(|r| r.action.clone()) 145 + .next_back() 146 + } 147 + 148 + #[cfg(test)] 149 + mod tests { 150 + use super::*; 151 + 152 + fn record(action: &str, did: &str) -> ColibriModeration { 153 + ColibriModeration { 154 + record_type: Some(MODERATION_NSID.to_string()), 155 + action: action.to_string(), 156 + subject: ColibriModerationSubject { 157 + did: Some(did.to_string()), 158 + uri: None, 159 + }, 160 + reason: None, 161 + created_by: String::from("did:plc:owner"), 162 + created_at: String::from("2026-05-13T00:00:00Z"), 163 + } 164 + } 165 + 166 + #[test] 167 + fn ban_then_unban_results_in_not_banned() { 168 + let log = vec![record("ban", "did:plc:alice"), record("unban", "did:plc:alice")]; 169 + assert_eq!( 170 + latest_action_for_did(&log, "did:plc:alice"), 171 + Some(String::from("unban")) 172 + ); 173 + } 174 + 175 + #[test] 176 + fn moderation_record_helper_sets_type() { 177 + let rec = moderation_record( 178 + ACTION_BAN, 179 + ColibriModerationSubject { 180 + did: Some(String::from("did:plc:alice")), 181 + uri: None, 182 + }, 183 + String::from("did:plc:owner"), 184 + String::from("2026-05-13T00:00:00Z"), 185 + Some(String::from("spam")), 186 + ); 187 + assert_eq!(rec.action, "ban"); 188 + assert_eq!(rec.created_by, "did:plc:owner"); 189 + assert_eq!(rec.record_type.as_deref(), Some(MODERATION_NSID)); 190 + assert_eq!(rec.reason.as_deref(), Some("spam")); 191 + } 192 + 193 + #[test] 194 + fn generate_tid_yields_13_char_lowercase_ascii() { 195 + let t = generate_tid(); 196 + assert_eq!(t.len(), 13); 197 + assert!(t.chars().all(|c| c.is_ascii_alphanumeric())); 198 + } 199 + }
+420
src/lib/notifications.rs
··· 1 + use std::collections::{HashMap, HashSet}; 2 + 3 + use sea_orm::{ 4 + ActiveValue, ColumnTrait, Condition, DatabaseConnection, DbErr, EntityTrait, PaginatorTrait, 5 + QueryFilter, QueryOrder, QuerySelect, sea_query, 6 + }; 7 + use serde::{Deserialize, Serialize}; 8 + use serde_json::Value; 9 + 10 + use crate::lib::at_uri::AtUri; 11 + use crate::lib::colibri::ColibriMessage; 12 + use crate::lib::time::current_iso8601_utc; 13 + use crate::models::{notifications, record_data}; 14 + 15 + pub const KIND_MENTION: &str = "mention"; 16 + pub const KIND_REPLY: &str = "reply"; 17 + pub const FACET_MENTION_TYPE: &str = "social.colibri.richtext.facet#mention"; 18 + 19 + /// Bundle carried over the WS-broadcast channel so each subscriber can render 20 + /// a notification without having to fetch the message body separately. 21 + #[derive(Debug, Clone)] 22 + pub struct IndexedNotification { 23 + pub row: notifications::Model, 24 + pub message: NotificationMessage, 25 + } 26 + 27 + /// Inline message body rendered alongside a notification so clients can show 28 + /// the contents without a follow-up fetch. Mirrors the subset of `ColibriMessage` 29 + /// useful for notification previews. 30 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 31 + pub struct NotificationMessage { 32 + pub text: String, 33 + #[serde(default)] 34 + pub facets: Vec<Value>, 35 + #[serde(rename = "createdAt")] 36 + pub created_at: String, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub parent: Option<String>, 39 + #[serde(default)] 40 + pub attachments: Vec<Value>, 41 + #[serde(default, skip_serializing_if = "Option::is_none")] 42 + pub edited: Option<bool>, 43 + } 44 + 45 + impl NotificationMessage { 46 + pub fn from_colibri_message(message: ColibriMessage) -> Self { 47 + Self { 48 + text: message.text, 49 + facets: message.facets.unwrap_or_default(), 50 + created_at: message.created_at, 51 + parent: message.parent, 52 + attachments: message.attachments.unwrap_or_default(), 53 + edited: message.edited, 54 + } 55 + } 56 + } 57 + 58 + /// One notification awaiting delivery, in the shape we send to clients. 59 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 60 + pub struct NotificationView { 61 + pub id: i64, 62 + #[serde(rename = "recipientDid")] 63 + pub recipient_did: String, 64 + pub kind: String, 65 + #[serde(rename = "messageUri")] 66 + pub message_uri: String, 67 + #[serde(rename = "authorDid")] 68 + pub author_did: String, 69 + #[serde(rename = "channelRkey")] 70 + pub channel_rkey: String, 71 + #[serde(rename = "indexedAt")] 72 + pub indexed_at: String, 73 + #[serde(rename = "seenAt", skip_serializing_if = "Option::is_none")] 74 + pub seen_at: Option<String>, 75 + /// Inline message body. `None` if the underlying message has been deleted 76 + /// from the AppView cache. 77 + #[serde(skip_serializing_if = "Option::is_none")] 78 + pub message: Option<NotificationMessage>, 79 + } 80 + 81 + impl NotificationView { 82 + pub fn from_row(row: notifications::Model, message: Option<NotificationMessage>) -> Self { 83 + Self { 84 + id: row.id, 85 + recipient_did: row.recipient_did, 86 + kind: row.kind, 87 + message_uri: row.message_uri, 88 + author_did: row.author_did, 89 + channel_rkey: row.channel_rkey, 90 + indexed_at: row.indexed_at, 91 + seen_at: row.seen_at, 92 + message, 93 + } 94 + } 95 + } 96 + 97 + /// Extracts every mentioned DID from the facets of a message. Returns DIDs in 98 + /// the order they first appear, deduplicated. 99 + pub fn extract_mentioned_dids(facets: &[Value]) -> Vec<String> { 100 + let mut out: Vec<String> = Vec::new(); 101 + let mut seen: HashSet<String> = HashSet::new(); 102 + for facet in facets { 103 + let Some(features) = facet.get("features").and_then(|f| f.as_array()) else { 104 + continue; 105 + }; 106 + for feature in features { 107 + let Some(kind) = feature.get("$type").and_then(|t| t.as_str()) else { 108 + continue; 109 + }; 110 + if kind != FACET_MENTION_TYPE { 111 + continue; 112 + } 113 + let Some(did) = feature.get("did").and_then(|d| d.as_str()) else { 114 + continue; 115 + }; 116 + if seen.insert(did.to_string()) { 117 + out.push(did.to_string()); 118 + } 119 + } 120 + } 121 + out 122 + } 123 + 124 + /// Looks up the author DID of a parent message inside the same channel. 125 + /// Returns `None` if the parent is not in the cache. 126 + pub async fn fetch_parent_author( 127 + db: &DatabaseConnection, 128 + channel_rkey: &str, 129 + parent_rkey: &str, 130 + ) -> Result<Option<String>, DbErr> { 131 + let record = record_data::Entity::find() 132 + .filter(record_data::Column::Nsid.eq("social.colibri.message")) 133 + .filter(record_data::Column::Rkey.eq(parent_rkey)) 134 + .filter(sea_orm::prelude::Expr::cust_with_values( 135 + r#""record_data"."data"->>'channel' = $1"#, 136 + vec![sea_orm::Value::from(channel_rkey.to_string())], 137 + )) 138 + .one(db) 139 + .await?; 140 + 141 + Ok(record.map(|r| r.did)) 142 + } 143 + 144 + /// Inserts notification rows for a freshly indexed message. Mentions of the 145 + /// author themself are skipped, as are duplicate insertions (the table has a 146 + /// unique index on `(recipient_did, message_uri, kind)`). 147 + pub async fn index_message_notifications( 148 + db: &DatabaseConnection, 149 + author_did: &str, 150 + message_uri: &str, 151 + message: &ColibriMessage, 152 + ) -> Result<Vec<IndexedNotification>, DbErr> { 153 + let mut recipients: Vec<(String, &'static str)> = Vec::new(); 154 + let mut seen: HashSet<(String, &'static str)> = HashSet::new(); 155 + 156 + if let Some(facets) = message.facets.as_ref() { 157 + for did in extract_mentioned_dids(facets) { 158 + if did == author_did { 159 + continue; 160 + } 161 + let key = (did.clone(), KIND_MENTION); 162 + if seen.insert(key.clone()) { 163 + recipients.push(key); 164 + } 165 + } 166 + } 167 + 168 + if let Some(parent_rkey) = message.parent.as_ref() 169 + && let Some(parent_author) = fetch_parent_author(db, &message.channel, parent_rkey).await? 170 + && parent_author != author_did 171 + { 172 + let key = (parent_author, KIND_REPLY); 173 + if seen.insert(key.clone()) { 174 + recipients.push(key); 175 + } 176 + } 177 + 178 + if recipients.is_empty() { 179 + return Ok(Vec::new()); 180 + } 181 + 182 + let now = current_iso8601_utc(); 183 + let mut inserted = Vec::new(); 184 + for (recipient_did, kind) in recipients { 185 + let row = notifications::ActiveModel { 186 + recipient_did: ActiveValue::Set(recipient_did.clone()), 187 + kind: ActiveValue::Set(kind.to_string()), 188 + message_uri: ActiveValue::Set(message_uri.to_string()), 189 + author_did: ActiveValue::Set(author_did.to_string()), 190 + channel_rkey: ActiveValue::Set(message.channel.clone()), 191 + indexed_at: ActiveValue::Set(now.clone()), 192 + seen_at: ActiveValue::Set(None), 193 + ..Default::default() 194 + }; 195 + 196 + let result = notifications::Entity::insert(row) 197 + .on_conflict( 198 + sea_query::OnConflict::columns([ 199 + notifications::Column::RecipientDid, 200 + notifications::Column::MessageUri, 201 + notifications::Column::Kind, 202 + ]) 203 + .do_nothing() 204 + .to_owned(), 205 + ) 206 + .exec(db) 207 + .await; 208 + 209 + // Conflict (= already indexed) is a no-op; surface other failures. 210 + match result { 211 + Ok(_) => {} 212 + Err(DbErr::RecordNotInserted) => continue, 213 + Err(e) => return Err(e), 214 + } 215 + 216 + if let Some(row) = notifications::Entity::find() 217 + .filter(notifications::Column::RecipientDid.eq(&recipient_did)) 218 + .filter(notifications::Column::MessageUri.eq(message_uri)) 219 + .filter(notifications::Column::Kind.eq(kind)) 220 + .one(db) 221 + .await? 222 + { 223 + inserted.push(IndexedNotification { 224 + row, 225 + message: NotificationMessage::from_colibri_message(message.clone()), 226 + }); 227 + } 228 + } 229 + Ok(inserted) 230 + } 231 + 232 + /// Lists notifications for a recipient, newest first. The optional cursor is 233 + /// the stringified id of the oldest notification on the previous page; rows 234 + /// with `id < cursor` are returned. 235 + pub async fn list_notifications( 236 + db: &DatabaseConnection, 237 + recipient_did: &str, 238 + limit: u64, 239 + cursor: Option<&str>, 240 + ) -> Result<Vec<notifications::Model>, DbErr> { 241 + let mut condition = Condition::all().add(notifications::Column::RecipientDid.eq(recipient_did)); 242 + 243 + if let Some(c) = cursor { 244 + let parsed: i64 = c 245 + .parse() 246 + .map_err(|_| DbErr::Custom(format!("invalid cursor: {c}")))?; 247 + condition = condition.add(notifications::Column::Id.lt(parsed)); 248 + } 249 + 250 + notifications::Entity::find() 251 + .filter(condition) 252 + .order_by_desc(notifications::Column::Id) 253 + .limit(limit) 254 + .all(db) 255 + .await 256 + } 257 + 258 + /// Fetches the cached `social.colibri.message` records referenced by the given 259 + /// AT-URIs and returns them keyed by URI. URIs that don't resolve to a 260 + /// matching record (deleted, never indexed, malformed URI) are simply absent 261 + /// from the result. 262 + pub async fn hydrate_messages( 263 + db: &DatabaseConnection, 264 + message_uris: &[String], 265 + ) -> Result<HashMap<String, NotificationMessage>, DbErr> { 266 + if message_uris.is_empty() { 267 + return Ok(HashMap::new()); 268 + } 269 + 270 + let mut by_uri: HashMap<String, (String, String)> = HashMap::new(); 271 + let mut condition = Condition::any(); 272 + for uri in message_uris { 273 + let Some(parsed) = AtUri::parse(uri) else { 274 + continue; 275 + }; 276 + if parsed.collection != "social.colibri.message" { 277 + continue; 278 + } 279 + let did = parsed.authority.clone(); 280 + let rkey = parsed.rkey.clone(); 281 + by_uri.insert(uri.clone(), (did.clone(), rkey.clone())); 282 + condition = condition.add( 283 + Condition::all() 284 + .add(record_data::Column::Did.eq(did)) 285 + .add(record_data::Column::Rkey.eq(rkey)), 286 + ); 287 + } 288 + 289 + if by_uri.is_empty() { 290 + return Ok(HashMap::new()); 291 + } 292 + 293 + let records = record_data::Entity::find() 294 + .filter(record_data::Column::Nsid.eq("social.colibri.message")) 295 + .filter(condition) 296 + .all(db) 297 + .await?; 298 + 299 + let mut by_key: HashMap<(String, String), NotificationMessage> = HashMap::new(); 300 + for record in records { 301 + if let Ok(message) = serde_json::from_value::<ColibriMessage>(record.data) { 302 + by_key.insert( 303 + (record.did, record.rkey), 304 + NotificationMessage::from_colibri_message(message), 305 + ); 306 + } 307 + } 308 + 309 + let mut out = HashMap::new(); 310 + for (uri, key) in by_uri { 311 + if let Some(msg) = by_key.remove(&key) { 312 + out.insert(uri, msg); 313 + } 314 + } 315 + Ok(out) 316 + } 317 + 318 + /// Counts unseen notifications for a recipient. 319 + pub async fn unseen_count(db: &DatabaseConnection, recipient_did: &str) -> Result<u64, DbErr> { 320 + notifications::Entity::find() 321 + .filter(notifications::Column::RecipientDid.eq(recipient_did)) 322 + .filter(notifications::Column::SeenAt.is_null()) 323 + .count(db) 324 + .await 325 + } 326 + 327 + /// Marks every unseen notification for the recipient with `indexed_at <= now` 328 + /// as seen at `seen_at`. Returns the number of rows updated. 329 + pub async fn mark_seen_up_to( 330 + db: &DatabaseConnection, 331 + recipient_did: &str, 332 + seen_at: &str, 333 + cutoff_indexed_at: &str, 334 + ) -> Result<u64, DbErr> { 335 + let res = notifications::Entity::update_many() 336 + .col_expr( 337 + notifications::Column::SeenAt, 338 + sea_query::Expr::value(seen_at), 339 + ) 340 + .filter(notifications::Column::RecipientDid.eq(recipient_did)) 341 + .filter(notifications::Column::SeenAt.is_null()) 342 + .filter(notifications::Column::IndexedAt.lte(cutoff_indexed_at)) 343 + .exec(db) 344 + .await?; 345 + Ok(res.rows_affected) 346 + } 347 + 348 + #[cfg(test)] 349 + mod tests { 350 + use super::*; 351 + use serde_json::json; 352 + 353 + fn message_with_facets(facets: Value, parent: Option<&str>) -> ColibriMessage { 354 + ColibriMessage { 355 + r#type: "social.colibri.message".to_string(), 356 + text: "hello".to_string(), 357 + facets: Some(facets.as_array().cloned().unwrap_or_default()), 358 + created_at: "2026-05-14T00:00:00Z".to_string(), 359 + channel: "chan-a".to_string(), 360 + edited: None, 361 + parent: parent.map(String::from), 362 + attachments: None, 363 + } 364 + } 365 + 366 + #[test] 367 + fn extract_mentioned_dids_pulls_did_from_mention_features() { 368 + let facets = json!([ 369 + { 370 + "index": {"byteStart": 0, "byteEnd": 5}, 371 + "features": [ 372 + {"$type": "social.colibri.richtext.facet#mention", "did": "did:plc:alice"} 373 + ] 374 + }, 375 + { 376 + "index": {"byteStart": 10, "byteEnd": 13}, 377 + "features": [ 378 + {"$type": "social.colibri.richtext.facet#bold"} 379 + ] 380 + }, 381 + { 382 + "index": {"byteStart": 20, "byteEnd": 25}, 383 + "features": [ 384 + {"$type": "social.colibri.richtext.facet#mention", "did": "did:plc:bob"}, 385 + {"$type": "social.colibri.richtext.facet#mention", "did": "did:plc:alice"} 386 + ] 387 + } 388 + ]); 389 + let dids = extract_mentioned_dids(facets.as_array().unwrap()); 390 + assert_eq!( 391 + dids, 392 + vec![String::from("did:plc:alice"), String::from("did:plc:bob")] 393 + ); 394 + } 395 + 396 + #[test] 397 + fn extract_mentioned_dids_ignores_non_mention_features_and_missing_dids() { 398 + let facets = json!([ 399 + { 400 + "features": [ 401 + {"$type": "social.colibri.richtext.facet#link", "uri": "https://example"} 402 + ] 403 + }, 404 + { 405 + "features": [ 406 + {"$type": "social.colibri.richtext.facet#mention"} 407 + ] 408 + } 409 + ]); 410 + assert!(extract_mentioned_dids(facets.as_array().unwrap()).is_empty()); 411 + } 412 + 413 + #[test] 414 + fn message_with_facets_is_constructible() { 415 + // Just guarantees that ColibriMessage stays compatible with this helper. 416 + let msg = message_with_facets(json!([]), Some("parent")); 417 + assert_eq!(msg.channel, "chan-a"); 418 + assert_eq!(msg.parent.as_deref(), Some("parent")); 419 + } 420 + }
+91
src/lib/permissions.rs
··· 1 + /// Enumerated permissions for the Colibri community role system. 2 + /// 3 + /// Permissions are stored as strings on `social.colibri.role` records, so this 4 + /// enum exists primarily to give the Rust code a single source of truth for 5 + /// the namespaced permission identifiers. 6 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 7 + pub enum Permission { 8 + CommunityUpdate, 9 + CommunityDelete, 10 + CategoryCreate, 11 + CategoryUpdate, 12 + CategoryDelete, 13 + ChannelCreate, 14 + ChannelUpdate, 15 + ChannelDelete, 16 + MessageDelete, 17 + MemberKick, 18 + MemberBan, 19 + MemberUnban, 20 + RoleManage, 21 + InvitationCreate, 22 + InvitationDelete, 23 + ModerationViewLog, 24 + ApprovalManage, 25 + } 26 + 27 + impl Permission { 28 + pub fn as_str(&self) -> &'static str { 29 + match self { 30 + Permission::CommunityUpdate => "community.update", 31 + Permission::CommunityDelete => "community.delete", 32 + Permission::CategoryCreate => "category.create", 33 + Permission::CategoryUpdate => "category.update", 34 + Permission::CategoryDelete => "category.delete", 35 + Permission::ChannelCreate => "channel.create", 36 + Permission::ChannelUpdate => "channel.update", 37 + Permission::ChannelDelete => "channel.delete", 38 + Permission::MessageDelete => "message.delete", 39 + Permission::MemberKick => "member.kick", 40 + Permission::MemberBan => "member.ban", 41 + Permission::MemberUnban => "member.unban", 42 + Permission::RoleManage => "role.manage", 43 + Permission::InvitationCreate => "invitation.create", 44 + Permission::InvitationDelete => "invitation.delete", 45 + Permission::ModerationViewLog => "moderation.viewLog", 46 + Permission::ApprovalManage => "approval.manage", 47 + } 48 + } 49 + } 50 + 51 + impl std::fmt::Display for Permission { 52 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 53 + write!(f, "{}", self.as_str()) 54 + } 55 + } 56 + 57 + #[cfg(test)] 58 + mod tests { 59 + use super::*; 60 + 61 + #[test] 62 + fn permission_strings_are_namespaced_and_unique() { 63 + use std::collections::HashSet; 64 + 65 + let all = [ 66 + Permission::CommunityUpdate, 67 + Permission::CommunityDelete, 68 + Permission::CategoryCreate, 69 + Permission::CategoryUpdate, 70 + Permission::CategoryDelete, 71 + Permission::ChannelCreate, 72 + Permission::ChannelUpdate, 73 + Permission::ChannelDelete, 74 + Permission::MessageDelete, 75 + Permission::MemberKick, 76 + Permission::MemberBan, 77 + Permission::MemberUnban, 78 + Permission::RoleManage, 79 + Permission::InvitationCreate, 80 + Permission::InvitationDelete, 81 + Permission::ModerationViewLog, 82 + Permission::ApprovalManage, 83 + ]; 84 + 85 + let strings: HashSet<&str> = all.iter().map(|p| p.as_str()).collect(); 86 + assert_eq!(strings.len(), all.len()); 87 + for s in &strings { 88 + assert!(s.contains('.'), "{s} should be namespaced"); 89 + } 90 + } 91 + }
+171
src/lib/reactions.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use sea_orm::prelude::Expr; 4 + use sea_orm::{ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter}; 5 + use serde::{Deserialize, Serialize}; 6 + 7 + use crate::models::record_data; 8 + 9 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 10 + pub struct ReactionSummary { 11 + pub emoji: String, 12 + pub count: u32, 13 + #[serde(rename = "reactorDIDs")] 14 + pub reactor_dids: Vec<String>, 15 + } 16 + 17 + #[derive(Deserialize)] 18 + struct StoredReaction { 19 + emoji: String, 20 + #[serde(rename = "targetMessage")] 21 + target_message: String, 22 + } 23 + 24 + /// Loads every reaction targeting the provided message rkeys and returns them 25 + /// keyed by the target message rkey, with reactions of the same emoji folded 26 + /// into a single summary entry. 27 + pub async fn group_reactions_for_messages( 28 + db: &DatabaseConnection, 29 + message_rkeys: &[String], 30 + ) -> Result<HashMap<String, Vec<ReactionSummary>>, DbErr> { 31 + if message_rkeys.is_empty() { 32 + return Ok(HashMap::new()); 33 + } 34 + 35 + let values: Vec<sea_orm::Value> = message_rkeys 36 + .iter() 37 + .map(|k| sea_orm::Value::from(k.clone())) 38 + .collect(); 39 + 40 + let placeholders: Vec<String> = (1..=values.len()).map(|i| format!("${i}")).collect(); 41 + let in_clause = format!( 42 + r#""record_data"."data"->>'targetMessage' IN ({})"#, 43 + placeholders.join(", ") 44 + ); 45 + 46 + let records = record_data::Entity::find() 47 + .filter(record_data::Column::Nsid.eq("social.colibri.reaction")) 48 + .filter(Expr::cust_with_values(in_clause, values)) 49 + .all(db) 50 + .await?; 51 + 52 + Ok(group_reaction_records(records)) 53 + } 54 + 55 + /// Loads every reaction targeting a single message rkey. 56 + pub async fn list_reactions_for_message( 57 + db: &DatabaseConnection, 58 + message_rkey: &str, 59 + ) -> Result<Vec<ReactionSummary>, DbErr> { 60 + let records = record_data::Entity::find() 61 + .filter(record_data::Column::Nsid.eq("social.colibri.reaction")) 62 + .filter(Expr::cust_with_values( 63 + r#""record_data"."data"->>'targetMessage' = $1"#, 64 + vec![sea_orm::Value::from(message_rkey.to_string())], 65 + )) 66 + .all(db) 67 + .await?; 68 + 69 + let mut grouped = group_reaction_records(records); 70 + Ok(grouped.remove(message_rkey).unwrap_or_default()) 71 + } 72 + 73 + fn group_reaction_records( 74 + records: Vec<record_data::Model>, 75 + ) -> HashMap<String, Vec<ReactionSummary>> { 76 + // (target_message_rkey, emoji) -> Vec<reactor_did> 77 + let mut buckets: HashMap<(String, String), Vec<String>> = HashMap::new(); 78 + 79 + for record in records { 80 + let Ok(stored) = serde_json::from_value::<StoredReaction>(record.data) else { 81 + continue; 82 + }; 83 + buckets 84 + .entry((stored.target_message, stored.emoji)) 85 + .or_default() 86 + .push(record.did); 87 + } 88 + 89 + let mut grouped: HashMap<String, Vec<ReactionSummary>> = HashMap::new(); 90 + for ((target, emoji), mut dids) in buckets { 91 + dids.sort(); 92 + dids.dedup(); 93 + grouped.entry(target).or_default().push(ReactionSummary { 94 + emoji, 95 + count: dids.len() as u32, 96 + reactor_dids: dids, 97 + }); 98 + } 99 + 100 + grouped 101 + } 102 + 103 + #[cfg(test)] 104 + mod tests { 105 + use super::*; 106 + 107 + fn reaction(did: &str, rkey: &str, target: &str, emoji: &str) -> record_data::Model { 108 + record_data::Model { 109 + id: 0, 110 + did: did.to_string(), 111 + nsid: String::from("social.colibri.reaction"), 112 + rkey: rkey.to_string(), 113 + data: serde_json::json!({ 114 + "emoji": emoji, 115 + "targetMessage": target, 116 + }), 117 + } 118 + } 119 + 120 + #[test] 121 + fn groups_reactions_by_message_and_emoji() { 122 + let records = vec![ 123 + reaction("did:plc:alice", "r1", "msg-1", "🦜"), 124 + reaction("did:plc:bob", "r2", "msg-1", "🦜"), 125 + reaction("did:plc:alice", "r3", "msg-1", "🔥"), 126 + reaction("did:plc:carol", "r4", "msg-2", "🔥"), 127 + ]; 128 + let grouped = group_reaction_records(records); 129 + 130 + let msg1 = grouped.get("msg-1").unwrap(); 131 + let parrot = msg1.iter().find(|r| r.emoji == "🦜").unwrap(); 132 + assert_eq!(parrot.count, 2); 133 + assert_eq!( 134 + parrot.reactor_dids, 135 + vec![String::from("did:plc:alice"), String::from("did:plc:bob")] 136 + ); 137 + 138 + let fire = msg1.iter().find(|r| r.emoji == "🔥").unwrap(); 139 + assert_eq!(fire.count, 1); 140 + 141 + let msg2 = grouped.get("msg-2").unwrap(); 142 + assert_eq!(msg2.len(), 1); 143 + assert_eq!(msg2[0].emoji, "🔥"); 144 + assert_eq!(msg2[0].reactor_dids, vec![String::from("did:plc:carol")]); 145 + } 146 + 147 + #[test] 148 + fn deduplicates_reactor_dids_for_same_emoji() { 149 + let records = vec![ 150 + reaction("did:plc:alice", "r1", "msg-1", "🦜"), 151 + reaction("did:plc:alice", "r2", "msg-1", "🦜"), 152 + ]; 153 + let grouped = group_reaction_records(records); 154 + let parrot = &grouped.get("msg-1").unwrap()[0]; 155 + assert_eq!(parrot.count, 1); 156 + assert_eq!(parrot.reactor_dids, vec![String::from("did:plc:alice")]); 157 + } 158 + 159 + #[test] 160 + fn skips_records_with_invalid_payload() { 161 + let records = vec![record_data::Model { 162 + id: 0, 163 + did: String::from("did:plc:alice"), 164 + nsid: String::from("social.colibri.reaction"), 165 + rkey: String::from("r1"), 166 + data: serde_json::json!({ "irrelevant": true }), 167 + }]; 168 + let grouped = group_reaction_records(records); 169 + assert!(grouped.is_empty()); 170 + } 171 + }
+66
src/lib/time.rs
··· 1 + /// Returns the current UTC timestamp in ISO 8601 form 2 + /// (`YYYY-MM-DDTHH:MM:SS.mmmZ`). Used wherever we mint AppView-side records. 3 + pub fn current_iso8601_utc() -> String { 4 + use std::time::{SystemTime, UNIX_EPOCH}; 5 + let now = SystemTime::now() 6 + .duration_since(UNIX_EPOCH) 7 + .unwrap_or_default(); 8 + iso8601_from_epoch(now.as_secs(), now.subsec_millis()) 9 + } 10 + 11 + /// Formats a (seconds_since_epoch, millis) pair as ISO 8601 UTC. 12 + pub fn iso8601_from_epoch(secs: u64, millis: u32) -> String { 13 + let (year, month, day, hour, minute, second) = epoch_to_ymdhms(secs); 14 + format!( 15 + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", 16 + year, month, day, hour, minute, second, millis 17 + ) 18 + } 19 + 20 + /// Howard Hinnant's "civil_from_days" algorithm — converts seconds since the 21 + /// Unix epoch to a (year, month, day, hour, minute, second) tuple in UTC. 22 + fn epoch_to_ymdhms(secs: u64) -> (u32, u32, u32, u32, u32, u32) { 23 + let days = (secs / 86_400) as i64; 24 + let seconds_of_day = (secs % 86_400) as u32; 25 + let hour = seconds_of_day / 3600; 26 + let minute = (seconds_of_day % 3600) / 60; 27 + let second = seconds_of_day % 60; 28 + 29 + let z = days + 719_468; 30 + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; 31 + let doe = (z - era * 146_097) as u64; 32 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; 33 + let y = yoe as i64 + era * 400; 34 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); 35 + let mp = (5 * doy + 2) / 153; 36 + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; 37 + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; 38 + let y = if m <= 2 { y + 1 } else { y }; 39 + 40 + (y as u32, m, d, hour, minute, second) 41 + } 42 + 43 + #[cfg(test)] 44 + mod tests { 45 + use super::*; 46 + 47 + #[test] 48 + fn formats_epoch_zero_as_unix_epoch_string() { 49 + assert_eq!( 50 + iso8601_from_epoch(0, 0), 51 + "1970-01-01T00:00:00.000Z" 52 + ); 53 + } 54 + 55 + #[test] 56 + fn formats_known_datetime() { 57 + // 2026-05-13T12:34:56.789Z 58 + // = (2026-1970) years -> days(2026-01-01) - days(1970-01-01) + 132 days 59 + // Easier: compute via known offset. 60 + // 1747139696 secs is 2025-05-13T12:34:56Z; +365.25*86400 ~ 2026-05-13T... 61 + // We just verify shape rather than hand-computing. 62 + let s = iso8601_from_epoch(1_778_675_696, 789); 63 + assert!(s.ends_with(".789Z")); 64 + assert_eq!(s.len(), "1970-01-01T00:00:00.000Z".len()); 65 + } 66 + }
+55 -1
src/main.rs
··· 2 2 3 3 extern crate pretty_env_logger; 4 4 5 + use crate::lib::colibri::ColibriMessage; 5 6 use crate::lib::db::init_db; 7 + use crate::lib::notifications::{IndexedNotification, index_message_notifications}; 6 8 use crate::lib::sentry::init_sentry; 7 9 use crate::lib::tap::{self, TapMessage, TapMessageRecord, TapStream, ack_tap_msg}; 8 10 use futures::{SinkExt, StreamExt}; ··· 30 32 #[allow(dead_code)] 31 33 channel: mpsc::Sender<String>, 32 34 broadcast: broadcast::Sender<TapMessageRecord>, 35 + notifications: broadcast::Sender<IndexedNotification>, 33 36 } 34 37 35 38 #[derive(Clone)] ··· 44 47 mut rx_outbound: mpsc::Receiver<String>, 45 48 tx_inbound: broadcast::Sender<TapMessageRecord>, 46 49 mut to_tap: mpsc::Sender<String>, 50 + notifications_tx: broadcast::Sender<IndexedNotification>, 47 51 ) { 48 52 loop { 49 53 tokio::select! { ··· 69 73 return; 70 74 } 71 75 72 - let _ = tx_inbound.send(tap_msg.record.unwrap()); 76 + let record = tap_msg.record.unwrap(); 77 + 78 + // Centralized notification indexing for newly authored 79 + // Colibri messages. Persists rows + broadcasts to live WS 80 + // subscribers, so each notification is written exactly 81 + // once regardless of how many clients are connected. 82 + if record.collection == "social.colibri.message" 83 + && record.action != "delete" 84 + && let Some(payload) = record.record.clone() 85 + && let Ok(message) = serde_json::from_value::<ColibriMessage>(payload) 86 + { 87 + let message_uri = format!( 88 + "at://{}/{}/{}", 89 + record.did, record.collection, record.rkey 90 + ); 91 + match index_message_notifications(&db, &record.did, &message_uri, &message) 92 + .await 93 + { 94 + Ok(rows) => { 95 + for row in rows { 96 + let _ = notifications_tx.send(row); 97 + } 98 + } 99 + Err(e) => { 100 + log::error!("notification indexing failed for {message_uri}: {e}"); 101 + } 102 + } 103 + } 104 + 105 + let _ = tx_inbound.send(record); 73 106 ack_tap_msg(&db, &mut to_tap, text.to_string()).await; 74 107 } 75 108 } ··· 122 155 123 156 let c2c_broadcast_channel = broadcast::channel::<EventNotification>(128); 124 157 158 + let (notification_broadcast, _) = broadcast::channel::<IndexedNotification>(128); 159 + 125 160 let tap_bridge = CommsBridge { 126 161 channel: to_tap.clone(), 127 162 broadcast: from_tap_broadcast.clone(), 163 + notifications: notification_broadcast.clone(), 128 164 }; 129 165 130 166 let db = match init_db().await { ··· 149 185 from_tap_channel, 150 186 from_tap_broadcast, 151 187 to_tap, 188 + notification_broadcast, 152 189 )); 153 190 154 191 rocket::build() ··· 165 202 xrpc::social::colibri::actor::get_data, 166 203 xrpc::social::colibri::actor::list_communities, 167 204 xrpc::social::colibri::actor::set_state, 205 + xrpc::social::colibri::channel::get_read_cursor, 206 + xrpc::social::colibri::channel::list_messages, 207 + xrpc::social::colibri::channel::list_reactions, 208 + xrpc::social::colibri::community::block_message, 209 + xrpc::social::colibri::community::block_user, 210 + xrpc::social::colibri::community::create_invitation, 211 + xrpc::social::colibri::community::delete_invitation, 212 + xrpc::social::colibri::community::get_invitation, 213 + xrpc::social::colibri::community::list_blocked_users, 214 + xrpc::social::colibri::community::list_categories, 215 + xrpc::social::colibri::community::list_channels, 216 + xrpc::social::colibri::community::list_invitations, 217 + xrpc::social::colibri::community::list_members, 218 + xrpc::social::colibri::community::unblock_user, 219 + xrpc::social::colibri::notification::get_unread_count, 220 + xrpc::social::colibri::notification::list_notifications, 221 + xrpc::social::colibri::notification::update_seen, 168 222 xrpc::social::colibri::sync::subscribe_events, 169 223 ], 170 224 )
+19
src/models/community_invitations.rs
··· 1 + use sea_orm::entity::prelude::*; 2 + use serde::Serialize; 3 + 4 + #[sea_orm::model] 5 + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] 6 + #[sea_orm(table_name = "community_invitations")] 7 + pub struct Model { 8 + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] 9 + pub code: String, 10 + #[sea_orm(column_type = "Text")] 11 + pub community_uri: String, 12 + #[sea_orm(column_type = "Text")] 13 + pub created_by: String, 14 + pub active: bool, 15 + #[sea_orm(column_type = "Text")] 16 + pub created_at: String, 17 + } 18 + 19 + impl ActiveModelBehavior for ActiveModel {}
+2
src/models/mod.rs
··· 3 3 pub mod prelude; 4 4 5 5 pub mod collection_cursors; 6 + pub mod community_invitations; 6 7 pub mod firehose_cursors; 7 8 pub mod list_repos_cursors; 9 + pub mod notifications; 8 10 pub mod outbox_buffers; 9 11 pub mod record_data; 10 12 pub mod repo_records;
+26
src/models/notifications.rs
··· 1 + use sea_orm::entity::prelude::*; 2 + use serde::Serialize; 3 + 4 + #[sea_orm::model] 5 + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] 6 + #[sea_orm(table_name = "notifications")] 7 + pub struct Model { 8 + #[sea_orm(primary_key)] 9 + pub id: i64, 10 + #[sea_orm(column_type = "Text")] 11 + pub recipient_did: String, 12 + #[sea_orm(column_type = "Text")] 13 + pub kind: String, 14 + #[sea_orm(column_type = "Text")] 15 + pub message_uri: String, 16 + #[sea_orm(column_type = "Text")] 17 + pub author_did: String, 18 + #[sea_orm(column_type = "Text")] 19 + pub channel_rkey: String, 20 + #[sea_orm(column_type = "Text")] 21 + pub indexed_at: String, 22 + #[sea_orm(column_type = "Text", nullable)] 23 + pub seen_at: Option<String>, 24 + } 25 + 26 + impl ActiveModelBehavior for ActiveModel {}
+40 -15
src/xrpc/com/atproto/sync/list_records_handler.rs
··· 9 9 10 10 #[derive(Serialize, Debug)] 11 11 pub struct ListRecordsResponse { 12 + #[serde(skip_serializing_if = "Option::is_none")] 12 13 pub cursor: Option<String>, 13 14 pub records: Vec<Value>, 14 15 } ··· 21 22 cursor: Option<&str>, 22 23 reverse: Option<bool>, 23 24 ) -> Result<Json<ListRecordsResponse>, ErrorResponse> { 24 - let records = list_atproto_records::<Value>( 25 + let result = list_atproto_records::<Value>( 25 26 repo.to_string(), 26 27 collection.to_string(), 27 28 limit, ··· 31 32 ) 32 33 .await; 33 34 34 - if records.is_err() { 35 + if result.is_err() { 35 36 return Err(ErrorResponse { 36 37 body: Json(ErrorBody { 37 38 error: String::from("InternalServerError"), ··· 40 41 }); 41 42 } 42 43 43 - let safe_records = records.unwrap(); 44 + let paged = result.unwrap(); 44 45 45 46 Ok(Json(ListRecordsResponse { 46 - cursor: Some(String::from(cursor.unwrap_or(""))), 47 - records: safe_records, 47 + cursor: paged.cursor, 48 + records: paged.records, 48 49 })) 49 50 } 50 51 51 52 #[get("/xrpc/com.atproto.sync.listRecords?<repo>&<collection>&<limit>&<cursor>&<reverse>")] 52 - /// Returns a cached record from the database. 53 + /// Returns a paginated list of cached records from the database. 53 54 pub async fn list_records( 54 55 repo: &str, 55 56 collection: &str, ··· 68 69 use sea_orm::{DatabaseBackend, MockDatabase}; 69 70 use serde_json::json; 70 71 72 + fn model(id: i64, rkey: &str, text: &str) -> crate::models::record_data::Model { 73 + crate::models::record_data::Model { 74 + id, 75 + did: String::from("did:plc:abc"), 76 + nsid: String::from("social.colibri.message"), 77 + rkey: String::from(rkey), 78 + data: json!({ "text": text }), 79 + } 80 + } 81 + 71 82 #[tokio::test] 72 83 async fn list_records_returns_records() { 73 84 let db = MockDatabase::new(DatabaseBackend::Postgres) 74 - .append_query_results([vec![crate::models::record_data::Model { 75 - id: 1, 76 - did: String::from("did:plc:abc"), 77 - nsid: String::from("social.colibri.message"), 78 - rkey: String::from("r1"), 79 - data: json!({"text":"hello"}), 80 - }]]) 85 + .append_query_results([vec![model(1, "r1", "hello")]]) 81 86 .into_connection(); 82 87 83 88 let result = list_records_with_db( ··· 85 90 "did:plc:abc", 86 91 "social.colibri.message", 87 92 Some(10), 88 - Some("cursor"), 93 + None, 89 94 None, 90 95 ) 91 96 .await 92 97 .unwrap(); 93 98 94 - assert_eq!(result.cursor.as_deref(), Some("cursor")); 95 99 assert_eq!(result.records.len(), 1); 96 100 assert_eq!(result.records[0]["text"], "hello"); 101 + assert!(result.cursor.is_none()); 102 + } 103 + 104 + #[tokio::test] 105 + async fn list_records_returns_cursor_when_page_is_full() { 106 + let db = MockDatabase::new(DatabaseBackend::Postgres) 107 + .append_query_results([vec![model(2, "r2", "second"), model(1, "r1", "first")]]) 108 + .into_connection(); 109 + 110 + let result = list_records_with_db( 111 + &db, 112 + "did:plc:abc", 113 + "social.colibri.message", 114 + Some(2), 115 + None, 116 + None, 117 + ) 118 + .await 119 + .unwrap(); 120 + 121 + assert_eq!(result.cursor.as_deref(), Some("r1")); 97 122 } 98 123 99 124 #[tokio::test]
+2 -2
src/xrpc/social/colibri/actor/get_data_handler.rs
··· 14 14 use crate::xrpc::com::atproto::identity::resolve_identity; 15 15 use crate::xrpc::social::colibri::actor::set_state_handler::UserState; 16 16 17 - #[derive(Serialize, Deserialize)] 17 + #[derive(Serialize, Deserialize, Debug)] 18 18 pub struct ActorStatus { 19 19 pub text: String, 20 20 #[serde(skip_serializing_if = "Option::is_none")] 21 21 pub emoji: Option<String>, 22 22 } 23 23 24 - #[derive(Serialize, Deserialize)] 24 + #[derive(Serialize, Deserialize, Debug)] 25 25 pub struct ActorData { 26 26 #[serde(rename = "displayName")] 27 27 pub display_name: String,
+232
src/xrpc/social/colibri/channel/get_read_cursor_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, get}; 4 + use sea_orm::prelude::Expr; 5 + use sea_orm::{ 6 + ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter, QueryOrder, QuerySelect, 7 + }; 8 + use serde::Serialize; 9 + 10 + use crate::lib::at_uri::AtUri; 11 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 12 + use crate::lib::service_auth::{self, ServiceAuthError}; 13 + use crate::models::record_data; 14 + 15 + #[derive(Serialize, Debug)] 16 + pub struct ReadCursor { 17 + pub uri: String, 18 + pub cursor: String, 19 + pub channel: String, 20 + } 21 + 22 + #[derive(serde::Deserialize)] 23 + struct StoredCursor { 24 + cursor: String, 25 + } 26 + 27 + pub async fn fetch_latest_read_cursor( 28 + db: &DatabaseConnection, 29 + did: &str, 30 + channel_uri: &str, 31 + ) -> Result<Option<record_data::Model>, DbErr> { 32 + record_data::Entity::find() 33 + .filter(record_data::Column::Did.eq(did)) 34 + .filter(record_data::Column::Nsid.eq("social.colibri.channel.read")) 35 + .filter(Expr::cust_with_values( 36 + r#""record_data"."data"->>'channel' = $1"#, 37 + vec![sea_orm::Value::from(channel_uri.to_string())], 38 + )) 39 + .order_by_desc(record_data::Column::Rkey) 40 + .limit(1) 41 + .one(db) 42 + .await 43 + } 44 + 45 + type VerifyAuthFn = 46 + fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>; 47 + type FetchCursorFn = fn( 48 + DatabaseConnection, 49 + String, 50 + String, 51 + ) -> BoxFuture<'static, Result<Option<record_data::Model>, DbErr>>; 52 + 53 + async fn get_read_cursor_with( 54 + channel_uri: String, 55 + auth: String, 56 + db: DatabaseConnection, 57 + verify_auth_fn: VerifyAuthFn, 58 + fetch_cursor_fn: FetchCursorFn, 59 + ) -> Result<Json<ReadCursor>, ErrorResponse> { 60 + if AtUri::parse(&channel_uri).is_none() { 61 + return Err(ErrorResponse { 62 + body: Json(ErrorBody { 63 + error: String::from("InvalidRequest"), 64 + message: String::from("Invalid channel AT-URI."), 65 + }), 66 + }); 67 + } 68 + 69 + let did = verify_auth_fn(auth, String::from("social.colibri.channel.getReadCursor")) 70 + .await 71 + .map_err(|e| ErrorResponse { 72 + body: Json(ErrorBody { 73 + error: String::from("AuthError"), 74 + message: e.to_string(), 75 + }), 76 + })?; 77 + 78 + let maybe_record = fetch_cursor_fn(db, did, channel_uri.clone()).await?; 79 + 80 + let record = maybe_record.ok_or_else(|| ErrorResponse { 81 + body: Json(ErrorBody { 82 + error: String::from("NotFound"), 83 + message: String::from("No read cursor exists for this channel."), 84 + }), 85 + })?; 86 + 87 + let stored: StoredCursor = serde_json::from_value(record.data.clone()).map_err(|e| { 88 + ErrorResponse { 89 + body: Json(ErrorBody { 90 + error: String::from("InternalServerError"), 91 + message: e.to_string(), 92 + }), 93 + } 94 + })?; 95 + 96 + Ok(Json(ReadCursor { 97 + uri: format!("at://{}/{}/{}", record.did, record.nsid, record.rkey), 98 + cursor: stored.cursor, 99 + channel: channel_uri, 100 + })) 101 + } 102 + 103 + fn verify_auth_boxed( 104 + auth: String, 105 + lxm: String, 106 + ) -> BoxFuture<'static, Result<String, ServiceAuthError>> { 107 + Box::pin(async move { service_auth::verify_service_auth(&auth, &lxm).await }) 108 + } 109 + 110 + fn fetch_cursor_boxed( 111 + db: DatabaseConnection, 112 + did: String, 113 + channel_uri: String, 114 + ) -> BoxFuture<'static, Result<Option<record_data::Model>, DbErr>> { 115 + Box::pin(async move { fetch_latest_read_cursor(&db, &did, &channel_uri).await }) 116 + } 117 + 118 + #[get("/xrpc/social.colibri.channel.getReadCursor?<channel>&<auth>")] 119 + /// Returns the latest read cursor for the authenticated user and channel. 120 + pub async fn get_read_cursor( 121 + channel: &str, 122 + auth: &str, 123 + db: &State<DatabaseConnection>, 124 + ) -> Result<Json<ReadCursor>, ErrorResponse> { 125 + get_read_cursor_with( 126 + channel.to_string(), 127 + auth.to_string(), 128 + db.inner().clone(), 129 + verify_auth_boxed, 130 + fetch_cursor_boxed, 131 + ) 132 + .await 133 + } 134 + 135 + #[cfg(test)] 136 + mod tests { 137 + use super::*; 138 + use rocket::tokio; 139 + use sea_orm::{DatabaseBackend, MockDatabase}; 140 + 141 + fn cursor_record(rkey: &str, cursor: &str) -> record_data::Model { 142 + record_data::Model { 143 + id: 1, 144 + did: String::from("did:plc:me"), 145 + nsid: String::from("social.colibri.channel.read"), 146 + rkey: rkey.to_string(), 147 + data: serde_json::json!({ 148 + "channel": "at://did:plc:owner/social.colibri.channel/chan-a", 149 + "cursor": cursor, 150 + }), 151 + } 152 + } 153 + 154 + #[tokio::test] 155 + async fn returns_cursor_from_stored_record() { 156 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 157 + let result = get_read_cursor_with( 158 + String::from("at://did:plc:owner/social.colibri.channel/chan-a"), 159 + String::from("token"), 160 + db, 161 + |_, _| Box::pin(async { Ok(String::from("did:plc:me")) }), 162 + |_, _, _| { 163 + Box::pin(async { 164 + Ok(Some(cursor_record( 165 + "rkey-latest", 166 + "2026-05-13T12:34:56.000Z", 167 + ))) 168 + }) 169 + }, 170 + ) 171 + .await 172 + .unwrap(); 173 + 174 + assert_eq!(result.channel, "at://did:plc:owner/social.colibri.channel/chan-a"); 175 + assert_eq!( 176 + result.uri, 177 + "at://did:plc:me/social.colibri.channel.read/rkey-latest" 178 + ); 179 + assert_eq!(result.cursor, "2026-05-13T12:34:56.000Z"); 180 + } 181 + 182 + #[tokio::test] 183 + async fn rejects_invalid_channel_uri() { 184 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 185 + let result = get_read_cursor_with( 186 + String::from("not-a-uri"), 187 + String::from("token"), 188 + db, 189 + |_, _| Box::pin(async { panic!("should not authenticate when uri is invalid") }), 190 + |_, _, _| Box::pin(async { panic!("should not fetch when uri is invalid") }), 191 + ) 192 + .await; 193 + 194 + assert!(result.is_err()); 195 + assert_eq!( 196 + result.err().unwrap().body.into_inner().error, 197 + "InvalidRequest" 198 + ); 199 + } 200 + 201 + #[tokio::test] 202 + async fn returns_auth_error_when_token_is_invalid() { 203 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 204 + let result = get_read_cursor_with( 205 + String::from("at://did:plc:owner/social.colibri.channel/chan-a"), 206 + String::from("token"), 207 + db, 208 + |_, _| Box::pin(async { Err(ServiceAuthError::InvalidSignature) }), 209 + |_, _, _| Box::pin(async { panic!("should not fetch when auth fails") }), 210 + ) 211 + .await; 212 + 213 + assert!(result.is_err()); 214 + assert_eq!(result.err().unwrap().body.into_inner().error, "AuthError"); 215 + } 216 + 217 + #[tokio::test] 218 + async fn returns_not_found_when_no_cursor_exists() { 219 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 220 + let result = get_read_cursor_with( 221 + String::from("at://did:plc:owner/social.colibri.channel/chan-a"), 222 + String::from("token"), 223 + db, 224 + |_, _| Box::pin(async { Ok(String::from("did:plc:me")) }), 225 + |_, _, _| Box::pin(async { Ok(None) }), 226 + ) 227 + .await; 228 + 229 + assert!(result.is_err()); 230 + assert_eq!(result.err().unwrap().body.into_inner().error, "NotFound"); 231 + } 232 + }
+418
src/xrpc/social/colibri/channel/list_messages_handler.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use futures::future::BoxFuture; 4 + use rocket::serde::json::Json; 5 + use rocket::{State, get}; 6 + use sea_orm::prelude::Expr; 7 + use sea_orm::{ 8 + ColumnTrait, Condition, DatabaseConnection, DbErr, EntityTrait, QueryFilter, QueryOrder, 9 + QuerySelect, 10 + }; 11 + use serde::{Deserialize, Serialize}; 12 + use serde_json::Value; 13 + 14 + use crate::lib::at_uri::AtUri; 15 + use crate::lib::reactions::{ReactionSummary, group_reactions_for_messages}; 16 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 17 + use crate::models::record_data; 18 + 19 + const MESSAGE_NSID: &str = "social.colibri.message"; 20 + const CHANNEL_NSID: &str = "social.colibri.channel"; 21 + const COMMUNITY_NSID: &str = "social.colibri.community"; 22 + const DEFAULT_LIMIT: u64 = 100; 23 + 24 + #[derive(Serialize, Deserialize, Debug)] 25 + pub struct Message { 26 + pub uri: String, 27 + pub text: String, 28 + #[serde(default)] 29 + pub facets: Vec<Value>, 30 + pub channel: String, 31 + pub community: String, 32 + pub author: String, 33 + #[serde(skip_serializing_if = "Option::is_none")] 34 + pub parent: Option<String>, 35 + #[serde(default)] 36 + pub attachments: Vec<Value>, 37 + pub reactions: Vec<ReactionSummary>, 38 + } 39 + 40 + #[derive(Serialize, Debug)] 41 + pub struct MessageList { 42 + #[serde(skip_serializing_if = "Option::is_none")] 43 + pub cursor: Option<String>, 44 + pub messages: Vec<Message>, 45 + } 46 + 47 + #[derive(Deserialize)] 48 + struct StoredMessage { 49 + text: String, 50 + #[serde(default)] 51 + facets: Option<Vec<Value>>, 52 + #[serde(default)] 53 + parent: Option<String>, 54 + #[serde(default)] 55 + attachments: Option<Vec<Value>>, 56 + } 57 + 58 + #[derive(Deserialize)] 59 + struct StoredChannel { 60 + community: String, 61 + } 62 + 63 + /// Fetches the channel record so the response can include a fully-qualified 64 + /// community AT-URI. Returns `None` if no matching channel record exists. 65 + pub async fn fetch_channel_record( 66 + db: &DatabaseConnection, 67 + authority: &str, 68 + channel_rkey: &str, 69 + ) -> Result<Option<record_data::Model>, DbErr> { 70 + record_data::Entity::find() 71 + .filter(record_data::Column::Did.eq(authority)) 72 + .filter(record_data::Column::Nsid.eq(CHANNEL_NSID)) 73 + .filter(record_data::Column::Rkey.eq(channel_rkey)) 74 + .one(db) 75 + .await 76 + } 77 + 78 + /// Fetches a page of message records targeting the given channel rkey, 79 + /// ordered by rkey descending. The optional cursor filters out rkeys at or 80 + /// past the cursor, matching the listRecords convention. 81 + pub async fn fetch_message_page( 82 + db: &DatabaseConnection, 83 + channel_rkey: &str, 84 + limit: u64, 85 + cursor: Option<&str>, 86 + ) -> Result<Vec<record_data::Model>, DbErr> { 87 + let mut condition = Condition::all() 88 + .add(record_data::Column::Nsid.eq(MESSAGE_NSID)) 89 + .add(Expr::cust_with_values( 90 + r#""record_data"."data"->>'channel' = $1"#, 91 + vec![sea_orm::Value::from(channel_rkey.to_string())], 92 + )); 93 + if let Some(c) = cursor { 94 + condition = condition.add(record_data::Column::Rkey.lt(c)); 95 + } 96 + 97 + record_data::Entity::find() 98 + .filter(condition) 99 + .order_by_desc(record_data::Column::Rkey) 100 + .limit(limit) 101 + .all(db) 102 + .await 103 + } 104 + 105 + /// Resolves parent message rkeys to fully-qualified AT-URIs. Looks up the 106 + /// authors of each rkey within the same channel. 107 + pub async fn fetch_parent_uris( 108 + db: &DatabaseConnection, 109 + channel_rkey: &str, 110 + parent_rkeys: &[String], 111 + ) -> Result<HashMap<String, String>, DbErr> { 112 + if parent_rkeys.is_empty() { 113 + return Ok(HashMap::new()); 114 + } 115 + let parents = record_data::Entity::find() 116 + .filter(record_data::Column::Nsid.eq(MESSAGE_NSID)) 117 + .filter(record_data::Column::Rkey.is_in(parent_rkeys.to_vec())) 118 + .filter(Expr::cust_with_values( 119 + r#""record_data"."data"->>'channel' = $1"#, 120 + vec![sea_orm::Value::from(channel_rkey.to_string())], 121 + )) 122 + .all(db) 123 + .await?; 124 + 125 + Ok(parents 126 + .into_iter() 127 + .map(|p| { 128 + ( 129 + p.rkey.clone(), 130 + format!("at://{}/{}/{}", p.did, p.nsid, p.rkey), 131 + ) 132 + }) 133 + .collect()) 134 + } 135 + 136 + fn build_message( 137 + record: &record_data::Model, 138 + channel_uri: &str, 139 + community_uri: &str, 140 + parent_uri: Option<String>, 141 + reactions: Vec<ReactionSummary>, 142 + ) -> Option<Message> { 143 + let stored = serde_json::from_value::<StoredMessage>(record.data.clone()).ok()?; 144 + Some(Message { 145 + uri: format!("at://{}/{}/{}", record.did, record.nsid, record.rkey), 146 + text: stored.text, 147 + facets: stored.facets.unwrap_or_default(), 148 + channel: channel_uri.to_string(), 149 + community: community_uri.to_string(), 150 + author: record.did.clone(), 151 + parent: parent_uri, 152 + attachments: stored.attachments.unwrap_or_default(), 153 + reactions, 154 + }) 155 + } 156 + 157 + pub struct MessagePage { 158 + pub records: Vec<record_data::Model>, 159 + pub community_uri: String, 160 + pub parents: HashMap<String, String>, 161 + pub reactions: HashMap<String, Vec<ReactionSummary>>, 162 + } 163 + 164 + pub async fn assemble_message_page( 165 + db: &DatabaseConnection, 166 + channel: &AtUri, 167 + limit: u64, 168 + cursor: Option<&str>, 169 + ) -> Result<MessagePage, DbErr> { 170 + let channel_record = fetch_channel_record(db, &channel.authority, &channel.rkey).await?; 171 + let community_uri = channel_record 172 + .and_then(|r| serde_json::from_value::<StoredChannel>(r.data).ok()) 173 + .map(|c| { 174 + format!( 175 + "at://{}/{}/{}", 176 + channel.authority, COMMUNITY_NSID, c.community 177 + ) 178 + }) 179 + .unwrap_or_default(); 180 + 181 + let records = fetch_message_page(db, &channel.rkey, limit, cursor).await?; 182 + 183 + let message_rkeys: Vec<String> = records.iter().map(|r| r.rkey.clone()).collect(); 184 + let reactions = group_reactions_for_messages(db, &message_rkeys).await?; 185 + 186 + let parent_rkeys: Vec<String> = records 187 + .iter() 188 + .filter_map(|r| { 189 + serde_json::from_value::<StoredMessage>(r.data.clone()) 190 + .ok() 191 + .and_then(|m| m.parent) 192 + }) 193 + .collect(); 194 + let parents = fetch_parent_uris(db, &channel.rkey, &parent_rkeys).await?; 195 + 196 + Ok(MessagePage { 197 + records, 198 + community_uri, 199 + parents, 200 + reactions, 201 + }) 202 + } 203 + 204 + type AssemblePageFn = fn( 205 + DatabaseConnection, 206 + AtUri, 207 + u64, 208 + Option<String>, 209 + ) -> BoxFuture<'static, Result<MessagePage, DbErr>>; 210 + 211 + async fn list_messages_with( 212 + channel_uri: String, 213 + limit: Option<u64>, 214 + cursor: Option<String>, 215 + db: DatabaseConnection, 216 + assemble_fn: AssemblePageFn, 217 + ) -> Result<Json<MessageList>, ErrorResponse> { 218 + let channel = AtUri::parse(&channel_uri).ok_or_else(|| ErrorResponse { 219 + body: Json(ErrorBody { 220 + error: String::from("InvalidRequest"), 221 + message: String::from("Invalid channel AT-URI."), 222 + }), 223 + })?; 224 + 225 + let effective_limit = limit.unwrap_or(DEFAULT_LIMIT); 226 + let page = assemble_fn(db, channel, effective_limit, cursor).await?; 227 + 228 + let next_cursor = if (page.records.len() as u64) == effective_limit { 229 + page.records.last().map(|r| r.rkey.clone()) 230 + } else { 231 + None 232 + }; 233 + 234 + let messages = page 235 + .records 236 + .iter() 237 + .filter_map(|record| { 238 + let stored = serde_json::from_value::<StoredMessage>(record.data.clone()).ok()?; 239 + let parent_uri = stored 240 + .parent 241 + .as_ref() 242 + .and_then(|rkey| page.parents.get(rkey).cloned()); 243 + let reactions = page 244 + .reactions 245 + .get(&record.rkey) 246 + .cloned() 247 + .unwrap_or_default(); 248 + build_message(record, &channel_uri, &page.community_uri, parent_uri, reactions) 249 + }) 250 + .collect(); 251 + 252 + Ok(Json(MessageList { 253 + cursor: next_cursor, 254 + messages, 255 + })) 256 + } 257 + 258 + fn assemble_message_page_boxed( 259 + db: DatabaseConnection, 260 + channel: AtUri, 261 + limit: u64, 262 + cursor: Option<String>, 263 + ) -> BoxFuture<'static, Result<MessagePage, DbErr>> { 264 + Box::pin(async move { assemble_message_page(&db, &channel, limit, cursor.as_deref()).await }) 265 + } 266 + 267 + #[get("/xrpc/social.colibri.channel.listMessages?<channel>&<limit>&<cursor>&<all>")] 268 + /// Returns a paginated list of messages for a channel, newest first. 269 + /// 270 + /// The `all` parameter is accepted to match the spec but is reserved for 271 + /// future filtering (e.g. including blocked messages). 272 + pub async fn list_messages( 273 + channel: &str, 274 + limit: Option<u64>, 275 + cursor: Option<&str>, 276 + all: Option<bool>, 277 + db: &State<DatabaseConnection>, 278 + ) -> Result<Json<MessageList>, ErrorResponse> { 279 + let _ = all; 280 + list_messages_with( 281 + channel.to_string(), 282 + limit, 283 + cursor.map(|c| c.to_string()), 284 + db.inner().clone(), 285 + assemble_message_page_boxed, 286 + ) 287 + .await 288 + } 289 + 290 + #[cfg(test)] 291 + mod tests { 292 + use super::*; 293 + use rocket::tokio; 294 + use sea_orm::{DatabaseBackend, MockDatabase}; 295 + 296 + fn message_record(rkey: &str, author: &str, text: &str, parent: Option<&str>) -> record_data::Model { 297 + let mut data = serde_json::json!({ 298 + "text": text, 299 + "channel": "chan-a", 300 + "createdAt": "2026-05-13T00:00:00Z", 301 + }); 302 + if let Some(p) = parent { 303 + data["parent"] = serde_json::Value::String(p.to_string()); 304 + } 305 + record_data::Model { 306 + id: 0, 307 + did: author.to_string(), 308 + nsid: MESSAGE_NSID.to_string(), 309 + rkey: rkey.to_string(), 310 + data, 311 + } 312 + } 313 + 314 + #[tokio::test] 315 + async fn builds_message_list_from_page() { 316 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 317 + 318 + let result = list_messages_with( 319 + String::from("at://did:plc:owner/social.colibri.channel/chan-a"), 320 + Some(2), 321 + None, 322 + db, 323 + |_, _, _, _| { 324 + Box::pin(async { 325 + Ok(MessagePage { 326 + records: vec![ 327 + message_record("msg-2", "did:plc:bob", "hi", Some("msg-1")), 328 + message_record("msg-1", "did:plc:alice", "hello", None), 329 + ], 330 + community_uri: String::from( 331 + "at://did:plc:owner/social.colibri.community/c1", 332 + ), 333 + parents: HashMap::from([( 334 + String::from("msg-1"), 335 + String::from("at://did:plc:alice/social.colibri.message/msg-1"), 336 + )]), 337 + reactions: HashMap::from([( 338 + String::from("msg-1"), 339 + vec![ReactionSummary { 340 + emoji: String::from("🦜"), 341 + count: 1, 342 + reactor_dids: vec![String::from("did:plc:carol")], 343 + }], 344 + )]), 345 + }) 346 + }) 347 + }, 348 + ) 349 + .await 350 + .unwrap(); 351 + 352 + assert_eq!(result.messages.len(), 2); 353 + assert_eq!(result.messages[0].uri, "at://did:plc:bob/social.colibri.message/msg-2"); 354 + assert_eq!( 355 + result.messages[0].community, 356 + "at://did:plc:owner/social.colibri.community/c1" 357 + ); 358 + assert_eq!( 359 + result.messages[0].channel, 360 + "at://did:plc:owner/social.colibri.channel/chan-a" 361 + ); 362 + assert_eq!( 363 + result.messages[0].parent.as_deref(), 364 + Some("at://did:plc:alice/social.colibri.message/msg-1") 365 + ); 366 + assert!(result.messages[0].reactions.is_empty()); 367 + assert_eq!(result.messages[1].reactions.len(), 1); 368 + assert_eq!(result.messages[1].reactions[0].emoji, "🦜"); 369 + 370 + assert_eq!(result.cursor.as_deref(), Some("msg-1")); 371 + } 372 + 373 + #[tokio::test] 374 + async fn omits_cursor_when_page_is_not_full() { 375 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 376 + let result = list_messages_with( 377 + String::from("at://did:plc:owner/social.colibri.channel/chan-a"), 378 + Some(10), 379 + None, 380 + db, 381 + |_, _, _, _| { 382 + Box::pin(async { 383 + Ok(MessagePage { 384 + records: vec![message_record("msg-1", "did:plc:alice", "hello", None)], 385 + community_uri: String::from( 386 + "at://did:plc:owner/social.colibri.community/c1", 387 + ), 388 + parents: HashMap::new(), 389 + reactions: HashMap::new(), 390 + }) 391 + }) 392 + }, 393 + ) 394 + .await 395 + .unwrap(); 396 + 397 + assert!(result.cursor.is_none()); 398 + } 399 + 400 + #[tokio::test] 401 + async fn rejects_invalid_channel_uri() { 402 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 403 + let result = list_messages_with( 404 + String::from("not-a-uri"), 405 + None, 406 + None, 407 + db, 408 + |_, _, _, _| Box::pin(async { panic!("should not assemble when uri is invalid") }), 409 + ) 410 + .await; 411 + 412 + assert!(result.is_err()); 413 + assert_eq!( 414 + result.err().unwrap().body.into_inner().error, 415 + "InvalidRequest" 416 + ); 417 + } 418 + }
+118
src/xrpc/social/colibri/channel/list_reactions_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, get}; 4 + use sea_orm::{DatabaseConnection, DbErr}; 5 + use serde::Serialize; 6 + 7 + use crate::lib::at_uri::AtUri; 8 + use crate::lib::reactions::{ReactionSummary, list_reactions_for_message}; 9 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 10 + 11 + #[derive(Serialize, Debug)] 12 + pub struct ReactionList { 13 + pub reactions: Vec<ReactionSummary>, 14 + } 15 + 16 + type ListReactionsFn = 17 + fn(DatabaseConnection, String) -> BoxFuture<'static, Result<Vec<ReactionSummary>, DbErr>>; 18 + 19 + async fn list_reactions_with( 20 + message_uri: String, 21 + db: DatabaseConnection, 22 + list_reactions_fn: ListReactionsFn, 23 + ) -> Result<Json<ReactionList>, ErrorResponse> { 24 + let parsed = AtUri::parse(&message_uri).ok_or_else(|| ErrorResponse { 25 + body: Json(ErrorBody { 26 + error: String::from("InvalidRequest"), 27 + message: String::from("Invalid message AT-URI."), 28 + }), 29 + })?; 30 + 31 + let reactions = list_reactions_fn(db, parsed.rkey).await?; 32 + Ok(Json(ReactionList { reactions })) 33 + } 34 + 35 + fn list_reactions_boxed( 36 + db: DatabaseConnection, 37 + message_rkey: String, 38 + ) -> BoxFuture<'static, Result<Vec<ReactionSummary>, DbErr>> { 39 + Box::pin(async move { list_reactions_for_message(&db, &message_rkey).await }) 40 + } 41 + 42 + #[get("/xrpc/social.colibri.channel.listReactions?<message>")] 43 + /// Lists all reactions targeting a specific message, grouped by emoji. 44 + pub async fn list_reactions( 45 + message: &str, 46 + db: &State<DatabaseConnection>, 47 + ) -> Result<Json<ReactionList>, ErrorResponse> { 48 + list_reactions_with(message.to_string(), db.inner().clone(), list_reactions_boxed).await 49 + } 50 + 51 + #[cfg(test)] 52 + mod tests { 53 + use super::*; 54 + use rocket::tokio; 55 + use sea_orm::{DatabaseBackend, MockDatabase}; 56 + 57 + #[tokio::test] 58 + async fn returns_reactions_from_summary_fn() { 59 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 60 + let result = list_reactions_with( 61 + String::from("at://did:plc:author/social.colibri.message/msg-1"), 62 + db, 63 + |_, rkey| { 64 + assert_eq!(rkey, "msg-1"); 65 + Box::pin(async { 66 + Ok(vec![ReactionSummary { 67 + emoji: String::from("🦜"), 68 + count: 2, 69 + reactor_dids: vec![ 70 + String::from("did:plc:alice"), 71 + String::from("did:plc:bob"), 72 + ], 73 + }]) 74 + }) 75 + }, 76 + ) 77 + .await 78 + .unwrap(); 79 + 80 + assert_eq!(result.reactions.len(), 1); 81 + assert_eq!(result.reactions[0].emoji, "🦜"); 82 + assert_eq!(result.reactions[0].count, 2); 83 + } 84 + 85 + #[tokio::test] 86 + async fn rejects_invalid_message_uri() { 87 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 88 + let result = list_reactions_with( 89 + String::from("invalid"), 90 + db, 91 + |_, _| Box::pin(async { panic!("should not call when uri is invalid") }), 92 + ) 93 + .await; 94 + 95 + assert!(result.is_err()); 96 + assert_eq!( 97 + result.err().unwrap().body.into_inner().error, 98 + "InvalidRequest" 99 + ); 100 + } 101 + 102 + #[tokio::test] 103 + async fn propagates_db_error_as_upstream_error() { 104 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 105 + let result = list_reactions_with( 106 + String::from("at://did:plc:author/social.colibri.message/msg-1"), 107 + db, 108 + |_, _| Box::pin(async { Err(DbErr::Custom(String::from("boom"))) }), 109 + ) 110 + .await; 111 + 112 + assert!(result.is_err()); 113 + assert_eq!( 114 + result.err().unwrap().body.into_inner().error, 115 + "UpstreamError" 116 + ); 117 + } 118 + }
+7
src/xrpc/social/colibri/channel/mod.rs
··· 1 + pub mod get_read_cursor_handler; 2 + pub mod list_messages_handler; 3 + pub mod list_reactions_handler; 4 + 5 + pub use get_read_cursor_handler::get_read_cursor; 6 + pub use list_messages_handler::list_messages; 7 + pub use list_reactions_handler::list_reactions;
+238
src/xrpc/social/colibri/community/block_message_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, post}; 4 + use sea_orm::{DatabaseConnection, DbErr}; 5 + use serde::Serialize; 6 + 7 + use crate::lib::at_uri::AtUri; 8 + use crate::lib::colibri::{ColibriModeration, ColibriModerationSubject}; 9 + use crate::lib::community_authz::{self, ActorAuthz}; 10 + use crate::lib::moderation::{self, ACTION_HIDE_MESSAGE}; 11 + use crate::lib::permissions::Permission; 12 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 13 + use crate::lib::service_auth::{self, ServiceAuthError}; 14 + use crate::lib::time::current_iso8601_utc; 15 + use crate::models::record_data; 16 + 17 + #[derive(Serialize, Debug)] 18 + pub struct BlockMessageResponse { 19 + pub message: String, 20 + } 21 + 22 + async fn block_message_with<V, W>( 23 + community_uri: String, 24 + message_uri: String, 25 + auth: String, 26 + db: DatabaseConnection, 27 + verify_auth_fn: V, 28 + load_authz_fn: W, 29 + write_record_fn: impl FnOnce( 30 + DatabaseConnection, 31 + AtUri, 32 + ColibriModeration, 33 + ) -> BoxFuture<'static, Result<record_data::Model, DbErr>>, 34 + ) -> Result<Json<BlockMessageResponse>, ErrorResponse> 35 + where 36 + V: Fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>, 37 + W: Fn(DatabaseConnection, String, String) -> BoxFuture<'static, Result<ActorAuthz, DbErr>>, 38 + { 39 + let community = AtUri::parse(&community_uri).ok_or_else(|| ErrorResponse { 40 + body: Json(ErrorBody { 41 + error: String::from("InvalidRequest"), 42 + message: String::from("Invalid community AT-URI."), 43 + }), 44 + })?; 45 + 46 + if AtUri::parse(&message_uri).is_none() { 47 + return Err(ErrorResponse { 48 + body: Json(ErrorBody { 49 + error: String::from("InvalidRequest"), 50 + message: String::from("Invalid message AT-URI."), 51 + }), 52 + }); 53 + } 54 + 55 + let caller_did = verify_auth_fn( 56 + auth, 57 + String::from("social.colibri.community.blockMessage"), 58 + ) 59 + .await 60 + .map_err(|e| ErrorResponse { 61 + body: Json(ErrorBody { 62 + error: String::from("AuthError"), 63 + message: e.to_string(), 64 + }), 65 + })?; 66 + 67 + let caller_authz = load_authz_fn(db.clone(), community_uri.clone(), caller_did.clone()).await?; 68 + if !caller_authz.has(Permission::MessageDelete, None) { 69 + return Err(ErrorResponse { 70 + body: Json(ErrorBody { 71 + error: String::from("Forbidden"), 72 + message: format!("Missing permission: {}", Permission::MessageDelete), 73 + }), 74 + }); 75 + } 76 + 77 + let record = moderation::moderation_record( 78 + ACTION_HIDE_MESSAGE, 79 + ColibriModerationSubject { 80 + did: None, 81 + uri: Some(message_uri.clone()), 82 + }, 83 + caller_did, 84 + current_iso8601_utc(), 85 + None, 86 + ); 87 + 88 + write_record_fn(db, community, record).await?; 89 + 90 + Ok(Json(BlockMessageResponse { 91 + message: message_uri, 92 + })) 93 + } 94 + 95 + fn verify_auth_boxed( 96 + auth: String, 97 + lxm: String, 98 + ) -> BoxFuture<'static, Result<String, ServiceAuthError>> { 99 + Box::pin(async move { service_auth::verify_service_auth(&auth, &lxm).await }) 100 + } 101 + 102 + fn load_authz_boxed( 103 + db: DatabaseConnection, 104 + community_uri: String, 105 + did: String, 106 + ) -> BoxFuture<'static, Result<ActorAuthz, DbErr>> { 107 + Box::pin(async move { community_authz::load_actor_authz(&db, &community_uri, &did).await }) 108 + } 109 + 110 + fn write_moderation_boxed( 111 + db: DatabaseConnection, 112 + community: AtUri, 113 + record: ColibriModeration, 114 + ) -> BoxFuture<'static, Result<record_data::Model, DbErr>> { 115 + Box::pin(async move { moderation::write_moderation_record(&db, &community, &record).await }) 116 + } 117 + 118 + #[post("/xrpc/social.colibri.community.blockMessage?<community>&<message>&<auth>")] 119 + /// Hides a message in a community by writing a `hideMessage` moderation record. 120 + pub async fn block_message( 121 + community: &str, 122 + message: &str, 123 + auth: &str, 124 + db: &State<DatabaseConnection>, 125 + ) -> Result<Json<BlockMessageResponse>, ErrorResponse> { 126 + block_message_with( 127 + community.to_string(), 128 + message.to_string(), 129 + auth.to_string(), 130 + db.inner().clone(), 131 + verify_auth_boxed, 132 + load_authz_boxed, 133 + write_moderation_boxed, 134 + ) 135 + .await 136 + } 137 + 138 + #[cfg(test)] 139 + mod tests { 140 + use super::*; 141 + use rocket::tokio; 142 + use sea_orm::{DatabaseBackend, MockDatabase}; 143 + use std::sync::{Arc, Mutex}; 144 + 145 + #[tokio::test] 146 + async fn block_message_writes_hide_record_when_authorized() { 147 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 148 + let captured: Arc<Mutex<Option<ColibriModeration>>> = Arc::new(Mutex::new(None)); 149 + let captured_clone = captured.clone(); 150 + 151 + let result = block_message_with( 152 + String::from("at://did:plc:owner/social.colibri.community/c1"), 153 + String::from("at://did:plc:alice/social.colibri.message/msg-1"), 154 + String::from("token"), 155 + db, 156 + |_, _| Box::pin(async { Ok(String::from("did:plc:owner")) }), 157 + |_, _, _| { 158 + Box::pin(async { 159 + Ok(ActorAuthz { 160 + is_owner: true, 161 + member: None, 162 + roles: vec![], 163 + }) 164 + }) 165 + }, 166 + move |_, _, record| { 167 + let captured = captured_clone.clone(); 168 + Box::pin(async move { 169 + *captured.lock().unwrap() = Some(record); 170 + Ok(record_data::Model { 171 + id: 1, 172 + did: String::from("did:plc:owner"), 173 + nsid: String::from("social.colibri.moderation"), 174 + rkey: String::from("mod-1"), 175 + data: serde_json::json!({}), 176 + }) 177 + }) 178 + }, 179 + ) 180 + .await 181 + .unwrap(); 182 + 183 + assert_eq!(result.message, "at://did:plc:alice/social.colibri.message/msg-1"); 184 + let written = captured.lock().unwrap().take().unwrap(); 185 + assert_eq!(written.action, "hideMessage"); 186 + assert_eq!( 187 + written.subject.uri.as_deref(), 188 + Some("at://did:plc:alice/social.colibri.message/msg-1") 189 + ); 190 + } 191 + 192 + #[tokio::test] 193 + async fn block_message_rejects_invalid_message_uri() { 194 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 195 + let result = block_message_with( 196 + String::from("at://did:plc:owner/social.colibri.community/c1"), 197 + String::from("not-a-uri"), 198 + String::from("token"), 199 + db, 200 + |_, _| Box::pin(async { panic!("should not authenticate when uri is invalid") }), 201 + |_, _, _| Box::pin(async { panic!("should not load authz") }), 202 + |_, _, _| Box::pin(async { panic!("should not write") }), 203 + ) 204 + .await; 205 + 206 + assert!(result.is_err()); 207 + assert_eq!( 208 + result.err().unwrap().body.into_inner().error, 209 + "InvalidRequest" 210 + ); 211 + } 212 + 213 + #[tokio::test] 214 + async fn block_message_rejects_when_caller_lacks_permission() { 215 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 216 + let result = block_message_with( 217 + String::from("at://did:plc:owner/social.colibri.community/c1"), 218 + String::from("at://did:plc:alice/social.colibri.message/msg-1"), 219 + String::from("token"), 220 + db, 221 + |_, _| Box::pin(async { Ok(String::from("did:plc:rando")) }), 222 + |_, _, _| { 223 + Box::pin(async { 224 + Ok(ActorAuthz { 225 + is_owner: false, 226 + member: None, 227 + roles: vec![], 228 + }) 229 + }) 230 + }, 231 + |_, _, _| Box::pin(async { panic!("should not write") }), 232 + ) 233 + .await; 234 + 235 + assert!(result.is_err()); 236 + assert_eq!(result.err().unwrap().body.into_inner().error, "Forbidden"); 237 + } 238 + }
+401
src/xrpc/social/colibri/community/block_user_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, post}; 4 + use sea_orm::{DatabaseConnection, DbErr}; 5 + use serde::Serialize; 6 + 7 + use crate::lib::at_uri::AtUri; 8 + use crate::lib::colibri::{ColibriModeration, ColibriModerationSubject}; 9 + use crate::lib::community_authz::{self, ActorAuthz}; 10 + use crate::lib::did_document::DidDocument; 11 + use crate::lib::moderation::{self, ACTION_BAN, ACTION_KICK, ACTION_UNBAN}; 12 + use crate::lib::permissions::Permission; 13 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 14 + use crate::lib::service_auth::{self, ServiceAuthError}; 15 + use crate::lib::time::current_iso8601_utc; 16 + use crate::models::record_data; 17 + use crate::xrpc::com::atproto::identity::resolve_identity; 18 + 19 + #[derive(Serialize, Debug)] 20 + pub struct BlockUserResponse { 21 + pub did: String, 22 + pub handle: String, 23 + } 24 + 25 + /// Helper that resolves an identifier (DID or handle) to a (did, handle) 26 + /// pair using the existing identity-resolution endpoint. 27 + pub async fn resolve_did_and_handle(identifier: &str) -> Result<(String, String), ErrorResponse> { 28 + let document = resolve_identity(identifier).await?; 29 + let handle = document 30 + .also_known_as 31 + .as_ref() 32 + .and_then(|aka| aka.first()) 33 + .map(|h| h.strip_prefix("at://").unwrap_or(h).to_string()) 34 + .unwrap_or_else(|| document.id.clone()); 35 + Ok((document.id.clone(), handle)) 36 + } 37 + 38 + /// Common moderation handler entry point used by both blockUser and 39 + /// unblockUser. `block` toggles between writing a `ban` or `unban` record. 40 + async fn moderate_user_with<R, W, V>( 41 + action: &'static str, 42 + community_uri: String, 43 + identifier: String, 44 + auth: String, 45 + db: DatabaseConnection, 46 + lxm: &'static str, 47 + permission: Permission, 48 + verify_auth_fn: V, 49 + resolve_fn: R, 50 + load_authz_fn: W, 51 + write_record_fn: impl FnOnce( 52 + DatabaseConnection, 53 + AtUri, 54 + ColibriModeration, 55 + ) -> BoxFuture<'static, Result<record_data::Model, DbErr>>, 56 + ) -> Result<Json<BlockUserResponse>, ErrorResponse> 57 + where 58 + V: Fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>, 59 + R: Fn(String) -> BoxFuture<'static, Result<(String, String), ErrorResponse>>, 60 + W: Fn(DatabaseConnection, String, String) -> BoxFuture<'static, Result<ActorAuthz, DbErr>>, 61 + { 62 + let community = AtUri::parse(&community_uri).ok_or_else(|| ErrorResponse { 63 + body: Json(ErrorBody { 64 + error: String::from("InvalidRequest"), 65 + message: String::from("Invalid community AT-URI."), 66 + }), 67 + })?; 68 + 69 + let caller_did = verify_auth_fn(auth, lxm.to_string()) 70 + .await 71 + .map_err(|e| ErrorResponse { 72 + body: Json(ErrorBody { 73 + error: String::from("AuthError"), 74 + message: e.to_string(), 75 + }), 76 + })?; 77 + 78 + let (target_did, target_handle) = resolve_fn(identifier).await?; 79 + 80 + let caller_authz = 81 + load_authz_fn(db.clone(), community_uri.clone(), caller_did.clone()).await?; 82 + if !caller_authz.has(permission, None) { 83 + return Err(ErrorResponse { 84 + body: Json(ErrorBody { 85 + error: String::from("Forbidden"), 86 + message: format!("Missing permission: {permission}"), 87 + }), 88 + }); 89 + } 90 + 91 + // Hierarchy check for any action that targets a present member (ban, 92 + // kick). Unban is exempt because the target is, by definition, not 93 + // currently in the community. 94 + if action == ACTION_BAN || action == ACTION_KICK { 95 + let target_authz = 96 + load_authz_fn(db.clone(), community_uri.clone(), target_did.clone()).await?; 97 + if !caller_authz.outranks(&target_authz) { 98 + return Err(ErrorResponse { 99 + body: Json(ErrorBody { 100 + error: String::from("Forbidden"), 101 + message: String::from( 102 + "Cannot act on a member with an equal or higher role position.", 103 + ), 104 + }), 105 + }); 106 + } 107 + } 108 + 109 + let record = moderation::moderation_record( 110 + action, 111 + ColibriModerationSubject { 112 + did: Some(target_did.clone()), 113 + uri: None, 114 + }, 115 + caller_did, 116 + current_iso8601_utc(), 117 + None, 118 + ); 119 + 120 + write_record_fn(db, community, record).await?; 121 + 122 + Ok(Json(BlockUserResponse { 123 + did: target_did, 124 + handle: target_handle, 125 + })) 126 + } 127 + 128 + fn verify_auth_boxed( 129 + auth: String, 130 + lxm: String, 131 + ) -> BoxFuture<'static, Result<String, ServiceAuthError>> { 132 + Box::pin(async move { service_auth::verify_service_auth(&auth, &lxm).await }) 133 + } 134 + 135 + fn resolve_boxed( 136 + identifier: String, 137 + ) -> BoxFuture<'static, Result<(String, String), ErrorResponse>> { 138 + Box::pin(async move { resolve_did_and_handle(&identifier).await }) 139 + } 140 + 141 + fn load_authz_boxed( 142 + db: DatabaseConnection, 143 + community_uri: String, 144 + did: String, 145 + ) -> BoxFuture<'static, Result<ActorAuthz, DbErr>> { 146 + Box::pin(async move { community_authz::load_actor_authz(&db, &community_uri, &did).await }) 147 + } 148 + 149 + fn write_moderation_boxed( 150 + db: DatabaseConnection, 151 + community: AtUri, 152 + record: ColibriModeration, 153 + ) -> BoxFuture<'static, Result<record_data::Model, DbErr>> { 154 + Box::pin(async move { moderation::write_moderation_record(&db, &community, &record).await }) 155 + } 156 + 157 + #[post("/xrpc/social.colibri.community.blockUser?<community>&<identifier>&<auth>")] 158 + /// Bans a user from a community by writing a `ban` moderation record. 159 + pub async fn block_user( 160 + community: &str, 161 + identifier: &str, 162 + auth: &str, 163 + db: &State<DatabaseConnection>, 164 + ) -> Result<Json<BlockUserResponse>, ErrorResponse> { 165 + moderate_user_with( 166 + ACTION_BAN, 167 + community.to_string(), 168 + identifier.to_string(), 169 + auth.to_string(), 170 + db.inner().clone(), 171 + "social.colibri.community.blockUser", 172 + Permission::MemberBan, 173 + verify_auth_boxed, 174 + resolve_boxed, 175 + load_authz_boxed, 176 + write_moderation_boxed, 177 + ) 178 + .await 179 + } 180 + 181 + #[post("/xrpc/social.colibri.community.unblockUser?<community>&<identifier>&<auth>")] 182 + /// Unbans a user from a community by writing an `unban` moderation record. 183 + pub async fn unblock_user( 184 + community: &str, 185 + identifier: &str, 186 + auth: &str, 187 + db: &State<DatabaseConnection>, 188 + ) -> Result<Json<BlockUserResponse>, ErrorResponse> { 189 + moderate_user_with( 190 + ACTION_UNBAN, 191 + community.to_string(), 192 + identifier.to_string(), 193 + auth.to_string(), 194 + db.inner().clone(), 195 + "social.colibri.community.unblockUser", 196 + Permission::MemberUnban, 197 + verify_auth_boxed, 198 + resolve_boxed, 199 + load_authz_boxed, 200 + write_moderation_boxed, 201 + ) 202 + .await 203 + } 204 + 205 + // Silence: import is used inside async closures via traits. 206 + #[allow(dead_code)] 207 + fn _force_did_document_import(d: DidDocument) -> DidDocument { 208 + d 209 + } 210 + 211 + #[cfg(test)] 212 + mod tests { 213 + use super::*; 214 + use crate::lib::colibri::{ColibriMember, ColibriRole}; 215 + use rocket::tokio; 216 + use sea_orm::{DatabaseBackend, MockDatabase}; 217 + use std::sync::{Arc, Mutex}; 218 + 219 + fn member(subject: &str, roles: Vec<&str>) -> ColibriMember { 220 + ColibriMember { 221 + record_type: None, 222 + subject: subject.to_string(), 223 + roles: roles.into_iter().map(String::from).collect(), 224 + joined_at: String::from("2026-05-13T00:00:00Z"), 225 + nickname: None, 226 + from_membership: None, 227 + } 228 + } 229 + 230 + fn role(position: i64, permissions: Vec<Permission>) -> ColibriRole { 231 + ColibriRole { 232 + record_type: None, 233 + name: String::from("R"), 234 + color: None, 235 + permissions: permissions.into_iter().map(|p| p.as_str().to_string()).collect(), 236 + position, 237 + hoisted: None, 238 + mentionable: None, 239 + channel_overrides: vec![], 240 + } 241 + } 242 + 243 + #[tokio::test] 244 + async fn block_user_writes_ban_record_when_owner() { 245 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 246 + let captured: Arc<Mutex<Option<ColibriModeration>>> = Arc::new(Mutex::new(None)); 247 + let captured_clone = captured.clone(); 248 + 249 + let result = moderate_user_with( 250 + ACTION_BAN, 251 + String::from("at://did:plc:owner/social.colibri.community/c1"), 252 + String::from("did:plc:target"), 253 + String::from("token"), 254 + db, 255 + "social.colibri.community.blockUser", 256 + Permission::MemberBan, 257 + |_, _| Box::pin(async { Ok(String::from("did:plc:owner")) }), 258 + |_| Box::pin(async { Ok((String::from("did:plc:target"), String::from("target.test"))) }), 259 + |_, _, did| { 260 + Box::pin(async move { 261 + Ok(ActorAuthz { 262 + is_owner: did == "did:plc:owner", 263 + member: None, 264 + roles: vec![], 265 + }) 266 + }) 267 + }, 268 + move |_, _, record| { 269 + let captured = captured_clone.clone(); 270 + Box::pin(async move { 271 + *captured.lock().unwrap() = Some(record); 272 + Ok(record_data::Model { 273 + id: 1, 274 + did: String::from("did:plc:owner"), 275 + nsid: String::from("social.colibri.moderation"), 276 + rkey: String::from("mod-1"), 277 + data: serde_json::json!({}), 278 + }) 279 + }) 280 + }, 281 + ) 282 + .await 283 + .unwrap(); 284 + 285 + assert_eq!(result.did, "did:plc:target"); 286 + assert_eq!(result.handle, "target.test"); 287 + let written = captured.lock().unwrap().take().unwrap(); 288 + assert_eq!(written.action, "ban"); 289 + assert_eq!(written.subject.did.as_deref(), Some("did:plc:target")); 290 + assert_eq!(written.created_by, "did:plc:owner"); 291 + } 292 + 293 + #[tokio::test] 294 + async fn block_user_rejects_when_caller_lacks_permission() { 295 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 296 + let result = moderate_user_with( 297 + ACTION_BAN, 298 + String::from("at://did:plc:owner/social.colibri.community/c1"), 299 + String::from("did:plc:target"), 300 + String::from("token"), 301 + db, 302 + "social.colibri.community.blockUser", 303 + Permission::MemberBan, 304 + |_, _| Box::pin(async { Ok(String::from("did:plc:rando")) }), 305 + |_| Box::pin(async { Ok((String::from("did:plc:target"), String::from("target.test"))) }), 306 + |_, _, _| { 307 + Box::pin(async { 308 + Ok(ActorAuthz { 309 + is_owner: false, 310 + member: None, 311 + roles: vec![], 312 + }) 313 + }) 314 + }, 315 + |_, _, _| Box::pin(async { panic!("should not write when permission missing") }), 316 + ) 317 + .await; 318 + 319 + assert!(result.is_err()); 320 + assert_eq!(result.err().unwrap().body.into_inner().error, "Forbidden"); 321 + } 322 + 323 + #[tokio::test] 324 + async fn block_user_rejects_when_hierarchy_blocks() { 325 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 326 + let result = moderate_user_with( 327 + ACTION_BAN, 328 + String::from("at://did:plc:owner/social.colibri.community/c1"), 329 + String::from("did:plc:target"), 330 + String::from("token"), 331 + db, 332 + "social.colibri.community.blockUser", 333 + Permission::MemberBan, 334 + |_, _| Box::pin(async { Ok(String::from("did:plc:mod")) }), 335 + |_| Box::pin(async { Ok((String::from("did:plc:target"), String::from("target.test"))) }), 336 + |_, _, did| { 337 + Box::pin(async move { 338 + if did == "did:plc:mod" { 339 + Ok(ActorAuthz { 340 + is_owner: false, 341 + member: Some(member("did:plc:mod", vec!["r1"])), 342 + roles: vec![role(10, vec![Permission::MemberBan])], 343 + }) 344 + } else { 345 + Ok(ActorAuthz { 346 + is_owner: false, 347 + member: Some(member("did:plc:target", vec!["r2"])), 348 + roles: vec![role(20, vec![])], 349 + }) 350 + } 351 + }) 352 + }, 353 + |_, _, _| Box::pin(async { panic!("should not write when hierarchy blocks") }), 354 + ) 355 + .await; 356 + 357 + assert!(result.is_err()); 358 + let err = result.err().unwrap().body.into_inner(); 359 + assert_eq!(err.error, "Forbidden"); 360 + assert!(err.message.contains("role position")); 361 + } 362 + 363 + #[tokio::test] 364 + async fn unblock_user_skips_hierarchy_check() { 365 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 366 + let result = moderate_user_with( 367 + ACTION_UNBAN, 368 + String::from("at://did:plc:owner/social.colibri.community/c1"), 369 + String::from("did:plc:target"), 370 + String::from("token"), 371 + db, 372 + "social.colibri.community.unblockUser", 373 + Permission::MemberUnban, 374 + |_, _| Box::pin(async { Ok(String::from("did:plc:mod")) }), 375 + |_| Box::pin(async { Ok((String::from("did:plc:target"), String::from("target.test"))) }), 376 + |_, _, _| { 377 + Box::pin(async { 378 + Ok(ActorAuthz { 379 + is_owner: false, 380 + member: Some(member("did:plc:mod", vec!["r1"])), 381 + roles: vec![role(5, vec![Permission::MemberUnban])], 382 + }) 383 + }) 384 + }, 385 + |_, _, _| { 386 + Box::pin(async { 387 + Ok(record_data::Model { 388 + id: 1, 389 + did: String::from("did:plc:owner"), 390 + nsid: String::from("social.colibri.moderation"), 391 + rkey: String::from("mod-1"), 392 + data: serde_json::json!({}), 393 + }) 394 + }) 395 + }, 396 + ) 397 + .await; 398 + 399 + assert!(result.is_ok()); 400 + } 401 + }
+640
src/xrpc/social/colibri/community/invitations_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rand::Rng; 3 + use rand::distributions::Alphanumeric; 4 + use rocket::serde::json::Json; 5 + use rocket::{State, get, post}; 6 + use sea_orm::{ 7 + ActiveValue, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter, sea_query, 8 + }; 9 + use serde::Serialize; 10 + 11 + use crate::lib::at_uri::AtUri; 12 + use crate::lib::community_authz::{self, ActorAuthz}; 13 + use crate::lib::permissions::Permission; 14 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 15 + use crate::lib::service_auth::{self, ServiceAuthError}; 16 + use crate::lib::time::current_iso8601_utc; 17 + use crate::models::community_invitations::{ 18 + self, ActiveModel as InvitationModel, Entity as Invitations, Model as Invitation, 19 + }; 20 + 21 + #[derive(Serialize, Debug)] 22 + pub struct InvitationView { 23 + pub code: String, 24 + pub community: String, 25 + #[serde(rename = "createdBy")] 26 + pub created_by: String, 27 + pub active: bool, 28 + } 29 + 30 + #[derive(Serialize, Debug)] 31 + pub struct InvitationListResponse { 32 + pub codes: Vec<InvitationView>, 33 + } 34 + 35 + #[derive(Serialize, Debug)] 36 + pub struct DeleteInvitationResponse { 37 + pub code: String, 38 + } 39 + 40 + impl From<Invitation> for InvitationView { 41 + fn from(value: Invitation) -> Self { 42 + InvitationView { 43 + code: value.code, 44 + community: value.community_uri, 45 + created_by: value.created_by, 46 + active: value.active, 47 + } 48 + } 49 + } 50 + 51 + const CODE_LENGTH: usize = 16; 52 + 53 + fn generate_invitation_code() -> String { 54 + rand::thread_rng() 55 + .sample_iter(&Alphanumeric) 56 + .take(CODE_LENGTH) 57 + .map(char::from) 58 + .collect() 59 + } 60 + 61 + // ---- DB helpers ---------------------------------------------------------- 62 + 63 + pub async fn insert_invitation( 64 + db: &DatabaseConnection, 65 + code: String, 66 + community_uri: String, 67 + created_by: String, 68 + ) -> Result<Invitation, DbErr> { 69 + let row = InvitationModel { 70 + code: ActiveValue::Set(code.clone()), 71 + community_uri: ActiveValue::Set(community_uri), 72 + created_by: ActiveValue::Set(created_by), 73 + active: ActiveValue::Set(true), 74 + created_at: ActiveValue::Set(current_iso8601_utc()), 75 + }; 76 + Invitations::insert(row).exec(db).await?; 77 + Invitations::find() 78 + .filter(community_invitations::Column::Code.eq(&code)) 79 + .one(db) 80 + .await? 81 + .ok_or_else(|| DbErr::Custom(format!("inserted invitation missing: {code}"))) 82 + } 83 + 84 + pub async fn fetch_invitation( 85 + db: &DatabaseConnection, 86 + code: &str, 87 + ) -> Result<Option<Invitation>, DbErr> { 88 + Invitations::find() 89 + .filter(community_invitations::Column::Code.eq(code)) 90 + .one(db) 91 + .await 92 + } 93 + 94 + pub async fn fetch_invitations_for_community( 95 + db: &DatabaseConnection, 96 + community_uri: &str, 97 + ) -> Result<Vec<Invitation>, DbErr> { 98 + Invitations::find() 99 + .filter(community_invitations::Column::CommunityUri.eq(community_uri)) 100 + .all(db) 101 + .await 102 + } 103 + 104 + pub async fn deactivate_invitation(db: &DatabaseConnection, code: &str) -> Result<u64, DbErr> { 105 + let res = Invitations::update_many() 106 + .col_expr( 107 + community_invitations::Column::Active, 108 + sea_query::Expr::value(false), 109 + ) 110 + .filter(community_invitations::Column::Code.eq(code)) 111 + .exec(db) 112 + .await?; 113 + Ok(res.rows_affected) 114 + } 115 + 116 + // ---- createInvitation ---------------------------------------------------- 117 + 118 + #[derive(Serialize, Debug)] 119 + pub struct CreateInvitationResponse { 120 + pub code: String, 121 + pub community: String, 122 + #[serde(rename = "createdBy")] 123 + pub created_by: String, 124 + pub active: bool, 125 + } 126 + 127 + async fn create_invitation_with<V, W, I>( 128 + community_uri: String, 129 + auth: String, 130 + db: DatabaseConnection, 131 + verify_auth_fn: V, 132 + load_authz_fn: W, 133 + insert_fn: I, 134 + code_generator: fn() -> String, 135 + ) -> Result<Json<CreateInvitationResponse>, ErrorResponse> 136 + where 137 + V: Fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>, 138 + W: Fn(DatabaseConnection, String, String) -> BoxFuture<'static, Result<ActorAuthz, DbErr>>, 139 + I: Fn(DatabaseConnection, String, String, String) -> BoxFuture<'static, Result<Invitation, DbErr>>, 140 + { 141 + if AtUri::parse(&community_uri).is_none() { 142 + return Err(invalid_community()); 143 + } 144 + 145 + let caller_did = verify_auth_fn( 146 + auth, 147 + String::from("social.colibri.community.createInvitation"), 148 + ) 149 + .await 150 + .map_err(auth_error)?; 151 + 152 + let authz = load_authz_fn(db.clone(), community_uri.clone(), caller_did.clone()).await?; 153 + require_permission(&authz, Permission::InvitationCreate)?; 154 + 155 + let code = code_generator(); 156 + let inv = insert_fn(db, code, community_uri, caller_did).await?; 157 + 158 + Ok(Json(CreateInvitationResponse { 159 + code: inv.code, 160 + community: inv.community_uri, 161 + created_by: inv.created_by, 162 + active: inv.active, 163 + })) 164 + } 165 + 166 + // ---- getInvitation ------------------------------------------------------- 167 + 168 + async fn get_invitation_with<F>( 169 + code: String, 170 + fetch_fn: F, 171 + ) -> Result<Json<InvitationView>, ErrorResponse> 172 + where 173 + F: FnOnce(String) -> BoxFuture<'static, Result<Option<Invitation>, DbErr>>, 174 + { 175 + let row = fetch_fn(code.clone()).await?.ok_or_else(|| ErrorResponse { 176 + body: Json(ErrorBody { 177 + error: String::from("NotFound"), 178 + message: format!("Invitation '{code}' not found."), 179 + }), 180 + })?; 181 + Ok(Json(row.into())) 182 + } 183 + 184 + // ---- listInvitations ----------------------------------------------------- 185 + 186 + async fn list_invitations_with<V, W, F>( 187 + community_uri: String, 188 + auth: String, 189 + db: DatabaseConnection, 190 + verify_auth_fn: V, 191 + load_authz_fn: W, 192 + fetch_fn: F, 193 + ) -> Result<Json<InvitationListResponse>, ErrorResponse> 194 + where 195 + V: Fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>, 196 + W: Fn(DatabaseConnection, String, String) -> BoxFuture<'static, Result<ActorAuthz, DbErr>>, 197 + F: Fn(DatabaseConnection, String) -> BoxFuture<'static, Result<Vec<Invitation>, DbErr>>, 198 + { 199 + if AtUri::parse(&community_uri).is_none() { 200 + return Err(invalid_community()); 201 + } 202 + 203 + let caller_did = verify_auth_fn( 204 + auth, 205 + String::from("social.colibri.community.listInvitations"), 206 + ) 207 + .await 208 + .map_err(auth_error)?; 209 + 210 + let authz = load_authz_fn(db.clone(), community_uri.clone(), caller_did).await?; 211 + require_permission(&authz, Permission::InvitationCreate)?; 212 + 213 + let rows = fetch_fn(db, community_uri).await?; 214 + Ok(Json(InvitationListResponse { 215 + codes: rows.into_iter().map(Into::into).collect(), 216 + })) 217 + } 218 + 219 + // ---- deleteInvitation ---------------------------------------------------- 220 + 221 + async fn delete_invitation_with<V, W, F, D>( 222 + community_uri: String, 223 + code: String, 224 + auth: String, 225 + db: DatabaseConnection, 226 + verify_auth_fn: V, 227 + load_authz_fn: W, 228 + fetch_fn: F, 229 + deactivate_fn: D, 230 + ) -> Result<Json<DeleteInvitationResponse>, ErrorResponse> 231 + where 232 + V: Fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>, 233 + W: Fn(DatabaseConnection, String, String) -> BoxFuture<'static, Result<ActorAuthz, DbErr>>, 234 + F: Fn(DatabaseConnection, String) -> BoxFuture<'static, Result<Option<Invitation>, DbErr>>, 235 + D: Fn(DatabaseConnection, String) -> BoxFuture<'static, Result<u64, DbErr>>, 236 + { 237 + if AtUri::parse(&community_uri).is_none() { 238 + return Err(invalid_community()); 239 + } 240 + 241 + let caller_did = verify_auth_fn( 242 + auth, 243 + String::from("social.colibri.community.deleteInvitation"), 244 + ) 245 + .await 246 + .map_err(auth_error)?; 247 + 248 + let authz = load_authz_fn(db.clone(), community_uri.clone(), caller_did).await?; 249 + require_permission(&authz, Permission::InvitationDelete)?; 250 + 251 + let invitation = fetch_fn(db.clone(), code.clone()) 252 + .await? 253 + .ok_or_else(|| ErrorResponse { 254 + body: Json(ErrorBody { 255 + error: String::from("NotFound"), 256 + message: format!("Invitation '{code}' not found."), 257 + }), 258 + })?; 259 + 260 + if invitation.community_uri != community_uri { 261 + return Err(ErrorResponse { 262 + body: Json(ErrorBody { 263 + error: String::from("InvalidRequest"), 264 + message: String::from("Invitation does not belong to the specified community."), 265 + }), 266 + }); 267 + } 268 + 269 + deactivate_fn(db, code.clone()).await?; 270 + Ok(Json(DeleteInvitationResponse { code })) 271 + } 272 + 273 + // ---- shared error helpers ------------------------------------------------ 274 + 275 + fn auth_error(err: ServiceAuthError) -> ErrorResponse { 276 + ErrorResponse { 277 + body: Json(ErrorBody { 278 + error: String::from("AuthError"), 279 + message: err.to_string(), 280 + }), 281 + } 282 + } 283 + 284 + fn invalid_community() -> ErrorResponse { 285 + ErrorResponse { 286 + body: Json(ErrorBody { 287 + error: String::from("InvalidRequest"), 288 + message: String::from("Invalid community AT-URI."), 289 + }), 290 + } 291 + } 292 + 293 + fn require_permission(authz: &ActorAuthz, permission: Permission) -> Result<(), ErrorResponse> { 294 + if !authz.has(permission, None) { 295 + return Err(ErrorResponse { 296 + body: Json(ErrorBody { 297 + error: String::from("Forbidden"), 298 + message: format!("Missing permission: {permission}"), 299 + }), 300 + }); 301 + } 302 + Ok(()) 303 + } 304 + 305 + // ---- Boxed dependencies -------------------------------------------------- 306 + 307 + fn verify_auth_boxed( 308 + auth: String, 309 + lxm: String, 310 + ) -> BoxFuture<'static, Result<String, ServiceAuthError>> { 311 + Box::pin(async move { service_auth::verify_service_auth(&auth, &lxm).await }) 312 + } 313 + 314 + fn load_authz_boxed( 315 + db: DatabaseConnection, 316 + community_uri: String, 317 + did: String, 318 + ) -> BoxFuture<'static, Result<ActorAuthz, DbErr>> { 319 + Box::pin(async move { community_authz::load_actor_authz(&db, &community_uri, &did).await }) 320 + } 321 + 322 + fn insert_boxed( 323 + db: DatabaseConnection, 324 + code: String, 325 + community_uri: String, 326 + created_by: String, 327 + ) -> BoxFuture<'static, Result<Invitation, DbErr>> { 328 + Box::pin(async move { insert_invitation(&db, code, community_uri, created_by).await }) 329 + } 330 + 331 + fn fetch_boxed( 332 + db: DatabaseConnection, 333 + code: String, 334 + ) -> BoxFuture<'static, Result<Option<Invitation>, DbErr>> { 335 + Box::pin(async move { fetch_invitation(&db, &code).await }) 336 + } 337 + 338 + fn fetch_by_community_boxed( 339 + db: DatabaseConnection, 340 + community_uri: String, 341 + ) -> BoxFuture<'static, Result<Vec<Invitation>, DbErr>> { 342 + Box::pin(async move { fetch_invitations_for_community(&db, &community_uri).await }) 343 + } 344 + 345 + fn deactivate_boxed( 346 + db: DatabaseConnection, 347 + code: String, 348 + ) -> BoxFuture<'static, Result<u64, DbErr>> { 349 + Box::pin(async move { deactivate_invitation(&db, &code).await }) 350 + } 351 + 352 + // ---- Public Rocket routes ----------------------------------------------- 353 + 354 + #[post("/xrpc/social.colibri.community.createInvitation?<community>&<auth>")] 355 + pub async fn create_invitation( 356 + community: &str, 357 + auth: &str, 358 + db: &State<DatabaseConnection>, 359 + ) -> Result<Json<CreateInvitationResponse>, ErrorResponse> { 360 + create_invitation_with( 361 + community.to_string(), 362 + auth.to_string(), 363 + db.inner().clone(), 364 + verify_auth_boxed, 365 + load_authz_boxed, 366 + insert_boxed, 367 + generate_invitation_code, 368 + ) 369 + .await 370 + } 371 + 372 + #[get("/xrpc/social.colibri.community.getInvitation?<code>")] 373 + pub async fn get_invitation( 374 + code: &str, 375 + db: &State<DatabaseConnection>, 376 + ) -> Result<Json<InvitationView>, ErrorResponse> { 377 + let db = db.inner().clone(); 378 + get_invitation_with(code.to_string(), move |c| { 379 + Box::pin(async move { fetch_invitation(&db, &c).await }) 380 + }) 381 + .await 382 + } 383 + 384 + #[post("/xrpc/social.colibri.community.listInvitations?<uri>&<auth>")] 385 + pub async fn list_invitations( 386 + uri: &str, 387 + auth: &str, 388 + db: &State<DatabaseConnection>, 389 + ) -> Result<Json<InvitationListResponse>, ErrorResponse> { 390 + list_invitations_with( 391 + uri.to_string(), 392 + auth.to_string(), 393 + db.inner().clone(), 394 + verify_auth_boxed, 395 + load_authz_boxed, 396 + fetch_by_community_boxed, 397 + ) 398 + .await 399 + } 400 + 401 + #[post("/xrpc/social.colibri.community.deleteInvitation?<uri>&<code>&<auth>")] 402 + pub async fn delete_invitation( 403 + uri: &str, 404 + code: &str, 405 + auth: &str, 406 + db: &State<DatabaseConnection>, 407 + ) -> Result<Json<DeleteInvitationResponse>, ErrorResponse> { 408 + delete_invitation_with( 409 + uri.to_string(), 410 + code.to_string(), 411 + auth.to_string(), 412 + db.inner().clone(), 413 + verify_auth_boxed, 414 + load_authz_boxed, 415 + fetch_boxed, 416 + deactivate_boxed, 417 + ) 418 + .await 419 + } 420 + 421 + #[cfg(test)] 422 + mod tests { 423 + use super::*; 424 + use rocket::tokio; 425 + use sea_orm::{DatabaseBackend, MockDatabase}; 426 + use std::sync::{Arc, Mutex}; 427 + 428 + fn invite(code: &str, community: &str, by: &str, active: bool) -> Invitation { 429 + Invitation { 430 + code: code.to_string(), 431 + community_uri: community.to_string(), 432 + created_by: by.to_string(), 433 + active, 434 + created_at: String::from("2026-05-14T00:00:00Z"), 435 + } 436 + } 437 + 438 + fn owner_authz() -> ActorAuthz { 439 + ActorAuthz { 440 + is_owner: true, 441 + member: None, 442 + roles: vec![], 443 + } 444 + } 445 + 446 + fn empty_authz() -> ActorAuthz { 447 + ActorAuthz { 448 + is_owner: false, 449 + member: None, 450 + roles: vec![], 451 + } 452 + } 453 + 454 + #[tokio::test] 455 + async fn create_invitation_returns_inserted_view() { 456 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 457 + let captured: Arc<Mutex<Option<(String, String, String)>>> = Arc::new(Mutex::new(None)); 458 + let captured_clone = captured.clone(); 459 + 460 + let result = create_invitation_with( 461 + String::from("at://did:plc:owner/social.colibri.community/c1"), 462 + String::from("token"), 463 + db, 464 + |_, _| Box::pin(async { Ok(String::from("did:plc:owner")) }), 465 + |_, _, _| Box::pin(async { Ok(owner_authz()) }), 466 + move |_, code, community, created_by| { 467 + let captured = captured_clone.clone(); 468 + Box::pin(async move { 469 + *captured.lock().unwrap() = Some((code.clone(), community.clone(), created_by.clone())); 470 + Ok(invite(&code, &community, &created_by, true)) 471 + }) 472 + }, 473 + || String::from("CODE-FIXED"), 474 + ) 475 + .await 476 + .unwrap(); 477 + 478 + let inserted = captured.lock().unwrap().take().unwrap(); 479 + assert_eq!(inserted.0, "CODE-FIXED"); 480 + assert_eq!(inserted.1, "at://did:plc:owner/social.colibri.community/c1"); 481 + assert_eq!(inserted.2, "did:plc:owner"); 482 + 483 + assert_eq!(result.code, "CODE-FIXED"); 484 + assert!(result.active); 485 + assert_eq!(result.created_by, "did:plc:owner"); 486 + } 487 + 488 + #[tokio::test] 489 + async fn create_invitation_rejects_without_permission() { 490 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 491 + let result = create_invitation_with( 492 + String::from("at://did:plc:owner/social.colibri.community/c1"), 493 + String::from("token"), 494 + db, 495 + |_, _| Box::pin(async { Ok(String::from("did:plc:rando")) }), 496 + |_, _, _| Box::pin(async { Ok(empty_authz()) }), 497 + |_, _, _, _| Box::pin(async { panic!("should not insert") }), 498 + || String::from("CODE"), 499 + ) 500 + .await; 501 + 502 + assert!(result.is_err()); 503 + assert_eq!(result.err().unwrap().body.into_inner().error, "Forbidden"); 504 + } 505 + 506 + #[tokio::test] 507 + async fn get_invitation_returns_view() { 508 + let result = get_invitation_with(String::from("CODE"), |c| { 509 + Box::pin(async move { 510 + Ok(Some(invite( 511 + &c, 512 + "at://did:plc:owner/social.colibri.community/c1", 513 + "did:plc:owner", 514 + true, 515 + ))) 516 + }) 517 + }) 518 + .await 519 + .unwrap(); 520 + 521 + assert_eq!(result.code, "CODE"); 522 + assert_eq!(result.community, "at://did:plc:owner/social.colibri.community/c1"); 523 + assert!(result.active); 524 + } 525 + 526 + #[tokio::test] 527 + async fn get_invitation_returns_not_found_when_missing() { 528 + let result = get_invitation_with(String::from("NOPE"), |_| { 529 + Box::pin(async { Ok(None) }) 530 + }) 531 + .await; 532 + 533 + assert!(result.is_err()); 534 + assert_eq!(result.err().unwrap().body.into_inner().error, "NotFound"); 535 + } 536 + 537 + #[tokio::test] 538 + async fn list_invitations_returns_all_for_community() { 539 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 540 + let result = list_invitations_with( 541 + String::from("at://did:plc:owner/social.colibri.community/c1"), 542 + String::from("token"), 543 + db, 544 + |_, _| Box::pin(async { Ok(String::from("did:plc:owner")) }), 545 + |_, _, _| Box::pin(async { Ok(owner_authz()) }), 546 + |_, _| { 547 + Box::pin(async { 548 + Ok(vec![ 549 + invite("A", "at://did:plc:owner/social.colibri.community/c1", "did:plc:owner", true), 550 + invite("B", "at://did:plc:owner/social.colibri.community/c1", "did:plc:owner", false), 551 + ]) 552 + }) 553 + }, 554 + ) 555 + .await 556 + .unwrap(); 557 + 558 + assert_eq!(result.codes.len(), 2); 559 + assert_eq!(result.codes[0].code, "A"); 560 + assert!(result.codes[0].active); 561 + assert!(!result.codes[1].active); 562 + } 563 + 564 + #[tokio::test] 565 + async fn delete_invitation_deactivates_when_authorized() { 566 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 567 + let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![])); 568 + let calls_clone = calls.clone(); 569 + 570 + let result = delete_invitation_with( 571 + String::from("at://did:plc:owner/social.colibri.community/c1"), 572 + String::from("CODE"), 573 + String::from("token"), 574 + db, 575 + |_, _| Box::pin(async { Ok(String::from("did:plc:owner")) }), 576 + |_, _, _| Box::pin(async { Ok(owner_authz()) }), 577 + |_, c| { 578 + Box::pin(async move { 579 + Ok(Some(invite( 580 + &c, 581 + "at://did:plc:owner/social.colibri.community/c1", 582 + "did:plc:owner", 583 + true, 584 + ))) 585 + }) 586 + }, 587 + move |_, c| { 588 + let calls = calls_clone.clone(); 589 + Box::pin(async move { 590 + calls.lock().unwrap().push(c); 591 + Ok(1) 592 + }) 593 + }, 594 + ) 595 + .await 596 + .unwrap(); 597 + 598 + assert_eq!(result.code, "CODE"); 599 + assert_eq!(*calls.lock().unwrap(), vec![String::from("CODE")]); 600 + } 601 + 602 + #[tokio::test] 603 + async fn delete_invitation_rejects_when_community_mismatches() { 604 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 605 + let result = delete_invitation_with( 606 + String::from("at://did:plc:owner/social.colibri.community/c1"), 607 + String::from("CODE"), 608 + String::from("token"), 609 + db, 610 + |_, _| Box::pin(async { Ok(String::from("did:plc:owner")) }), 611 + |_, _, _| Box::pin(async { Ok(owner_authz()) }), 612 + |_, c| { 613 + Box::pin(async move { 614 + Ok(Some(invite( 615 + &c, 616 + "at://did:plc:other/social.colibri.community/c2", 617 + "did:plc:other", 618 + true, 619 + ))) 620 + }) 621 + }, 622 + |_, _| Box::pin(async { panic!("should not deactivate when community mismatches") }), 623 + ) 624 + .await; 625 + 626 + assert!(result.is_err()); 627 + let err = result.err().unwrap().body.into_inner(); 628 + assert_eq!(err.error, "InvalidRequest"); 629 + } 630 + 631 + #[test] 632 + fn invitation_codes_are_unique_alphanumeric_strings_of_expected_length() { 633 + let a = generate_invitation_code(); 634 + let b = generate_invitation_code(); 635 + assert_eq!(a.len(), CODE_LENGTH); 636 + assert_eq!(b.len(), CODE_LENGTH); 637 + assert_ne!(a, b); 638 + assert!(a.chars().all(|c| c.is_ascii_alphanumeric())); 639 + } 640 + }
+99
src/xrpc/social/colibri/community/list_blocked_users_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, get}; 4 + use sea_orm::{DatabaseConnection, DbErr}; 5 + use serde::Serialize; 6 + 7 + use crate::lib::at_uri::AtUri; 8 + use crate::lib::moderation::currently_banned_dids; 9 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 10 + 11 + #[derive(Serialize, Debug)] 12 + pub struct BlockedUsersResponse { 13 + pub dids: Vec<String>, 14 + } 15 + 16 + type FetchBannedFn = 17 + fn(DatabaseConnection, AtUri) -> BoxFuture<'static, Result<Vec<String>, DbErr>>; 18 + 19 + async fn list_blocked_users_with( 20 + community_uri: String, 21 + db: DatabaseConnection, 22 + fetch_fn: FetchBannedFn, 23 + ) -> Result<Json<BlockedUsersResponse>, ErrorResponse> { 24 + let community = AtUri::parse(&community_uri).ok_or_else(|| ErrorResponse { 25 + body: Json(ErrorBody { 26 + error: String::from("InvalidRequest"), 27 + message: String::from("Invalid community AT-URI."), 28 + }), 29 + })?; 30 + 31 + let dids = fetch_fn(db, community).await?; 32 + Ok(Json(BlockedUsersResponse { dids })) 33 + } 34 + 35 + fn fetch_banned_boxed( 36 + db: DatabaseConnection, 37 + community: AtUri, 38 + ) -> BoxFuture<'static, Result<Vec<String>, DbErr>> { 39 + Box::pin(async move { currently_banned_dids(&db, &community).await }) 40 + } 41 + 42 + #[get("/xrpc/social.colibri.community.listBlockedUsers?<community>")] 43 + /// Lists every DID currently banned in the community (derived from the 44 + /// `social.colibri.moderation` event log on the community repo). 45 + pub async fn list_blocked_users( 46 + community: &str, 47 + db: &State<DatabaseConnection>, 48 + ) -> Result<Json<BlockedUsersResponse>, ErrorResponse> { 49 + list_blocked_users_with(community.to_string(), db.inner().clone(), fetch_banned_boxed).await 50 + } 51 + 52 + #[cfg(test)] 53 + mod tests { 54 + use super::*; 55 + use rocket::tokio; 56 + use sea_orm::{DatabaseBackend, MockDatabase}; 57 + 58 + #[tokio::test] 59 + async fn returns_banned_dids_from_helper() { 60 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 61 + let result = list_blocked_users_with( 62 + String::from("at://did:plc:owner/social.colibri.community/c1"), 63 + db, 64 + |_, community| { 65 + assert_eq!(community.authority, "did:plc:owner"); 66 + Box::pin(async { 67 + Ok(vec![ 68 + String::from("did:plc:alice"), 69 + String::from("did:plc:bob"), 70 + ]) 71 + }) 72 + }, 73 + ) 74 + .await 75 + .unwrap(); 76 + 77 + assert_eq!( 78 + result.dids, 79 + vec![String::from("did:plc:alice"), String::from("did:plc:bob")] 80 + ); 81 + } 82 + 83 + #[tokio::test] 84 + async fn rejects_invalid_community_uri() { 85 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 86 + let result = list_blocked_users_with( 87 + String::from("not-a-uri"), 88 + db, 89 + |_, _| Box::pin(async { panic!("should not fetch when uri is invalid") }), 90 + ) 91 + .await; 92 + 93 + assert!(result.is_err()); 94 + assert_eq!( 95 + result.err().unwrap().body.into_inner().error, 96 + "InvalidRequest" 97 + ); 98 + } 99 + }
+216
src/xrpc/social/colibri/community/list_categories_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, get}; 4 + use sea_orm::prelude::Expr; 5 + use sea_orm::{ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter}; 6 + use serde::{Deserialize, Serialize}; 7 + 8 + use crate::lib::at_uri::AtUri; 9 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 10 + use crate::models::record_data; 11 + 12 + #[derive(Serialize, Deserialize, Debug)] 13 + pub struct Category { 14 + pub uri: String, 15 + pub name: String, 16 + #[serde(rename = "channelOrder")] 17 + pub channel_order: Vec<String>, 18 + } 19 + 20 + #[derive(Serialize, Deserialize, Debug)] 21 + pub struct CategoryList { 22 + pub categories: Vec<Category>, 23 + } 24 + 25 + #[derive(Deserialize)] 26 + struct StoredCategory { 27 + name: String, 28 + #[serde(rename = "channelOrder", default)] 29 + channel_order: Vec<String>, 30 + } 31 + 32 + pub async fn fetch_category_records( 33 + db: &DatabaseConnection, 34 + authority: &str, 35 + community_rkey: &str, 36 + ) -> Result<Vec<record_data::Model>, DbErr> { 37 + record_data::Entity::find() 38 + .filter(record_data::Column::Did.eq(authority)) 39 + .filter(record_data::Column::Nsid.eq("social.colibri.category")) 40 + .filter(Expr::cust_with_values( 41 + r#""record_data"."data"->>'community' = $1"#, 42 + vec![sea_orm::Value::from(community_rkey.to_string())], 43 + )) 44 + .all(db) 45 + .await 46 + } 47 + 48 + type FetchCategoriesFn = fn( 49 + DatabaseConnection, 50 + String, 51 + String, 52 + ) -> BoxFuture<'static, Result<Vec<record_data::Model>, DbErr>>; 53 + 54 + async fn list_categories_with( 55 + community_uri: String, 56 + db: DatabaseConnection, 57 + fetch_categories_fn: FetchCategoriesFn, 58 + ) -> Result<Json<CategoryList>, ErrorResponse> { 59 + let community = AtUri::parse(&community_uri).ok_or_else(|| ErrorResponse { 60 + body: Json(ErrorBody { 61 + error: String::from("InvalidRequest"), 62 + message: String::from("Invalid community AT-URI."), 63 + }), 64 + })?; 65 + 66 + let records = 67 + fetch_categories_fn(db, community.authority.clone(), community.rkey.clone()).await?; 68 + 69 + let categories = records 70 + .into_iter() 71 + .filter_map(|r| { 72 + let stored = serde_json::from_value::<StoredCategory>(r.data).ok()?; 73 + let channel_order = stored 74 + .channel_order 75 + .into_iter() 76 + .map(|rkey| { 77 + format!( 78 + "at://{}/social.colibri.channel/{}", 79 + community.authority, rkey 80 + ) 81 + }) 82 + .collect(); 83 + Some(Category { 84 + uri: format!("at://{}/{}/{}", r.did, r.nsid, r.rkey), 85 + name: stored.name, 86 + channel_order, 87 + }) 88 + }) 89 + .collect(); 90 + 91 + Ok(Json(CategoryList { categories })) 92 + } 93 + 94 + fn fetch_categories_boxed( 95 + db: DatabaseConnection, 96 + authority: String, 97 + community_rkey: String, 98 + ) -> BoxFuture<'static, Result<Vec<record_data::Model>, DbErr>> { 99 + Box::pin(async move { fetch_category_records(&db, &authority, &community_rkey).await }) 100 + } 101 + 102 + #[get("/xrpc/social.colibri.community.listCategories?<community>")] 103 + /// Lists all categories defined under a Colibri community. 104 + pub async fn list_categories( 105 + community: &str, 106 + db: &State<DatabaseConnection>, 107 + ) -> Result<Json<CategoryList>, ErrorResponse> { 108 + list_categories_with( 109 + community.to_string(), 110 + db.inner().clone(), 111 + fetch_categories_boxed, 112 + ) 113 + .await 114 + } 115 + 116 + #[cfg(test)] 117 + mod tests { 118 + use super::*; 119 + use rocket::tokio; 120 + use sea_orm::{DatabaseBackend, MockDatabase}; 121 + 122 + #[tokio::test] 123 + async fn maps_records_to_category_response() { 124 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 125 + let result = list_categories_with( 126 + String::from("at://did:plc:owner/social.colibri.community/c1"), 127 + db, 128 + |_, _, _| { 129 + Box::pin(async { 130 + Ok(vec![record_data::Model { 131 + id: 1, 132 + did: String::from("did:plc:owner"), 133 + nsid: String::from("social.colibri.category"), 134 + rkey: String::from("cat1"), 135 + data: serde_json::json!({ 136 + "name": "General", 137 + "channelOrder": ["chan-a", "chan-b"], 138 + "community": "c1" 139 + }), 140 + }]) 141 + }) 142 + }, 143 + ) 144 + .await 145 + .unwrap(); 146 + 147 + assert_eq!(result.categories.len(), 1); 148 + assert_eq!( 149 + result.categories[0].uri, 150 + "at://did:plc:owner/social.colibri.category/cat1" 151 + ); 152 + assert_eq!(result.categories[0].name, "General"); 153 + assert_eq!( 154 + result.categories[0].channel_order, 155 + vec![ 156 + String::from("at://did:plc:owner/social.colibri.channel/chan-a"), 157 + String::from("at://did:plc:owner/social.colibri.channel/chan-b"), 158 + ] 159 + ); 160 + } 161 + 162 + #[tokio::test] 163 + async fn rejects_invalid_community_uri() { 164 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 165 + let result = list_categories_with( 166 + String::from("not-a-uri"), 167 + db, 168 + |_, _, _| Box::pin(async { panic!("should not fetch when uri is invalid") }), 169 + ) 170 + .await; 171 + 172 + assert!(result.is_err()); 173 + assert_eq!( 174 + result.err().unwrap().body.into_inner().error, 175 + "InvalidRequest" 176 + ); 177 + } 178 + 179 + #[tokio::test] 180 + async fn skips_records_with_invalid_payload() { 181 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 182 + let result = list_categories_with( 183 + String::from("at://did:plc:owner/social.colibri.community/c1"), 184 + db, 185 + |_, _, _| { 186 + Box::pin(async { 187 + Ok(vec![ 188 + record_data::Model { 189 + id: 1, 190 + did: String::from("did:plc:owner"), 191 + nsid: String::from("social.colibri.category"), 192 + rkey: String::from("cat1"), 193 + data: serde_json::json!({ "irrelevant": true }), 194 + }, 195 + record_data::Model { 196 + id: 2, 197 + did: String::from("did:plc:owner"), 198 + nsid: String::from("social.colibri.category"), 199 + rkey: String::from("cat2"), 200 + data: serde_json::json!({ 201 + "name": "Voice", 202 + "channelOrder": [], 203 + "community": "c1" 204 + }), 205 + }, 206 + ]) 207 + }) 208 + }, 209 + ) 210 + .await 211 + .unwrap(); 212 + 213 + assert_eq!(result.categories.len(), 1); 214 + assert_eq!(result.categories[0].name, "Voice"); 215 + } 216 + }
+173
src/xrpc/social/colibri/community/list_channels_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, get}; 4 + use sea_orm::prelude::Expr; 5 + use sea_orm::{ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter}; 6 + use serde::{Deserialize, Serialize}; 7 + 8 + use crate::lib::at_uri::AtUri; 9 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 10 + use crate::models::record_data; 11 + 12 + #[derive(Serialize, Deserialize, Debug)] 13 + pub struct Channel { 14 + pub uri: String, 15 + pub name: String, 16 + #[serde(rename = "type")] 17 + pub channel_type: String, 18 + pub category: String, 19 + } 20 + 21 + #[derive(Serialize, Deserialize, Debug)] 22 + pub struct ChannelList { 23 + pub channels: Vec<Channel>, 24 + } 25 + 26 + #[derive(Deserialize)] 27 + struct StoredChannel { 28 + name: String, 29 + #[serde(rename = "type")] 30 + channel_type: String, 31 + category: String, 32 + } 33 + 34 + pub async fn fetch_channel_records( 35 + db: &DatabaseConnection, 36 + authority: &str, 37 + community_rkey: &str, 38 + ) -> Result<Vec<record_data::Model>, DbErr> { 39 + record_data::Entity::find() 40 + .filter(record_data::Column::Did.eq(authority)) 41 + .filter(record_data::Column::Nsid.eq("social.colibri.channel")) 42 + .filter(Expr::cust_with_values( 43 + r#""record_data"."data"->>'community' = $1"#, 44 + vec![sea_orm::Value::from(community_rkey.to_string())], 45 + )) 46 + .all(db) 47 + .await 48 + } 49 + 50 + type FetchChannelsFn = fn( 51 + DatabaseConnection, 52 + String, 53 + String, 54 + ) -> BoxFuture<'static, Result<Vec<record_data::Model>, DbErr>>; 55 + 56 + async fn list_channels_with( 57 + community_uri: String, 58 + db: DatabaseConnection, 59 + fetch_channels_fn: FetchChannelsFn, 60 + ) -> Result<Json<ChannelList>, ErrorResponse> { 61 + let community = AtUri::parse(&community_uri).ok_or_else(|| ErrorResponse { 62 + body: Json(ErrorBody { 63 + error: String::from("InvalidRequest"), 64 + message: String::from("Invalid community AT-URI."), 65 + }), 66 + })?; 67 + 68 + let records = 69 + fetch_channels_fn(db, community.authority.clone(), community.rkey.clone()).await?; 70 + 71 + let channels = records 72 + .into_iter() 73 + .filter_map(|r| { 74 + let stored = serde_json::from_value::<StoredChannel>(r.data).ok()?; 75 + Some(Channel { 76 + uri: format!("at://{}/{}/{}", r.did, r.nsid, r.rkey), 77 + name: stored.name, 78 + channel_type: stored.channel_type, 79 + category: format!( 80 + "at://{}/social.colibri.category/{}", 81 + community.authority, stored.category 82 + ), 83 + }) 84 + }) 85 + .collect(); 86 + 87 + Ok(Json(ChannelList { channels })) 88 + } 89 + 90 + fn fetch_channels_boxed( 91 + db: DatabaseConnection, 92 + authority: String, 93 + community_rkey: String, 94 + ) -> BoxFuture<'static, Result<Vec<record_data::Model>, DbErr>> { 95 + Box::pin(async move { fetch_channel_records(&db, &authority, &community_rkey).await }) 96 + } 97 + 98 + #[get("/xrpc/social.colibri.community.listChannels?<community>")] 99 + /// Lists all channels defined under a Colibri community. 100 + pub async fn list_channels( 101 + community: &str, 102 + db: &State<DatabaseConnection>, 103 + ) -> Result<Json<ChannelList>, ErrorResponse> { 104 + list_channels_with( 105 + community.to_string(), 106 + db.inner().clone(), 107 + fetch_channels_boxed, 108 + ) 109 + .await 110 + } 111 + 112 + #[cfg(test)] 113 + mod tests { 114 + use super::*; 115 + use rocket::tokio; 116 + use sea_orm::{DatabaseBackend, MockDatabase}; 117 + 118 + #[tokio::test] 119 + async fn maps_records_to_channel_response() { 120 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 121 + let result = list_channels_with( 122 + String::from("at://did:plc:owner/social.colibri.community/c1"), 123 + db, 124 + |_, _, _| { 125 + Box::pin(async { 126 + Ok(vec![record_data::Model { 127 + id: 1, 128 + did: String::from("did:plc:owner"), 129 + nsid: String::from("social.colibri.channel"), 130 + rkey: String::from("chan-a"), 131 + data: serde_json::json!({ 132 + "name": "general", 133 + "type": "social.colibri.channel.text", 134 + "category": "cat1", 135 + "community": "c1" 136 + }), 137 + }]) 138 + }) 139 + }, 140 + ) 141 + .await 142 + .unwrap(); 143 + 144 + assert_eq!(result.channels.len(), 1); 145 + assert_eq!( 146 + result.channels[0].uri, 147 + "at://did:plc:owner/social.colibri.channel/chan-a" 148 + ); 149 + assert_eq!(result.channels[0].name, "general"); 150 + assert_eq!(result.channels[0].channel_type, "social.colibri.channel.text"); 151 + assert_eq!( 152 + result.channels[0].category, 153 + "at://did:plc:owner/social.colibri.category/cat1" 154 + ); 155 + } 156 + 157 + #[tokio::test] 158 + async fn rejects_invalid_community_uri() { 159 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 160 + let result = list_channels_with( 161 + String::from("not-a-uri"), 162 + db, 163 + |_, _, _| Box::pin(async { panic!("should not fetch when uri is invalid") }), 164 + ) 165 + .await; 166 + 167 + assert!(result.is_err()); 168 + assert_eq!( 169 + result.err().unwrap().body.into_inner().error, 170 + "InvalidRequest" 171 + ); 172 + } 173 + }
+333
src/xrpc/social/colibri/community/list_members_handler.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use futures::future::BoxFuture; 4 + use rocket::serde::json::Json; 5 + use rocket::{State, get}; 6 + use sea_orm::prelude::Expr; 7 + use sea_orm::{ColumnTrait, Condition, DatabaseConnection, DbErr, EntityTrait, QueryFilter}; 8 + use serde::{Deserialize, Serialize}; 9 + use serde_json::Value; 10 + 11 + use crate::lib::at_uri::AtUri; 12 + use crate::lib::bsky::ActorProfile; 13 + use crate::lib::colibri::ColibriActorData; 14 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 15 + use crate::models::{record_data, repos, user_states}; 16 + use crate::xrpc::social::colibri::actor::get_data_handler::{ActorData, ActorStatus}; 17 + 18 + #[derive(Serialize, Deserialize, Debug)] 19 + pub struct Member { 20 + pub did: String, 21 + pub handle: String, 22 + pub data: ActorData, 23 + } 24 + 25 + #[derive(Serialize, Deserialize, Debug)] 26 + pub struct MemberList { 27 + pub members: Vec<Member>, 28 + } 29 + 30 + /// Data fetched in a single round-trip per backing table, keyed by member DID. 31 + pub struct MemberAggregate { 32 + pub member_dids: Vec<String>, 33 + pub profiles: HashMap<String, ActorProfile>, 34 + pub actor_data: HashMap<String, ColibriActorData>, 35 + pub states: HashMap<String, String>, 36 + pub handles: HashMap<String, String>, 37 + } 38 + 39 + pub async fn fetch_member_aggregate( 40 + db: &DatabaseConnection, 41 + community_uri: &str, 42 + ) -> Result<MemberAggregate, DbErr> { 43 + let memberships = record_data::Entity::find() 44 + .filter(record_data::Column::Nsid.eq("social.colibri.membership")) 45 + .filter(Expr::cust_with_values( 46 + r#""record_data"."data"->>'community' = $1"#, 47 + vec![sea_orm::Value::from(community_uri.to_string())], 48 + )) 49 + .all(db) 50 + .await?; 51 + 52 + let mut member_dids: Vec<String> = memberships.into_iter().map(|m| m.did).collect(); 53 + member_dids.sort(); 54 + member_dids.dedup(); 55 + 56 + if member_dids.is_empty() { 57 + return Ok(MemberAggregate { 58 + member_dids, 59 + profiles: HashMap::new(), 60 + actor_data: HashMap::new(), 61 + states: HashMap::new(), 62 + handles: HashMap::new(), 63 + }); 64 + } 65 + 66 + let self_records = record_data::Entity::find() 67 + .filter(record_data::Column::Did.is_in(member_dids.clone())) 68 + .filter(record_data::Column::Rkey.eq("self")) 69 + .filter( 70 + Condition::any() 71 + .add(record_data::Column::Nsid.eq("app.bsky.actor.profile")) 72 + .add(record_data::Column::Nsid.eq("social.colibri.actor.data")), 73 + ) 74 + .all(db) 75 + .await?; 76 + 77 + let mut profiles: HashMap<String, ActorProfile> = HashMap::new(); 78 + let mut actor_data: HashMap<String, ColibriActorData> = HashMap::new(); 79 + for record in self_records { 80 + if record.nsid == "app.bsky.actor.profile" { 81 + if let Ok(profile) = serde_json::from_value::<ActorProfile>(record.data) { 82 + profiles.insert(record.did, profile); 83 + } 84 + } else if record.nsid == "social.colibri.actor.data" 85 + && let Ok(data) = serde_json::from_value::<ColibriActorData>(record.data) 86 + { 87 + actor_data.insert(record.did, data); 88 + } 89 + } 90 + 91 + let state_rows = user_states::Entity::find() 92 + .filter(user_states::Column::Did.is_in(member_dids.clone())) 93 + .all(db) 94 + .await?; 95 + let states: HashMap<String, String> = state_rows 96 + .into_iter() 97 + .map(|row| (row.did, row.state)) 98 + .collect(); 99 + 100 + let repo_rows = repos::Entity::find() 101 + .filter(repos::Column::Did.is_in(member_dids.clone())) 102 + .all(db) 103 + .await?; 104 + let handles: HashMap<String, String> = repo_rows 105 + .into_iter() 106 + .filter_map(|row| row.handle.map(|h| (row.did, h))) 107 + .collect(); 108 + 109 + Ok(MemberAggregate { 110 + member_dids, 111 + profiles, 112 + actor_data, 113 + states, 114 + handles, 115 + }) 116 + } 117 + 118 + pub fn build_member( 119 + did: String, 120 + handle: Option<String>, 121 + profile: Option<ActorProfile>, 122 + actor_data: Option<ColibriActorData>, 123 + state: Option<String>, 124 + ) -> Member { 125 + let handle = handle.unwrap_or_else(|| did.clone()); 126 + let display_name = profile 127 + .as_ref() 128 + .and_then(|p| p.display_name.clone()) 129 + .unwrap_or_else(|| handle.clone()); 130 + 131 + let (avatar, banner, description): (Option<Value>, Option<Value>, Option<String>) = 132 + match profile { 133 + Some(p) => (p.avatar, p.banner, p.description), 134 + None => (None, None, None), 135 + }; 136 + 137 + let status = actor_data 138 + .map(|d| ActorStatus { 139 + text: d.status, 140 + emoji: d.emoji, 141 + }) 142 + .unwrap_or(ActorStatus { 143 + text: String::new(), 144 + emoji: None, 145 + }); 146 + 147 + let online_state = state.unwrap_or_else(|| String::from("offline")); 148 + 149 + Member { 150 + did, 151 + handle, 152 + data: ActorData { 153 + display_name, 154 + avatar, 155 + banner, 156 + description, 157 + online_state, 158 + status, 159 + }, 160 + } 161 + } 162 + 163 + type FetchAggregateFn = 164 + fn(DatabaseConnection, String) -> BoxFuture<'static, Result<MemberAggregate, DbErr>>; 165 + 166 + async fn list_members_with( 167 + community_uri: String, 168 + db: DatabaseConnection, 169 + fetch_aggregate_fn: FetchAggregateFn, 170 + ) -> Result<Json<MemberList>, ErrorResponse> { 171 + if AtUri::parse(&community_uri).is_none() { 172 + return Err(ErrorResponse { 173 + body: Json(ErrorBody { 174 + error: String::from("InvalidRequest"), 175 + message: String::from("Invalid community AT-URI."), 176 + }), 177 + }); 178 + } 179 + 180 + let aggregate = fetch_aggregate_fn(db, community_uri).await?; 181 + 182 + let MemberAggregate { 183 + member_dids, 184 + mut profiles, 185 + mut actor_data, 186 + mut states, 187 + mut handles, 188 + } = aggregate; 189 + 190 + let members = member_dids 191 + .into_iter() 192 + .map(|did| { 193 + let handle = handles.remove(&did); 194 + let profile = profiles.remove(&did); 195 + let data = actor_data.remove(&did); 196 + let state = states.remove(&did); 197 + build_member(did, handle, profile, data, state) 198 + }) 199 + .collect(); 200 + 201 + Ok(Json(MemberList { members })) 202 + } 203 + 204 + fn fetch_aggregate_boxed( 205 + db: DatabaseConnection, 206 + community_uri: String, 207 + ) -> BoxFuture<'static, Result<MemberAggregate, DbErr>> { 208 + Box::pin(async move { fetch_member_aggregate(&db, &community_uri).await }) 209 + } 210 + 211 + #[get("/xrpc/social.colibri.community.listMembers?<community>")] 212 + /// Lists every Colibri member of a community along with their profile, status, 213 + /// and online state. 214 + pub async fn list_members( 215 + community: &str, 216 + db: &State<DatabaseConnection>, 217 + ) -> Result<Json<MemberList>, ErrorResponse> { 218 + list_members_with( 219 + community.to_string(), 220 + db.inner().clone(), 221 + fetch_aggregate_boxed, 222 + ) 223 + .await 224 + } 225 + 226 + #[cfg(test)] 227 + mod tests { 228 + use super::*; 229 + use rocket::tokio; 230 + use sea_orm::{DatabaseBackend, MockDatabase}; 231 + 232 + fn profile_for(name: &str) -> ActorProfile { 233 + ActorProfile { 234 + display_name: Some(name.to_string()), 235 + description: Some(format!("{name} bio")), 236 + pronouns: None, 237 + website: None, 238 + avatar: Some(serde_json::json!({ "ref": format!("{name}-avatar") })), 239 + banner: None, 240 + labels: None, 241 + joined_via_starter_pack: None, 242 + pinned_post: None, 243 + created_at: None, 244 + } 245 + } 246 + 247 + fn actor_data_for(emoji: Option<&str>, status: &str) -> ColibriActorData { 248 + ColibriActorData { 249 + record_type: None, 250 + emoji: emoji.map(|e| e.to_string()), 251 + status: status.to_string(), 252 + communities: vec![], 253 + } 254 + } 255 + 256 + #[tokio::test] 257 + async fn assembles_members_from_aggregate() { 258 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 259 + let result = list_members_with( 260 + String::from("at://did:plc:owner/social.colibri.community/c1"), 261 + db, 262 + |_, _| { 263 + Box::pin(async { 264 + Ok(MemberAggregate { 265 + member_dids: vec![String::from("did:plc:alice"), String::from("did:plc:bob")], 266 + profiles: HashMap::from([ 267 + (String::from("did:plc:alice"), profile_for("Alice")), 268 + (String::from("did:plc:bob"), profile_for("Bob")), 269 + ]), 270 + actor_data: HashMap::from([( 271 + String::from("did:plc:alice"), 272 + actor_data_for(Some("🦜"), "Working"), 273 + )]), 274 + states: HashMap::from([(String::from("did:plc:alice"), String::from("online"))]), 275 + handles: HashMap::from([ 276 + (String::from("did:plc:alice"), String::from("alice.test")), 277 + (String::from("did:plc:bob"), String::from("bob.test")), 278 + ]), 279 + }) 280 + }) 281 + }, 282 + ) 283 + .await 284 + .unwrap(); 285 + 286 + assert_eq!(result.members.len(), 2); 287 + let alice = result 288 + .members 289 + .iter() 290 + .find(|m| m.did == "did:plc:alice") 291 + .unwrap(); 292 + assert_eq!(alice.handle, "alice.test"); 293 + assert_eq!(alice.data.display_name, "Alice"); 294 + assert_eq!(alice.data.online_state, "online"); 295 + assert_eq!(alice.data.status.text, "Working"); 296 + assert_eq!(alice.data.status.emoji.as_deref(), Some("🦜")); 297 + 298 + let bob = result 299 + .members 300 + .iter() 301 + .find(|m| m.did == "did:plc:bob") 302 + .unwrap(); 303 + assert_eq!(bob.data.online_state, "offline"); 304 + assert_eq!(bob.data.status.text, ""); 305 + } 306 + 307 + #[tokio::test] 308 + async fn falls_back_to_did_when_handle_and_profile_missing() { 309 + let member = build_member(String::from("did:plc:alice"), None, None, None, None); 310 + assert_eq!(member.handle, "did:plc:alice"); 311 + assert_eq!(member.data.display_name, "did:plc:alice"); 312 + assert_eq!(member.data.online_state, "offline"); 313 + assert_eq!(member.data.status.text, ""); 314 + assert!(member.data.status.emoji.is_none()); 315 + } 316 + 317 + #[tokio::test] 318 + async fn rejects_invalid_community_uri() { 319 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 320 + let result = list_members_with( 321 + String::from("not-a-uri"), 322 + db, 323 + |_, _| Box::pin(async { panic!("should not fetch when uri is invalid") }), 324 + ) 325 + .await; 326 + 327 + assert!(result.is_err()); 328 + assert_eq!( 329 + result.err().unwrap().body.into_inner().error, 330 + "InvalidRequest" 331 + ); 332 + } 333 + }
+17
src/xrpc/social/colibri/community/mod.rs
··· 1 + pub mod block_message_handler; 2 + pub mod block_user_handler; 3 + pub mod invitations_handler; 4 + pub mod list_blocked_users_handler; 5 + pub mod list_categories_handler; 6 + pub mod list_channels_handler; 7 + pub mod list_members_handler; 8 + 9 + pub use block_message_handler::block_message; 10 + pub use block_user_handler::{block_user, unblock_user}; 11 + pub use invitations_handler::{ 12 + create_invitation, delete_invitation, get_invitation, list_invitations, 13 + }; 14 + pub use list_blocked_users_handler::list_blocked_users; 15 + pub use list_categories_handler::list_categories; 16 + pub use list_channels_handler::list_channels; 17 + pub use list_members_handler::list_members;
+4
src/xrpc/social/colibri/mod.rs
··· 13 13 pub use list_communities_handler::list_communities; 14 14 pub use set_state_handler::set_state; 15 15 } 16 + 17 + pub mod channel; 18 + pub mod community; 19 + pub mod notification;
+109
src/xrpc/social/colibri/notification/get_unread_count_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, get}; 4 + use sea_orm::{DatabaseConnection, DbErr}; 5 + use serde::Serialize; 6 + 7 + use crate::lib::notifications; 8 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 9 + use crate::lib::service_auth::{self, ServiceAuthError}; 10 + 11 + #[derive(Serialize, Debug)] 12 + pub struct UnreadCountResponse { 13 + pub count: u64, 14 + } 15 + 16 + async fn get_unread_count_with<V, C>( 17 + auth: String, 18 + db: DatabaseConnection, 19 + verify_auth_fn: V, 20 + count_fn: C, 21 + ) -> Result<Json<UnreadCountResponse>, ErrorResponse> 22 + where 23 + V: Fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>, 24 + C: Fn(DatabaseConnection, String) -> BoxFuture<'static, Result<u64, DbErr>>, 25 + { 26 + let did = verify_auth_fn( 27 + auth, 28 + String::from("social.colibri.notification.getUnreadCount"), 29 + ) 30 + .await 31 + .map_err(|e| ErrorResponse { 32 + body: Json(ErrorBody { 33 + error: String::from("AuthError"), 34 + message: e.to_string(), 35 + }), 36 + })?; 37 + 38 + let count = count_fn(db, did).await?; 39 + Ok(Json(UnreadCountResponse { count })) 40 + } 41 + 42 + fn verify_auth_boxed( 43 + auth: String, 44 + lxm: String, 45 + ) -> BoxFuture<'static, Result<String, ServiceAuthError>> { 46 + Box::pin(async move { service_auth::verify_service_auth(&auth, &lxm).await }) 47 + } 48 + 49 + fn count_boxed( 50 + db: DatabaseConnection, 51 + did: String, 52 + ) -> BoxFuture<'static, Result<u64, DbErr>> { 53 + Box::pin(async move { notifications::unseen_count(&db, &did).await }) 54 + } 55 + 56 + #[get("/xrpc/social.colibri.notification.getUnreadCount?<auth>")] 57 + /// Returns the number of unseen notifications for the authenticated user. 58 + pub async fn get_unread_count( 59 + auth: &str, 60 + db: &State<DatabaseConnection>, 61 + ) -> Result<Json<UnreadCountResponse>, ErrorResponse> { 62 + get_unread_count_with( 63 + auth.to_string(), 64 + db.inner().clone(), 65 + verify_auth_boxed, 66 + count_boxed, 67 + ) 68 + .await 69 + } 70 + 71 + #[cfg(test)] 72 + mod tests { 73 + use super::*; 74 + use rocket::tokio; 75 + use sea_orm::{DatabaseBackend, MockDatabase}; 76 + 77 + #[tokio::test] 78 + async fn returns_count_from_helper() { 79 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 80 + let result = get_unread_count_with( 81 + String::from("token"), 82 + db, 83 + |_, _| Box::pin(async { Ok(String::from("did:plc:me")) }), 84 + |_, did| { 85 + assert_eq!(did, "did:plc:me"); 86 + Box::pin(async { Ok(7) }) 87 + }, 88 + ) 89 + .await 90 + .unwrap(); 91 + 92 + assert_eq!(result.count, 7); 93 + } 94 + 95 + #[tokio::test] 96 + async fn returns_auth_error_when_token_is_invalid() { 97 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 98 + let result = get_unread_count_with( 99 + String::from("token"), 100 + db, 101 + |_, _| Box::pin(async { Err(ServiceAuthError::InvalidSignature) }), 102 + |_, _| Box::pin(async { panic!("should not count when auth fails") }), 103 + ) 104 + .await; 105 + 106 + assert!(result.is_err()); 107 + assert_eq!(result.err().unwrap().body.into_inner().error, "AuthError"); 108 + } 109 + }
+228
src/xrpc/social/colibri/notification/list_notifications_handler.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use futures::future::BoxFuture; 4 + use rocket::serde::json::Json; 5 + use rocket::{State, get}; 6 + use sea_orm::{DatabaseConnection, DbErr}; 7 + use serde::Serialize; 8 + 9 + use crate::lib::notifications::{self, NotificationMessage, NotificationView}; 10 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 11 + use crate::lib::service_auth::{self, ServiceAuthError}; 12 + use crate::models::notifications as notifications_model; 13 + 14 + const DEFAULT_LIMIT: u64 = 50; 15 + 16 + #[derive(Serialize, Debug)] 17 + pub struct ListNotificationsResponse { 18 + #[serde(skip_serializing_if = "Option::is_none")] 19 + pub cursor: Option<String>, 20 + pub notifications: Vec<NotificationView>, 21 + } 22 + 23 + async fn list_notifications_with<V, F, H>( 24 + auth: String, 25 + limit: Option<u64>, 26 + cursor: Option<String>, 27 + db: DatabaseConnection, 28 + verify_auth_fn: V, 29 + fetch_fn: F, 30 + hydrate_fn: H, 31 + ) -> Result<Json<ListNotificationsResponse>, ErrorResponse> 32 + where 33 + V: Fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>, 34 + F: Fn( 35 + DatabaseConnection, 36 + String, 37 + u64, 38 + Option<String>, 39 + ) -> BoxFuture<'static, Result<Vec<notifications_model::Model>, DbErr>>, 40 + H: Fn( 41 + DatabaseConnection, 42 + Vec<String>, 43 + ) -> BoxFuture<'static, Result<HashMap<String, NotificationMessage>, DbErr>>, 44 + { 45 + let did = verify_auth_fn( 46 + auth, 47 + String::from("social.colibri.notification.listNotifications"), 48 + ) 49 + .await 50 + .map_err(|e| ErrorResponse { 51 + body: Json(ErrorBody { 52 + error: String::from("AuthError"), 53 + message: e.to_string(), 54 + }), 55 + })?; 56 + 57 + let effective_limit = limit.unwrap_or(DEFAULT_LIMIT); 58 + let rows = fetch_fn(db.clone(), did, effective_limit, cursor).await?; 59 + 60 + let next_cursor = if (rows.len() as u64) == effective_limit { 61 + rows.last().map(|r| r.id.to_string()) 62 + } else { 63 + None 64 + }; 65 + 66 + let message_uris: Vec<String> = rows.iter().map(|r| r.message_uri.clone()).collect(); 67 + let mut hydrated = hydrate_fn(db, message_uris).await?; 68 + 69 + let notifications = rows 70 + .into_iter() 71 + .map(|row| { 72 + let message = hydrated.remove(&row.message_uri); 73 + NotificationView::from_row(row, message) 74 + }) 75 + .collect(); 76 + 77 + Ok(Json(ListNotificationsResponse { 78 + cursor: next_cursor, 79 + notifications, 80 + })) 81 + } 82 + 83 + fn verify_auth_boxed( 84 + auth: String, 85 + lxm: String, 86 + ) -> BoxFuture<'static, Result<String, ServiceAuthError>> { 87 + Box::pin(async move { service_auth::verify_service_auth(&auth, &lxm).await }) 88 + } 89 + 90 + fn fetch_boxed( 91 + db: DatabaseConnection, 92 + did: String, 93 + limit: u64, 94 + cursor: Option<String>, 95 + ) -> BoxFuture<'static, Result<Vec<notifications_model::Model>, DbErr>> { 96 + Box::pin(async move { 97 + notifications::list_notifications(&db, &did, limit, cursor.as_deref()).await 98 + }) 99 + } 100 + 101 + fn hydrate_boxed( 102 + db: DatabaseConnection, 103 + uris: Vec<String>, 104 + ) -> BoxFuture<'static, Result<HashMap<String, NotificationMessage>, DbErr>> { 105 + Box::pin(async move { notifications::hydrate_messages(&db, &uris).await }) 106 + } 107 + 108 + #[get("/xrpc/social.colibri.notification.listNotifications?<limit>&<cursor>&<auth>")] 109 + /// Lists notifications for the authenticated user, newest first. 110 + pub async fn list_notifications( 111 + auth: &str, 112 + limit: Option<u64>, 113 + cursor: Option<&str>, 114 + db: &State<DatabaseConnection>, 115 + ) -> Result<Json<ListNotificationsResponse>, ErrorResponse> { 116 + list_notifications_with( 117 + auth.to_string(), 118 + limit, 119 + cursor.map(|c| c.to_string()), 120 + db.inner().clone(), 121 + verify_auth_boxed, 122 + fetch_boxed, 123 + hydrate_boxed, 124 + ) 125 + .await 126 + } 127 + 128 + #[cfg(test)] 129 + mod tests { 130 + use super::*; 131 + use rocket::tokio; 132 + use sea_orm::{DatabaseBackend, MockDatabase}; 133 + 134 + fn row(id: i64, kind: &str) -> notifications_model::Model { 135 + notifications_model::Model { 136 + id, 137 + recipient_did: String::from("did:plc:me"), 138 + kind: kind.to_string(), 139 + message_uri: format!("at://did:plc:author/social.colibri.message/m{id}"), 140 + author_did: String::from("did:plc:author"), 141 + channel_rkey: String::from("chan-a"), 142 + indexed_at: String::from("2026-05-14T00:00:00Z"), 143 + seen_at: None, 144 + } 145 + } 146 + 147 + fn sample_message(text: &str) -> NotificationMessage { 148 + NotificationMessage { 149 + text: text.to_string(), 150 + facets: vec![], 151 + created_at: String::from("2026-05-14T00:00:00Z"), 152 + parent: None, 153 + attachments: vec![], 154 + edited: None, 155 + } 156 + } 157 + 158 + #[tokio::test] 159 + async fn returns_rows_with_hydrated_messages_and_cursor_when_page_is_full() { 160 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 161 + let result = list_notifications_with( 162 + String::from("token"), 163 + Some(2), 164 + None, 165 + db, 166 + |_, _| Box::pin(async { Ok(String::from("did:plc:me")) }), 167 + |_, _, _, _| Box::pin(async { Ok(vec![row(20, "mention"), row(10, "reply")]) }), 168 + |_, uris| { 169 + Box::pin(async move { 170 + let mut map = HashMap::new(); 171 + for uri in uris { 172 + let text = format!("body of {uri}"); 173 + map.insert(uri, sample_message(&text)); 174 + } 175 + Ok(map) 176 + }) 177 + }, 178 + ) 179 + .await 180 + .unwrap(); 181 + 182 + assert_eq!(result.notifications.len(), 2); 183 + assert_eq!(result.notifications[0].id, 20); 184 + assert_eq!( 185 + result.notifications[0].message.as_ref().unwrap().text, 186 + "body of at://did:plc:author/social.colibri.message/m20" 187 + ); 188 + assert_eq!(result.notifications[1].kind, "reply"); 189 + assert_eq!(result.cursor.as_deref(), Some("10")); 190 + } 191 + 192 + #[tokio::test] 193 + async fn omits_message_when_hydration_returns_no_match() { 194 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 195 + let result = list_notifications_with( 196 + String::from("token"), 197 + Some(10), 198 + None, 199 + db, 200 + |_, _| Box::pin(async { Ok(String::from("did:plc:me")) }), 201 + |_, _, _, _| Box::pin(async { Ok(vec![row(1, "mention")]) }), 202 + |_, _| Box::pin(async { Ok(HashMap::new()) }), 203 + ) 204 + .await 205 + .unwrap(); 206 + 207 + assert!(result.cursor.is_none()); 208 + assert!(result.notifications[0].message.is_none()); 209 + } 210 + 211 + #[tokio::test] 212 + async fn returns_auth_error_when_token_is_invalid() { 213 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 214 + let result = list_notifications_with( 215 + String::from("token"), 216 + None, 217 + None, 218 + db, 219 + |_, _| Box::pin(async { Err(ServiceAuthError::InvalidSignature) }), 220 + |_, _, _, _| Box::pin(async { panic!("should not fetch when auth fails") }), 221 + |_, _| Box::pin(async { panic!("should not hydrate when auth fails") }), 222 + ) 223 + .await; 224 + 225 + assert!(result.is_err()); 226 + assert_eq!(result.err().unwrap().body.into_inner().error, "AuthError"); 227 + } 228 + }
+7
src/xrpc/social/colibri/notification/mod.rs
··· 1 + pub mod get_unread_count_handler; 2 + pub mod list_notifications_handler; 3 + pub mod update_seen_handler; 4 + 5 + pub use get_unread_count_handler::get_unread_count; 6 + pub use list_notifications_handler::list_notifications; 7 + pub use update_seen_handler::update_seen;
+162
src/xrpc/social/colibri/notification/update_seen_handler.rs
··· 1 + use futures::future::BoxFuture; 2 + use rocket::serde::json::Json; 3 + use rocket::{State, post}; 4 + use sea_orm::{DatabaseConnection, DbErr}; 5 + use serde::Serialize; 6 + 7 + use crate::lib::notifications; 8 + use crate::lib::responses::{ErrorBody, ErrorResponse}; 9 + use crate::lib::service_auth::{self, ServiceAuthError}; 10 + use crate::lib::time::current_iso8601_utc; 11 + 12 + #[derive(Serialize, Debug)] 13 + pub struct UpdateSeenResponse { 14 + pub updated: u64, 15 + } 16 + 17 + async fn update_seen_with<V, M>( 18 + auth: String, 19 + seen_at_input: Option<String>, 20 + db: DatabaseConnection, 21 + verify_auth_fn: V, 22 + mark_seen_fn: M, 23 + ) -> Result<Json<UpdateSeenResponse>, ErrorResponse> 24 + where 25 + V: Fn(String, String) -> BoxFuture<'static, Result<String, ServiceAuthError>>, 26 + M: Fn(DatabaseConnection, String, String, String) -> BoxFuture<'static, Result<u64, DbErr>>, 27 + { 28 + let did = verify_auth_fn( 29 + auth, 30 + String::from("social.colibri.notification.updateSeen"), 31 + ) 32 + .await 33 + .map_err(|e| ErrorResponse { 34 + body: Json(ErrorBody { 35 + error: String::from("AuthError"), 36 + message: e.to_string(), 37 + }), 38 + })?; 39 + 40 + // The cutoff defaults to "now" — anything indexed at or before this point 41 + // counts as "seen". Lets clients catch up without a timestamp round-trip. 42 + let cutoff = seen_at_input.clone().unwrap_or_else(current_iso8601_utc); 43 + let seen_at = seen_at_input.unwrap_or_else(current_iso8601_utc); 44 + 45 + let updated = mark_seen_fn(db, did, seen_at, cutoff).await?; 46 + Ok(Json(UpdateSeenResponse { updated })) 47 + } 48 + 49 + fn verify_auth_boxed( 50 + auth: String, 51 + lxm: String, 52 + ) -> BoxFuture<'static, Result<String, ServiceAuthError>> { 53 + Box::pin(async move { service_auth::verify_service_auth(&auth, &lxm).await }) 54 + } 55 + 56 + fn mark_seen_boxed( 57 + db: DatabaseConnection, 58 + did: String, 59 + seen_at: String, 60 + cutoff: String, 61 + ) -> BoxFuture<'static, Result<u64, DbErr>> { 62 + Box::pin(async move { notifications::mark_seen_up_to(&db, &did, &seen_at, &cutoff).await }) 63 + } 64 + 65 + #[post("/xrpc/social.colibri.notification.updateSeen?<seen_at>&<auth>")] 66 + /// Marks every unseen notification for the authenticated user with 67 + /// `indexed_at <= seen_at` as seen. Defaults to `now` when `seen_at` is omitted. 68 + pub async fn update_seen( 69 + seen_at: Option<&str>, 70 + auth: &str, 71 + db: &State<DatabaseConnection>, 72 + ) -> Result<Json<UpdateSeenResponse>, ErrorResponse> { 73 + update_seen_with( 74 + auth.to_string(), 75 + seen_at.map(|s| s.to_string()), 76 + db.inner().clone(), 77 + verify_auth_boxed, 78 + mark_seen_boxed, 79 + ) 80 + .await 81 + } 82 + 83 + #[cfg(test)] 84 + mod tests { 85 + use super::*; 86 + use rocket::tokio; 87 + use sea_orm::{DatabaseBackend, MockDatabase}; 88 + use std::sync::{Arc, Mutex}; 89 + 90 + #[tokio::test] 91 + async fn marks_seen_with_default_cutoff() { 92 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 93 + let captured: Arc<Mutex<Option<(String, String, String)>>> = Arc::new(Mutex::new(None)); 94 + let captured_clone = captured.clone(); 95 + 96 + let result = update_seen_with( 97 + String::from("token"), 98 + None, 99 + db, 100 + |_, _| Box::pin(async { Ok(String::from("did:plc:me")) }), 101 + move |_, did, seen, cutoff| { 102 + let captured = captured_clone.clone(); 103 + Box::pin(async move { 104 + *captured.lock().unwrap() = Some((did, seen, cutoff)); 105 + Ok(3) 106 + }) 107 + }, 108 + ) 109 + .await 110 + .unwrap(); 111 + 112 + assert_eq!(result.updated, 3); 113 + let (did, seen, cutoff) = captured.lock().unwrap().take().unwrap(); 114 + assert_eq!(did, "did:plc:me"); 115 + // When no seen_at is provided, both values should be the same "now" timestamp. 116 + assert_eq!(seen, cutoff); 117 + assert!(seen.ends_with('Z')); 118 + } 119 + 120 + #[tokio::test] 121 + async fn uses_provided_seen_at_when_given() { 122 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 123 + let captured: Arc<Mutex<Option<(String, String)>>> = Arc::new(Mutex::new(None)); 124 + let captured_clone = captured.clone(); 125 + 126 + let _ = update_seen_with( 127 + String::from("token"), 128 + Some(String::from("2026-05-14T05:00:00.000Z")), 129 + db, 130 + |_, _| Box::pin(async { Ok(String::from("did:plc:me")) }), 131 + move |_, _, seen, cutoff| { 132 + let captured = captured_clone.clone(); 133 + Box::pin(async move { 134 + *captured.lock().unwrap() = Some((seen, cutoff)); 135 + Ok(1) 136 + }) 137 + }, 138 + ) 139 + .await 140 + .unwrap(); 141 + 142 + let (seen, cutoff) = captured.lock().unwrap().take().unwrap(); 143 + assert_eq!(seen, "2026-05-14T05:00:00.000Z"); 144 + assert_eq!(cutoff, "2026-05-14T05:00:00.000Z"); 145 + } 146 + 147 + #[tokio::test] 148 + async fn returns_auth_error_when_token_is_invalid() { 149 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); 150 + let result = update_seen_with( 151 + String::from("token"), 152 + None, 153 + db, 154 + |_, _| Box::pin(async { Err(ServiceAuthError::InvalidSignature) }), 155 + |_, _, _, _| Box::pin(async { panic!("should not mark when auth fails") }), 156 + ) 157 + .await; 158 + 159 + assert!(result.is_err()); 160 + assert_eq!(result.err().unwrap().body.into_inner().error, "AuthError"); 161 + } 162 + }
+59 -1
src/xrpc/social/colibri/sync/subscribe_events_handler.rs
··· 1 - use crate::lib::events::{ColibriClientEventData, ColibriServerEventData, TypingEventData}; 1 + use crate::lib::events::{ 2 + ColibriClientEventData, ColibriServerEventData, NotificationEventData, NotificationEventMessage, 3 + TypingEventData, 4 + }; 2 5 use crate::lib::get_state::get_did_states; 3 6 use crate::lib::map_tap_event::map_tap_event; 7 + use crate::lib::notifications::IndexedNotification; 4 8 use crate::lib::state::{join_vc, leave_vc, view_channel}; 5 9 use crate::lib::tap::TapMessageRecord; 6 10 use crate::{CommsBridge, EventNotification}; ··· 236 240 } 237 241 } 238 242 243 + fn serialize_notification_for( 244 + indexed: IndexedNotification, 245 + did: &str, 246 + ) -> Option<String> { 247 + if indexed.row.recipient_did != did { 248 + return None; 249 + } 250 + let event = ColibriServerEvent { 251 + event_type: String::from("notification_event"), 252 + data: Some(ColibriServerEventData::Notification(NotificationEventData { 253 + id: indexed.row.id, 254 + kind: indexed.row.kind, 255 + message_uri: indexed.row.message_uri, 256 + author_did: indexed.row.author_did, 257 + channel_rkey: indexed.row.channel_rkey, 258 + indexed_at: indexed.row.indexed_at, 259 + message: NotificationEventMessage { 260 + text: indexed.message.text, 261 + facets: indexed.message.facets, 262 + created_at: indexed.message.created_at, 263 + parent: indexed.message.parent, 264 + attachments: indexed.message.attachments, 265 + edited: indexed.message.edited, 266 + }, 267 + })), 268 + is_relevant: true, 269 + }; 270 + Some(event.serialize()) 271 + } 272 + 273 + async fn handle_notification_msg( 274 + ws_sink: &mut WsSink, 275 + msg: Result<IndexedNotification, RecvError>, 276 + did: &str, 277 + ) -> bool { 278 + match msg { 279 + Ok(indexed) => { 280 + if let Some(payload) = serialize_notification_for(indexed, did) { 281 + let _ = ws_sink.send(WsMessage::Text(payload)).await; 282 + } 283 + true 284 + } 285 + Err(e) => { 286 + eprintln!("Notification broadcast error: {e}"); 287 + false 288 + } 289 + } 290 + } 291 + 239 292 /// Handles the event loop and allows both messages from Tap and the Client to get processed. 240 293 async fn run_event_loop( 241 294 io: DuplexStream, ··· 244 297 db: DatabaseConnection, 245 298 to_c2c_broadcast: Sender<EventNotification>, 246 299 from_c2c_broadcast: Receiver<EventNotification>, 300 + from_notifications: Receiver<IndexedNotification>, 247 301 ) { 248 302 let (mut ws_sink, mut ws_source) = io.split(); 249 303 let mut from_tap = from_tap; 250 304 let mut from_c2c_broadcast = from_c2c_broadcast; 305 + let mut from_notifications = from_notifications; 251 306 252 307 save_state(&db, did.clone(), String::from("online")).await; 253 308 register_dids(vec![did.clone()]).await; ··· 257 312 msg = from_tap.recv() => handle_tap_message(&mut ws_sink, msg, did.clone(), &db).await, 258 313 msg = ws_source.next() => handle_client_message(&mut ws_sink, msg, did.clone(), &to_c2c_broadcast, &db).await, 259 314 msg = from_c2c_broadcast.recv() => handle_client_broadcast_msg(&mut ws_sink, msg, did.clone(), &db).await, 315 + msg = from_notifications.recv() => handle_notification_msg(&mut ws_sink, msg, &did).await, 260 316 }; 261 317 262 318 if !connected { ··· 289 345 let cloned_db = db.inner().clone(); 290 346 291 347 let from_tap = bridge.broadcast.subscribe(); 348 + let from_notifications = bridge.notifications.subscribe(); 292 349 293 350 let to_c2c_broadcast = c2c_broadcast_channel.0.clone(); 294 351 let from_c2c_broadcast = to_c2c_broadcast.subscribe(); ··· 302 359 cloned_db, 303 360 to_c2c_broadcast, 304 361 from_c2c_broadcast, 362 + from_notifications, 305 363 ) 306 364 .await; 307 365 Ok(())