tinyrelayfile
tinyrelay/internal/community/rooms.go/

internal/community/rooms.go raw
1package community
2
3import (
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.
19const roomSchema = `
20CREATE 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);
21CREATE 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));
22CREATE INDEX IF NOT EXISTS room_members_pubkey ON room_members(pubkey);
23CREATE TRIGGER IF NOT EXISTS room_mirror_insert AFTER INSERT ON community_members
24BEGIN
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;
28END;
29CREATE TRIGGER IF NOT EXISTS room_mirror_update AFTER UPDATE OF role ON community_members
30BEGIN
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;
33END;
34CREATE TRIGGER IF NOT EXISTS room_mirror_delete AFTER DELETE ON community_members
35BEGIN
36 DELETE FROM room_members WHERE pubkey=OLD.pubkey;
37END;
38CREATE TRIGGER IF NOT EXISTS room_member_projection_insert AFTER INSERT ON room_members
39BEGIN
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'));
42END;
43CREATE TRIGGER IF NOT EXISTS room_member_projection_update AFTER UPDATE OF role ON room_members
44BEGIN
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'));
47END;
48CREATE TRIGGER IF NOT EXISTS room_member_projection_delete AFTER DELETE ON room_members
49BEGIN
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'));
52END;
53`
54
55const (
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.
65type 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.
78type RoomMember struct {
79 PubKey string `json:"pubkey"`
80 Role string `json:"role"`
81 AddedAt int64 `json:"added_at"`
82}
83
84var roomIDRE = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
85
86// ValidRoomID reports whether id has the shape of a room id.
87func 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.
91func 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.
100func 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.
105func 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.
117func (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.
158func (s *Service) SlugRoom() string {
159 s.mu.RLock()
160 defer s.mu.RUnlock()
161 return s.slug
162}
163
164func (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.
171func (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
175func 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
179func 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.
192func (r Room) Live() bool { return r.ID != "" && r.DeletedAt == 0 }
193
194// Rooms lists the live rooms ordered by id.
195func (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.
213func (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.
231func (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.
240func (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
259func impliedRoomRole(tenantRole string) string {
260 switch tenantRole {
261 case "owner":
262 return RoomRoleOwner
263 case "moderator":
264 return RoomRoleAdmin
265 }
266 return ""
267}
268
269func 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
278func (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
286func (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
295func 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.
300func (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.
322func (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.
344func (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.
375func (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
556func (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
590func 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
601func 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.
614func 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.
635func 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
672func 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
682func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs