| 1 | package community |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 12 | ) |
| 13 | |
| 14 | // Rooms are NIP-29 groups that live inside one tenant. The room whose id |
| 15 | // equals the tenant slug mirrors the tenant roster; every other room keeps |
| 16 | // its own member list with room-level roles. Membership rows are kept in |
| 17 | // step with the tenant roster by triggers so every roster path (management |
| 18 | // calls, invites, NIP-29 and NIP-43 events, owner transfer) is covered. |
| 19 | const roomSchema = ` |
| 20 | CREATE TABLE IF NOT EXISTS rooms(id TEXT PRIMARY KEY,name TEXT NOT NULL DEFAULT '' ,about TEXT NOT NULL DEFAULT '' ,picture TEXT NOT NULL DEFAULT '' ,access TEXT NOT NULL DEFAULT 'open' ,created_by TEXT NOT NULL DEFAULT '' ,created_at INTEGER NOT NULL,event_id TEXT NOT NULL DEFAULT '' ,deleted_at INTEGER NOT NULL DEFAULT 0); |
| 21 | CREATE TABLE IF NOT EXISTS room_members(room_id TEXT NOT NULL,pubkey TEXT NOT NULL,role TEXT NOT NULL DEFAULT 'member' ,added_at INTEGER NOT NULL,PRIMARY KEY(room_id,pubkey)); |
| 22 | CREATE INDEX IF NOT EXISTS room_members_pubkey ON room_members(pubkey); |
| 23 | CREATE TRIGGER IF NOT EXISTS room_mirror_insert AFTER INSERT ON community_members |
| 24 | BEGIN |
| 25 | INSERT INTO room_members(room_id,pubkey,role,added_at) |
| 26 | SELECT value,NEW.pubkey,CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END,NEW.created_at FROM community_meta WHERE key='rooms.slug' |
| 27 | ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role; |
| 28 | END; |
| 29 | CREATE TRIGGER IF NOT EXISTS room_mirror_update AFTER UPDATE OF role ON community_members |
| 30 | BEGIN |
| 31 | UPDATE room_members SET role=CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END |
| 32 | WHERE pubkey=NEW.pubkey AND room_id IN (SELECT value FROM community_meta WHERE key='rooms.slug' ) AND role<>CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END; |
| 33 | END; |
| 34 | CREATE TRIGGER IF NOT EXISTS room_mirror_delete AFTER DELETE ON community_members |
| 35 | BEGIN |
| 36 | DELETE FROM room_members WHERE pubkey=OLD.pubkey; |
| 37 | END; |
| 38 | CREATE TRIGGER IF NOT EXISTS room_member_projection_insert AFTER INSERT ON room_members |
| 39 | BEGIN |
| 40 | INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) |
| 41 | VALUES('room-member-' ||lower(hex(randomblob(16))),'records-projection' ,'room-member-' ||lower(hex(randomblob(16))),'room' ,json_object('action' ,'room' ,'room' ,NEW.room_id,'pubkey' ,NEW.pubkey,'role' ,NEW.role),strftime('%s' ,'now' ),strftime('%s' ,'now' ),strftime('%s' ,'now' )); |
| 42 | END; |
| 43 | CREATE TRIGGER IF NOT EXISTS room_member_projection_update AFTER UPDATE OF role ON room_members |
| 44 | BEGIN |
| 45 | INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) |
| 46 | VALUES('room-role-' ||lower(hex(randomblob(16))),'records-projection' ,'room-role-' ||lower(hex(randomblob(16))),'room' ,json_object('action' ,'room' ,'room' ,NEW.room_id),strftime('%s' ,'now' ),strftime('%s' ,'now' ),strftime('%s' ,'now' )); |
| 47 | END; |
| 48 | CREATE TRIGGER IF NOT EXISTS room_member_projection_delete AFTER DELETE ON room_members |
| 49 | BEGIN |
| 50 | INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) |
| 51 | VALUES('room-member-delete-' ||lower(hex(randomblob(16))),'records-projection' ,'room-member-delete-' ||lower(hex(randomblob(16))),'room' ,json_object('action' ,'room' ,'room' ,OLD.room_id,'pubkey' ,OLD.pubkey,'role' ,OLD.role,'removed' ,1),strftime('%s' ,'now' ),strftime('%s' ,'now' ),strftime('%s' ,'now' )); |
| 52 | END; |
| 53 | ` |
| 54 | |
| 55 | const ( |
| 56 | RoomOpen = "open" |
| 57 | RoomMembers = "members" |
| 58 | |
| 59 | RoomRoleOwner = "owner" |
| 60 | RoomRoleAdmin = "admin" |
| 61 | RoomRoleMember = "member" |
| 62 | ) |
| 63 | |
| 64 | // Room is a chat room inside the tenant. |
| 65 | type Room struct { |
| 66 | ID string `json:"id"` |
| 67 | Name string `json:"name"` |
| 68 | About string `json:"about"` |
| 69 | Picture string `json:"picture"` |
| 70 | Access string `json:"access"` |
| 71 | CreatedBy string `json:"created_by"` |
| 72 | CreatedAt int64 `json:"created_at"` |
| 73 | EventID string `json:"event_id"` |
| 74 | DeletedAt int64 `json:"deleted_at,omitempty"` |
| 75 | } |
| 76 | |
| 77 | // RoomMember is one member row of a room. |
| 78 | type RoomMember struct { |
| 79 | PubKey string `json:"pubkey"` |
| 80 | Role string `json:"role"` |
| 81 | AddedAt int64 `json:"added_at"` |
| 82 | } |
| 83 | |
| 84 | var roomIDRE = regexp.MustCompile(`^[a-z0-9_-]{1,64}$` ) |
| 85 | |
| 86 | // ValidRoomID reports whether id has the shape of a room id. |
| 87 | func ValidRoomID(id string) bool { return roomIDRE.MatchString(id) } |
| 88 | |
| 89 | // RoomAdminKind reports whether kind is a NIP-29 management event that a |
| 90 | // room handles itself when it carries the room's h tag. |
| 91 | func RoomAdminKind(kind int) bool { |
| 92 | switch kind { |
| 93 | case event.KIND_PUT_USER, event.KIND_REMOVE_USER, event.KIND_EDIT_METADATA, event.KIND_DELETE_EVENT, event.KIND_CREATE_GROUP, event.KIND_DELETE_GROUP, event.KIND_CREATE_INVITE, event.KIND_PINS, event.KIND_JOIN, event.KIND_LEAVE: |
| 94 | return true |
| 95 | } |
| 96 | return false |
| 97 | } |
| 98 | |
| 99 | // RoomNoticeKind reports whether kind is a relay-signed member notice. |
| 100 | func RoomNoticeKind(kind int) bool { |
| 101 | return kind == event.KIND_ROOM_MEMBER_ADDED || kind == event.KIND_ROOM_MEMBER_REMOVED |
| 102 | } |
| 103 | |
| 104 | // RoomAccessFor maps the tenant read rule onto the slug room's access. |
| 105 | func RoomAccessFor(reads string) string { |
| 106 | if reads == "members" { |
| 107 | return RoomMembers |
| 108 | } |
| 109 | return RoomOpen |
| 110 | } |
| 111 | |
| 112 | // EnsureSlugRoom creates or refreshes the room that stands for the tenant's |
| 113 | // own group. Its access follows the tenant read rule and its members mirror |
| 114 | // the tenant roster: the owner is the room owner and moderators are admins. |
| 115 | // The first mirror is silent so an existing roster does not announce every |
| 116 | // member as newly added. |
| 117 | func (s *Service) EnsureSlugRoom(ctx context.Context, slug, access string) error { |
| 118 | if !ValidRoomID(slug) { |
| 119 | return fmt.Errorf("invalid: room id %q" , slug) |
| 120 | } |
| 121 | if access != RoomMembers { |
| 122 | access = RoomOpen |
| 123 | } |
| 124 | err := s.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 125 | if _, err := tx.ExecContext(ctx, `INSERT INTO community_meta(key,value) VALUES('rooms.slug',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value` , slug); err != nil { |
| 126 | return err |
| 127 | } |
| 128 | var existing string |
| 129 | err := tx.QueryRowContext(ctx, `SELECT id FROM rooms WHERE id=?` , slug).Scan(&existing) |
| 130 | fresh := errors.Is(err, sql.ErrNoRows) |
| 131 | if err != nil && !fresh { |
| 132 | return err |
| 133 | } |
| 134 | if _, err := tx.ExecContext(ctx, `INSERT INTO rooms(id,access,created_by,created_at) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET access=excluded.access,deleted_at=0` , slug, access, s.currentOwner(), now()); err != nil { |
| 135 | return err |
| 136 | } |
| 137 | if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) SELECT ?,pubkey,CASE role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END,created_at FROM community_members WHERE true ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role` , slug); err != nil { |
| 138 | return err |
| 139 | } |
| 140 | if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey NOT IN (SELECT pubkey FROM community_members)` , slug); err != nil { |
| 141 | return err |
| 142 | } |
| 143 | if fresh { |
| 144 | _, err = tx.ExecContext(ctx, `DELETE FROM work_intents WHERE kind='records-projection' AND target='room' AND state='pending' AND json_extract(payload,'$.room')=? AND json_extract(payload,'$.pubkey') IS NOT NULL` , slug) |
| 145 | } |
| 146 | return err |
| 147 | }) |
| 148 | if err != nil { |
| 149 | return err |
| 150 | } |
| 151 | s.mu.Lock() |
| 152 | s.slug = slug |
| 153 | s.mu.Unlock() |
| 154 | return nil |
| 155 | } |
| 156 | |
| 157 | // SlugRoom returns the id of the room that mirrors the tenant roster. |
| 158 | func (s *Service) SlugRoom() string { |
| 159 | s.mu.RLock() |
| 160 | defer s.mu.RUnlock() |
| 161 | return s.slug |
| 162 | } |
| 163 | |
| 164 | func (s *Service) currentOwner() string { |
| 165 | s.mu.RLock() |
| 166 | defer s.mu.RUnlock() |
| 167 | return s.owner |
| 168 | } |
| 169 | |
| 170 | // Room returns the room row, including a deleted one, or an empty Room. |
| 171 | func (s *Service) Room(ctx context.Context, id string) (Room, error) { |
| 172 | return scanRoom(s.store.DB().QueryRowContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE id=?` , id)) |
| 173 | } |
| 174 | |
| 175 | func roomTx(ctx context.Context, tx *sql.Tx, id string) (Room, error) { |
| 176 | return scanRoom(tx.QueryRowContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE id=?` , id)) |
| 177 | } |
| 178 | |
| 179 | func scanRoom(row *sql.Row) (Room, error) { |
| 180 | var r Room |
| 181 | err := row.Scan(&r.ID, &r.Name, &r.About, &r.Picture, &r.Access, &r.CreatedBy, &r.CreatedAt, &r.EventID, &r.DeletedAt) |
| 182 | if errors.Is(err, sql.ErrNoRows) { |
| 183 | return Room{}, nil |
| 184 | } |
| 185 | if err != nil { |
| 186 | return Room{}, fmt.Errorf("room: %w" , err) |
| 187 | } |
| 188 | return r, nil |
| 189 | } |
| 190 | |
| 191 | // Live reports whether the room exists and has not been deleted. |
| 192 | func (r Room) Live() bool { return r.ID != "" && r.DeletedAt == 0 } |
| 193 | |
| 194 | // Rooms lists the live rooms ordered by id. |
| 195 | func (s *Service) Rooms(ctx context.Context) ([]Room, error) { |
| 196 | rows, err := s.store.DB().QueryContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE deleted_at=0 ORDER BY id` ) |
| 197 | if err != nil { |
| 198 | return nil, fmt.Errorf("rooms: %w" , err) |
| 199 | } |
| 200 | defer rows.Close() |
| 201 | out := []Room{} |
| 202 | for rows.Next() { |
| 203 | var r Room |
| 204 | if err := rows.Scan(&r.ID, &r.Name, &r.About, &r.Picture, &r.Access, &r.CreatedBy, &r.CreatedAt, &r.EventID, &r.DeletedAt); err != nil { |
| 205 | return nil, err |
| 206 | } |
| 207 | out = append(out, r) |
| 208 | } |
| 209 | return out, rows.Err() |
| 210 | } |
| 211 | |
| 212 | // RoomMembers lists a room's members, owners and admins first. |
| 213 | func (s *Service) RoomMembers(ctx context.Context, id string) ([]RoomMember, error) { |
| 214 | rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,role,added_at FROM room_members WHERE room_id=? ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,added_at,pubkey` , id) |
| 215 | if err != nil { |
| 216 | return nil, fmt.Errorf("room members: %w" , err) |
| 217 | } |
| 218 | defer rows.Close() |
| 219 | out := []RoomMember{} |
| 220 | for rows.Next() { |
| 221 | var m RoomMember |
| 222 | if err := rows.Scan(&m.PubKey, &m.Role, &m.AddedAt); err != nil { |
| 223 | return nil, err |
| 224 | } |
| 225 | out = append(out, m) |
| 226 | } |
| 227 | return out, rows.Err() |
| 228 | } |
| 229 | |
| 230 | // RoomMemberCount counts a room's members. |
| 231 | func (s *Service) RoomMemberCount(ctx context.Context, id string) (int, error) { |
| 232 | var n int |
| 233 | err := s.store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room_id=?` , id).Scan(&n) |
| 234 | return n, err |
| 235 | } |
| 236 | |
| 237 | // RoomRole returns the effective role of pubkey in a room: the stored room |
| 238 | // role, or owner and admin for the tenant owner and moderators, who may |
| 239 | // moderate every room. It is empty for people outside the room. |
| 240 | func (s *Service) RoomRole(ctx context.Context, roomID, pubkey string) (string, error) { |
| 241 | if pubkey == "" { |
| 242 | return "" , nil |
| 243 | } |
| 244 | var role string |
| 245 | err := s.store.DB().QueryRowContext(ctx, `SELECT role FROM room_members WHERE room_id=? AND pubkey=?` , roomID, pubkey).Scan(&role) |
| 246 | if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 247 | return "" , fmt.Errorf("room role: %w" , err) |
| 248 | } |
| 249 | if role != "" { |
| 250 | return role, nil |
| 251 | } |
| 252 | tenantRole, err := s.Role(ctx, pubkey) |
| 253 | if err != nil { |
| 254 | return "" , err |
| 255 | } |
| 256 | return impliedRoomRole(tenantRole), nil |
| 257 | } |
| 258 | |
| 259 | func impliedRoomRole(tenantRole string) string { |
| 260 | switch tenantRole { |
| 261 | case "owner" : |
| 262 | return RoomRoleOwner |
| 263 | case "moderator" : |
| 264 | return RoomRoleAdmin |
| 265 | } |
| 266 | return "" |
| 267 | } |
| 268 | |
| 269 | func roomRoleTx(ctx context.Context, tx *sql.Tx, roomID, pubkey string) (string, error) { |
| 270 | var role string |
| 271 | err := tx.QueryRowContext(ctx, `SELECT role FROM room_members WHERE room_id=? AND pubkey=?` , roomID, pubkey).Scan(&role) |
| 272 | if errors.Is(err, sql.ErrNoRows) { |
| 273 | return "" , nil |
| 274 | } |
| 275 | return role, err |
| 276 | } |
| 277 | |
| 278 | func (s *Service) effectiveRoomRoleTx(ctx context.Context, tx *sql.Tx, roomID, pubkey string) (string, error) { |
| 279 | role, err := roomRoleTx(ctx, tx, roomID, pubkey) |
| 280 | if err != nil || role != "" { |
| 281 | return role, err |
| 282 | } |
| 283 | return impliedRoomRole(s.tenantRoleTx(ctx, tx, pubkey)), nil |
| 284 | } |
| 285 | |
| 286 | func (s *Service) tenantRoleTx(ctx context.Context, tx *sql.Tx, pubkey string) string { |
| 287 | if pubkey == s.currentOwner() { |
| 288 | return "owner" |
| 289 | } |
| 290 | var role string |
| 291 | _ = tx.QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?` , pubkey).Scan(&role) |
| 292 | return role |
| 293 | } |
| 294 | |
| 295 | func roomAdmin(role string) bool { return role == RoomRoleOwner || role == RoomRoleAdmin } |
| 296 | |
| 297 | // RoomWriteAllowed decides whether pubkey may post to a room. Members always |
| 298 | // may; an open room also accepts tenant members, who join on their first |
| 299 | // message. The second result reports that such a join is pending. |
| 300 | func (s *Service) RoomWriteAllowed(ctx context.Context, roomID, pubkey string) (bool, bool, error) { |
| 301 | room, err := s.Room(ctx, roomID) |
| 302 | if err != nil || !room.Live() { |
| 303 | return false, false, err |
| 304 | } |
| 305 | role, err := s.RoomRole(ctx, roomID, pubkey) |
| 306 | if err != nil { |
| 307 | return false, false, err |
| 308 | } |
| 309 | if role != "" { |
| 310 | return true, false, nil |
| 311 | } |
| 312 | if room.Access != RoomOpen { |
| 313 | return false, false, nil |
| 314 | } |
| 315 | member, err := s.IsMember(ctx, pubkey) |
| 316 | return member, member, err |
| 317 | } |
| 318 | |
| 319 | // RoomVisible decides whether any of the keys may read a room's events. |
| 320 | // Open rooms follow the tenant read rule; members-only rooms are limited to |
| 321 | // their members, the tenant owner and moderators. |
| 322 | func (s *Service) RoomVisible(ctx context.Context, roomID string, keys []string) (bool, error) { |
| 323 | room, err := s.Room(ctx, roomID) |
| 324 | if err != nil || !room.Live() { |
| 325 | return false, err |
| 326 | } |
| 327 | if room.Access != RoomMembers { |
| 328 | return true, nil |
| 329 | } |
| 330 | for _, key := range keys { |
| 331 | role, err := s.RoomRole(ctx, roomID, key) |
| 332 | if err != nil { |
| 333 | return false, err |
| 334 | } |
| 335 | if role != "" { |
| 336 | return true, nil |
| 337 | } |
| 338 | } |
| 339 | return false, nil |
| 340 | } |
| 341 | |
| 342 | // HandleRoomMessageTx stores a room message and, for an open room, adds a |
| 343 | // tenant member to the room on their first message in the same transaction. |
| 344 | func (s *Service) HandleRoomMessageTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) error { |
| 345 | if persist == nil { |
| 346 | return errors.New("community: nil event persistence callback" ) |
| 347 | } |
| 348 | roomID := tagValue(ev, "h" ) |
| 349 | return s.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 350 | room, err := roomTx(ctx, tx, roomID) |
| 351 | if err != nil { |
| 352 | return err |
| 353 | } |
| 354 | if !room.Live() { |
| 355 | return fmt.Errorf("invalid: unknown room %s" , roomID) |
| 356 | } |
| 357 | role, err := s.effectiveRoomRoleTx(ctx, tx, roomID, ev.PubKey) |
| 358 | if err != nil { |
| 359 | return err |
| 360 | } |
| 361 | if role == "" { |
| 362 | if room.Access != RoomOpen || s.tenantRoleTx(ctx, tx, ev.PubKey) == "" { |
| 363 | return fmt.Errorf("restricted: not a member of room %s" , roomID) |
| 364 | } |
| 365 | if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)` , roomID, ev.PubKey, RoomRoleMember, now()); err != nil { |
| 366 | return err |
| 367 | } |
| 368 | } |
| 369 | return persist(tx) |
| 370 | }) |
| 371 | } |
| 372 | |
| 373 | // HandleRoomEventTx applies a NIP-29 management event addressed to a room |
| 374 | // other than the slug room and stores it in the same transaction. |
| 375 | func (s *Service) HandleRoomEventTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) (MembershipResult, error) { |
| 376 | if persist == nil { |
| 377 | return MembershipResult{}, errors.New("community: nil event persistence callback" ) |
| 378 | } |
| 379 | if !validPubKey(ev.PubKey) { |
| 380 | return MembershipResult{}, errors.New("invalid: event pubkey" ) |
| 381 | } |
| 382 | roomID := tagValue(ev, "h" ) |
| 383 | if !ValidRoomID(roomID) { |
| 384 | return MembershipResult{}, errors.New("invalid: a room id is 1 to 64 characters of a-z, 0-9, - and _" ) |
| 385 | } |
| 386 | if roomID == s.SlugRoom() { |
| 387 | return MembershipResult{}, errors.New("invalid: the main room is managed by the relay" ) |
| 388 | } |
| 389 | if !RoomAdminKind(ev.Kind) { |
| 390 | return MembershipResult{}, fmt.Errorf("unsupported: room event kind %d" , ev.Kind) |
| 391 | } |
| 392 | if ev.Kind == event.KIND_CREATE_INVITE || ev.Kind == event.KIND_PINS { |
| 393 | return MembershipResult{}, fmt.Errorf("unsupported: kind %d is not available inside rooms" , ev.Kind) |
| 394 | } |
| 395 | tenantRole, err := s.Role(ctx, ev.PubKey) |
| 396 | if err != nil { |
| 397 | return MembershipResult{}, err |
| 398 | } |
| 399 | if tenantRole == "" { |
| 400 | return MembershipResult{}, errors.New("restricted: rooms are for members of this relay" ) |
| 401 | } |
| 402 | out := MembershipResult{OK: true, Stored: true} |
| 403 | err = s.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 404 | room, err := roomTx(ctx, tx, roomID) |
| 405 | if err != nil { |
| 406 | return err |
| 407 | } |
| 408 | if ev.Kind == event.KIND_CREATE_GROUP { |
| 409 | return s.createRoomTx(ctx, tx, ev, room, persist) |
| 410 | } |
| 411 | if !room.Live() { |
| 412 | return fmt.Errorf("invalid: unknown room %s" , roomID) |
| 413 | } |
| 414 | role, err := s.effectiveRoomRoleTx(ctx, tx, roomID, ev.PubKey) |
| 415 | if err != nil { |
| 416 | return err |
| 417 | } |
| 418 | switch ev.Kind { |
| 419 | case event.KIND_EDIT_METADATA: |
| 420 | if !roomAdmin(role) { |
| 421 | return errors.New("restricted: only room admins edit the room" ) |
| 422 | } |
| 423 | meta := roomMetadata(ev, room) |
| 424 | if _, err := tx.ExecContext(ctx, `UPDATE rooms SET name=?,about=?,picture=?,access=? WHERE id=?` , meta.Name, meta.About, meta.Picture, meta.Access, roomID); err != nil { |
| 425 | return err |
| 426 | } |
| 427 | if err := enqueueRoomProjectionTx(ctx, tx, ev.ID, roomID, false); err != nil { |
| 428 | return err |
| 429 | } |
| 430 | case event.KIND_PUT_USER: |
| 431 | if !roomAdmin(role) { |
| 432 | return errors.New("restricted: only room admins add members" ) |
| 433 | } |
| 434 | target, wanted := roomTarget(ev) |
| 435 | if !validPubKey(target) { |
| 436 | return errors.New("invalid: membership event needs a p tag" ) |
| 437 | } |
| 438 | if s.tenantRoleTx(ctx, tx, target) == "" { |
| 439 | return errors.New("restricted: the person must be a member of this relay first" ) |
| 440 | } |
| 441 | if wanted == RoomRoleOwner && role != RoomRoleOwner { |
| 442 | return errors.New("restricted: only a room owner appoints owners" ) |
| 443 | } |
| 444 | if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?) ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role` , roomID, target, wanted, now()); err != nil { |
| 445 | return err |
| 446 | } |
| 447 | case event.KIND_REMOVE_USER: |
| 448 | if !roomAdmin(role) { |
| 449 | return errors.New("restricted: only room admins remove members" ) |
| 450 | } |
| 451 | target, _ := roomTarget(ev) |
| 452 | if !validPubKey(target) { |
| 453 | return errors.New("invalid: membership event needs a p tag" ) |
| 454 | } |
| 455 | current, err := roomRoleTx(ctx, tx, roomID, target) |
| 456 | if err != nil { |
| 457 | return err |
| 458 | } |
| 459 | if current == "" { |
| 460 | out = MembershipResult{OK: true, Message: "duplicate: not a member" , Stored: false} |
| 461 | return nil |
| 462 | } |
| 463 | if current == RoomRoleOwner { |
| 464 | if role != RoomRoleOwner { |
| 465 | return errors.New("restricted: only a room owner removes an owner" ) |
| 466 | } |
| 467 | if err := guardLastOwnerTx(ctx, tx, roomID); err != nil { |
| 468 | return err |
| 469 | } |
| 470 | } |
| 471 | if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey=?` , roomID, target); err != nil { |
| 472 | return err |
| 473 | } |
| 474 | case event.KIND_JOIN: |
| 475 | current, err := roomRoleTx(ctx, tx, roomID, ev.PubKey) |
| 476 | if err != nil { |
| 477 | return err |
| 478 | } |
| 479 | if current != "" { |
| 480 | out = MembershipResult{OK: true, Message: "duplicate: already a member" , Stored: false} |
| 481 | return nil |
| 482 | } |
| 483 | if room.Access != RoomOpen { |
| 484 | return errors.New("restricted: this room is members-only; ask a room admin to add you" ) |
| 485 | } |
| 486 | if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)` , roomID, ev.PubKey, RoomRoleMember, now()); err != nil { |
| 487 | return err |
| 488 | } |
| 489 | case event.KIND_LEAVE: |
| 490 | current, err := roomRoleTx(ctx, tx, roomID, ev.PubKey) |
| 491 | if err != nil { |
| 492 | return err |
| 493 | } |
| 494 | if current == "" { |
| 495 | return errors.New("invalid: not a member of this room" ) |
| 496 | } |
| 497 | if current == RoomRoleOwner { |
| 498 | if err := guardLastOwnerTx(ctx, tx, roomID); err != nil { |
| 499 | return err |
| 500 | } |
| 501 | } |
| 502 | if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey=?` , roomID, ev.PubKey); err != nil { |
| 503 | return err |
| 504 | } |
| 505 | case event.KIND_DELETE_EVENT: |
| 506 | ids := []string{} |
| 507 | for _, tag := range ev.Tags { |
| 508 | if len(tag) > 1 && tag[0] == "e" && validPubKey(tag[1]) { |
| 509 | ids = append(ids, tag[1]) |
| 510 | } |
| 511 | } |
| 512 | if len(ids) == 0 { |
| 513 | return errors.New("invalid: delete-event needs an e tag" ) |
| 514 | } |
| 515 | for _, id := range ids { |
| 516 | var author string |
| 517 | err := tx.QueryRowContext(ctx, `SELECT pubkey FROM events WHERE id=? AND EXISTS(SELECT 1 FROM tags WHERE event_id=events.id AND name='h' AND value=?)` , id, roomID).Scan(&author) |
| 518 | if errors.Is(err, sql.ErrNoRows) { |
| 519 | continue |
| 520 | } |
| 521 | if err != nil { |
| 522 | return err |
| 523 | } |
| 524 | if !roomAdmin(role) && author != ev.PubKey { |
| 525 | return errors.New("restricted: only room admins delete other people's messages" ) |
| 526 | } |
| 527 | if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id=?` , id); err != nil { |
| 528 | return err |
| 529 | } |
| 530 | } |
| 531 | case event.KIND_DELETE_GROUP: |
| 532 | if role != RoomRoleOwner { |
| 533 | return errors.New("restricted: only a room owner deletes the room" ) |
| 534 | } |
| 535 | if _, err := tx.ExecContext(ctx, `UPDATE rooms SET deleted_at=? WHERE id=?` , now(), roomID); err != nil { |
| 536 | return err |
| 537 | } |
| 538 | if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=?` , roomID); err != nil { |
| 539 | return err |
| 540 | } |
| 541 | if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id IN (SELECT event_id FROM tags WHERE name='h' AND value=?)` , roomID); err != nil { |
| 542 | return err |
| 543 | } |
| 544 | if err := enqueueRoomProjectionTx(ctx, tx, ev.ID, roomID, true); err != nil { |
| 545 | return err |
| 546 | } |
| 547 | } |
| 548 | if err := s.recordTx(ctx, tx, ev.PubKey, roomAction(ev.Kind), roomID, strings.TrimSpace(tagValue(ev, "p" ))); err != nil { |
| 549 | return err |
| 550 | } |
| 551 | return persist(tx) |
| 552 | }) |
| 553 | return out, err |
| 554 | } |
| 555 | |
| 556 | func (s *Service) createRoomTx(ctx context.Context, tx *sql.Tx, ev event.Event, room Room, persist func(*sql.Tx) error) error { |
| 557 | if room.Live() { |
| 558 | return errors.New("duplicate: a room with this id exists" ) |
| 559 | } |
| 560 | limit := s.currentPolicy().Rooms |
| 561 | if limit <= 0 { |
| 562 | return errors.New("restricted: room creation is switched off on this relay" ) |
| 563 | } |
| 564 | var count int |
| 565 | if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM rooms WHERE deleted_at=0 AND id<>?` , s.SlugRoom()).Scan(&count); err != nil { |
| 566 | return err |
| 567 | } |
| 568 | if count >= limit { |
| 569 | return fmt.Errorf("restricted: this relay allows %d rooms" , limit) |
| 570 | } |
| 571 | meta := roomMetadata(ev, Room{ID: tagValue(ev, "h" ), Access: RoomOpen}) |
| 572 | if meta.Name == "" { |
| 573 | meta.Name = meta.ID |
| 574 | } |
| 575 | if _, err := tx.ExecContext(ctx, `INSERT INTO rooms(id,name,about,picture,access,created_by,created_at,event_id,deleted_at) VALUES(?,?,?,?,?,?,?,?,0) ON CONFLICT(id) DO UPDATE SET name=excluded.name,about=excluded.about,picture=excluded.picture,access=excluded.access,created_by=excluded.created_by,created_at=excluded.created_at,event_id=excluded.event_id,deleted_at=0` , meta.ID, meta.Name, meta.About, meta.Picture, meta.Access, ev.PubKey, now(), ev.ID); err != nil { |
| 576 | return err |
| 577 | } |
| 578 | if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=?` , meta.ID); err != nil { |
| 579 | return err |
| 580 | } |
| 581 | if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)` , meta.ID, ev.PubKey, RoomRoleOwner, now()); err != nil { |
| 582 | return err |
| 583 | } |
| 584 | if err := s.recordTx(ctx, tx, ev.PubKey, "create-room" , meta.ID, meta.Access); err != nil { |
| 585 | return err |
| 586 | } |
| 587 | return persist(tx) |
| 588 | } |
| 589 | |
| 590 | func guardLastOwnerTx(ctx context.Context, tx *sql.Tx, roomID string) error { |
| 591 | var owners int |
| 592 | if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room_id=? AND role='owner'` , roomID).Scan(&owners); err != nil { |
| 593 | return err |
| 594 | } |
| 595 | if owners <= 1 { |
| 596 | return errors.New("restricted: a room keeps at least one owner; appoint another owner first" ) |
| 597 | } |
| 598 | return nil |
| 599 | } |
| 600 | |
| 601 | func enqueueRoomProjectionTx(ctx context.Context, tx *sql.Tx, eventID, roomID string, deleted bool) error { |
| 602 | payload := fmt.Sprintf(`{"action":"room","room":%q}` , roomID) |
| 603 | if deleted { |
| 604 | payload = fmt.Sprintf(`{"action":"room","room":%q,"deleted":1}` , roomID) |
| 605 | } |
| 606 | id := "room-" + roomID + "-" + eventID |
| 607 | stamp := now() |
| 608 | _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)` , id, "records-projection" , id, "room" , payload, stamp, stamp, stamp) |
| 609 | return err |
| 610 | } |
| 611 | |
| 612 | // roomTarget returns the p tag target and the requested room role, which |
| 613 | // defaults to member. |
| 614 | func roomTarget(ev event.Event) (string, string) { |
| 615 | for _, tag := range ev.Tags { |
| 616 | if len(tag) > 1 && tag[0] == "p" { |
| 617 | role := RoomRoleMember |
| 618 | if len(tag) > 2 { |
| 619 | switch strings.ToLower(strings.TrimSpace(tag[2])) { |
| 620 | case RoomRoleOwner: |
| 621 | role = RoomRoleOwner |
| 622 | case RoomRoleAdmin, "moderator" : |
| 623 | role = RoomRoleAdmin |
| 624 | } |
| 625 | } |
| 626 | return tag[1], role |
| 627 | } |
| 628 | } |
| 629 | return "" , RoomRoleMember |
| 630 | } |
| 631 | |
| 632 | // roomMetadata applies the metadata tags of a 9007 or 9002 on top of the |
| 633 | // current room. Buzz's channel_type and NIP-29's visibility markers both |
| 634 | // select the access rule. |
| 635 | func roomMetadata(ev event.Event, current Room) Room { |
| 636 | next := current |
| 637 | for _, tag := range ev.Tags { |
| 638 | if len(tag) == 0 { |
| 639 | continue |
| 640 | } |
| 641 | value := "" |
| 642 | if len(tag) > 1 { |
| 643 | value = strings.TrimSpace(tag[1]) |
| 644 | } |
| 645 | switch tag[0] { |
| 646 | case "name" : |
| 647 | next.Name = value[:min(200, len(value))] |
| 648 | case "about" : |
| 649 | next.About = value[:min(2000, len(value))] |
| 650 | case "picture" : |
| 651 | next.Picture = value[:min(2000, len(value))] |
| 652 | case "visibility" , "channel_type" , "access" : |
| 653 | if access, ok := roomAccessValue(value); ok { |
| 654 | next.Access = access |
| 655 | } |
| 656 | case "open" , "public" : |
| 657 | if len(tag) == 1 { |
| 658 | next.Access = RoomOpen |
| 659 | } |
| 660 | case "closed" , "private" : |
| 661 | if len(tag) == 1 { |
| 662 | next.Access = RoomMembers |
| 663 | } |
| 664 | } |
| 665 | } |
| 666 | if next.Access != RoomMembers { |
| 667 | next.Access = RoomOpen |
| 668 | } |
| 669 | return next |
| 670 | } |
| 671 | |
| 672 | func roomAccessValue(value string) (string, bool) { |
| 673 | switch strings.ToLower(value) { |
| 674 | case "open" , "public" : |
| 675 | return RoomOpen, true |
| 676 | case "members" , "members-only" , "private" , "closed" , "invite" : |
| 677 | return RoomMembers, true |
| 678 | } |
| 679 | return "" , false |
| 680 | } |
| 681 | |
| 682 | func roomAction(kind int) string { |
| 683 | switch kind { |
| 684 | case event.KIND_EDIT_METADATA: |
| 685 | return "edit-room" |
| 686 | case event.KIND_PUT_USER: |
| 687 | return "room-put-user" |
| 688 | case event.KIND_REMOVE_USER: |
| 689 | return "room-remove-user" |
| 690 | case event.KIND_JOIN: |
| 691 | return "room-join" |
| 692 | case event.KIND_LEAVE: |
| 693 | return "room-leave" |
| 694 | case event.KIND_DELETE_EVENT: |
| 695 | return "room-delete-event" |
| 696 | case event.KIND_DELETE_GROUP: |
| 697 | return "delete-room" |
| 698 | } |
| 699 | return "room" |
| 700 | } |