clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / internal / community / rooms.go
rooms.go 25.1K
1package community23import (4 "context"5 "database/sql"6 "errors"7 "fmt"8 "regexp"9 "strings"1011 "github.com/FelineStateMachine/tinyrelay/internal/event"12)1314// Rooms are NIP-29 groups that live inside one tenant. The room whose id15// equals the tenant slug mirrors the tenant roster; every other room keeps16// its own member list with room-level roles. Membership rows are kept in17// step with the tenant roster by triggers so every roster path (management18// calls, invites, NIP-29 and NIP-43 events, owner transfer) is covered.19const roomSchema = `20CREATE TABLE IF NOT EXISTS rooms(id TEXT PRIMARY KEY,name TEXT NOT NULL DEFAULT '',about TEXT NOT NULL DEFAULT '',picture TEXT NOT NULL DEFAULT '',access TEXT NOT NULL DEFAULT 'open',created_by TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,event_id TEXT NOT NULL DEFAULT '',deleted_at INTEGER NOT NULL DEFAULT 0);21CREATE TABLE IF NOT EXISTS room_members(room_id TEXT NOT NULL,pubkey TEXT NOT NULL,role TEXT NOT NULL DEFAULT 'member',added_at INTEGER NOT NULL,PRIMARY KEY(room_id,pubkey));22CREATE INDEX IF NOT EXISTS room_members_pubkey ON room_members(pubkey);23CREATE TRIGGER IF NOT EXISTS room_mirror_insert AFTER INSERT ON community_members24BEGIN25 INSERT INTO room_members(room_id,pubkey,role,added_at)26 SELECT value,NEW.pubkey,CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END,NEW.created_at FROM community_meta WHERE key='rooms.slug'27 ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role;28END;29CREATE TRIGGER IF NOT EXISTS room_mirror_update AFTER UPDATE OF role ON community_members30BEGIN31 UPDATE room_members SET role=CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END32 WHERE pubkey=NEW.pubkey AND room_id IN (SELECT value FROM community_meta WHERE key='rooms.slug') AND role<>CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END;33END;34CREATE TRIGGER IF NOT EXISTS room_mirror_delete AFTER DELETE ON community_members35BEGIN36 DELETE FROM room_members WHERE pubkey=OLD.pubkey;37END;38CREATE TRIGGER IF NOT EXISTS room_member_projection_insert AFTER INSERT ON room_members39BEGIN40 INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)41 VALUES('room-member-'||lower(hex(randomblob(16))),'records-projection','room-member-'||lower(hex(randomblob(16))),'room',json_object('action','room','room',NEW.room_id,'pubkey',NEW.pubkey,'role',NEW.role),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));42END;43CREATE TRIGGER IF NOT EXISTS room_member_projection_update AFTER UPDATE OF role ON room_members44BEGIN45 INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)46 VALUES('room-role-'||lower(hex(randomblob(16))),'records-projection','room-role-'||lower(hex(randomblob(16))),'room',json_object('action','room','room',NEW.room_id),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));47END;48CREATE TRIGGER IF NOT EXISTS room_member_projection_delete AFTER DELETE ON room_members49BEGIN50 INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)51 VALUES('room-member-delete-'||lower(hex(randomblob(16))),'records-projection','room-member-delete-'||lower(hex(randomblob(16))),'room',json_object('action','room','room',OLD.room_id,'pubkey',OLD.pubkey,'role',OLD.role,'removed',1),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));52END;53`5455const (56 RoomOpen = "open"57 RoomMembers = "members"5859 RoomRoleOwner = "owner"60 RoomRoleAdmin = "admin"61 RoomRoleMember = "member"62)6364// Room is a chat room inside the tenant.65type Room struct {66 ID string `json:"id"`67 Name string `json:"name"`68 About string `json:"about"`69 Picture string `json:"picture"`70 Access string `json:"access"`71 CreatedBy string `json:"created_by"`72 CreatedAt int64 `json:"created_at"`73 EventID string `json:"event_id"`74 DeletedAt int64 `json:"deleted_at,omitempty"`75}7677// RoomMember is one member row of a room.78type RoomMember struct {79 PubKey string `json:"pubkey"`80 Role string `json:"role"`81 AddedAt int64 `json:"added_at"`82}8384var roomIDRE = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)8586// ValidRoomID reports whether id has the shape of a room id.87func ValidRoomID(id string) bool { return roomIDRE.MatchString(id) }8889// RoomAdminKind reports whether kind is a NIP-29 management event that a90// room handles itself when it carries the room's h tag.91func RoomAdminKind(kind int) bool {92 switch kind {93 case event.KIND_PUT_USER, event.KIND_REMOVE_USER, event.KIND_EDIT_METADATA, event.KIND_DELETE_EVENT, event.KIND_CREATE_GROUP, event.KIND_DELETE_GROUP, event.KIND_CREATE_INVITE, event.KIND_PINS, event.KIND_JOIN, event.KIND_LEAVE:94 return true95 }96 return false97}9899// RoomNoticeKind reports whether kind is a relay-signed member notice.100func RoomNoticeKind(kind int) bool {101 return kind == event.KIND_ROOM_MEMBER_ADDED || kind == event.KIND_ROOM_MEMBER_REMOVED102}103104// RoomAccessFor maps the tenant read rule onto the slug room's access.105func RoomAccessFor(reads string) string {106 if reads == "members" {107 return RoomMembers108 }109 return RoomOpen110}111112// EnsureSlugRoom creates or refreshes the room that stands for the tenant's113// own group. Its access follows the tenant read rule and its members mirror114// the tenant roster: the owner is the room owner and moderators are admins.115// The first mirror is silent so an existing roster does not announce every116// member as newly added.117func (s *Service) EnsureSlugRoom(ctx context.Context, slug, access string) error {118 if !ValidRoomID(slug) {119 return fmt.Errorf("invalid: room id %q", slug)120 }121 if access != RoomMembers {122 access = RoomOpen123 }124 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {125 if _, err := tx.ExecContext(ctx, `INSERT INTO community_meta(key,value) VALUES('rooms.slug',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, slug); err != nil {126 return err127 }128 var existing string129 err := tx.QueryRowContext(ctx, `SELECT id FROM rooms WHERE id=?`, slug).Scan(&existing)130 fresh := errors.Is(err, sql.ErrNoRows)131 if err != nil && !fresh {132 return err133 }134 if _, err := tx.ExecContext(ctx, `INSERT INTO rooms(id,access,created_by,created_at) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET access=excluded.access,deleted_at=0`, slug, access, s.currentOwner(), now()); err != nil {135 return err136 }137 if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) SELECT ?,pubkey,CASE role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END,created_at FROM community_members WHERE true ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role`, slug); err != nil {138 return err139 }140 if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey NOT IN (SELECT pubkey FROM community_members)`, slug); err != nil {141 return err142 }143 if fresh {144 _, err = tx.ExecContext(ctx, `DELETE FROM work_intents WHERE kind='records-projection' AND target='room' AND state='pending' AND json_extract(payload,'$.room')=? AND json_extract(payload,'$.pubkey') IS NOT NULL`, slug)145 }146 return err147 })148 if err != nil {149 return err150 }151 s.mu.Lock()152 s.slug = slug153 s.mu.Unlock()154 return nil155}156157// SlugRoom returns the id of the room that mirrors the tenant roster.158func (s *Service) SlugRoom() string {159 s.mu.RLock()160 defer s.mu.RUnlock()161 return s.slug162}163164func (s *Service) currentOwner() string {165 s.mu.RLock()166 defer s.mu.RUnlock()167 return s.owner168}169170// Room returns the room row, including a deleted one, or an empty Room.171func (s *Service) Room(ctx context.Context, id string) (Room, error) {172 return scanRoom(s.store.DB().QueryRowContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE id=?`, id))173}174175func roomTx(ctx context.Context, tx *sql.Tx, id string) (Room, error) {176 return scanRoom(tx.QueryRowContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE id=?`, id))177}178179func scanRoom(row *sql.Row) (Room, error) {180 var r Room181 err := row.Scan(&r.ID, &r.Name, &r.About, &r.Picture, &r.Access, &r.CreatedBy, &r.CreatedAt, &r.EventID, &r.DeletedAt)182 if errors.Is(err, sql.ErrNoRows) {183 return Room{}, nil184 }185 if err != nil {186 return Room{}, fmt.Errorf("room: %w", err)187 }188 return r, nil189}190191// Live reports whether the room exists and has not been deleted.192func (r Room) Live() bool { return r.ID != "" && r.DeletedAt == 0 }193194// Rooms lists the live rooms ordered by id.195func (s *Service) Rooms(ctx context.Context) ([]Room, error) {196 rows, err := s.store.DB().QueryContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE deleted_at=0 ORDER BY id`)197 if err != nil {198 return nil, fmt.Errorf("rooms: %w", err)199 }200 defer rows.Close()201 out := []Room{}202 for rows.Next() {203 var r Room204 if err := rows.Scan(&r.ID, &r.Name, &r.About, &r.Picture, &r.Access, &r.CreatedBy, &r.CreatedAt, &r.EventID, &r.DeletedAt); err != nil {205 return nil, err206 }207 out = append(out, r)208 }209 return out, rows.Err()210}211212// RoomMembers lists a room's members, owners and admins first.213func (s *Service) RoomMembers(ctx context.Context, id string) ([]RoomMember, error) {214 rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,role,added_at FROM room_members WHERE room_id=? ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,added_at,pubkey`, id)215 if err != nil {216 return nil, fmt.Errorf("room members: %w", err)217 }218 defer rows.Close()219 out := []RoomMember{}220 for rows.Next() {221 var m RoomMember222 if err := rows.Scan(&m.PubKey, &m.Role, &m.AddedAt); err != nil {223 return nil, err224 }225 out = append(out, m)226 }227 return out, rows.Err()228}229230// RoomMemberCount counts a room's members.231func (s *Service) RoomMemberCount(ctx context.Context, id string) (int, error) {232 var n int233 err := s.store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room_id=?`, id).Scan(&n)234 return n, err235}236237// RoomRole returns the effective role of pubkey in a room: the stored room238// role, or owner and admin for the tenant owner and moderators, who may239// moderate every room. It is empty for people outside the room.240func (s *Service) RoomRole(ctx context.Context, roomID, pubkey string) (string, error) {241 if pubkey == "" {242 return "", nil243 }244 var role string245 err := s.store.DB().QueryRowContext(ctx, `SELECT role FROM room_members WHERE room_id=? AND pubkey=?`, roomID, pubkey).Scan(&role)246 if err != nil && !errors.Is(err, sql.ErrNoRows) {247 return "", fmt.Errorf("room role: %w", err)248 }249 if role != "" {250 return role, nil251 }252 tenantRole, err := s.Role(ctx, pubkey)253 if err != nil {254 return "", err255 }256 return impliedRoomRole(tenantRole), nil257}258259func impliedRoomRole(tenantRole string) string {260 switch tenantRole {261 case "owner":262 return RoomRoleOwner263 case "moderator":264 return RoomRoleAdmin265 }266 return ""267}268269func roomRoleTx(ctx context.Context, tx *sql.Tx, roomID, pubkey string) (string, error) {270 var role string271 err := tx.QueryRowContext(ctx, `SELECT role FROM room_members WHERE room_id=? AND pubkey=?`, roomID, pubkey).Scan(&role)272 if errors.Is(err, sql.ErrNoRows) {273 return "", nil274 }275 return role, err276}277278func (s *Service) effectiveRoomRoleTx(ctx context.Context, tx *sql.Tx, roomID, pubkey string) (string, error) {279 role, err := roomRoleTx(ctx, tx, roomID, pubkey)280 if err != nil || role != "" {281 return role, err282 }283 return impliedRoomRole(s.tenantRoleTx(ctx, tx, pubkey)), nil284}285286func (s *Service) tenantRoleTx(ctx context.Context, tx *sql.Tx, pubkey string) string {287 if pubkey == s.currentOwner() {288 return "owner"289 }290 var role string291 _ = tx.QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, pubkey).Scan(&role)292 return role293}294295func roomAdmin(role string) bool { return role == RoomRoleOwner || role == RoomRoleAdmin }296297// RoomWriteAllowed decides whether pubkey may post to a room. Members always298// may; an open room also accepts tenant members, who join on their first299// message. The second result reports that such a join is pending.300func (s *Service) RoomWriteAllowed(ctx context.Context, roomID, pubkey string) (bool, bool, error) {301 room, err := s.Room(ctx, roomID)302 if err != nil || !room.Live() {303 return false, false, err304 }305 role, err := s.RoomRole(ctx, roomID, pubkey)306 if err != nil {307 return false, false, err308 }309 if role != "" {310 return true, false, nil311 }312 if room.Access != RoomOpen {313 return false, false, nil314 }315 member, err := s.IsMember(ctx, pubkey)316 return member, member, err317}318319// RoomVisible decides whether any of the keys may read a room's events.320// Open rooms follow the tenant read rule; members-only rooms are limited to321// their members, the tenant owner and moderators.322func (s *Service) RoomVisible(ctx context.Context, roomID string, keys []string) (bool, error) {323 room, err := s.Room(ctx, roomID)324 if err != nil || !room.Live() {325 return false, err326 }327 if room.Access != RoomMembers {328 return true, nil329 }330 for _, key := range keys {331 role, err := s.RoomRole(ctx, roomID, key)332 if err != nil {333 return false, err334 }335 if role != "" {336 return true, nil337 }338 }339 return false, nil340}341342// HandleRoomMessageTx stores a room message and, for an open room, adds a343// tenant member to the room on their first message in the same transaction.344func (s *Service) HandleRoomMessageTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) error {345 if persist == nil {346 return errors.New("community: nil event persistence callback")347 }348 roomID := tagValue(ev, "h")349 return s.store.WithTx(ctx, func(tx *sql.Tx) error {350 room, err := roomTx(ctx, tx, roomID)351 if err != nil {352 return err353 }354 if !room.Live() {355 return fmt.Errorf("invalid: unknown room %s", roomID)356 }357 role, err := s.effectiveRoomRoleTx(ctx, tx, roomID, ev.PubKey)358 if err != nil {359 return err360 }361 if role == "" {362 if room.Access != RoomOpen || s.tenantRoleTx(ctx, tx, ev.PubKey) == "" {363 return fmt.Errorf("restricted: not a member of room %s", roomID)364 }365 if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)`, roomID, ev.PubKey, RoomRoleMember, now()); err != nil {366 return err367 }368 }369 return persist(tx)370 })371}372373// HandleRoomEventTx applies a NIP-29 management event addressed to a room374// other than the slug room and stores it in the same transaction.375func (s *Service) HandleRoomEventTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) (MembershipResult, error) {376 if persist == nil {377 return MembershipResult{}, errors.New("community: nil event persistence callback")378 }379 if !validPubKey(ev.PubKey) {380 return MembershipResult{}, errors.New("invalid: event pubkey")381 }382 roomID := tagValue(ev, "h")383 if !ValidRoomID(roomID) {384 return MembershipResult{}, errors.New("invalid: a room id is 1 to 64 characters of a-z, 0-9, - and _")385 }386 if roomID == s.SlugRoom() {387 return MembershipResult{}, errors.New("invalid: the main room is managed by the relay")388 }389 if !RoomAdminKind(ev.Kind) {390 return MembershipResult{}, fmt.Errorf("unsupported: room event kind %d", ev.Kind)391 }392 if ev.Kind == event.KIND_CREATE_INVITE || ev.Kind == event.KIND_PINS {393 return MembershipResult{}, fmt.Errorf("unsupported: kind %d is not available inside rooms", ev.Kind)394 }395 tenantRole, err := s.Role(ctx, ev.PubKey)396 if err != nil {397 return MembershipResult{}, err398 }399 if tenantRole == "" {400 return MembershipResult{}, errors.New("restricted: rooms are for members of this relay")401 }402 out := MembershipResult{OK: true, Stored: true}403 err = s.store.WithTx(ctx, func(tx *sql.Tx) error {404 room, err := roomTx(ctx, tx, roomID)405 if err != nil {406 return err407 }408 if ev.Kind == event.KIND_CREATE_GROUP {409 return s.createRoomTx(ctx, tx, ev, room, persist)410 }411 if !room.Live() {412 return fmt.Errorf("invalid: unknown room %s", roomID)413 }414 role, err := s.effectiveRoomRoleTx(ctx, tx, roomID, ev.PubKey)415 if err != nil {416 return err417 }418 switch ev.Kind {419 case event.KIND_EDIT_METADATA:420 if !roomAdmin(role) {421 return errors.New("restricted: only room admins edit the room")422 }423 meta := roomMetadata(ev, room)424 if _, err := tx.ExecContext(ctx, `UPDATE rooms SET name=?,about=?,picture=?,access=? WHERE id=?`, meta.Name, meta.About, meta.Picture, meta.Access, roomID); err != nil {425 return err426 }427 if err := enqueueRoomProjectionTx(ctx, tx, ev.ID, roomID, false); err != nil {428 return err429 }430 case event.KIND_PUT_USER:431 if !roomAdmin(role) {432 return errors.New("restricted: only room admins add members")433 }434 target, wanted := roomTarget(ev)435 if !validPubKey(target) {436 return errors.New("invalid: membership event needs a p tag")437 }438 if s.tenantRoleTx(ctx, tx, target) == "" {439 return errors.New("restricted: the person must be a member of this relay first")440 }441 if wanted == RoomRoleOwner && role != RoomRoleOwner {442 return errors.New("restricted: only a room owner appoints owners")443 }444 if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?) ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role`, roomID, target, wanted, now()); err != nil {445 return err446 }447 case event.KIND_REMOVE_USER:448 if !roomAdmin(role) {449 return errors.New("restricted: only room admins remove members")450 }451 target, _ := roomTarget(ev)452 if !validPubKey(target) {453 return errors.New("invalid: membership event needs a p tag")454 }455 current, err := roomRoleTx(ctx, tx, roomID, target)456 if err != nil {457 return err458 }459 if current == "" {460 out = MembershipResult{OK: true, Message: "duplicate: not a member", Stored: false}461 return nil462 }463 if current == RoomRoleOwner {464 if role != RoomRoleOwner {465 return errors.New("restricted: only a room owner removes an owner")466 }467 if err := guardLastOwnerTx(ctx, tx, roomID); err != nil {468 return err469 }470 }471 if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey=?`, roomID, target); err != nil {472 return err473 }474 case event.KIND_JOIN:475 current, err := roomRoleTx(ctx, tx, roomID, ev.PubKey)476 if err != nil {477 return err478 }479 if current != "" {480 out = MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}481 return nil482 }483 if room.Access != RoomOpen {484 return errors.New("restricted: this room is members-only; ask a room admin to add you")485 }486 if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)`, roomID, ev.PubKey, RoomRoleMember, now()); err != nil {487 return err488 }489 case event.KIND_LEAVE:490 current, err := roomRoleTx(ctx, tx, roomID, ev.PubKey)491 if err != nil {492 return err493 }494 if current == "" {495 return errors.New("invalid: not a member of this room")496 }497 if current == RoomRoleOwner {498 if err := guardLastOwnerTx(ctx, tx, roomID); err != nil {499 return err500 }501 }502 if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey=?`, roomID, ev.PubKey); err != nil {503 return err504 }505 case event.KIND_DELETE_EVENT:506 ids := []string{}507 for _, tag := range ev.Tags {508 if len(tag) > 1 && tag[0] == "e" && validPubKey(tag[1]) {509 ids = append(ids, tag[1])510 }511 }512 if len(ids) == 0 {513 return errors.New("invalid: delete-event needs an e tag")514 }515 for _, id := range ids {516 var author string517 err := tx.QueryRowContext(ctx, `SELECT pubkey FROM events WHERE id=? AND EXISTS(SELECT 1 FROM tags WHERE event_id=events.id AND name='h' AND value=?)`, id, roomID).Scan(&author)518 if errors.Is(err, sql.ErrNoRows) {519 continue520 }521 if err != nil {522 return err523 }524 if !roomAdmin(role) && author != ev.PubKey {525 return errors.New("restricted: only room admins delete other people's messages")526 }527 if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id=?`, id); err != nil {528 return err529 }530 }531 case event.KIND_DELETE_GROUP:532 if role != RoomRoleOwner {533 return errors.New("restricted: only a room owner deletes the room")534 }535 if _, err := tx.ExecContext(ctx, `UPDATE rooms SET deleted_at=? WHERE id=?`, now(), roomID); err != nil {536 return err537 }538 if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=?`, roomID); err != nil {539 return err540 }541 if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id IN (SELECT event_id FROM tags WHERE name='h' AND value=?)`, roomID); err != nil {542 return err543 }544 if err := enqueueRoomProjectionTx(ctx, tx, ev.ID, roomID, true); err != nil {545 return err546 }547 }548 if err := s.recordTx(ctx, tx, ev.PubKey, roomAction(ev.Kind), roomID, strings.TrimSpace(tagValue(ev, "p"))); err != nil {549 return err550 }551 return persist(tx)552 })553 return out, err554}555556func (s *Service) createRoomTx(ctx context.Context, tx *sql.Tx, ev event.Event, room Room, persist func(*sql.Tx) error) error {557 if room.Live() {558 return errors.New("duplicate: a room with this id exists")559 }560 limit := s.currentPolicy().Rooms561 if limit <= 0 {562 return errors.New("restricted: room creation is switched off on this relay")563 }564 var count int565 if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM rooms WHERE deleted_at=0 AND id<>?`, s.SlugRoom()).Scan(&count); err != nil {566 return err567 }568 if count >= limit {569 return fmt.Errorf("restricted: this relay allows %d rooms", limit)570 }571 meta := roomMetadata(ev, Room{ID: tagValue(ev, "h"), Access: RoomOpen})572 if meta.Name == "" {573 meta.Name = meta.ID574 }575 if _, err := tx.ExecContext(ctx, `INSERT INTO rooms(id,name,about,picture,access,created_by,created_at,event_id,deleted_at) VALUES(?,?,?,?,?,?,?,?,0) ON CONFLICT(id) DO UPDATE SET name=excluded.name,about=excluded.about,picture=excluded.picture,access=excluded.access,created_by=excluded.created_by,created_at=excluded.created_at,event_id=excluded.event_id,deleted_at=0`, meta.ID, meta.Name, meta.About, meta.Picture, meta.Access, ev.PubKey, now(), ev.ID); err != nil {576 return err577 }578 if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=?`, meta.ID); err != nil {579 return err580 }581 if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)`, meta.ID, ev.PubKey, RoomRoleOwner, now()); err != nil {582 return err583 }584 if err := s.recordTx(ctx, tx, ev.PubKey, "create-room", meta.ID, meta.Access); err != nil {585 return err586 }587 return persist(tx)588}589590func guardLastOwnerTx(ctx context.Context, tx *sql.Tx, roomID string) error {591 var owners int592 if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room_id=? AND role='owner'`, roomID).Scan(&owners); err != nil {593 return err594 }595 if owners <= 1 {596 return errors.New("restricted: a room keeps at least one owner; appoint another owner first")597 }598 return nil599}600601func enqueueRoomProjectionTx(ctx context.Context, tx *sql.Tx, eventID, roomID string, deleted bool) error {602 payload := fmt.Sprintf(`{"action":"room","room":%q}`, roomID)603 if deleted {604 payload = fmt.Sprintf(`{"action":"room","room":%q,"deleted":1}`, roomID)605 }606 id := "room-" + roomID + "-" + eventID607 stamp := now()608 _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, id, "records-projection", id, "room", payload, stamp, stamp, stamp)609 return err610}611612// roomTarget returns the p tag target and the requested room role, which613// defaults to member.614func roomTarget(ev event.Event) (string, string) {615 for _, tag := range ev.Tags {616 if len(tag) > 1 && tag[0] == "p" {617 role := RoomRoleMember618 if len(tag) > 2 {619 switch strings.ToLower(strings.TrimSpace(tag[2])) {620 case RoomRoleOwner:621 role = RoomRoleOwner622 case RoomRoleAdmin, "moderator":623 role = RoomRoleAdmin624 }625 }626 return tag[1], role627 }628 }629 return "", RoomRoleMember630}631632// roomMetadata applies the metadata tags of a 9007 or 9002 on top of the633// current room. Buzz's channel_type and NIP-29's visibility markers both634// select the access rule.635func roomMetadata(ev event.Event, current Room) Room {636 next := current637 for _, tag := range ev.Tags {638 if len(tag) == 0 {639 continue640 }641 value := ""642 if len(tag) > 1 {643 value = strings.TrimSpace(tag[1])644 }645 switch tag[0] {646 case "name":647 next.Name = value[:min(200, len(value))]648 case "about":649 next.About = value[:min(2000, len(value))]650 case "picture":651 next.Picture = value[:min(2000, len(value))]652 case "visibility", "channel_type", "access":653 if access, ok := roomAccessValue(value); ok {654 next.Access = access655 }656 case "open", "public":657 if len(tag) == 1 {658 next.Access = RoomOpen659 }660 case "closed", "private":661 if len(tag) == 1 {662 next.Access = RoomMembers663 }664 }665 }666 if next.Access != RoomMembers {667 next.Access = RoomOpen668 }669 return next670}671672func roomAccessValue(value string) (string, bool) {673 switch strings.ToLower(value) {674 case "open", "public":675 return RoomOpen, true676 case "members", "members-only", "private", "closed", "invite":677 return RoomMembers, true678 }679 return "", false680}681682func roomAction(kind int) string {683 switch kind {684 case event.KIND_EDIT_METADATA:685 return "edit-room"686 case event.KIND_PUT_USER:687 return "room-put-user"688 case event.KIND_REMOVE_USER:689 return "room-remove-user"690 case event.KIND_JOIN:691 return "room-join"692 case event.KIND_LEAVE:693 return "room-leave"694 case event.KIND_DELETE_EVENT:695 return "room-delete-event"696 case event.KIND_DELETE_GROUP:697 return "delete-room"698 }699 return "room"700}