| 1 | diff --git a/internal/community/community.go b/internal/community/community.go |
| 2 | index 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 |
| 23 | diff --git a/internal/community/rooms.go b/internal/community/rooms.go |
| 24 | new file mode 100644 |
| 25 | index 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 | +} |
| 727 | diff --git a/internal/event/kinds.go b/internal/event/kinds.go |
| 728 | index 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 | ) |
| 839 | diff --git a/internal/gates/gates.go b/internal/gates/gates.go |
| 840 | index 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 { |
| 976 | diff --git a/internal/policy/policy.go b/internal/policy/policy.go |
| 977 | index 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) { |
| 1025 | diff --git a/internal/records/rooms.go b/internal/records/rooms.go |
| 1026 | new file mode 100644 |
| 1027 | index 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 | +} |
| 1126 | diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go |
| 1127 | index 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": {}} |