Add the tier 3 utility IRC commands (closes #87)
check / check (push) Waiting to run

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:
2026-10-08 00:26:08 +00:00
parent b119b094f5
commit e28fa005ed
19 changed files with 3931 additions and 85 deletions
+16
View File
@@ -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
}
+203
View File
@@ -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
}
+124
View File
@@ -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")
}
}
+1
View File
@@ -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 '',