fix: 群写操作在事务内复核并去重建群成员

This commit is contained in:
Nixevol
2026-09-30 16:22:38 +08:00
parent 7c926a0904
commit ad4f13193c
4 changed files with 648 additions and 159 deletions
+391 -159
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"database/sql"
"errors"
"strings"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
@@ -27,6 +28,14 @@ const (
idAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
)
func (a *App) memberRequestCap() int {
n := a.maxMem * 2
if n < 256 {
n = 256
}
return n
}
// TalkGate checks talk password when adding members (implemented by identity).
type TalkGate interface {
CheckTalkPasswordForJoin(ctx context.Context, actorID, targetID, talkPassword, remoteIP string) error
@@ -105,6 +114,9 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
if !protocol.ValidEndpointID(actorID) {
return CreateResult{}, errCode(protocol.CodeBadRequest, "invalid actor")
}
if err := a.rejectOversizedMemberList(len(req.Members)); err != nil {
return CreateResult{}, err
}
gid := req.ID
if gid == "" {
var genErr error
@@ -115,12 +127,17 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
}
now := a.nowMs()
failed := make([]MemberFail, 0)
added := make([]string, 0, len(req.Members))
for _, m := range req.Members {
if m.ID == actorID {
continue
}
uniq := dedupeMemberIns(req.Members, actorID)
room := a.maxMem - 1
if room < 0 {
room = 0
}
toCheck, overflow := splitMemberIns(uniq, room)
for _, m := range overflow {
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeGroupFull})
}
added := make([]string, 0, len(toCheck))
for _, m := range toCheck {
if checkErr := a.checkAddMember(ctx, actorID, m.ID, m.TalkPassword); checkErr != nil {
failed = append(failed, MemberFail{ID: m.ID, Code: failCode(checkErr)})
continue
@@ -128,10 +145,7 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
added = append(added, m.ID)
}
if 1+len(added) > a.maxMem {
return CreateResult{}, errCode(protocol.CodeGroupFull, "group full")
}
var inserted []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
var exists int
qErr := tx.QueryRow(`SELECT 1 FROM groups WHERE id = ?`, gid).Scan(&exists)
@@ -148,15 +162,19 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
}
return e
}
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
gid, actorID, now); e != nil {
if e := insertMemberTx(tx, gid, actorID, now); e != nil {
return e
}
inserted = inserted[:0]
for _, id := range added {
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
gid, id, now); e != nil {
if e := endpointCheckTx(tx, id); e != nil {
failed = append(failed, MemberFail{ID: id, Code: failCode(e)})
continue
}
if e := insertMemberTx(tx, gid, id, now); e != nil {
return e
}
inserted = append(inserted, id)
}
return nil
})
@@ -164,8 +182,9 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
return CreateResult{}, err
}
for _, id := range added {
a.emit(ctx, append([]string{actorID}, added...), gid, eventMemberAdded, id, now)
notify := append([]string{actorID}, inserted...)
for _, id := range inserted {
a.emit(ctx, notify, gid, eventMemberAdded, id, now)
}
return CreateResult{ID: gid, Name: req.Name, OwnerID: actorID, Failed: failed}, nil
}
@@ -178,6 +197,9 @@ func (a *App) Add(ctx context.Context, actorID string, req *protocol.GroupAdd) (
if err := req.Validate(); err != nil {
return AddResult{}, err
}
if err := a.rejectOversizedMemberList(len(req.Members)); err != nil {
return AddResult{}, err
}
owner, members, err := a.loadGroup(ctx, req.GroupID)
if err != nil {
return AddResult{}, err
@@ -187,17 +209,23 @@ func (a *App) Add(ctx context.Context, actorID string, req *protocol.GroupAdd) (
}
failed := make([]MemberFail, 0)
added := make([]string, 0)
now := a.nowMs()
for _, m := range req.Members {
if contains(members, m.ID) {
continue
}
if len(members)+len(added) >= a.maxMem {
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeGroupFull})
uniq := dedupeMemberIns(req.Members, actorID)
already := memberSet(members)
candidates := make([]protocol.GroupMemberIn, 0, len(uniq))
for _, m := range uniq {
if _, ok := already[m.ID]; ok {
continue
}
candidates = append(candidates, m)
}
room := a.maxMem - len(members)
toCheck, overflow := splitMemberIns(candidates, room)
for _, m := range overflow {
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeGroupFull})
}
added := make([]string, 0, len(toCheck))
for _, m := range toCheck {
if checkErr := a.checkAddMember(ctx, actorID, m.ID, m.TalkPassword); checkErr != nil {
failed = append(failed, MemberFail{ID: m.ID, Code: failCode(checkErr)})
continue
@@ -205,23 +233,53 @@ func (a *App) Add(ctx context.Context, actorID string, req *protocol.GroupAdd) (
added = append(added, m.ID)
}
if len(added) > 0 {
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
for _, id := range added {
if _, e := tx.Exec(`INSERT OR IGNORE INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
req.GroupID, id, now); e != nil {
return e
}
}
return nil
})
if err != nil {
return AddResult{}, err
if len(added) == 0 {
return AddResult{Failed: failed}, nil
}
var inserted []string
var notify []string
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
curOwner, curMembers, e := loadGroupTx(tx, req.GroupID)
if e != nil {
return e
}
all := append(append([]string{}, members...), added...)
if curOwner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
present := memberSet(curMembers)
count := len(curMembers)
inserted = inserted[:0]
for _, id := range added {
a.emit(ctx, all, req.GroupID, eventMemberAdded, id, now)
if _, ok := present[id]; ok {
continue
}
if count >= a.maxMem {
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
continue
}
if checkErr := endpointCheckTx(tx, id); checkErr != nil {
failed = append(failed, MemberFail{ID: id, Code: failCode(checkErr)})
continue
}
if insErr := insertMemberTx(tx, req.GroupID, id, now); insErr != nil {
return insErr
}
present[id] = struct{}{}
count++
inserted = append(inserted, id)
}
notify = make([]string, 0, count)
for id := range present {
notify = append(notify, id)
}
return nil
})
if err != nil {
return AddResult{}, err
}
for _, id := range inserted {
a.emit(ctx, notify, req.GroupID, eventMemberAdded, id, now)
}
return AddResult{Failed: failed}, nil
}
@@ -234,34 +292,37 @@ func (a *App) Remove(ctx context.Context, actorID string, req *protocol.GroupRem
if err := req.Validate(); err != nil {
return err
}
owner, members, err := a.loadGroup(ctx, req.GroupID)
if err != nil {
return err
}
if owner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
if req.EndpointID == owner {
return errCode(protocol.CodeBadRequest, "cannot remove owner")
}
if !contains(members, req.EndpointID) {
return errCode(protocol.CodeNotFound, "member not found")
}
now := a.nowMs()
var revokes []revokeItem
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
var notify []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
owner, members, e := loadGroupTx(tx, req.GroupID)
if e != nil {
return e
}
if owner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
if req.EndpointID == owner {
return errCode(protocol.CodeBadRequest, "cannot remove owner")
}
if !contains(members, req.EndpointID) {
return errCode(protocol.CodeNotFound, "member not found")
}
if _, e := tx.Exec(`DELETE FROM group_members WHERE group_id = ? AND endpoint_id = ?`,
req.GroupID, req.EndpointID); e != nil {
return e
}
return voidMemberDeliveriesTx(tx, req.GroupID, req.EndpointID, reasonLeftGroup, now, &revokes)
if e := voidMemberDeliveriesTx(tx, req.GroupID, req.EndpointID, reasonLeftGroup, now, &revokes); e != nil {
return e
}
notify = append(without(members, req.EndpointID), req.EndpointID)
return nil
})
if err != nil {
return err
}
a.publishRevokes(ctx, revokes)
left := without(members, req.EndpointID)
notify := append(left, req.EndpointID)
a.emit(ctx, notify, req.GroupID, eventMemberRemoved, req.EndpointID, now)
return nil
}
@@ -274,31 +335,34 @@ func (a *App) Leave(ctx context.Context, actorID string, req *protocol.GroupLeav
if err := req.Validate(); err != nil {
return err
}
owner, members, err := a.loadGroup(ctx, req.GroupID)
if err != nil {
return err
}
if !contains(members, actorID) {
return errCode(protocol.CodeNotMember, "not a member")
}
if owner == actorID {
return errCode(protocol.CodeOwnerCannotLeave, "owner cannot leave")
}
now := a.nowMs()
var revokes []revokeItem
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
var notify []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
owner, members, e := loadGroupTx(tx, req.GroupID)
if e != nil {
return e
}
if !contains(members, actorID) {
return errCode(protocol.CodeNotMember, "not a member")
}
if owner == actorID {
return errCode(protocol.CodeOwnerCannotLeave, "owner cannot leave")
}
if _, e := tx.Exec(`DELETE FROM group_members WHERE group_id = ? AND endpoint_id = ?`,
req.GroupID, actorID); e != nil {
return e
}
return voidMemberDeliveriesTx(tx, req.GroupID, actorID, reasonLeftGroup, now, &revokes)
if e := voidMemberDeliveriesTx(tx, req.GroupID, actorID, reasonLeftGroup, now, &revokes); e != nil {
return e
}
notify = append(without(members, actorID), actorID)
return nil
})
if err != nil {
return err
}
a.publishRevokes(ctx, revokes)
left := without(members, actorID)
notify := append(left, actorID)
a.emit(ctx, notify, req.GroupID, eventLeft, actorID, now)
return nil
}
@@ -311,20 +375,24 @@ func (a *App) Transfer(ctx context.Context, actorID string, req *protocol.GroupT
if err := req.Validate(); err != nil {
return err
}
owner, members, err := a.loadGroup(ctx, req.GroupID)
if err != nil {
return err
}
if owner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
if !contains(members, req.EndpointID) {
return errCode(protocol.CodeNotFound, "member not found")
}
now := a.nowMs()
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
_, e := tx.Exec(`UPDATE groups SET owner_id = ? WHERE id = ?`, req.EndpointID, req.GroupID)
return e
var members []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
owner, cur, e := loadGroupTx(tx, req.GroupID)
if e != nil {
return e
}
if owner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
if !contains(cur, req.EndpointID) {
return errCode(protocol.CodeNotFound, "member not found")
}
if _, e := tx.Exec(`UPDATE groups SET owner_id = ? WHERE id = ?`, req.EndpointID, req.GroupID); e != nil {
return e
}
members = cur
return nil
})
if err != nil {
return err
@@ -341,17 +409,21 @@ func (a *App) Rename(ctx context.Context, actorID string, req *protocol.GroupRen
if err := req.Validate(); err != nil {
return err
}
owner, members, err := a.loadGroup(ctx, req.GroupID)
if err != nil {
return err
}
if owner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
now := a.nowMs()
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
_, e := tx.Exec(`UPDATE groups SET name = ? WHERE id = ?`, req.Name, req.GroupID)
return e
var members []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
owner, cur, e := loadGroupTx(tx, req.GroupID)
if e != nil {
return e
}
if owner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
if _, e := tx.Exec(`UPDATE groups SET name = ? WHERE id = ?`, req.Name, req.GroupID); e != nil {
return e
}
members = cur
return nil
})
if err != nil {
return err
@@ -368,24 +440,28 @@ func (a *App) Dissolve(ctx context.Context, actorID string, req *protocol.GroupD
if err := req.Validate(); err != nil {
return err
}
owner, members, err := a.loadGroup(ctx, req.GroupID)
if err != nil {
return err
}
if owner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
now := a.nowMs()
var revokes []revokeItem
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
var members []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
owner, cur, e := loadGroupTx(tx, req.GroupID)
if e != nil {
return e
}
if owner != actorID {
return errCode(protocol.CodeForbidden, "not owner")
}
if e := voidGroupAllTx(tx, req.GroupID, now, &revokes); e != nil {
return e
}
if _, e := tx.Exec(`DELETE FROM group_members WHERE group_id = ?`, req.GroupID); e != nil {
return e
}
_, e := tx.Exec(`DELETE FROM groups WHERE id = ?`, req.GroupID)
return e
if _, e := tx.Exec(`DELETE FROM groups WHERE id = ?`, req.GroupID); e != nil {
return e
}
members = cur
return nil
})
if err != nil {
return err
@@ -512,59 +588,80 @@ func (a *App) AdminCreate(ctx context.Context, name, ownerID string, memberIDs [
// AdminAddMembers adds members without talk-password checks.
func (a *App) AdminAddMembers(ctx context.Context, groupID string, memberIDs []string) (AddResult, error) {
if err := a.rejectOversizedMemberList(len(memberIDs)); err != nil {
return AddResult{}, err
}
_, members, err := a.loadGroup(ctx, groupID)
if err != nil {
return AddResult{}, err
}
failed := make([]MemberFail, 0)
added := make([]string, 0)
now := a.nowMs()
for _, id := range memberIDs {
if contains(members, id) {
uniq := dedupeIDs(memberIDs, "")
already := memberSet(members)
candidates := make([]string, 0, len(uniq))
for _, id := range uniq {
if _, ok := already[id]; ok {
continue
}
var enabled int
e := a.db.Read.QueryRowContext(ctx, `SELECT enabled FROM endpoints WHERE id = ?`, id).Scan(&enabled)
if errors.Is(e, sql.ErrNoRows) {
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeInvalidTarget})
continue
}
if e != nil {
return AddResult{}, e
}
if enabled == 0 {
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeEndpointDisabled})
continue
}
if len(members)+len(added) >= a.maxMem {
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
continue
}
added = append(added, id)
candidates = append(candidates, id)
}
if len(added) == 0 {
room := a.maxMem - len(members)
toAdd, overflow := splitIDs(candidates, room)
for _, id := range overflow {
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
}
if len(toAdd) == 0 {
return AddResult{Failed: failed}, nil
}
now := a.nowMs()
var inserted []string
var notify []string
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
for _, id := range added {
if _, e := tx.Exec(`INSERT OR IGNORE INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
groupID, id, now); e != nil {
return e
_, curMembers, e := loadGroupTx(tx, groupID)
if e != nil {
return e
}
present := memberSet(curMembers)
count := len(curMembers)
inserted = inserted[:0]
for _, id := range toAdd {
if _, ok := present[id]; ok {
continue
}
if count >= a.maxMem {
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
continue
}
if checkErr := endpointCheckTx(tx, id); checkErr != nil {
failed = append(failed, MemberFail{ID: id, Code: failCode(checkErr)})
continue
}
if insErr := insertMemberTx(tx, groupID, id, now); insErr != nil {
return insErr
}
present[id] = struct{}{}
count++
inserted = append(inserted, id)
}
notify = make([]string, 0, count)
for id := range present {
notify = append(notify, id)
}
return nil
})
if err != nil {
return AddResult{}, err
}
all := append(append([]string{}, members...), added...)
for _, id := range added {
a.emit(ctx, all, groupID, eventMemberAdded, id, now)
for _, id := range inserted {
a.emit(ctx, notify, groupID, eventMemberAdded, id, now)
}
return AddResult{Failed: failed}, nil
}
func (a *App) createAdmin(ctx context.Context, ownerID, name, gid string, members []protocol.GroupMemberIn) (CreateResult, error) {
if !protocol.ValidEndpointID(ownerID) {
return CreateResult{}, errCode(protocol.CodeBadRequest, "invalid owner")
}
if gid == "" {
var genErr error
gid, genErr = generateGroupID()
@@ -575,32 +672,29 @@ func (a *App) createAdmin(ctx context.Context, ownerID, name, gid string, member
if !protocol.ValidName(name) || name == "" {
return CreateResult{}, errCode(protocol.CodeBadRequest, "invalid name")
}
if err := a.rejectOversizedMemberList(len(members)); err != nil {
return CreateResult{}, err
}
now := a.nowMs()
failed := make([]MemberFail, 0)
added := make([]string, 0)
for _, m := range members {
if m.ID == ownerID {
continue
}
var enabled int
e := a.db.Read.QueryRowContext(ctx, `SELECT enabled FROM endpoints WHERE id = ?`, m.ID).Scan(&enabled)
if errors.Is(e, sql.ErrNoRows) {
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeInvalidTarget})
continue
}
if e != nil {
return CreateResult{}, e
}
if enabled == 0 {
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeEndpointDisabled})
continue
}
added = append(added, m.ID)
}
if 1+len(added) > a.maxMem {
return CreateResult{}, errCode(protocol.CodeGroupFull, "group full")
uniq := dedupeMemberIns(members, ownerID)
room := a.maxMem - 1
toAdd, overflow := splitMemberIns(uniq, room)
for _, m := range overflow {
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeGroupFull})
}
var inserted []string
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
if e := endpointCheckTx(tx, ownerID); e != nil {
if protoCode(e) == protocol.CodeInvalidTarget {
return errCode(protocol.CodeInvalidTarget, "owner not found")
}
if protoCode(e) == protocol.CodeEndpointDisabled {
return errCode(protocol.CodeEndpointDisabled, "owner disabled")
}
return e
}
if _, e := tx.Exec(`INSERT INTO groups(id, name, owner_id, created_at) VALUES(?,?,?,?)`,
gid, name, ownerID, now); e != nil {
if isUnique(e) {
@@ -608,21 +702,29 @@ func (a *App) createAdmin(ctx context.Context, ownerID, name, gid string, member
}
return e
}
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
gid, ownerID, now); e != nil {
if e := insertMemberTx(tx, gid, ownerID, now); e != nil {
return e
}
for _, id := range added {
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
gid, id, now); e != nil {
inserted = inserted[:0]
for _, m := range toAdd {
if checkErr := endpointCheckTx(tx, m.ID); checkErr != nil {
failed = append(failed, MemberFail{ID: m.ID, Code: failCode(checkErr)})
continue
}
if e := insertMemberTx(tx, gid, m.ID, now); e != nil {
return e
}
inserted = append(inserted, m.ID)
}
return nil
})
if err != nil {
return CreateResult{}, err
}
notify := append([]string{ownerID}, inserted...)
for _, id := range inserted {
a.emit(ctx, notify, gid, eventMemberAdded, id, now)
}
return CreateResult{ID: gid, Name: name, OwnerID: ownerID, Failed: failed}, nil
}
@@ -633,6 +735,136 @@ func (a *App) checkAddMember(ctx context.Context, actorID, targetID, talkPasswor
return a.talk.CheckTalkPasswordForJoin(ctx, actorID, targetID, talkPassword, a.remoteIP)
}
func (a *App) rejectOversizedMemberList(n int) error {
if n > a.memberRequestCap() {
return errCode(protocol.CodeBadRequest, "too many members")
}
return nil
}
func loadGroupTx(tx *sql.Tx, groupID string) (owner string, members []string, err error) {
err = tx.QueryRow(`SELECT owner_id FROM groups WHERE id = ?`, groupID).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return "", nil, errCode(protocol.CodeNotFound, "group not found")
}
if err != nil {
return "", nil, err
}
rows, qErr := tx.Query(`SELECT endpoint_id FROM group_members WHERE group_id = ?`, groupID)
if qErr != nil {
return "", nil, qErr
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id string
if scanErr := rows.Scan(&id); scanErr != nil {
return "", nil, scanErr
}
members = append(members, id)
}
return owner, members, rows.Err()
}
func endpointCheckTx(tx *sql.Tx, id string) error {
var enabled int
err := tx.QueryRow(`SELECT enabled FROM endpoints WHERE id = ?`, id).Scan(&enabled)
if errors.Is(err, sql.ErrNoRows) {
return errCode(protocol.CodeInvalidTarget, "target not found")
}
if err != nil {
return err
}
if enabled == 0 {
return errCode(protocol.CodeEndpointDisabled, "endpoint disabled")
}
return nil
}
func insertMemberTx(tx *sql.Tx, groupID, endpointID string, now int64) error {
_, err := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
groupID, endpointID, now)
if err == nil {
return nil
}
if isForeignKey(err) {
return errCode(protocol.CodeNotFound, "group not found")
}
if isUnique(err) {
return nil
}
return err
}
func isForeignKey(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "foreign key")
}
func dedupeMemberIns(members []protocol.GroupMemberIn, skipID string) []protocol.GroupMemberIn {
seen := make(map[string]struct{}, len(members)+1)
if skipID != "" {
seen[skipID] = struct{}{}
}
out := make([]protocol.GroupMemberIn, 0, len(members))
for _, m := range members {
if _, ok := seen[m.ID]; ok {
continue
}
seen[m.ID] = struct{}{}
out = append(out, m)
}
return out
}
func splitMemberIns(members []protocol.GroupMemberIn, room int) (keep, overflow []protocol.GroupMemberIn) {
if room < 0 {
room = 0
}
if len(members) <= room {
return members, nil
}
return members[:room], members[room:]
}
func dedupeIDs(ids []string, skipID string) []string {
seen := make(map[string]struct{}, len(ids)+1)
if skipID != "" {
seen[skipID] = struct{}{}
}
out := make([]string, 0, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func splitIDs(ids []string, room int) (keep, overflow []string) {
if room < 0 {
room = 0
}
if len(ids) <= room {
return ids, nil
}
return ids[:room], ids[room:]
}
func memberSet(ss []string) map[string]struct{} {
m := make(map[string]struct{}, len(ss))
for _, s := range ss {
m[s] = struct{}{}
}
return m
}
func (a *App) loadGroup(ctx context.Context, groupID string) (owner string, members []string, err error) {
err = a.db.Read.QueryRowContext(ctx, `SELECT owner_id FROM groups WHERE id = ?`, groupID).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {