tinyrelaycommit

Commit 758f7de87455

browse files at this commit | history

1diff --git a/internal/community/community.go b/internal/community/community.go
2index ef51221..cc8b46f 100644
3--- a/internal/community/community.go
4+++ b/internal/community/community.go
5@@ -90,6 +90,7 @@ type Service struct {
6 store *storage.Store
7 mu sync.RWMutex
8 owner string
9+ slug string
10 policy func() policy.Policy
11 }
12
13@@ -104,6 +105,9 @@ func New(ctx context.Context, store *storage.Store, owner string) (*Service, err
14 if _, err := store.DB().ExecContext(ctx, schema+agentSchema); err != nil {
15 return nil, fmt.Errorf("community schema: %w", err)
16 }
17+ if _, err := store.DB().ExecContext(ctx, roomSchema); err != nil {
18+ return nil, fmt.Errorf("room schema: %w", err)
19+ }
20 if err := store.WithTx(ctx, func(tx *sql.Tx) error {
21 _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO community_members(pubkey,role,created_at) VALUES(?,?,?)`, owner, "owner", time.Now().Unix())
22 return err
23diff --git a/internal/community/rooms.go b/internal/community/rooms.go
24new file mode 100644
25index 0000000..fc51964
26--- /dev/null
27+++ b/internal/community/rooms.go
28@@ -0,0 +1,698 @@
29+package community
30+
31+import (
32+ "context"
33+ "database/sql"
34+ "errors"
35+ "fmt"
36+ "regexp"
37+ "strings"
38+
39+ "github.com/FelineStateMachine/tinyrelay/internal/event"
40+)
41+
42+// Rooms are NIP-29 groups that live inside one tenant. The room whose id
43+// equals the tenant slug mirrors the tenant roster; every other room keeps
44+// its own member list with room-level roles. Membership rows are kept in
45+// step with the tenant roster by triggers so every roster path (management
46+// calls, invites, NIP-29 and NIP-43 events, owner transfer) is covered.
47+const roomSchema = `
48+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);
49+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));
50+CREATE INDEX IF NOT EXISTS room_members_pubkey ON room_members(pubkey);
51+CREATE TRIGGER IF NOT EXISTS room_mirror_insert AFTER INSERT ON community_members
52+BEGIN
53+ INSERT INTO room_members(room_id,pubkey,role,added_at)
54+ 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'
55+ ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role;
56+END;
57+CREATE TRIGGER IF NOT EXISTS room_mirror_update AFTER UPDATE OF role ON community_members
58+BEGIN
59+ UPDATE room_members SET role=CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END
60+ 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;
61+END;
62+CREATE TRIGGER IF NOT EXISTS room_mirror_delete AFTER DELETE ON community_members
63+BEGIN
64+ DELETE FROM room_members WHERE pubkey=OLD.pubkey;
65+END;
66+CREATE TRIGGER IF NOT EXISTS room_member_projection_insert AFTER INSERT ON room_members
67+BEGIN
68+ INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)
69+ 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'));
70+END;
71+CREATE TRIGGER IF NOT EXISTS room_member_projection_update AFTER UPDATE OF role ON room_members
72+BEGIN
73+ INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)
74+ 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'));
75+END;
76+CREATE TRIGGER IF NOT EXISTS room_member_projection_delete AFTER DELETE ON room_members
77+BEGIN
78+ INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)
79+ 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'));
80+END;
81+`
82+
83+const (
84+ RoomOpen = "open"
85+ RoomMembers = "members"
86+
87+ RoomRoleOwner = "owner"
88+ RoomRoleAdmin = "admin"
89+ RoomRoleMember = "member"
90+)
91+
92+// Room is a chat room inside the tenant.
93+type Room struct {
94+ ID string `json:"id"`
95+ Name string `json:"name"`
96+ About string `json:"about"`
97+ Picture string `json:"picture"`
98+ Access string `json:"access"`
99+ CreatedBy string `json:"created_by"`
100+ CreatedAt int64 `json:"created_at"`
101+ EventID string `json:"event_id"`
102+ DeletedAt int64 `json:"deleted_at,omitempty"`
103+}
104+
105+// RoomMember is one member row of a room.
106+type RoomMember struct {
107+ PubKey string `json:"pubkey"`
108+ Role string `json:"role"`
109+ AddedAt int64 `json:"added_at"`
110+}
111+
112+var roomIDRE = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
113+
114+// ValidRoomID reports whether id has the shape of a room id.
115+func ValidRoomID(id string) bool { return roomIDRE.MatchString(id) }
116+
117+// RoomAdminKind reports whether kind is a NIP-29 management event that a
118+// room handles itself when it carries the room's h tag.
119+func RoomAdminKind(kind int) bool {
120+ switch kind {
121+ 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:
122+ return true
123+ }
124+ return false
125+}
126+
127+// RoomNoticeKind reports whether kind is a relay-signed member notice.
128+func RoomNoticeKind(kind int) bool { return kind == event.KIND_ROOM_MEMBER_ADDED || kind == event.KIND_ROOM_MEMBER_REMOVED }
129+
130+// RoomAccessFor maps the tenant read rule onto the slug room's access.
131+func RoomAccessFor(reads string) string {
132+ if reads == "members" {
133+ return RoomMembers
134+ }
135+ return RoomOpen
136+}
137+
138+// EnsureSlugRoom creates or refreshes the room that stands for the tenant's
139+// own group. Its access follows the tenant read rule and its members mirror
140+// the tenant roster: the owner is the room owner and moderators are admins.
141+// The first mirror is silent so an existing roster does not announce every
142+// member as newly added.
143+func (s *Service) EnsureSlugRoom(ctx context.Context, slug, access string) error {
144+ if !ValidRoomID(slug) {
145+ return fmt.Errorf("invalid: room id %q", slug)
146+ }
147+ if access != RoomMembers {
148+ access = RoomOpen
149+ }
150+ err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
151+ 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 {
152+ return err
153+ }
154+ var existing string
155+ err := tx.QueryRowContext(ctx, `SELECT id FROM rooms WHERE id=?`, slug).Scan(&existing)
156+ fresh := errors.Is(err, sql.ErrNoRows)
157+ if err != nil && !fresh {
158+ return err
159+ }
160+ 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 {
161+ return err
162+ }
163+ 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 {
164+ return err
165+ }
166+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey NOT IN (SELECT pubkey FROM community_members)`, slug); err != nil {
167+ return err
168+ }
169+ if fresh {
170+ _, 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)
171+ }
172+ return err
173+ })
174+ if err != nil {
175+ return err
176+ }
177+ s.mu.Lock()
178+ s.slug = slug
179+ s.mu.Unlock()
180+ return nil
181+}
182+
183+// SlugRoom returns the id of the room that mirrors the tenant roster.
184+func (s *Service) SlugRoom() string {
185+ s.mu.RLock()
186+ defer s.mu.RUnlock()
187+ return s.slug
188+}
189+
190+func (s *Service) currentOwner() string {
191+ s.mu.RLock()
192+ defer s.mu.RUnlock()
193+ return s.owner
194+}
195+
196+// Room returns the room row, including a deleted one, or an empty Room.
197+func (s *Service) Room(ctx context.Context, id string) (Room, error) {
198+ 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))
199+}
200+
201+func roomTx(ctx context.Context, tx *sql.Tx, id string) (Room, error) {
202+ return scanRoom(tx.QueryRowContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE id=?`, id))
203+}
204+
205+func scanRoom(row *sql.Row) (Room, error) {
206+ var r Room
207+ err := row.Scan(&r.ID, &r.Name, &r.About, &r.Picture, &r.Access, &r.CreatedBy, &r.CreatedAt, &r.EventID, &r.DeletedAt)
208+ if errors.Is(err, sql.ErrNoRows) {
209+ return Room{}, nil
210+ }
211+ if err != nil {
212+ return Room{}, fmt.Errorf("room: %w", err)
213+ }
214+ return r, nil
215+}
216+
217+// Live reports whether the room exists and has not been deleted.
218+func (r Room) Live() bool { return r.ID != "" && r.DeletedAt == 0 }
219+
220+// Rooms lists the live rooms ordered by id.
221+func (s *Service) Rooms(ctx context.Context) ([]Room, error) {
222+ 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`)
223+ if err != nil {
224+ return nil, fmt.Errorf("rooms: %w", err)
225+ }
226+ defer rows.Close()
227+ out := []Room{}
228+ for rows.Next() {
229+ var r Room
230+ if err := rows.Scan(&r.ID, &r.Name, &r.About, &r.Picture, &r.Access, &r.CreatedBy, &r.CreatedAt, &r.EventID, &r.DeletedAt); err != nil {
231+ return nil, err
232+ }
233+ out = append(out, r)
234+ }
235+ return out, rows.Err()
236+}
237+
238+// RoomMembers lists a room's members, owners and admins first.
239+func (s *Service) RoomMembers(ctx context.Context, id string) ([]RoomMember, error) {
240+ 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)
241+ if err != nil {
242+ return nil, fmt.Errorf("room members: %w", err)
243+ }
244+ defer rows.Close()
245+ out := []RoomMember{}
246+ for rows.Next() {
247+ var m RoomMember
248+ if err := rows.Scan(&m.PubKey, &m.Role, &m.AddedAt); err != nil {
249+ return nil, err
250+ }
251+ out = append(out, m)
252+ }
253+ return out, rows.Err()
254+}
255+
256+// RoomMemberCount counts a room's members.
257+func (s *Service) RoomMemberCount(ctx context.Context, id string) (int, error) {
258+ var n int
259+ err := s.store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room_id=?`, id).Scan(&n)
260+ return n, err
261+}
262+
263+// RoomRole returns the effective role of pubkey in a room: the stored room
264+// role, or owner and admin for the tenant owner and moderators, who may
265+// moderate every room. It is empty for people outside the room.
266+func (s *Service) RoomRole(ctx context.Context, roomID, pubkey string) (string, error) {
267+ if pubkey == "" {
268+ return "", nil
269+ }
270+ var role string
271+ err := s.store.DB().QueryRowContext(ctx, `SELECT role FROM room_members WHERE room_id=? AND pubkey=?`, roomID, pubkey).Scan(&role)
272+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
273+ return "", fmt.Errorf("room role: %w", err)
274+ }
275+ if role != "" {
276+ return role, nil
277+ }
278+ tenantRole, err := s.Role(ctx, pubkey)
279+ if err != nil {
280+ return "", err
281+ }
282+ return impliedRoomRole(tenantRole), nil
283+}
284+
285+func impliedRoomRole(tenantRole string) string {
286+ switch tenantRole {
287+ case "owner":
288+ return RoomRoleOwner
289+ case "moderator":
290+ return RoomRoleAdmin
291+ }
292+ return ""
293+}
294+
295+func roomRoleTx(ctx context.Context, tx *sql.Tx, roomID, pubkey string) (string, error) {
296+ var role string
297+ err := tx.QueryRowContext(ctx, `SELECT role FROM room_members WHERE room_id=? AND pubkey=?`, roomID, pubkey).Scan(&role)
298+ if errors.Is(err, sql.ErrNoRows) {
299+ return "", nil
300+ }
301+ return role, err
302+}
303+
304+func (s *Service) effectiveRoomRoleTx(ctx context.Context, tx *sql.Tx, roomID, pubkey string) (string, error) {
305+ role, err := roomRoleTx(ctx, tx, roomID, pubkey)
306+ if err != nil || role != "" {
307+ return role, err
308+ }
309+ return impliedRoomRole(s.tenantRoleTx(ctx, tx, pubkey)), nil
310+}
311+
312+func (s *Service) tenantRoleTx(ctx context.Context, tx *sql.Tx, pubkey string) string {
313+ if pubkey == s.currentOwner() {
314+ return "owner"
315+ }
316+ var role string
317+ _ = tx.QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, pubkey).Scan(&role)
318+ return role
319+}
320+
321+func roomAdmin(role string) bool { return role == RoomRoleOwner || role == RoomRoleAdmin }
322+
323+// RoomWriteAllowed decides whether pubkey may post to a room. Members always
324+// may; an open room also accepts tenant members, who join on their first
325+// message. The second result reports that such a join is pending.
326+func (s *Service) RoomWriteAllowed(ctx context.Context, roomID, pubkey string) (bool, bool, error) {
327+ room, err := s.Room(ctx, roomID)
328+ if err != nil || !room.Live() {
329+ return false, false, err
330+ }
331+ role, err := s.RoomRole(ctx, roomID, pubkey)
332+ if err != nil {
333+ return false, false, err
334+ }
335+ if role != "" {
336+ return true, false, nil
337+ }
338+ if room.Access != RoomOpen {
339+ return false, false, nil
340+ }
341+ member, err := s.IsMember(ctx, pubkey)
342+ return member, member, err
343+}
344+
345+// RoomVisible decides whether any of the keys may read a room's events.
346+// Open rooms follow the tenant read rule; members-only rooms are limited to
347+// their members, the tenant owner and moderators.
348+func (s *Service) RoomVisible(ctx context.Context, roomID string, keys []string) (bool, error) {
349+ room, err := s.Room(ctx, roomID)
350+ if err != nil || !room.Live() {
351+ return false, err
352+ }
353+ if room.Access != RoomMembers {
354+ return true, nil
355+ }
356+ for _, key := range keys {
357+ role, err := s.RoomRole(ctx, roomID, key)
358+ if err != nil {
359+ return false, err
360+ }
361+ if role != "" {
362+ return true, nil
363+ }
364+ }
365+ return false, nil
366+}
367+
368+// HandleRoomMessageTx stores a room message and, for an open room, adds a
369+// tenant member to the room on their first message in the same transaction.
370+func (s *Service) HandleRoomMessageTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) error {
371+ if persist == nil {
372+ return errors.New("community: nil event persistence callback")
373+ }
374+ roomID := tagValue(ev, "h")
375+ return s.store.WithTx(ctx, func(tx *sql.Tx) error {
376+ room, err := roomTx(ctx, tx, roomID)
377+ if err != nil {
378+ return err
379+ }
380+ if !room.Live() {
381+ return fmt.Errorf("invalid: unknown room %s", roomID)
382+ }
383+ role, err := s.effectiveRoomRoleTx(ctx, tx, roomID, ev.PubKey)
384+ if err != nil {
385+ return err
386+ }
387+ if role == "" {
388+ if room.Access != RoomOpen || s.tenantRoleTx(ctx, tx, ev.PubKey) == "" {
389+ return fmt.Errorf("restricted: not a member of room %s", roomID)
390+ }
391+ 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 {
392+ return err
393+ }
394+ }
395+ return persist(tx)
396+ })
397+}
398+
399+// HandleRoomEventTx applies a NIP-29 management event addressed to a room
400+// other than the slug room and stores it in the same transaction.
401+func (s *Service) HandleRoomEventTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) (MembershipResult, error) {
402+ if persist == nil {
403+ return MembershipResult{}, errors.New("community: nil event persistence callback")
404+ }
405+ if !validPubKey(ev.PubKey) {
406+ return MembershipResult{}, errors.New("invalid: event pubkey")
407+ }
408+ roomID := tagValue(ev, "h")
409+ if !ValidRoomID(roomID) {
410+ return MembershipResult{}, errors.New("invalid: a room id is 1 to 64 characters of a-z, 0-9, - and _")
411+ }
412+ if roomID == s.SlugRoom() {
413+ return MembershipResult{}, errors.New("invalid: the main room is managed by the relay")
414+ }
415+ if !RoomAdminKind(ev.Kind) {
416+ return MembershipResult{}, fmt.Errorf("unsupported: room event kind %d", ev.Kind)
417+ }
418+ if ev.Kind == event.KIND_CREATE_INVITE || ev.Kind == event.KIND_PINS {
419+ return MembershipResult{}, fmt.Errorf("unsupported: kind %d is not available inside rooms", ev.Kind)
420+ }
421+ tenantRole, err := s.Role(ctx, ev.PubKey)
422+ if err != nil {
423+ return MembershipResult{}, err
424+ }
425+ if tenantRole == "" {
426+ return MembershipResult{}, errors.New("restricted: rooms are for members of this relay")
427+ }
428+ out := MembershipResult{OK: true, Stored: true}
429+ err = s.store.WithTx(ctx, func(tx *sql.Tx) error {
430+ room, err := roomTx(ctx, tx, roomID)
431+ if err != nil {
432+ return err
433+ }
434+ if ev.Kind == event.KIND_CREATE_GROUP {
435+ return s.createRoomTx(ctx, tx, ev, room, persist)
436+ }
437+ if !room.Live() {
438+ return fmt.Errorf("invalid: unknown room %s", roomID)
439+ }
440+ role, err := s.effectiveRoomRoleTx(ctx, tx, roomID, ev.PubKey)
441+ if err != nil {
442+ return err
443+ }
444+ switch ev.Kind {
445+ case event.KIND_EDIT_METADATA:
446+ if !roomAdmin(role) {
447+ return errors.New("restricted: only room admins edit the room")
448+ }
449+ meta := roomMetadata(ev, room)
450+ 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 {
451+ return err
452+ }
453+ if err := enqueueRoomProjectionTx(ctx, tx, ev.ID, roomID, false); err != nil {
454+ return err
455+ }
456+ case event.KIND_PUT_USER:
457+ if !roomAdmin(role) {
458+ return errors.New("restricted: only room admins add members")
459+ }
460+ target, wanted := roomTarget(ev)
461+ if !validPubKey(target) {
462+ return errors.New("invalid: membership event needs a p tag")
463+ }
464+ if s.tenantRoleTx(ctx, tx, target) == "" {
465+ return errors.New("restricted: the person must be a member of this relay first")
466+ }
467+ if wanted == RoomRoleOwner && role != RoomRoleOwner {
468+ return errors.New("restricted: only a room owner appoints owners")
469+ }
470+ 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 {
471+ return err
472+ }
473+ case event.KIND_REMOVE_USER:
474+ if !roomAdmin(role) {
475+ return errors.New("restricted: only room admins remove members")
476+ }
477+ target, _ := roomTarget(ev)
478+ if !validPubKey(target) {
479+ return errors.New("invalid: membership event needs a p tag")
480+ }
481+ current, err := roomRoleTx(ctx, tx, roomID, target)
482+ if err != nil {
483+ return err
484+ }
485+ if current == "" {
486+ out = MembershipResult{OK: true, Message: "duplicate: not a member", Stored: false}
487+ return nil
488+ }
489+ if current == RoomRoleOwner {
490+ if role != RoomRoleOwner {
491+ return errors.New("restricted: only a room owner removes an owner")
492+ }
493+ if err := guardLastOwnerTx(ctx, tx, roomID); err != nil {
494+ return err
495+ }
496+ }
497+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey=?`, roomID, target); err != nil {
498+ return err
499+ }
500+ case event.KIND_JOIN:
501+ current, err := roomRoleTx(ctx, tx, roomID, ev.PubKey)
502+ if err != nil {
503+ return err
504+ }
505+ if current != "" {
506+ out = MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}
507+ return nil
508+ }
509+ if room.Access != RoomOpen {
510+ return errors.New("restricted: this room is members-only; ask a room admin to add you")
511+ }
512+ if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)`, roomID, ev.PubKey, RoomRoleMember, now()); err != nil {
513+ return err
514+ }
515+ case event.KIND_LEAVE:
516+ current, err := roomRoleTx(ctx, tx, roomID, ev.PubKey)
517+ if err != nil {
518+ return err
519+ }
520+ if current == "" {
521+ return errors.New("invalid: not a member of this room")
522+ }
523+ if current == RoomRoleOwner {
524+ if err := guardLastOwnerTx(ctx, tx, roomID); err != nil {
525+ return err
526+ }
527+ }
528+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey=?`, roomID, ev.PubKey); err != nil {
529+ return err
530+ }
531+ case event.KIND_DELETE_EVENT:
532+ ids := []string{}
533+ for _, tag := range ev.Tags {
534+ if len(tag) > 1 && tag[0] == "e" && validPubKey(tag[1]) {
535+ ids = append(ids, tag[1])
536+ }
537+ }
538+ if len(ids) == 0 {
539+ return errors.New("invalid: delete-event needs an e tag")
540+ }
541+ for _, id := range ids {
542+ var author string
543+ 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)
544+ if errors.Is(err, sql.ErrNoRows) {
545+ continue
546+ }
547+ if err != nil {
548+ return err
549+ }
550+ if !roomAdmin(role) && author != ev.PubKey {
551+ return errors.New("restricted: only room admins delete other people's messages")
552+ }
553+ if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id=?`, id); err != nil {
554+ return err
555+ }
556+ }
557+ case event.KIND_DELETE_GROUP:
558+ if role != RoomRoleOwner {
559+ return errors.New("restricted: only a room owner deletes the room")
560+ }
561+ if _, err := tx.ExecContext(ctx, `UPDATE rooms SET deleted_at=? WHERE id=?`, now(), roomID); err != nil {
562+ return err
563+ }
564+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=?`, roomID); err != nil {
565+ return err
566+ }
567+ if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id IN (SELECT event_id FROM tags WHERE name='h' AND value=?)`, roomID); err != nil {
568+ return err
569+ }
570+ if err := enqueueRoomProjectionTx(ctx, tx, ev.ID, roomID, true); err != nil {
571+ return err
572+ }
573+ }
574+ if err := s.recordTx(ctx, tx, ev.PubKey, roomAction(ev.Kind), roomID, strings.TrimSpace(tagValue(ev, "p"))); err != nil {
575+ return err
576+ }
577+ return persist(tx)
578+ })
579+ return out, err
580+}
581+
582+func (s *Service) createRoomTx(ctx context.Context, tx *sql.Tx, ev event.Event, room Room, persist func(*sql.Tx) error) error {
583+ if room.Live() {
584+ return errors.New("duplicate: a room with this id exists")
585+ }
586+ limit := s.currentPolicy().Rooms
587+ if limit <= 0 {
588+ return errors.New("restricted: room creation is switched off on this relay")
589+ }
590+ var count int
591+ if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM rooms WHERE deleted_at=0 AND id<>?`, s.SlugRoom()).Scan(&count); err != nil {
592+ return err
593+ }
594+ if count >= limit {
595+ return fmt.Errorf("restricted: this relay allows %d rooms", limit)
596+ }
597+ meta := roomMetadata(ev, Room{ID: tagValue(ev, "h"), Access: RoomOpen})
598+ if meta.Name == "" {
599+ meta.Name = meta.ID
600+ }
601+ 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 {
602+ return err
603+ }
604+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=?`, meta.ID); err != nil {
605+ return err
606+ }
607+ if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)`, meta.ID, ev.PubKey, RoomRoleOwner, now()); err != nil {
608+ return err
609+ }
610+ if err := s.recordTx(ctx, tx, ev.PubKey, "create-room", meta.ID, meta.Access); err != nil {
611+ return err
612+ }
613+ return persist(tx)
614+}
615+
616+func guardLastOwnerTx(ctx context.Context, tx *sql.Tx, roomID string) error {
617+ var owners int
618+ if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room_id=? AND role='owner'`, roomID).Scan(&owners); err != nil {
619+ return err
620+ }
621+ if owners <= 1 {
622+ return errors.New("restricted: a room keeps at least one owner; appoint another owner first")
623+ }
624+ return nil
625+}
626+
627+func enqueueRoomProjectionTx(ctx context.Context, tx *sql.Tx, eventID, roomID string, deleted bool) error {
628+ payload := fmt.Sprintf(`{"action":"room","room":%q}`, roomID)
629+ if deleted {
630+ payload = fmt.Sprintf(`{"action":"room","room":%q,"deleted":1}`, roomID)
631+ }
632+ id := "room-" + roomID + "-" + eventID
633+ stamp := now()
634+ _, 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)
635+ return err
636+}
637+
638+// roomTarget returns the p tag target and the requested room role, which
639+// defaults to member.
640+func roomTarget(ev event.Event) (string, string) {
641+ for _, tag := range ev.Tags {
642+ if len(tag) > 1 && tag[0] == "p" {
643+ role := RoomRoleMember
644+ if len(tag) > 2 {
645+ switch strings.ToLower(strings.TrimSpace(tag[2])) {
646+ case RoomRoleOwner:
647+ role = RoomRoleOwner
648+ case RoomRoleAdmin, "moderator":
649+ role = RoomRoleAdmin
650+ }
651+ }
652+ return tag[1], role
653+ }
654+ }
655+ return "", RoomRoleMember
656+}
657+
658+// roomMetadata applies the metadata tags of a 9007 or 9002 on top of the
659+// current room. Buzz's channel_type and NIP-29's visibility markers both
660+// select the access rule.
661+func roomMetadata(ev event.Event, current Room) Room {
662+ next := current
663+ for _, tag := range ev.Tags {
664+ if len(tag) == 0 {
665+ continue
666+ }
667+ value := ""
668+ if len(tag) > 1 {
669+ value = strings.TrimSpace(tag[1])
670+ }
671+ switch tag[0] {
672+ case "name":
673+ next.Name = value[:min(200, len(value))]
674+ case "about":
675+ next.About = value[:min(2000, len(value))]
676+ case "picture":
677+ next.Picture = value[:min(2000, len(value))]
678+ case "visibility", "channel_type", "access":
679+ if access, ok := roomAccessValue(value); ok {
680+ next.Access = access
681+ }
682+ case "open", "public":
683+ if len(tag) == 1 {
684+ next.Access = RoomOpen
685+ }
686+ case "closed", "private":
687+ if len(tag) == 1 {
688+ next.Access = RoomMembers
689+ }
690+ }
691+ }
692+ if next.Access != RoomMembers {
693+ next.Access = RoomOpen
694+ }
695+ return next
696+}
697+
698+func roomAccessValue(value string) (string, bool) {
699+ switch strings.ToLower(value) {
700+ case "open", "public":
701+ return RoomOpen, true
702+ case "members", "members-only", "private", "closed", "invite":
703+ return RoomMembers, true
704+ }
705+ return "", false
706+}
707+
708+func roomAction(kind int) string {
709+ switch kind {
710+ case event.KIND_EDIT_METADATA:
711+ return "edit-room"
712+ case event.KIND_PUT_USER:
713+ return "room-put-user"
714+ case event.KIND_REMOVE_USER:
715+ return "room-remove-user"
716+ case event.KIND_JOIN:
717+ return "room-join"
718+ case event.KIND_LEAVE:
719+ return "room-leave"
720+ case event.KIND_DELETE_EVENT:
721+ return "room-delete-event"
722+ case event.KIND_DELETE_GROUP:
723+ return "delete-room"
724+ }
725+ return "room"
726+}
727diff --git a/internal/event/kinds.go b/internal/event/kinds.go
728index 77e5a8c..4405f49 100644
729--- a/internal/event/kinds.go
730+++ b/internal/event/kinds.go
731@@ -1,51 +1,60 @@
732 package event
733
734 const (
735- KIND_PROFILE = 0
736- KIND_DELETION = 5
737- KIND_VANISH = 62
738- KIND_AUTH = 22242
739- KIND_DM = 14
740- KIND_WRAP = 1059
741- KIND_REPORT = 1984
742- KIND_NOSTR_CONNECT = 24133
743- KIND_APP_DATA = 30078
744- KIND_VIEW = KIND_APP_DATA
745- KIND_PRESENCE = 20078
746- KIND_MEMBER_ADDED = 8000
747- KIND_MEMBER_REMOVED = 8001
748- KIND_ROSTER = 13534
749- KIND_ROLE_DEF = 33534
750- KIND_NIP43_JOIN = 28934
751- KIND_NIP43_INVITE = 28935
752- KIND_NIP43_LEAVE = 28936
753- KIND_PUT_USER = 9000
754- KIND_REMOVE_USER = 9001
755- KIND_EDIT_METADATA = 9002
756- KIND_DELETE_EVENT = 9005
757- KIND_CREATE_GROUP = 9007
758- KIND_DELETE_GROUP = 9008
759- KIND_CREATE_INVITE = 9009
760- KIND_PINS = 9010
761- KIND_JOIN = 9021
762- KIND_LEAVE = 9022
763- KIND_GROUP_METADATA = 39000
764- KIND_GROUP_ADMINS = 39001
765- KIND_GROUP_MEMBERS = 39002
766- KIND_GROUP_ROLES = 39003
767- KIND_GROUP_PINS = 39005
768- KIND_RELAY_DISCOVERY = 30166
769- KIND_SITE = 15128
770- KIND_NAMED_SITE = 35128
771- KIND_SITE_SNAPSHOT = 5128
772- KIND_MARMOT_GROUP = 445
773- KIND_MARMOT_KEY_PACKAGE = 30443
774- KIND_REPO = 30617
775- KIND_REPO_STATE = 30618
776- KIND_GIT_PATCH = 1617
777- KIND_GIT_PR = 1618
778- KIND_GIT_PR_UPDATE = 1619
779- KIND_GIT_ISSUE = 1621
780- KIND_PUSH_REGISTRATION = 30390
781- KIND_AGENT_GRANT = 30392
782+ KIND_PROFILE = 0
783+ KIND_DELETION = 5
784+ KIND_VANISH = 62
785+ KIND_AUTH = 22242
786+ KIND_DM = 14
787+ KIND_WRAP = 1059
788+ KIND_REPORT = 1984
789+ KIND_NOSTR_CONNECT = 24133
790+ KIND_APP_DATA = 30078
791+ KIND_VIEW = KIND_APP_DATA
792+ KIND_PRESENCE = 20078
793+ KIND_MEMBER_ADDED = 8000
794+ KIND_MEMBER_REMOVED = 8001
795+ KIND_ROSTER = 13534
796+ KIND_ROLE_DEF = 33534
797+ KIND_NIP43_JOIN = 28934
798+ KIND_NIP43_INVITE = 28935
799+ KIND_NIP43_LEAVE = 28936
800+ KIND_PUT_USER = 9000
801+ KIND_REMOVE_USER = 9001
802+ KIND_EDIT_METADATA = 9002
803+ KIND_DELETE_EVENT = 9005
804+ KIND_CREATE_GROUP = 9007
805+ KIND_DELETE_GROUP = 9008
806+ KIND_CREATE_INVITE = 9009
807+ KIND_PINS = 9010
808+ KIND_JOIN = 9021
809+ KIND_LEAVE = 9022
810+ KIND_GROUP_METADATA = 39000
811+ KIND_GROUP_ADMINS = 39001
812+ KIND_GROUP_MEMBERS = 39002
813+ KIND_GROUP_ROLES = 39003
814+ KIND_GROUP_PINS = 39005
815+ KIND_RELAY_DISCOVERY = 30166
816+ KIND_SITE = 15128
817+ KIND_NAMED_SITE = 35128
818+ KIND_SITE_SNAPSHOT = 5128
819+ KIND_MARMOT_GROUP = 445
820+ KIND_MARMOT_KEY_PACKAGE = 30443
821+ KIND_REPO = 30617
822+ KIND_REPO_STATE = 30618
823+ KIND_GIT_PATCH = 1617
824+ KIND_GIT_PR = 1618
825+ KIND_GIT_PR_UPDATE = 1619
826+ KIND_GIT_ISSUE = 1621
827+ KIND_PUSH_REGISTRATION = 30390
828+ KIND_CHAT = 9
829+ KIND_THREAD = 11
830+ KIND_THREAD_REPLY = 12
831+ KIND_ROOM_PRESENCE = 20001
832+ KIND_ROOM_TYPING = 20002
833+ KIND_RICH_CONTENT = 40002
834+ KIND_CONTENT_EDIT = 40003
835+ KIND_ROOM_MEMBER_ADDED = 44100
836+ KIND_ROOM_MEMBER_REMOVED = 44101
837+ KIND_AGENT_GRANT = 30392
838 )
839diff --git a/internal/gates/gates.go b/internal/gates/gates.go
840index 46db622..2837a4f 100644
841--- a/internal/gates/gates.go
842+++ b/internal/gates/gates.go
843@@ -192,8 +192,8 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
844 }
845 return nil
846 }
847- if tag := event.Tag(e, "h"); tag != "" && tag != g.cfg.Slug && e.Kind != event.KIND_MARMOT_GROUP {
848- return fmt.Errorf("blocked: this relay hosts one group: %s", g.cfg.Slug)
849+ if err := g.roomWrite(ctx, e); err != nil {
850+ return err
851 }
852 if p.Inbox.Targeted && !inboxAdmission(p, e) {
853 return errors.New("blocked: inbox event must target the relay owner")
854@@ -327,12 +327,100 @@ func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {
855 return err
856 }
857 }
858- if tag := event.Tag(e, "h"); tag != "" && tag != g.cfg.Slug && e.Kind != event.KIND_MARMOT_GROUP {
859+ if err := g.roomExists(ctx, e); err != nil {
860+ return err
861+ }
862+ return nil
863+}
864+
865+// roomID names the room an event belongs to, or nothing for events outside
866+// the room dialect. Marmot group messages reuse the h tag for other ends.
867+func (g *Gate) roomID(e event.Event) string {
868+ if e.Kind == event.KIND_MARMOT_GROUP {
869+ return ""
870+ }
871+ return event.Tag(e, "h")
872+}
873+
874+// roomExists is the common room check: an h tag must name the slug room or
875+// a live room. Room creation is the one event allowed to name a new room.
876+func (g *Gate) roomExists(ctx context.Context, e event.Event) error {
877+ id := g.roomID(e)
878+ if id == "" || id == g.cfg.Slug || e.Kind == event.KIND_CREATE_GROUP {
879+ return nil
880+ }
881+ if g.cfg.Community == nil {
882 return fmt.Errorf("blocked: this relay hosts one group: %s", g.cfg.Slug)
883 }
884+ room, err := g.cfg.Community.Room(ctx, id)
885+ if err != nil {
886+ return fmt.Errorf("check room: %w", err)
887+ }
888+ if !room.Live() {
889+ return fmt.Errorf("invalid: unknown room %s", id)
890+ }
891 return nil
892 }
893
894+// roomWrite applies room membership to client writes. The slug room keeps
895+// the tenant's own rules. In other rooms, management events are checked by
896+// the room handler; every other event needs room membership, which an open
897+// room grants to any tenant member.
898+func (g *Gate) roomWrite(ctx context.Context, e event.Event) error {
899+ if err := g.roomExists(ctx, e); err != nil {
900+ return err
901+ }
902+ id := g.roomID(e)
903+ if id != "" && community.RoomNoticeKind(e.Kind) {
904+ return errors.New("blocked: member notices are signed by the relay")
905+ }
906+ if id == "" || id == g.cfg.Slug || e.Kind == event.KIND_CREATE_GROUP || community.RoomAdminKind(e.Kind) {
907+ return nil
908+ }
909+ allowed, _, err := g.cfg.Community.RoomWriteAllowed(ctx, id, e.PubKey)
910+ if err != nil {
911+ return fmt.Errorf("check room membership: %w", err)
912+ }
913+ if !allowed {
914+ return fmt.Errorf("restricted: not a member of room %s", id)
915+ }
916+ return nil
917+}
918+
919+// roomVisible applies room access to reads. Members-only rooms, including
920+// the relay's own 39000 to 39002 records for them, are served only to room
921+// members, the tenant owner and moderators.
922+func (g *Gate) roomVisible(ctx context.Context, e event.Event, s relay.Session) bool {
923+ id := g.roomID(e)
924+ if id == "" && (e.Kind == event.KIND_GROUP_METADATA || e.Kind == event.KIND_GROUP_ADMINS || e.Kind == event.KIND_GROUP_MEMBERS) {
925+ id = event.Tag(e, "d")
926+ }
927+ if id == "" || id == g.cfg.Slug || g.cfg.Community == nil {
928+ return true
929+ }
930+ visible, err := g.cfg.Community.RoomVisible(ctx, id, s.PubKeys)
931+ return err == nil && visible
932+}
933+
934+// membersOnlyRoomFilter reports whether a filter asks for a members-only
935+// room, so an unauthenticated subscriber is told to AUTH instead of waiting
936+// on an empty stream.
937+func (g *Gate) membersOnlyRoomFilter(ctx context.Context, f event.Filter) bool {
938+ if g.cfg.Community == nil {
939+ return false
940+ }
941+ for _, id := range f.Tags["h"] {
942+ if id == g.cfg.Slug {
943+ continue
944+ }
945+ room, err := g.cfg.Community.Room(ctx, id)
946+ if err == nil && room.Live() && room.Access == community.RoomMembers {
947+ return true
948+ }
949+ }
950+ return false
951+}
952+
953 func (g *Gate) kindRules(ctx context.Context, p policy.Policy, kind int) (bool, bool, error) {
954 if g.cfg.Community == nil {
955 return len(p.AllowedKinds) == 0 || containsInt(p.AllowedKinds, kind), containsInt(p.BlockedKinds, kind), nil
956@@ -528,6 +616,9 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session
957 privateOnly := true
958 authHint := false
959 for _, f := range filters {
960+ if len(a.PubKeys) == 0 && g.membersOnlyRoomFilter(ctx, f) {
961+ authHint = true
962+ }
963 if len(f.Kinds) == 0 {
964 privateOnly = false
965 authHint = true
966@@ -589,6 +680,9 @@ func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *ev
967 if !policy.CanRead(p, e, a) {
968 return false
969 }
970+ if !g.roomVisible(ctx, e, s) {
971+ return false
972+ }
973 if event.IsPrivate(e.Kind) {
974 for _, key := range s.PubKeys {
975 if key == e.PubKey {
976diff --git a/internal/policy/policy.go b/internal/policy/policy.go
977index 49599ad..4332396 100644
978--- a/internal/policy/policy.go
979+++ b/internal/policy/policy.go
980@@ -121,13 +121,31 @@ type Policy struct {
981 Inbox Inbox `json:"inbox"`
982 Dumps string `json:"dumps"`
983 DumpsKeep int `json:"dumpsKeep"`
984- BlockedKinds []int `json:"-"`
985- AllowedKinds []int `json:"-"`
986- Retention []RetentionRule `json:"-"`
987+ // Rooms caps the chat rooms a tenant may create beside its main group.
988+ // Zero switches room creation off.
989+ Rooms int `json:"rooms"`
990+ BlockedKinds []int `json:"-"`
991+ AllowedKinds []int `json:"-"`
992+ Retention []RetentionRule `json:"-"`
993+}
994+
995+// DefaultRooms is the room allowance applied when a policy does not name one.
996+const DefaultRooms = 64
997+
998+// UnmarshalJSON keeps the room allowance at its default when a stored policy
999+// predates the field, so existing tenants do not lose room creation.
1000+func (p *Policy) UnmarshalJSON(data []byte) error {
1001+ type plain Policy
1002+ decoded := plain{Rooms: DefaultRooms}
1003+ if err := json.Unmarshal(data, &decoded); err != nil {
1004+ return err
1005+ }
1006+ *p = Policy(decoded)
1007+ return nil
1008 }
1009
1010 func Defaults(owner string) Policy {
1011- return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7,
1012+ return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7, Rooms: DefaultRooms,
1013 Features: Features{Search: "prose", Sync: true, Count: true, Discovery: true, Names: true, Files: true, Pages: true, Signer: true, Sites: Sites{Enabled: true, Mirror: true}},
1014 Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}}
1015 }
1016@@ -189,7 +207,7 @@ func Validate(p Policy) error {
1017 }
1018 }
1019 }
1020- if p.ReportThreshold < 0 || p.MinPow < 0 || p.MaxFuture < 0 || p.MemberInvites.Depth < 0 || p.MemberInvites.Quota < 0 {
1021+ if p.ReportThreshold < 0 || p.MinPow < 0 || p.MaxFuture < 0 || p.MemberInvites.Depth < 0 || p.MemberInvites.Quota < 0 || p.Rooms < 0 {
1022 return errors.New("policy: numeric values cannot be negative")
1023 }
1024 if p.Succession != nil && (p.Succession.Heir == "" || p.Succession.AfterDays <= 0) {
1025diff --git a/internal/records/rooms.go b/internal/records/rooms.go
1026new file mode 100644
1027index 0000000..f3f1e91
1028--- /dev/null
1029+++ b/internal/records/rooms.go
1030@@ -0,0 +1,95 @@
1031+package records
1032+
1033+import (
1034+ "context"
1035+ "database/sql"
1036+ "errors"
1037+ "fmt"
1038+
1039+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1040+)
1041+
1042+// PublishRoom emits the relay-signed metadata (39000), admins (39001) and
1043+// members (39002) records for one room. A room that no longer exists has its
1044+// records retired instead. The slug room is covered by PublishMembership.
1045+func (s *Service) PublishRoom(ctx context.Context, roomID string, now int64) ([]event.Event, error) {
1046+ var name, about, picture, access string
1047+ var deleted int64
1048+ err := s.store.DB().QueryRowContext(ctx, `SELECT name,about,picture,access,deleted_at FROM rooms WHERE id=?`, roomID).Scan(&name, &about, &picture, &access, &deleted)
1049+ if errors.Is(err, sql.ErrNoRows) || (err == nil && deleted > 0) {
1050+ return nil, s.RetireRoom(ctx, roomID)
1051+ }
1052+ if err != nil {
1053+ return nil, fmt.Errorf("room record: %w", err)
1054+ }
1055+ rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,role FROM room_members WHERE room_id=? ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,pubkey`, roomID)
1056+ if err != nil {
1057+ return nil, fmt.Errorf("room members: %w", err)
1058+ }
1059+ defer rows.Close()
1060+ var admins, members [][]string
1061+ for rows.Next() {
1062+ var pk, role string
1063+ if err := rows.Scan(&pk, &role); err != nil {
1064+ return nil, err
1065+ }
1066+ members = append(members, []string{"p", pk})
1067+ if role == "owner" || role == "admin" {
1068+ admins = append(admins, []string{"p", pk, role})
1069+ }
1070+ }
1071+ if err := rows.Err(); err != nil {
1072+ return nil, err
1073+ }
1074+ p := s.policy()
1075+ if access == "open" && !p.DirectoryPublic {
1076+ members = nil
1077+ }
1078+ meta := [][]string{{"-"}, {"d", roomID}, {"name", name}, {"about", about}}
1079+ if picture != "" {
1080+ meta = append(meta, []string{"picture", picture})
1081+ }
1082+ if access == "members" {
1083+ meta = append(meta, []string{"closed"})
1084+ } else {
1085+ meta = append(meta, []string{"open"})
1086+ }
1087+ if p.Reads == "members" {
1088+ meta = append(meta, []string{"private"})
1089+ } else {
1090+ meta = append(meta, []string{"public"})
1091+ }
1092+ vals := []struct {
1093+ kind int
1094+ tags [][]string
1095+ }{
1096+ {event.KIND_GROUP_METADATA, meta},
1097+ {event.KIND_GROUP_ADMINS, append([][]string{{"-"}, {"d", roomID}}, admins...)},
1098+ {event.KIND_GROUP_MEMBERS, append([][]string{{"-"}, {"d", roomID}}, members...)},
1099+ }
1100+ out := make([]event.Event, 0, len(vals))
1101+ for _, v := range vals {
1102+ e, err := s.signed(ctx, v.kind, v.tags, "", now)
1103+ if err != nil {
1104+ return nil, err
1105+ }
1106+ out = append(out, e)
1107+ }
1108+ return out, nil
1109+}
1110+
1111+// RoomNotice emits the relay-signed member notice a Buzz-compatible client
1112+// shows when someone is added to (44100) or removed from (44101) a room.
1113+func (s *Service) RoomNotice(ctx context.Context, roomID, pubkey string, added bool, now int64) (event.Event, error) {
1114+ kind := event.KIND_ROOM_MEMBER_REMOVED
1115+ if added {
1116+ kind = event.KIND_ROOM_MEMBER_ADDED
1117+ }
1118+ return s.signed(ctx, kind, [][]string{{"h", roomID}, {"p", pubkey}}, "", now)
1119+}
1120+
1121+// RetireRoom removes the relay's own records for a room that was deleted.
1122+func (s *Service) RetireRoom(ctx context.Context, roomID string) error {
1123+ _, err := s.store.DB().ExecContext(ctx, `DELETE FROM events WHERE pubkey=? AND kind IN (?,?,?) AND d=?`, s.PublicKey(), event.KIND_GROUP_METADATA, event.KIND_GROUP_ADMINS, event.KIND_GROUP_MEMBERS, roomID)
1124+ return err
1125+}
1126diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
1127index efb28bd..690c7df 100644
1128--- a/internal/telemetry/telemetry.go
1129+++ b/internal/telemetry/telemetry.go
1130@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
1131 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
1132 }
1133
1134-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}}
1135+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}}
1136 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}
1137 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}
1138 var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}}

About

ownerc01503e8035f
visibilitypublic
head758f7de87455

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs