USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS on both the HTTP API and the IRC listener, and user mode +w. Model: opus-5-5
This commit is contained in:
@@ -58,3 +58,19 @@ func (database *Database) Close() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecForTest runs a raw statement against the test
|
||||
// database. Tests use it to install SQLite triggers that
|
||||
// force a specific write to fail, so that the atomicity of
|
||||
// multi-statement helpers can be exercised.
|
||||
func (database *Database) ExecForTest(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec for test: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -2423,3 +2424,205 @@ func (database *Database) SetChannelUserLimit(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSessionUserModes applies a set of user-mode flag
|
||||
// changes to a session inside a single transaction, so a
|
||||
// multi-mode change such as "+w-o" is all-or-nothing. A nil
|
||||
// pointer means the caller did not mention that mode and
|
||||
// the stored value must be left untouched.
|
||||
func (database *Database) SetSessionUserModes(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
wallops *bool,
|
||||
oper *bool,
|
||||
) error {
|
||||
if wallops == nil && oper == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
|
||||
if wallops != nil {
|
||||
if _, err := transaction.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||
boolToInt(*wallops), sessionID,
|
||||
); err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return fmt.Errorf(
|
||||
"set session wallops: %w", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if oper != nil {
|
||||
if _, err := transaction.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
|
||||
boolToInt(*oper), sessionID,
|
||||
); err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return fmt.Errorf("set session oper: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := transaction.Commit(); err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return fmt.Errorf("commit user modes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// boolToInt renders a Go bool as the 0/1 integer used for
|
||||
// boolean columns in the SQLite schema.
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// SetSessionWallops sets the wallops (+w) flag on a
|
||||
// session.
|
||||
func (database *Database) SetSessionWallops(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
enabled bool,
|
||||
) error {
|
||||
val := 0
|
||||
if enabled {
|
||||
val = 1
|
||||
}
|
||||
|
||||
_, err := database.conn.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||
val, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set session wallops: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSessionWallops returns whether the session has the
|
||||
// wallops (+w) usermode set.
|
||||
func (database *Database) IsSessionWallops(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (bool, error) {
|
||||
var isWallops int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT is_wallops FROM sessions WHERE id = ?`,
|
||||
sessionID,
|
||||
).Scan(&isWallops)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check session wallops: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return isWallops != 0, nil
|
||||
}
|
||||
|
||||
// GetWallopsSessionIDs returns all session IDs that have
|
||||
// the wallops (+w) usermode set.
|
||||
func (database *Database) GetWallopsSessionIDs(
|
||||
ctx context.Context,
|
||||
) ([]int64, error) {
|
||||
rows, err := database.conn.QueryContext(
|
||||
ctx,
|
||||
`SELECT id FROM sessions WHERE is_wallops = 1`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get wallops sessions: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var ids []int64
|
||||
|
||||
for rows.Next() {
|
||||
var sessionID int64
|
||||
if scanErr := rows.Scan(&sessionID); scanErr != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"scan wallops session: %w", scanErr,
|
||||
)
|
||||
}
|
||||
|
||||
ids = append(ids, sessionID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"iterate wallops sessions: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// UserhostInfo holds the data needed for RPL_USERHOST.
|
||||
type UserhostInfo struct {
|
||||
Nick string
|
||||
Username string
|
||||
Hostname string
|
||||
IsOper bool
|
||||
AwayMessage string
|
||||
}
|
||||
|
||||
// GetUserhostInfo returns USERHOST info for the given
|
||||
// nicks. Nicks with no session are omitted from the
|
||||
// result; any other database failure is returned, because
|
||||
// an unreadable row is not the same as an absent one.
|
||||
func (database *Database) GetUserhostInfo(
|
||||
ctx context.Context,
|
||||
nicks []string,
|
||||
) ([]UserhostInfo, error) {
|
||||
if len(nicks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
results := make([]UserhostInfo, 0, len(nicks))
|
||||
|
||||
for _, nick := range nicks {
|
||||
var info UserhostInfo
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT nick, username, hostname,
|
||||
is_oper, away_message
|
||||
FROM sessions WHERE nick = ?`,
|
||||
nick,
|
||||
).Scan(
|
||||
&info.Nick, &info.Username, &info.Hostname,
|
||||
&info.IsOper, &info.AwayMessage,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue // nick not online
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"userhost lookup %q: %w", nick, err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, info)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -1491,3 +1491,127 @@ func TestChannelUserLimit(t *testing.T) {
|
||||
t.Fatalf("expected 0, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSessionUserModesIsAtomic proves that a multi-mode
|
||||
// change is all-or-nothing. A trigger makes the is_oper
|
||||
// UPDATE fail after the is_wallops UPDATE has already run,
|
||||
// which is exactly the "+w-o" partial-failure the previous
|
||||
// implementation exhibited: it issued the two UPDATEs
|
||||
// independently, so +w persisted while the caller reported
|
||||
// total failure.
|
||||
func TestSetSessionUserModesIsAtomic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
ctx, "alice", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.ExecForTest(ctx,
|
||||
`CREATE TRIGGER reject_oper
|
||||
BEFORE UPDATE OF is_oper ON sessions
|
||||
BEGIN SELECT RAISE(ABORT, 'oper write rejected');
|
||||
END`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wallops := true
|
||||
oper := false
|
||||
|
||||
err = database.SetSessionUserModes(
|
||||
ctx, sessionID, &wallops, &oper,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected the rejected oper write to fail")
|
||||
}
|
||||
|
||||
gotWallops, err := database.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if gotWallops {
|
||||
t.Error(
|
||||
"wallops persisted despite the transaction " +
|
||||
"failing; the apply stage is not atomic",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSessionUserModesAppliesBoth is the success-path
|
||||
// counterpart: when nothing fails, both flags are written,
|
||||
// and a nil pointer leaves that flag untouched.
|
||||
func TestSetSessionUserModesAppliesBoth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
ctx, "alice", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := database.SetSessionOper(
|
||||
ctx, sessionID, true,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wallops := true
|
||||
oper := false
|
||||
|
||||
if err := database.SetSessionUserModes(
|
||||
ctx, sessionID, &wallops, &oper,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotWallops, err := database.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotOper, err := database.IsSessionOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !gotWallops || gotOper {
|
||||
t.Errorf(
|
||||
"want wallops=true oper=false, got %v/%v",
|
||||
gotWallops, gotOper,
|
||||
)
|
||||
}
|
||||
|
||||
// A nil pointer must leave the stored value alone.
|
||||
if err := database.SetSessionUserModes(
|
||||
ctx, sessionID, nil, nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotWallops, err = database.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !gotWallops {
|
||||
t.Error("nil pointers must not clear wallops")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||
is_wallops INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
signing_key TEXT NOT NULL DEFAULT '',
|
||||
away_message TEXT NOT NULL DEFAULT '',
|
||||
|
||||
Reference in New Issue
Block a user