Run JOIN, PART and KICK as one transaction each (closes #153)
check / check (push) Waiting to run
check / check (push) Waiting to run
JoinChannel made the channel, counted its members, ran the join checks and inserted the member as separate statements, so two requests could each decide on a count the other was about to change: +l was exceeded, two first joiners both became operator, and a JOIN racing the last PART failed its foreign key and answered 500. db.CreateOrJoinChannel now does all of that, and uses up the invite, in one transaction; the join checks moved into it, and the service turns its refusals into the same IRC errors as before. PART and KICK remove the member and delete an empty channel in one transaction (db.PartChannelAndDeleteIfEmpty). The db functions this leaves with no caller are removed. Model: opus-5-5
This commit was merged in pull request #164.
This commit is contained in:
+229
-42
@@ -462,46 +462,207 @@ func (database *Database) JoinChannel(
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinChannelAsOperator adds a session to a channel with
|
||||
// operator status. Used when a user creates a new channel.
|
||||
func (database *Database) JoinChannelAsOperator(
|
||||
// Errors that CreateOrJoinChannel refuses a join with.
|
||||
var (
|
||||
ErrBannedFromChannel = errors.New("banned from channel")
|
||||
ErrInviteOnlyChannel = errors.New("channel is invite-only")
|
||||
ErrBadChannelKey = errors.New("wrong channel key")
|
||||
ErrChannelIsFull = errors.New("channel is full")
|
||||
)
|
||||
|
||||
// CreateOrJoinChannel adds a session to the channel called
|
||||
// name, creating the channel if it does not exist. The
|
||||
// session is made operator if the channel has no members;
|
||||
// otherwise checkJoinRestrictions may refuse it. A join
|
||||
// uses up the session's invite to the channel. It returns
|
||||
// the channel ID and whether the session was made operator.
|
||||
// All of it is one transaction, and the database has one
|
||||
// connection, so every other statement waits until the
|
||||
// join is decided.
|
||||
func (database *Database) CreateOrJoinChannel(
|
||||
ctx context.Context,
|
||||
channelID, sessionID int64,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
name string,
|
||||
sessionID int64,
|
||||
suppliedKey string,
|
||||
) (int64, bool, error) {
|
||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("begin join: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = transaction.Rollback() }()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
_, err = transaction.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO channels
|
||||
(name, created_at, updated_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
name, now, now)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("create channel: %w", err)
|
||||
}
|
||||
|
||||
var channelID, memberCount int64
|
||||
|
||||
err = transaction.QueryRowContext(ctx,
|
||||
`SELECT id,
|
||||
(SELECT COUNT(*) FROM channel_members
|
||||
WHERE channel_id = channels.id)
|
||||
FROM channels WHERE name = ?`,
|
||||
name,
|
||||
).Scan(&channelID, &memberCount)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("get channel: %w", err)
|
||||
}
|
||||
|
||||
isOperator := memberCount == 0
|
||||
if !isOperator {
|
||||
err = checkJoinRestrictions(
|
||||
ctx, transaction, channelID, sessionID,
|
||||
suppliedKey, memberCount,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = transaction.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO channel_members
|
||||
(channel_id, session_id, is_operator, joined_at)
|
||||
VALUES (?, ?, 1, ?)`,
|
||||
channelID, sessionID, time.Now())
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
channelID, sessionID, isOperator, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"join channel as operator: %w", err,
|
||||
)
|
||||
return 0, false, fmt.Errorf("join channel: %w", err)
|
||||
}
|
||||
|
||||
_, err = transaction.ExecContext(ctx,
|
||||
`DELETE FROM channel_invites
|
||||
WHERE channel_id = ? AND session_id = ?`,
|
||||
channelID, sessionID)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("clear invite: %w", err)
|
||||
}
|
||||
|
||||
err = transaction.Commit()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("commit join: %w", err)
|
||||
}
|
||||
|
||||
return channelID, isOperator, nil
|
||||
}
|
||||
|
||||
// checkJoinRestrictions returns the error that refuses a
|
||||
// session's join of a channel with memberCount members:
|
||||
// ErrBannedFromChannel when a ban matches its hostmask,
|
||||
// ErrInviteOnlyChannel when the channel is +i and the
|
||||
// session has no invite, ErrBadChannelKey when suppliedKey
|
||||
// is not the +k key, or ErrChannelIsFull when the channel
|
||||
// has at least its +l number of members. It returns nil
|
||||
// when none applies.
|
||||
func checkJoinRestrictions(
|
||||
ctx context.Context,
|
||||
transaction *sql.Tx,
|
||||
channelID, sessionID int64,
|
||||
suppliedKey string,
|
||||
memberCount int64,
|
||||
) error {
|
||||
banned, err := isSessionBannedInTx(
|
||||
ctx, transaction, channelID, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if banned {
|
||||
return ErrBannedFromChannel
|
||||
}
|
||||
|
||||
var (
|
||||
inviteOnly, invited bool
|
||||
key string
|
||||
userLimit int64
|
||||
)
|
||||
|
||||
err = transaction.QueryRowContext(ctx,
|
||||
`SELECT is_invite_only, channel_key, user_limit,
|
||||
EXISTS (SELECT 1 FROM channel_invites
|
||||
WHERE channel_id = channels.id
|
||||
AND session_id = ?)
|
||||
FROM channels WHERE id = ?`,
|
||||
sessionID, channelID,
|
||||
).Scan(&inviteOnly, &key, &userLimit, &invited)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get channel modes: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case inviteOnly && !invited:
|
||||
return ErrInviteOnlyChannel
|
||||
case key != "" && suppliedKey != key:
|
||||
return ErrBadChannelKey
|
||||
case userLimit > 0 && memberCount >= userLimit:
|
||||
return ErrChannelIsFull
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountChannelMembers returns the number of members in
|
||||
// a channel.
|
||||
func (database *Database) CountChannelMembers(
|
||||
// isSessionBannedInTx is IsSessionBanned, reading through
|
||||
// transaction.
|
||||
func isSessionBannedInTx(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (int64, error) {
|
||||
var count int64
|
||||
transaction *sql.Tx,
|
||||
channelID, sessionID int64,
|
||||
) (bool, error) {
|
||||
var nick, username, hostname string
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM channel_members
|
||||
WHERE channel_id = ?`,
|
||||
channelID,
|
||||
).Scan(&count)
|
||||
err := transaction.QueryRowContext(ctx,
|
||||
`SELECT nick, username, hostname
|
||||
FROM sessions WHERE id = ?`,
|
||||
sessionID,
|
||||
).Scan(&nick, &username, &hostname)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"count channel members: %w", err,
|
||||
return false, fmt.Errorf(
|
||||
"get session hostmask: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
hostmask := FormatHostmask(nick, username, hostname)
|
||||
|
||||
rows, err := transaction.QueryContext(ctx,
|
||||
`SELECT mask FROM channel_bans
|
||||
WHERE channel_id = ?`,
|
||||
channelID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("list channel bans: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var mask string
|
||||
|
||||
err = rows.Scan(&mask)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"scan channel ban: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if MatchBanMask(mask, hostmask) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"iterate channel bans: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// IsChannelOperator checks if a session has operator
|
||||
@@ -729,6 +890,49 @@ func (database *Database) DeleteChannelIfEmpty(
|
||||
return nil
|
||||
}
|
||||
|
||||
// PartChannelAndDeleteIfEmpty removes a session from a
|
||||
// channel and deletes the channel if that left it with no
|
||||
// members, in one transaction, so that no JOIN lands
|
||||
// between the two.
|
||||
func (database *Database) PartChannelAndDeleteIfEmpty(
|
||||
ctx context.Context,
|
||||
channelID, sessionID int64,
|
||||
) error {
|
||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin part: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = transaction.Rollback() }()
|
||||
|
||||
_, err = transaction.ExecContext(ctx,
|
||||
`DELETE FROM channel_members
|
||||
WHERE channel_id = ? AND session_id = ?`,
|
||||
channelID, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("part channel: %w", err)
|
||||
}
|
||||
|
||||
_, err = transaction.ExecContext(ctx,
|
||||
`DELETE FROM channels WHERE id = ?
|
||||
AND NOT EXISTS
|
||||
(SELECT 1 FROM channel_members
|
||||
WHERE channel_id = ?)`,
|
||||
channelID, channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"delete channel if empty: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
err = transaction.Commit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit part: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanChannels scans rows into a ChannelInfo slice.
|
||||
func scanChannels(
|
||||
rows *sql.Rows,
|
||||
@@ -2100,23 +2304,6 @@ func (database *Database) HasChannelInvite(
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ClearChannelInvite removes a session's invite to a
|
||||
// channel (called after successful JOIN).
|
||||
func (database *Database) ClearChannelInvite(
|
||||
ctx context.Context,
|
||||
channelID, sessionID int64,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`DELETE FROM channel_invites
|
||||
WHERE channel_id = ? AND session_id = ?`,
|
||||
channelID, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear invite: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Tier 2: Secret (+s) ---
|
||||
|
||||
// IsChannelSecret checks if a channel has +s mode.
|
||||
|
||||
@@ -1342,17 +1342,6 @@ func TestChannelInviteCRUD(t *testing.T) {
|
||||
if !has {
|
||||
t.Fatal("should have invite")
|
||||
}
|
||||
|
||||
// Clear invite.
|
||||
err = database.ClearChannelInvite(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
has, _ = database.HasChannelInvite(ctx, chID, sid)
|
||||
if has {
|
||||
t.Fatal("invite should be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelSecret(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user