diff --git a/.gitattributes b/.gitattributes index 00fa1e3..639a243 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,9 @@ bundle/*.tar.xz filter=lfs diff=lfs merge=lfs -text bundle/*.sha256 -text + +# Go tooling (gofmt, staticcheck) assumes LF; without this, Windows +# checkouts under core.autocrlf=true get CRLF working copies that make +# `gofmt -l` flag every file. +*.go text eol=lf +go.mod text eol=lf +go.sum text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b5a5fff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +# Build, vet, test, and static-analyze on every push and PR. Runs on Linux +# deliberately: internal/carbonyl's runner is //go:build linux, so analyzing +# under any other GOOS false-positives its helpers as dead code. +# +# No LFS checkout here — the Carbonyl bundle is only needed for docker +# builds (deploy.yml); pulling ~107 MB per CI run would burn LFS bandwidth +# for nothing. +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: build + test + lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::gofmt needs to be run on:" + echo "$unformatted" + exit 1 + fi + + - name: staticcheck + run: | + go install honnef.co/go/tools/cmd/staticcheck@latest + staticcheck ./... + + # The deadcode tool always exits 0, so fail on any output. Whole-program + # reachability from every cmd/ entry point; test-only helpers don't + # count as live, which is why the known noop-recorder line is allowed. + - name: deadcode + run: | + go install golang.org/x/tools/cmd/deadcode@latest + out=$(deadcode ./cmd/... | grep -v "NoopRecorder.Record" || true) + if [ -n "$out" ]; then + echo "::error::unreachable code found:" + echo "$out" + exit 1 + fi diff --git a/cmd/ansiconvert/main.go b/cmd/ansiconvert/main.go index 53ba3a5..1a2a540 100644 --- a/cmd/ansiconvert/main.go +++ b/cmd/ansiconvert/main.go @@ -332,9 +332,7 @@ var palette16 = [16][3]uint8{ var palette256 [256][3]uint8 func init() { - for i, c := range palette16 { - palette256[i] = c - } + copy(palette256[:], palette16[:]) cube := [6]uint8{0, 95, 135, 175, 215, 255} idx := 16 for r := 0; r < 6; r++ { diff --git a/cmd/nightms/main.go b/cmd/nightms/main.go index 10529da..a298276 100644 --- a/cmd/nightms/main.go +++ b/cmd/nightms/main.go @@ -531,7 +531,7 @@ func buildSessionDeps( // phase machine — the wheel runs forever, even with zero subscribers. walletSvc := &doors.WalletService{Queries: queries} rouletteReg := roulettemp.NewRegistry( - ctx, queries, mpLedger, + queries, mpLedger, &roulettemp.WalletAdapter{Svc: walletSvc}, doors.CryptoRng{}, logger.With("component", "roulette-mp"), diff --git a/cmd/smoketest/main.go b/cmd/smoketest/main.go index d60d4ad..933999c 100644 --- a/cmd/smoketest/main.go +++ b/cmd/smoketest/main.go @@ -184,7 +184,7 @@ func stripAnsi(s string) string { // ESC followed by '[' → CSI. Skip until a letter byte (final). if in[i+1] == '[' { i += 2 - for i < len(in) && !((in[i] >= 0x40 && in[i] <= 0x7E)) { + for i < len(in) && !(in[i] >= 0x40 && in[i] <= 0x7E) { i++ } continue diff --git a/cmd/wsprobe/main.go b/cmd/wsprobe/main.go index 57d6597..ffb72af 100644 --- a/cmd/wsprobe/main.go +++ b/cmd/wsprobe/main.go @@ -1,12 +1,12 @@ // wsprobe is a one-shot client that exercises the web login + /ws/bbs path // without needing a browser. Mirrors what xterm.js does in the page: // -// 1. GET /login (collect the CSRF cookie + token) -// 2. POST /login with credentials and the token (gets nightms_session cookie) -// 3. WebSocket upgrade /ws/bbs (passing the session cookie + Origin) -// 4. Send {"type":"resize","cols":80,"rows":24} -// 5. Read binary frames for ~2s and assert an expected substring appears -// in the rendered output. +// 1. GET /login (collect the CSRF cookie + token) +// 2. POST /login with credentials and the token (gets nightms_session cookie) +// 3. WebSocket upgrade /ws/bbs (passing the session cookie + Origin) +// 4. Send {"type":"resize","cols":80,"rows":24} +// 5. Read binary frames for ~2s and assert an expected substring appears +// in the rendered output. package main import ( diff --git a/internal/auth/argon2id.go b/internal/auth/argon2id.go index fd7a212..6b4fedb 100644 --- a/internal/auth/argon2id.go +++ b/internal/auth/argon2id.go @@ -43,7 +43,6 @@ type Hasher struct { dummyOnce sync.Once dummyHash []byte - dummyAlgo string } func NewHasher(p Argon2Params) *Hasher { return &Hasher{params: p} } @@ -137,7 +136,10 @@ func (h *Hasher) VerifyDummy(password string) { h.params.Iterations, h.params.MemoryKB, h.params.Parallelism, h.params.HashBytes) return } - _ = h.Verify(password, h.dummyHash, "") + // Evaluate the PHC path directly rather than going back through Verify — + // Verify's unknown-format branch calls VerifyDummy, so a dummyHash that + // ever lost its PHC prefix would recurse forever. + _, _ = h.verifyPHC(password, h.dummyHash) } // --- legacy --- diff --git a/internal/auth/devicecode/devicecode.go b/internal/auth/devicecode/devicecode.go index e1b24e5..74df4c0 100644 --- a/internal/auth/devicecode/devicecode.go +++ b/internal/auth/devicecode/devicecode.go @@ -50,10 +50,10 @@ import ( // scan from the sysop console can survey active flows without sweeping the // whole keyspace. const ( - keyFlowPrefix = "oauth:device:flow:" // + flow_id → JSON state, TTL = expires_in - keyUserPrefix = "oauth:device:user:" // + user_id:provider → flow_id, TTL = expires_in - keyBeginBucket = "oauth:device:begin:" // + user_id → INCR counter, EX 60s - beginRateLimit = 6 // starts per minute per user + keyFlowPrefix = "oauth:device:flow:" // + flow_id → JSON state, TTL = expires_in + keyUserPrefix = "oauth:device:user:" // + user_id:provider → flow_id, TTL = expires_in + keyBeginBucket = "oauth:device:begin:" // + user_id → INCR counter, EX 60s + beginRateLimit = 6 // starts per minute per user beginRateTTL = 60 * time.Second ) @@ -71,7 +71,7 @@ var ErrRateLimited = errors.New("devicecode: too many recent attempts") // (UserCode, VerificationURL) plus the opaque server-side handle the TUI // uses on subsequent Poll calls. type Flow struct { - ID string // opaque server-minted handle; client passes back to Poll + ID string // opaque server-minted handle; client passes back to Poll Provider auth.OAuthProviderKind UserCode string // short code the user types into the verification page VerificationURL string // URL the user opens in a browser @@ -509,13 +509,13 @@ type deviceCodeResp struct { } type rawDeviceCodeResp struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - VerificationURIComplete string `json:"verification_uri_complete"` - VerificationURL string `json:"verification_url"` // Google's older field name - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + VerificationURL string `json:"verification_url"` // Google's older field name + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` } func (s *Service) requestDeviceCode(ctx context.Context, p *auth.OAuthProvider) (*deviceCodeResp, error) { diff --git a/internal/auth/oauthrefresh/refresher.go b/internal/auth/oauthrefresh/refresher.go index e108883..bfeaa08 100644 --- a/internal/auth/oauthrefresh/refresher.go +++ b/internal/auth/oauthrefresh/refresher.go @@ -46,12 +46,12 @@ import ( // per-tick batch of 50 covers a fully-saturated keyspace within one tick, // and the 4-worker pool keeps provider HTTP RTTs from serializing. const ( - defaultInterval = 60 * time.Second - defaultLeadTime = 10 * time.Minute - defaultWorkers = 4 - defaultBatchSize = 50 + defaultInterval = 60 * time.Second + defaultLeadTime = 10 * time.Minute + defaultWorkers = 4 + defaultBatchSize = 50 defaultReauthAfter = 5 // soft failures before we give up and flip needs_reauth - perWorkerTimeout = 20 * time.Second + perWorkerTimeout = 20 * time.Second ) // Config bundles construction params. Most have sensible zero-value diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go index ceab7f6..6993b0c 100644 --- a/internal/auth/ratelimit.go +++ b/internal/auth/ratelimit.go @@ -19,13 +19,3 @@ type RateLimiter interface { RecordFailure(ctx context.Context, handle string, sourceIP net.Addr) error Clear(ctx context.Context, handle string) error } - -// NoopRateLimiter never locks out. Used by tests that don't need to exercise -// the real limiter. -type NoopRateLimiter struct{} - -func (NoopRateLimiter) Check(_ context.Context, _ string, _ net.Addr) (RateLimitCheck, error) { - return RateLimitCheck{}, nil -} -func (NoopRateLimiter) RecordFailure(_ context.Context, _ string, _ net.Addr) error { return nil } -func (NoopRateLimiter) Clear(_ context.Context, _ string) error { return nil } diff --git a/internal/auth/ratelimit_redis.go b/internal/auth/ratelimit_redis.go index 35c3ef5..e49b74f 100644 --- a/internal/auth/ratelimit_redis.go +++ b/internal/auth/ratelimit_redis.go @@ -46,21 +46,6 @@ type RateLimitParams struct { LockcountWindow time.Duration } -// DefaultRateLimitParams returns the same numbers the legacy stack defaults -// to, so two stacks pointed at the same Redis would lock the same accounts -// at the same thresholds during cutover. Adds the Phase B defaults. -func DefaultRateLimitParams() RateLimitParams { - return RateLimitParams{ - HandleThreshold: 5, - IPThreshold: 20, - WindowDuration: 15 * time.Minute, - LockDuration: 15 * time.Minute, - BackoffMax: 5, - PersistentBanThreshold: 3, - LockcountWindow: 24 * time.Hour, - } -} - // RedisRateLimiter is the production limiter, backed by Redis INCR/EXPIRE // counters and TTL'd lock keys. Implements the RateLimiter interface. // @@ -115,12 +100,14 @@ func (r *RedisRateLimiter) effective() RateLimitParams { return p } -func failHandleKey(handle string) string { return "auth:fail:handle:" + strings.ToLower(handle) } -func failIPKey(ip string) string { return "auth:fail:ip:" + ip } -func lockHandleKey(handle string) string { return "auth:lock:handle:" + strings.ToLower(handle) } -func lockIPKey(ip string) string { return "auth:lock:ip:" + ip } -func lockcountHandleKey(handle string) string { return "auth:lockcount:handle:" + strings.ToLower(handle) } -func lockcountIPKey(ip string) string { return "auth:lockcount:ip:" + ip } +func failHandleKey(handle string) string { return "auth:fail:handle:" + strings.ToLower(handle) } +func failIPKey(ip string) string { return "auth:fail:ip:" + ip } +func lockHandleKey(handle string) string { return "auth:lock:handle:" + strings.ToLower(handle) } +func lockIPKey(ip string) string { return "auth:lock:ip:" + ip } +func lockcountHandleKey(handle string) string { + return "auth:lockcount:handle:" + strings.ToLower(handle) +} +func lockcountIPKey(ip string) string { return "auth:lockcount:ip:" + ip } // normalizeIP strips the port from a net.Addr.String() so the lockout key is // stable across multiple connection attempts from the same address. diff --git a/internal/auth/ratelimit_redis_test.go b/internal/auth/ratelimit_redis_test.go index f32b740..1cc7e80 100644 --- a/internal/auth/ratelimit_redis_test.go +++ b/internal/auth/ratelimit_redis_test.go @@ -24,7 +24,7 @@ func TestComputeLockDuration_ExponentialSequence(t *testing.T) { {4, 2 * time.Hour}, {5, 4 * time.Hour}, {6, 8 * time.Hour}, - {7, 8 * time.Hour}, // capped + {7, 8 * time.Hour}, // capped {20, 8 * time.Hour}, // still capped, no overflow } for _, tc := range cases { diff --git a/internal/auth/register.go b/internal/auth/register.go index b364f5d..5394710 100644 --- a/internal/auth/register.go +++ b/internal/auth/register.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" @@ -206,7 +205,5 @@ func classifyInsertError(err error) error { return &RegistrationErr{Kind: RegErrKeyAlreadyUsed, Err: err} } } - // pgx may wrap differently; fall through. - _ = pgx.ErrNoRows return &RegistrationErr{Kind: RegErrInternal, Err: err} } diff --git a/internal/auth/sysop.go b/internal/auth/sysop.go index cd9b25d..53818cf 100644 --- a/internal/auth/sysop.go +++ b/internal/auth/sysop.go @@ -43,9 +43,9 @@ func BootstrapSysop( defer tx.Rollback(ctx) var ( - userID int64 - isSysop bool - hasPwd bool + userID int64 + isSysop bool + hasPwd bool ) err = tx.QueryRow(ctx, `SELECT id, is_sysop, password_hash IS NOT NULL FROM users WHERE handle = $1`, diff --git a/internal/config/options.go b/internal/config/options.go index 0260b85..4f5bb79 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -45,14 +45,14 @@ type Options struct { BootstrapSysopHandle string BootstrapSysopPassword string - WebPublicHost string + WebPublicHost string // WebSSHHost is the host string rendered into "ssh -p 2222 you@" // snippets on the landing/login/profile pages. Distinct from WebPublicHost // because the SSH listener and the HTTP listener can live on different // names (e.g. SSH direct at ssh.night.ms, HTTP behind Cloudflare at // k.night.ms). Defaults to WebPublicHost when NIGHTMS_SSH_HOST is unset, // preserving the old single-host behavior. - WebSSHHost string + WebSSHHost string // WebSSHPort is the externally-reachable SSH port shown to users in those // same snippets. Distinct from the bind port (BBS_SSH_PORT) because the // container may bind to a non-privileged port while the host forwards the @@ -286,7 +286,7 @@ func Load() Options { OAuthRefreshBatchSize: int(uintEnv("NIGHTMS_OAUTH_REFRESH_BATCH_SIZE", 50)), OAuthRefreshWorkers: int(uintEnv("NIGHTMS_OAUTH_REFRESH_WORKERS", 4)), OAuthTokenSecret: hexBytesEnv("NIGHTMS_OAUTH_TOKEN_SECRET"), - ORSAPIKey: os.Getenv("NIGHTMS_ORS_API_KEY"), + ORSAPIKey: os.Getenv("NIGHTMS_ORS_API_KEY"), Carbonyl: CarbonylOptions{ BinPath: envOr("NIGHTMS_CARBONYL_BIN_PATH", "/opt/carbonyl/carbonyl"), DataDir: envOr("NIGHTMS_CARBONYL_DATA_DIR", filepath.Join("data", "carbonyl")), diff --git a/internal/data/events_filtered.go b/internal/data/events_filtered.go index f3b0990..667a01a 100644 --- a/internal/data/events_filtered.go +++ b/internal/data/events_filtered.go @@ -184,4 +184,3 @@ func ListUnifiedEventsFiltered( } return out, nil } - diff --git a/internal/doors/holdem/holdem.go b/internal/doors/holdem/holdem.go index 370bc65..82a56cb 100644 --- a/internal/doors/holdem/holdem.go +++ b/internal/doors/holdem/holdem.go @@ -2,11 +2,11 @@ // multiplayer table actor model lives in the sibling multiplayer/ package. // // Lifecycle per hand: -// 1. Post blinds (CPU=SB, player=BB or vice-versa, alternating). -// 2. Deal hole cards. -// 3. Preflop betting round (button acts first preflop in HU). -// 4. Flop, turn, river — each followed by a betting round. -// 5. Showdown or fold; pot awarded; advance button. +// 1. Post blinds (CPU=SB, player=BB or vice-versa, alternating). +// 2. Deal hole cards. +// 3. Preflop betting round (button acts first preflop in HU). +// 4. Flop, turn, river — each followed by a betting round. +// 5. Showdown or fold; pot awarded; advance button. package holdem import ( @@ -79,7 +79,7 @@ type Game struct { bigBlind int32 rng doors.CryptoRng - winnerSeat int // -1 = no winner yet + winnerSeat int // -1 = no winner yet winRank cards.HandRank winBoard bool // true at showdown so screen reveals bot's cards } @@ -148,15 +148,15 @@ func (g *Game) BotHole() ([2]cards.Card, bool) { } return [2]cards.Card{}, false } -func (g *Game) Board() []cards.Card { return append([]cards.Card(nil), g.board...) } -func (g *Game) Stacks() [2]int32 { return g.stacks } -func (g *Game) Bets() [2]int32 { return g.bets } -func (g *Game) Pot() int32 { return g.pot + g.bets[0] + g.bets[1] } -func (g *Game) Street() Street { return g.street } -func (g *Game) ToAct() int { return g.toAct } -func (g *Game) WinnerSeat() int { return g.winnerSeat } -func (g *Game) WinRank() cards.HandRank { return g.winRank } -func (g *Game) BigBlind() int32 { return g.bigBlind } +func (g *Game) Board() []cards.Card { return append([]cards.Card(nil), g.board...) } +func (g *Game) Stacks() [2]int32 { return g.stacks } +func (g *Game) Bets() [2]int32 { return g.bets } +func (g *Game) Pot() int32 { return g.pot + g.bets[0] + g.bets[1] } +func (g *Game) Street() Street { return g.street } +func (g *Game) ToAct() int { return g.toAct } +func (g *Game) WinnerSeat() int { return g.winnerSeat } +func (g *Game) WinRank() cards.HandRank { return g.winRank } +func (g *Game) BigBlind() int32 { return g.bigBlind } // ToCall returns the amount the to-act seat must put in to call. func (g *Game) ToCall() int32 { diff --git a/internal/doors/holdem/multiplayer/coordinator.go b/internal/doors/holdem/multiplayer/coordinator.go index a2b1257..1e0390b 100644 --- a/internal/doors/holdem/multiplayer/coordinator.go +++ b/internal/doors/holdem/multiplayer/coordinator.go @@ -16,19 +16,19 @@ import ( // every state mutation. Per-seat hole cards are masked except for the seat's // own user and showdown. The screen renders directly from the snapshot. type TableSnapshot struct { - TableID int64 - Seats []SeatView - Board []cards.Card - Pot int32 - Street Street - ToAct int - Button int - BigBlind int32 - SmallBlind int32 - Winner int - WinRank cards.HandRank - HandRunning bool - CapSeats int + TableID int64 + Seats []SeatView + Board []cards.Card + Pot int32 + Street Street + ToAct int + Button int + BigBlind int32 + SmallBlind int32 + Winner int + WinRank cards.HandRank + HandRunning bool + CapSeats int OccupiedSeats int } @@ -52,11 +52,11 @@ type Coordinator struct { TableID int64 Name string - cmds chan command - game *Game - subs []*subscriber - subsMu sync.Mutex - closed atomic.Bool + cmds chan command + game *Game + subs []*subscriber + subsMu sync.Mutex + closed atomic.Bool // onSettlement fires once per settled hand. The registry wires it to a // closure that calls the multiplayer ledger to persist multiplayer_hands diff --git a/internal/doors/holdem/multiplayer/engine.go b/internal/doors/holdem/multiplayer/engine.go index a11b8cb..1feac06 100644 --- a/internal/doors/holdem/multiplayer/engine.go +++ b/internal/doors/holdem/multiplayer/engine.go @@ -55,9 +55,9 @@ type Seat struct { // Game is the N-seat Hold'em engine. Generalized from the heads-up version // in the parent package — same payout math, same Street/Action enums. type Game struct { - deck []cards.Card - seats []Seat - board []cards.Card + deck []cards.Card + seats []Seat + board []cards.Card button int // seat index that has the button this hand toAct int // next seat to act; -1 between hands @@ -72,7 +72,7 @@ type Game struct { // the bet or folded. lastRaiserIdx int - winner int // winning seat index after hand resolved; -1 when split + winner int // winning seat index after hand resolved; -1 when split winRank cards.HandRank // lastPayouts is the per-seat chips awarded this hand, indexed by seat @@ -131,7 +131,7 @@ func (g *Game) OccupiedSeats() []int { } // Board, Pot, Street, ToAct, Button, BigBlind expose state. -func (g *Game) Board() []cards.Card { return append([]cards.Card(nil), g.board...) } +func (g *Game) Board() []cards.Card { return append([]cards.Card(nil), g.board...) } func (g *Game) Pot() int32 { total := g.pot for _, s := range g.seats { @@ -139,11 +139,11 @@ func (g *Game) Pot() int32 { } return total } -func (g *Game) Street() Street { return g.street } -func (g *Game) ToAct() int { return g.toAct } -func (g *Game) Button() int { return g.button } -func (g *Game) BigBlind() int32 { return g.bigBlind } -func (g *Game) Winner() int { return g.winner } +func (g *Game) Street() Street { return g.street } +func (g *Game) ToAct() int { return g.toAct } +func (g *Game) Button() int { return g.button } +func (g *Game) BigBlind() int32 { return g.bigBlind } +func (g *Game) Winner() int { return g.winner } func (g *Game) WinRank() cards.HandRank { return g.winRank } // Payouts returns per-seat chips awarded by the most recently settled hand, diff --git a/internal/doors/holdem/multiplayer/registry.go b/internal/doors/holdem/multiplayer/registry.go index ae9082f..b91cb31 100644 --- a/internal/doors/holdem/multiplayer/registry.go +++ b/internal/doors/holdem/multiplayer/registry.go @@ -21,9 +21,9 @@ import ( // table from the holdem_tables row on Start and writes a snapshot back to // the same row on graceful Shutdown. type Registry struct { - mu sync.Mutex - tables map[int64]*tableEntry - nextID atomic.Int64 + mu sync.Mutex + tables map[int64]*tableEntry + nextID atomic.Int64 rootCtx context.Context persistence *gen.Queries diff --git a/internal/doors/roulette/bets.go b/internal/doors/roulette/bets.go index b171843..2d6f40c 100644 --- a/internal/doors/roulette/bets.go +++ b/internal/doors/roulette/bets.go @@ -151,17 +151,6 @@ func Evaluate(p Pocket, k BetKey) (won bool, mult int32) { return false, 0 } -// Payout returns the *winnings* in credits for a settled bet (0 on a loss). -// The original stake was already debited at place-time; coordinator credits -// Amount + Payout to the winner so the house keeps the stake on a loss. -func Payout(p Pocket, b Bet) int32 { - won, mult := Evaluate(p, b.Key) - if !won { - return 0 - } - return b.Amount * mult -} - // GrossReturn is the total amount returned to the wallet on a win: stake plus // winnings. Coordinator uses this to issue a single Credit call per winning // bet — clearer than two Credits of stake + payout. On a loss returns 0. diff --git a/internal/doors/roulette/multiplayer/coordinator.go b/internal/doors/roulette/multiplayer/coordinator.go index be31d1a..e2903bc 100644 --- a/internal/doors/roulette/multiplayer/coordinator.go +++ b/internal/doors/roulette/multiplayer/coordinator.go @@ -607,4 +607,3 @@ func (c *Coordinator) DrainPending() []userBet { // ComputeStats returns the hot/cold/streak rollup over the full history // ring. Cheap (≤100 entries) so screens can call this on every paint. func (c *Coordinator) ComputeStats() Stats { return c.history.ComputeStats() } - diff --git a/internal/doors/roulette/multiplayer/history.go b/internal/doors/roulette/multiplayer/history.go index 0c75c87..df3ff93 100644 --- a/internal/doors/roulette/multiplayer/history.go +++ b/internal/doors/roulette/multiplayer/history.go @@ -70,14 +70,14 @@ func (r *historyRing) Replace(items []roulette.Pocket) { // Stats summarises the contents of the history ring. The TUI stats overlay // renders these aggregates directly. type Stats struct { - TotalSpins int - RedCount int - BlackCount int - GreenCount int - LongestRed int // longest consecutive run of red outcomes - LongestBlack int // longest consecutive run of black outcomes - Hot []HotEntry // pockets that came up most often, top 5, descending - Cold []HotEntry // pockets that came up least often (still > 0), bottom 5 ascending + TotalSpins int + RedCount int + BlackCount int + GreenCount int + LongestRed int // longest consecutive run of red outcomes + LongestBlack int // longest consecutive run of black outcomes + Hot []HotEntry // pockets that came up most often, top 5, descending + Cold []HotEntry // pockets that came up least often (still > 0), bottom 5 ascending } // HotEntry pairs a pocket with the number of times it appeared in the diff --git a/internal/doors/roulette/multiplayer/registry.go b/internal/doors/roulette/multiplayer/registry.go index d156cc3..a974aff 100644 --- a/internal/doors/roulette/multiplayer/registry.go +++ b/internal/doors/roulette/multiplayer/registry.go @@ -26,9 +26,7 @@ const SingletonKey = "global" // Persistence is optional — when Queries is nil the registry runs purely // in-memory and history evaporates on restart. type Registry struct { - coord *Coordinator - ctx context.Context - cancel context.CancelFunc + coord *Coordinator queries *gen.Queries wallet Wallet @@ -36,13 +34,15 @@ type Registry struct { } // NewRegistry constructs the registry and returns it before Run starts. +// Caller owns the coordinator's run lifetime: cancel the ctx passed to Run +// to stop the actor loop (in-flight bets refund via the shutdown hook). // Caller should typically: // -// reg := NewRegistry(rootCtx, queries, ledger, wallet, rng, logger) +// reg := NewRegistry(queries, ledger, wallet, rng, logger) // if err := reg.Restore(rootCtx); err != nil { ... } -// go reg.Coordinator().Run(reg.ctx) +// go reg.Coordinator().Run(rootCtx) // defer reg.Persist(shutdownCtx) -func NewRegistry(rootCtx context.Context, queries *gen.Queries, ledger gamesmp.Ledger, wallet Wallet, rng Rng, logger *slog.Logger) *Registry { +func NewRegistry(queries *gen.Queries, ledger gamesmp.Ledger, wallet Wallet, rng Rng, logger *slog.Logger) *Registry { cfg := Config{ Durations: DefaultPhaseDurations, LastBetCutoff: DefaultLastBetCutoff, @@ -52,11 +52,8 @@ func NewRegistry(rootCtx context.Context, queries *gen.Queries, ledger gamesmp.L Logger: logger, } coord := NewCoordinator(cfg) - runCtx, cancel := context.WithCancel(rootCtx) r := &Registry{ coord: coord, - ctx: runCtx, - cancel: cancel, queries: queries, wallet: wallet, logger: logger, @@ -137,7 +134,3 @@ func (r *Registry) Persist(ctx context.Context) error { Snapshot: body, }) } - -// Stop cancels the registry's coordinator-Run context. Call after Persist -// during shutdown so the actor loop exits cleanly. -func (r *Registry) Stop() { r.cancel() } diff --git a/internal/doors/roulette/multiplayer/snapshot.go b/internal/doors/roulette/multiplayer/snapshot.go index ef1ba81..9a85031 100644 --- a/internal/doors/roulette/multiplayer/snapshot.go +++ b/internal/doors/roulette/multiplayer/snapshot.go @@ -53,13 +53,13 @@ func (p Phase) String() string { // snapshot column on graceful shutdown so the rolling history survives // restart. PhaseToken seeds the next-boot counter so monotonicity holds. type PhaseMsg struct { - Phase Phase `json:"phase"` - EndsAt time.Time `json:"ends_at"` - PhaseToken int64 `json:"phase_token"` - Winning *roulette.Pocket `json:"winning,omitempty"` // nil during Betting/NoMoreBets - Aggregate map[string]int32 `json:"aggregate,omitempty"` // BetKey.String() → total chips - History []roulette.Pocket `json:"history,omitempty"` // last N pockets, oldest first - Occupants int `json:"occupants"` + Phase Phase `json:"phase"` + EndsAt time.Time `json:"ends_at"` + PhaseToken int64 `json:"phase_token"` + Winning *roulette.Pocket `json:"winning,omitempty"` // nil during Betting/NoMoreBets + Aggregate map[string]int32 `json:"aggregate,omitempty"` // BetKey.String() → total chips + History []roulette.Pocket `json:"history,omitempty"` // last N pockets, oldest first + Occupants int `json:"occupants"` } // PersistShape is the minimal subset of state we serialise to Postgres on diff --git a/internal/doors/roulette/roulette_test.go b/internal/doors/roulette/roulette_test.go index 6ded0e2..73dc5b3 100644 --- a/internal/doors/roulette/roulette_test.go +++ b/internal/doors/roulette/roulette_test.go @@ -161,30 +161,26 @@ func TestStraightUpPaysOnlyMatch(t *testing.T) { } } -// TestPayoutAmounts pins down a few hand-checked cases so the (amount × mult) -// math doesn't drift if Evaluate is ever refactored to return different -// multipliers. -func TestPayoutAmounts(t *testing.T) { +// TestGrossReturnAmounts pins down a few hand-checked cases so the +// (amount × (1 + mult)) math doesn't drift if Evaluate is ever refactored +// to return different multipliers. +func TestGrossReturnAmounts(t *testing.T) { cases := []struct { desc string pocket Pocket bet Bet - wantPay int32 wantGross int32 }{ - {"red bet on red pocket", Pocket(7), Bet{BetKey{Type: BetRed}, 5}, 5, 10}, - {"red bet on black pocket", Pocket(2), Bet{BetKey{Type: BetRed}, 5}, 0, 0}, - {"red bet on zero", Pocket(0), Bet{BetKey{Type: BetRed}, 5}, 0, 0}, - {"dozen1 bet on pocket 7", Pocket(7), Bet{BetKey{Type: BetDozen1}, 10}, 20, 30}, - {"dozen1 bet on pocket 13", Pocket(13), Bet{BetKey{Type: BetDozen1}, 10}, 0, 0}, - {"straight 17 on 17", Pocket(17), Bet{BetKey{Type: BetStraight, Number: 17}, 1}, 35, 36}, - {"straight 17 on 18", Pocket(18), Bet{BetKey{Type: BetStraight, Number: 17}, 1}, 0, 0}, - {"straight 00 on 00", Pocket00, Bet{BetKey{Type: BetStraight, Number: Pocket00}, 2}, 70, 72}, + {"red bet on red pocket", Pocket(7), Bet{BetKey{Type: BetRed}, 5}, 10}, + {"red bet on black pocket", Pocket(2), Bet{BetKey{Type: BetRed}, 5}, 0}, + {"red bet on zero", Pocket(0), Bet{BetKey{Type: BetRed}, 5}, 0}, + {"dozen1 bet on pocket 7", Pocket(7), Bet{BetKey{Type: BetDozen1}, 10}, 30}, + {"dozen1 bet on pocket 13", Pocket(13), Bet{BetKey{Type: BetDozen1}, 10}, 0}, + {"straight 17 on 17", Pocket(17), Bet{BetKey{Type: BetStraight, Number: 17}, 1}, 36}, + {"straight 17 on 18", Pocket(18), Bet{BetKey{Type: BetStraight, Number: 17}, 1}, 0}, + {"straight 00 on 00", Pocket00, Bet{BetKey{Type: BetStraight, Number: Pocket00}, 2}, 72}, } for _, c := range cases { - if got := Payout(c.pocket, c.bet); got != c.wantPay { - t.Errorf("%s: Payout want %d, got %d", c.desc, c.wantPay, got) - } if got := GrossReturn(c.pocket, c.bet); got != c.wantGross { t.Errorf("%s: GrossReturn want %d, got %d", c.desc, c.wantGross, got) } @@ -196,11 +192,11 @@ func TestPayoutAmounts(t *testing.T) { // double-count chips across bet types. func TestBetKeyString(t *testing.T) { cases := map[BetKey]string{ - {Type: BetRed}: "red", - {Type: BetDozen2}: "2nd12", - {Type: BetStraight, Number: 17}: "straight:17", - {Type: BetStraight, Number: Pocket00}: "straight:00", - {Type: BetStraight, Number: Pocket(0)}: "straight:0", + {Type: BetRed}: "red", + {Type: BetDozen2}: "2nd12", + {Type: BetStraight, Number: 17}: "straight:17", + {Type: BetStraight, Number: Pocket00}: "straight:00", + {Type: BetStraight, Number: Pocket(0)}: "straight:0", } seen := make(map[string]BetKey, len(cases)) for k, want := range cases { diff --git a/internal/doors/videopoker/videopoker.go b/internal/doors/videopoker/videopoker.go index 4467b46..a6a6035 100644 --- a/internal/doors/videopoker/videopoker.go +++ b/internal/doors/videopoker/videopoker.go @@ -14,15 +14,15 @@ import ( // HandRank → multiplier of the bet. 9 for full house, 6 for flush — the // "9/6" designation. Values are flat per-credit; bet × multiplier = payout. var PayTable9_6 = map[cards.HandRank]int{ - cards.JacksOrBetter: 1, - cards.TwoPair: 2, - cards.ThreeOfAKind: 3, - cards.Straight: 4, - cards.Flush: 6, - cards.FullHouse: 9, - cards.FourOfAKind: 25, - cards.StraightFlush: 50, - cards.RoyalFlush: 800, + cards.JacksOrBetter: 1, + cards.TwoPair: 2, + cards.ThreeOfAKind: 3, + cards.Straight: 4, + cards.Flush: 6, + cards.FullHouse: 9, + cards.FourOfAKind: 25, + cards.StraightFlush: 50, + cards.RoyalFlush: 800, } // PayTableRow is a presentation row for the paytable view. @@ -48,11 +48,11 @@ func Schedule() []PayTableRow { // Game tracks one hand from deal through draw. The same Game value is read by // the screen each frame — it doesn't store any view state, just game state. type Game struct { - deck []cards.Card // remaining cards after deal (mutates on Draw) - hand [5]cards.Card - held [5]bool - dealt bool - drawn bool + deck []cards.Card // remaining cards after deal (mutates on Draw) + hand [5]cards.Card + held [5]bool + dealt bool + drawn bool finalRank cards.HandRank payout int32 diff --git a/internal/imaging/graphics/graphics.go b/internal/imaging/graphics/graphics.go index e232cfb..db2d029 100644 --- a/internal/imaging/graphics/graphics.go +++ b/internal/imaging/graphics/graphics.go @@ -1,15 +1,11 @@ -// Package graphics dispatches inline-image rendering to whichever terminal -// graphics protocol the connected SSH client supports. Halfblock is the -// universal fallback and reuses the existing imaging.RenderToANSILines. -// Kitty + iTerm2 + Sixel layer on for clients that advertise them via -// $TERM / $TERM_PROGRAM at PTY allocation time. +// Package graphics detects which terminal graphics protocol the connected +// SSH client supports via $TERM / $TERM_PROGRAM at PTY allocation time. +// Halfblock (imaging.RenderToANSILines) is the universal fallback and +// currently the only renderer; per-protocol encoders would layer on here. package graphics import ( - "image" "strings" - - "github.com/nickna/ssh.night.ms/internal/imaging" ) // Protocol identifies the inline-image transport. Halfblock is the default @@ -77,40 +73,6 @@ func Detect(term string, environ []string) Protocol { return Halfblock } -// Encode renders img to a slice of terminal rows for the given protocol. -// cellCols is the desired cell-width; the protocol may scale to honor or -// approximate it. Returns nil on unsupported protocols or render failure -// so the caller can fall through to the halfblock encoder. -func Encode(p Protocol, img image.Image, cellCols int) []string { - if img == nil || cellCols <= 0 { - return nil - } - switch p { - case None: - return nil - case Kitty: - return encodeKitty(img, cellCols) - case Iterm2: - return encodeIterm2(img, cellCols) - case Sixel: - return encodeSixel(img, cellCols) - default: - return imaging.RenderToANSILines(img, cellCols) - } -} - -// EncodeWithFallback runs Encode and, when the result is empty (a protocol -// stub returning nil, or a failure), falls back to halfblock so the caller -// always gets *something* paintable. Use this from screens that don't want -// to write their own retry. -func EncodeWithFallback(p Protocol, img image.Image, cellCols int) []string { - out := Encode(p, img, cellCols) - if len(out) > 0 { - return out - } - return imaging.RenderToANSILines(img, cellCols) -} - func parseEnviron(env []string) map[string]string { m := make(map[string]string, len(env)) for _, kv := range env { diff --git a/internal/imaging/graphics/iterm2.go b/internal/imaging/graphics/iterm2.go deleted file mode 100644 index f0379f4..0000000 --- a/internal/imaging/graphics/iterm2.go +++ /dev/null @@ -1,47 +0,0 @@ -package graphics - -import ( - "bytes" - "encoding/base64" - "image" - "image/png" - "strconv" -) - -// encodeIterm2 emits the iTerm2 inline-image OSC sequence (OSC 1337 File=...). -// Width is given in cells via the width= attribute. Like Kitty, the whole -// payload sits in row[0] and the remaining returned rows are blank so the -// caller's line accounting matches the on-screen footprint. -func encodeIterm2(img image.Image, cellCols int) []string { - if img == nil || cellCols <= 0 { - return nil - } - rows := approxRowsForCols(img, cellCols) - if rows <= 0 { - rows = 1 - } - var pngBuf bytes.Buffer - if err := png.Encode(&pngBuf, img); err != nil { - return nil - } - encoded := base64.StdEncoding.EncodeToString(pngBuf.Bytes()) - if encoded == "" { - return nil - } - - var out bytes.Buffer - out.WriteString("\x1b]1337;File=inline=1;width=") - out.WriteString(strconv.Itoa(cellCols)) - out.WriteString(";height=") - out.WriteString(strconv.Itoa(rows)) - out.WriteString(";preserveAspectRatio=1:") - out.WriteString(encoded) - out.WriteString("\x07") - - lines := make([]string, rows) - lines[0] = out.String() - for i := 1; i < rows; i++ { - lines[i] = "" - } - return lines -} diff --git a/internal/imaging/graphics/kitty.go b/internal/imaging/graphics/kitty.go deleted file mode 100644 index c6a6f31..0000000 --- a/internal/imaging/graphics/kitty.go +++ /dev/null @@ -1,104 +0,0 @@ -package graphics - -import ( - "bytes" - "encoding/base64" - "image" - "image/png" - "strconv" -) - -// kittyChunkSize is the per-APC-payload byte budget. The Kitty protocol -// recommends ≤4096 base64 chars; staying under that keeps wide compatibility -// with terminals that copy chunks through fixed buffers. -const kittyChunkSize = 4096 - -// kittyCellPixels is the assumed pixel-per-cell ratio used when sizing the -// output. Most terminals run roughly 8×16 or 10×20; the protocol's c=cols -// and r=rows attributes do the final scaling, so this is just a hint that -// keeps the encoded PNG from being needlessly oversized for narrow widths. -const kittyCellPixels = 16 - -// encodeKitty emits a Kitty graphics protocol image directly to the terminal. -// The full payload is built into a single returned row[0]; the rest of the -// returned slice is empty rows so the caller's vertical-layout accounting -// matches the on-screen cell footprint. -func encodeKitty(img image.Image, cellCols int) []string { - if img == nil || cellCols <= 0 { - return nil - } - rows := approxRowsForCols(img, cellCols) - if rows <= 0 { - rows = 1 - } - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - return nil - } - encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) - if encoded == "" { - return nil - } - - var out bytes.Buffer - // First chunk carries the format + size hints; subsequent chunks - // continue with m=1 except the last which sets m=0. - for i := 0; i < len(encoded); i += kittyChunkSize { - end := i + kittyChunkSize - if end > len(encoded) { - end = len(encoded) - } - chunk := encoded[i:end] - more := end < len(encoded) - out.WriteString("\x1b_G") - if i == 0 { - out.WriteString("a=T,f=100,c=") - out.WriteString(strconv.Itoa(cellCols)) - out.WriteString(",r=") - out.WriteString(strconv.Itoa(rows)) - } - if more { - if i > 0 { - out.WriteString("m=1") - } else { - out.WriteString(",m=1") - } - } else if i > 0 { - out.WriteString("m=0") - } - out.WriteByte(';') - out.WriteString(chunk) - out.WriteString("\x1b\\") - } - - lines := make([]string, rows) - lines[0] = out.String() - for i := 1; i < rows; i++ { - lines[i] = "" - } - return lines -} - -// approxRowsForCols returns how many terminal rows we expect the scaled -// image to occupy, used both to pad the returned slice and to advise the -// Kitty/iTerm2 protocol on cell extents. Aspect ratio is preserved from the -// source image, assuming kittyCellPixels for height. -func approxRowsForCols(img image.Image, cellCols int) int { - if img == nil { - return 0 - } - w := img.Bounds().Dx() - h := img.Bounds().Dy() - if w == 0 || h == 0 { - return 0 - } - // Pixel width per cell — we don't know exactly, so assume 8. - const cellPxWidth = 8 - targetPxW := cellCols * cellPxWidth - scaledH := h * targetPxW / w - rows := scaledH / kittyCellPixels - if rows < 1 { - rows = 1 - } - return rows -} diff --git a/internal/imaging/graphics/sixel.go b/internal/imaging/graphics/sixel.go deleted file mode 100644 index a62c9e8..0000000 --- a/internal/imaging/graphics/sixel.go +++ /dev/null @@ -1,15 +0,0 @@ -package graphics - -import "image" - -// encodeSixel is a stub. A real implementation would shell out to -// `github.com/mattn/go-sixel` (or hand-roll the encoder) but the dep adds a -// non-trivial transitive footprint and the protocols already covered -// (Kitty, iTerm2, halfblock fallback) reach most users. When this returns -// nil, EncodeWithFallback in graphics.go transparently falls back to the -// halfblock encoder. -func encodeSixel(img image.Image, cellCols int) []string { - _ = img - _ = cellCols - return nil -} diff --git a/internal/providers/finance/coingecko.go b/internal/providers/finance/coingecko.go index 972479c..f90f731 100644 --- a/internal/providers/finance/coingecko.go +++ b/internal/providers/finance/coingecko.go @@ -2,11 +2,13 @@ package finance import ( "context" - "encoding/json" + "errors" "fmt" "net/http" "strings" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/httpjson" ) // CoinGecko fetches crypto quotes from https://api.coingecko.com — free, @@ -21,6 +23,9 @@ func NewCoinGecko() *CoinGecko { return &CoinGecko{HTTPClient: &http.Client{Timeout: 8 * time.Second}} } +// cgHeaders identifies the BBS on every CoinGecko request. +var cgHeaders = map[string]string{"User-Agent": "nightms-bbs/1.0"} + // market is the shared decoding shape for the /coins/markets endpoint. type cgMarket struct { ID string `json:"id"` @@ -40,25 +45,13 @@ func (p *CoinGecko) fetchMarket(ctx context.Context, id string) (*cgMarket, erro "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=%s&price_change_percentage=24h", id, ) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "nightms-bbs/1.0") - resp, err := p.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("coingecko: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode == 429 { - return nil, fmt.Errorf("coingecko: rate limited (429) — try again in a moment") - } - if resp.StatusCode != 200 { - return nil, fmt.Errorf("coingecko: status %d", resp.StatusCode) - } var rows []cgMarket - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return nil, fmt.Errorf("coingecko: decode: %w", err) + if err := httpjson.Get(ctx, p.HTTPClient, url, &rows, cgHeaders); err != nil { + var se *httpjson.StatusError + if errors.As(err, &se) && se.Code == 429 { + return nil, fmt.Errorf("coingecko: rate limited (429) — try again in a moment") + } + return nil, fmt.Errorf("coingecko: %w", err) } if len(rows) == 0 { return nil, fmt.Errorf("coingecko: id %q not found", id) @@ -128,24 +121,11 @@ func (p *CoinGecko) fetchSeries(ctx context.Context, id, days string) ([]float64 "https://api.coingecko.com/api/v3/coins/%s/market_chart?vs_currency=usd&days=%s", id, days, ) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "nightms-bbs/1.0") - resp, err := p.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("coingecko series: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return nil, fmt.Errorf("coingecko series: status %d", resp.StatusCode) - } var body struct { Prices [][]float64 `json:"prices"` } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("coingecko series: decode: %w", err) + if err := httpjson.Get(ctx, p.HTTPClient, url, &body, cgHeaders); err != nil { + return nil, fmt.Errorf("coingecko series: %w", err) } out := make([]float64, 0, len(body.Prices)) for _, p := range body.Prices { diff --git a/internal/providers/finance/finance.go b/internal/providers/finance/finance.go index 7313863..d8b8233 100644 --- a/internal/providers/finance/finance.go +++ b/internal/providers/finance/finance.go @@ -51,14 +51,14 @@ type Quote struct { // All optional pointers are nil when the upstream doesn't expose the field // (e.g. CoinGecko has no 52-week range; Frankfurter has no volume). type Detail struct { - Quote // the same fields rendered on the list row - Open *float64 // session open (or 1-year open for FX) - DayLow *float64 // 24h low - DayHigh *float64 // 24h high - Week52Low *float64 // 52-week low (stocks only) - Week52High *float64 // 52-week high (stocks only) - Volume *int64 // session volume (stocks only) - Series []float64 // chart series — intraday for stocks/crypto, daily for FX + Quote // the same fields rendered on the list row + Open *float64 // session open (or 1-year open for FX) + DayLow *float64 // 24h low + DayHigh *float64 // 24h high + Week52Low *float64 // 52-week low (stocks only) + Week52High *float64 // 52-week high (stocks only) + Volume *int64 // session volume (stocks only) + Series []float64 // chart series — intraday for stocks/crypto, daily for FX } // AssetProvider is implemented by a per-asset-class backend (Yahoo for diff --git a/internal/providers/finance/frankfurter.go b/internal/providers/finance/frankfurter.go index 749e910..790579d 100644 --- a/internal/providers/finance/frankfurter.go +++ b/internal/providers/finance/frankfurter.go @@ -2,11 +2,12 @@ package finance import ( "context" - "encoding/json" "fmt" "net/http" "sort" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/httpjson" ) // Frankfurter delivers FX rates via api.frankfurter.dev (ECB reference rates, @@ -42,24 +43,12 @@ func (f *Frankfurter) fetchSeries(ctx context.Context, base, quote string, days "https://api.frankfurter.dev/v1/%s..%s?base=%s&symbols=%s", from.Format("2006-01-02"), to.Format("2006-01-02"), base, quote, ) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "nightms-bbs/1.0 (+https://night.ms)") - resp, err := f.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("frankfurter: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return nil, fmt.Errorf("frankfurter: status %d", resp.StatusCode) - } var body struct { Rates map[string]map[string]float64 `json:"rates"` } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("frankfurter: decode: %w", err) + headers := map[string]string{"User-Agent": "nightms-bbs/1.0 (+https://night.ms)"} + if err := httpjson.Get(ctx, f.HTTPClient, url, &body, headers); err != nil { + return nil, fmt.Errorf("frankfurter: %w", err) } type dp struct { Date string diff --git a/internal/providers/finance/news.go b/internal/providers/finance/news.go index 15c67fe..74a771f 100644 --- a/internal/providers/finance/news.go +++ b/internal/providers/finance/news.go @@ -8,8 +8,9 @@ import ( "net/url" "sort" "strings" - "sync" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/ttlcache" ) // Headline is a single finance-news item rendered in the bottom pane of the @@ -29,29 +30,20 @@ type NewsProvider interface { // YahooRSSNews fetches headlines from finance.yahoo.com/rss/headline. RSS 2.0, // no key. Per-(ticker-set, max) entries cached for 5 minutes so two users with -// the same watchlist share a fetch. +// the same watchlist share a fetch; a fetch failure serves the last-known +// headlines rather than blanking the pane (ttlcache.StaleOnError). type YahooRSSNews struct { HTTPClient *http.Client - TTL time.Duration - - mu sync.Mutex - cache map[string]*rssCacheEntry + cache *ttlcache.Cache[string, []Headline] } func NewYahooRSSNews() *YahooRSSNews { return &YahooRSSNews{ HTTPClient: &http.Client{Timeout: 10 * time.Second}, - TTL: 5 * time.Minute, - cache: map[string]*rssCacheEntry{}, + cache: ttlcache.New[string, []Headline](5*time.Minute, nil, ttlcache.StaleOnError()), } } -type rssCacheEntry struct { - mu sync.Mutex - items []Headline - fetched time.Time -} - var fallbackSymbols = []string{"^DJI", "^GSPC", "^IXIC"} func normalizeNewsSymbols(in []string) []string { @@ -87,35 +79,9 @@ func (y *YahooRSSNews) ForTickers(ctx context.Context, tickers []string, max int } symbols := normalizeNewsSymbols(tickers) key := fmt.Sprintf("%s|%d", strings.Join(symbols, ","), max) - - y.mu.Lock() - e, ok := y.cache[key] - if !ok { - e = &rssCacheEntry{} - y.cache[key] = e - } - y.mu.Unlock() - - e.mu.Lock() - if e.items != nil && time.Since(e.fetched) < y.TTL { - out := e.items - e.mu.Unlock() - return out, nil - } - e.mu.Unlock() - - items, err := y.fetch(ctx, symbols, max) - e.mu.Lock() - defer e.mu.Unlock() - if err != nil { - if e.items != nil { - return e.items, nil - } - return nil, err - } - e.items = items - e.fetched = time.Now() - return items, nil + return y.cache.Get(ctx, key, func(ctx context.Context) ([]Headline, error) { + return y.fetch(ctx, symbols, max) + }) } func (y *YahooRSSNews) fetch(ctx context.Context, symbols []string, max int) ([]Headline, error) { diff --git a/internal/providers/finance/resolver.go b/internal/providers/finance/resolver.go index 703ceb4..8755395 100644 --- a/internal/providers/finance/resolver.go +++ b/internal/providers/finance/resolver.go @@ -71,14 +71,14 @@ func Resolve(input string) (Resolved, error) { if rest, ok := stripPrefixCI(in, "s:"); ok { s := strings.ToUpper(strings.TrimSpace(rest)) if s == "" { - return Resolved{}, errors.New("empty stock symbol after s:") + return Resolved{}, errors.New("empty stock symbol after s: prefix") } return Resolved{Kind: KindStock, Canonical: s, DisplayHint: s}, nil } if rest, ok := stripPrefixCI(in, "c:"); ok { s := strings.TrimSpace(rest) if s == "" { - return Resolved{}, errors.New("empty crypto symbol after c:") + return Resolved{}, errors.New("empty crypto symbol after c: prefix") } up := strings.ToUpper(s) if id, found := KnownCryptoIDs[up]; found { diff --git a/internal/providers/finance/resolver_test.go b/internal/providers/finance/resolver_test.go index c96c402..3317a87 100644 --- a/internal/providers/finance/resolver_test.go +++ b/internal/providers/finance/resolver_test.go @@ -64,10 +64,10 @@ func TestResolve(t *testing.T) { func TestCryptoDisplay(t *testing.T) { cases := map[string]string{ - "bitcoin": "BTC", - "ethereum": "ETH", - "dogecoin": "DOGE", - "unknown-id": "UNKNOWN-ID", + "bitcoin": "BTC", + "ethereum": "ETH", + "dogecoin": "DOGE", + "unknown-id": "UNKNOWN-ID", } for id, want := range cases { if got := CryptoDisplay(id); got != want { diff --git a/internal/providers/finance/yahoo.go b/internal/providers/finance/yahoo.go index 9246d99..90b85fb 100644 --- a/internal/providers/finance/yahoo.go +++ b/internal/providers/finance/yahoo.go @@ -2,11 +2,13 @@ package finance import ( "context" - "encoding/json" + "errors" "fmt" "net/http" "net/url" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/httpjson" ) // Yahoo fetches stock quotes via Yahoo Finance's unofficial endpoints — @@ -25,23 +27,15 @@ func NewYahoo() *Yahoo { const yahooUA = "Mozilla/5.0 (compatible; nightms-bbs/1.0; +https://night.ms)" func (y *Yahoo) httpGet(ctx context.Context, urlStr string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) - if err != nil { - return err - } - req.Header.Set("User-Agent", yahooUA) - resp, err := y.HTTPClient.Do(req) + err := httpjson.Get(ctx, y.HTTPClient, urlStr, out, map[string]string{"User-Agent": yahooUA}) if err != nil { + var se *httpjson.StatusError + if errors.As(err, &se) && se.Code == 429 { + return fmt.Errorf("yahoo: rate limited (429)") + } return fmt.Errorf("yahoo: %w", err) } - defer resp.Body.Close() - if resp.StatusCode == 429 { - return fmt.Errorf("yahoo: rate limited (429)") - } - if resp.StatusCode != 200 { - return fmt.Errorf("yahoo: status %d", resp.StatusCode) - } - return json.NewDecoder(resp.Body).Decode(out) + return nil } type yQuoteRow struct { diff --git a/internal/providers/geocoding/openmeteo.go b/internal/providers/geocoding/openmeteo.go index 7c9a720..c3bc83c 100644 --- a/internal/providers/geocoding/openmeteo.go +++ b/internal/providers/geocoding/openmeteo.go @@ -11,13 +11,14 @@ package geocoding import ( "context" - "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/httpjson" ) // Result is a single candidate from the geocoder. Latitude / Longitude @@ -104,30 +105,14 @@ func (p *OpenMeteo) Search(ctx context.Context, query string, max int) ([]Result q.Set("count", strconv.Itoa(max)) q.Set("language", "en") q.Set("format", "json") - req, err := http.NewRequestWithContext(ctx, http.MethodGet, openMeteoBase+"?"+q.Encode(), nil) - if err != nil { - return nil, fmt.Errorf("geocoding: build request: %w", err) - } - resp, err := p.client().Do(req) - if err != nil { - return nil, fmt.Errorf("geocoding: do request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("geocoding: upstream status %d", resp.StatusCode) + var raw openMeteoResponse + if err := httpjson.Get(ctx, p.client(), openMeteoBase+"?"+q.Encode(), &raw, nil); err != nil { + return nil, fmt.Errorf("geocoding: %w", err) } - return parseOpenMeteoResults(resp.Body) + return resultsFromResponse(raw), nil } -// parseOpenMeteoResults is split out so the test suite can exercise the -// JSON-decoding path without standing up a live HTTP server. -func parseOpenMeteoResults(body interface { - Read([]byte) (int, error) -}) ([]Result, error) { - var raw openMeteoResponse - if err := json.NewDecoder(body).Decode(&raw); err != nil { - return nil, fmt.Errorf("geocoding: decode: %w", err) - } +func resultsFromResponse(raw openMeteoResponse) []Result { out := make([]Result, 0, len(raw.Results)) for _, r := range raw.Results { out = append(out, Result{ @@ -138,7 +123,7 @@ func parseOpenMeteoResults(body interface { Longitude: r.Longitude, }) } - return out, nil + return out } func (p *OpenMeteo) client() *http.Client { diff --git a/internal/providers/geocoding/openmeteo_test.go b/internal/providers/geocoding/openmeteo_test.go index 72003b2..3b3f4bc 100644 --- a/internal/providers/geocoding/openmeteo_test.go +++ b/internal/providers/geocoding/openmeteo_test.go @@ -1,10 +1,24 @@ package geocoding import ( + "encoding/json" + "fmt" + "io" "strings" "testing" ) +// parseOpenMeteoResults exercises the JSON-decoding path without standing +// up a live HTTP server. Lives in the test file because the production +// Search path decodes via httpjson.Get, so this helper is test-only. +func parseOpenMeteoResults(body io.Reader) ([]Result, error) { + var raw openMeteoResponse + if err := json.NewDecoder(body).Decode(&raw); err != nil { + return nil, fmt.Errorf("geocoding: decode: %w", err) + } + return resultsFromResponse(raw), nil +} + // openMeteoFixture is a trimmed real response from the live endpoint. // Covers the two cases the renderer cares about: a city with admin1 + // country (Paris, France) and one with admin1 equal-or-missing (a diff --git a/internal/providers/httpjson/httpjson.go b/internal/providers/httpjson/httpjson.go new file mode 100644 index 0000000..ae3d64c --- /dev/null +++ b/internal/providers/httpjson/httpjson.go @@ -0,0 +1,61 @@ +// Package httpjson is the one GET-and-decode helper shared by the outbound +// JSON providers (news, finance, weather, geocoding). It owns the repetitive +// request/status/decode plumbing; callers keep their provider-specific error +// prefixes by wrapping the returned error, and can special-case upstream +// statuses (a CoinGecko 429, say) by errors.As-ing into *StatusError. +package httpjson + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// StatusError is returned when the upstream answers with a non-200 status. +// Snippet carries the first bytes of the response body for diagnostics — +// many of these APIs put the useful "why" in the error body. +type StatusError struct { + Code int + Snippet string +} + +func (e *StatusError) Error() string { + if e.Snippet == "" { + return fmt.Sprintf("status %d", e.Code) + } + return fmt.Sprintf("status %d: %s", e.Code, e.Snippet) +} + +// snippetMax bounds how much of an error body lands in StatusError.Snippet. +const snippetMax = 512 + +// Get issues a GET request for url with the supplied headers (nil is fine), +// requires a 200, and JSON-decodes the body into out. A non-200 response +// yields a *StatusError. out may be nil to discard the body. +func Get(ctx context.Context, client *http.Client, url string, out any, headers map[string]string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, snippetMax)) + return &StatusError{Code: resp.StatusCode, Snippet: string(body)} + } + if out == nil { + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode: %w", err) + } + return nil +} diff --git a/internal/providers/httpjson/httpjson_test.go b/internal/providers/httpjson/httpjson_test.go new file mode 100644 index 0000000..317fc81 --- /dev/null +++ b/internal/providers/httpjson/httpjson_test.go @@ -0,0 +1,61 @@ +package httpjson + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetDecodesJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("User-Agent"); got != "test-ua" { + t.Errorf("User-Agent = %q, want test-ua", got) + } + _, _ = w.Write([]byte(`{"name":"nightms"}`)) + })) + defer srv.Close() + + var out struct { + Name string `json:"name"` + } + err := Get(context.Background(), srv.Client(), srv.URL, &out, map[string]string{"User-Agent": "test-ua"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if out.Name != "nightms" { + t.Errorf("Name = %q, want nightms", out.Name) + } +} + +func TestGetNon200ReturnsStatusError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte("slow down")) + })) + defer srv.Close() + + err := Get(context.Background(), srv.Client(), srv.URL, nil, nil) + var se *StatusError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *StatusError", err) + } + if se.Code != http.StatusTooManyRequests { + t.Errorf("Code = %d, want 429", se.Code) + } + if se.Snippet != "slow down" { + t.Errorf("Snippet = %q, want body text", se.Snippet) + } +} + +func TestGetNilOutDiscardsBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`not even json`)) + })) + defer srv.Close() + + if err := Get(context.Background(), srv.Client(), srv.URL, nil, nil); err != nil { + t.Fatalf("Get with nil out: %v", err) + } +} diff --git a/internal/providers/news/hackernews.go b/internal/providers/news/hackernews.go index 9aa93f0..18ba0e2 100644 --- a/internal/providers/news/hackernews.go +++ b/internal/providers/news/hackernews.go @@ -2,12 +2,12 @@ package news import ( "context" - "encoding/json" "fmt" - "io" "net/http" "sync" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/httpjson" ) // HackerNews fetches stories from the public Firebase-hosted HN API. Free, @@ -112,42 +112,17 @@ func (p *HackerNews) TopStories(ctx context.Context, limit int) ([]Story, error) } func (p *HackerNews) fetchTopIDs(ctx context.Context) ([]int64, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, hnBaseURL+"/topstories.json", nil) - if err != nil { - return nil, err - } - resp, err := p.HTTPClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body) - } var ids []int64 - if err := json.NewDecoder(resp.Body).Decode(&ids); err != nil { - return nil, fmt.Errorf("decode top: %w", err) + if err := httpjson.Get(ctx, p.HTTPClient, hnBaseURL+"/topstories.json", &ids, nil); err != nil { + return nil, fmt.Errorf("top: %w", err) } return ids, nil } func (p *HackerNews) fetchItem(ctx context.Context, id int64) (hnItem, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/item/%d.json", hnBaseURL, id), nil) - if err != nil { - return hnItem{}, err - } - resp, err := p.HTTPClient.Do(req) - if err != nil { - return hnItem{}, err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return hnItem{}, fmt.Errorf("item %d status %d", id, resp.StatusCode) - } var item hnItem - if err := json.NewDecoder(resp.Body).Decode(&item); err != nil { - return hnItem{}, fmt.Errorf("decode item %d: %w", id, err) + if err := httpjson.Get(ctx, p.HTTPClient, fmt.Sprintf("%s/item/%d.json", hnBaseURL, id), &item, nil); err != nil { + return hnItem{}, fmt.Errorf("item %d: %w", id, err) } if item.Dead { return hnItem{}, fmt.Errorf("item %d is dead", id) diff --git a/internal/providers/news/lobsters.go b/internal/providers/news/lobsters.go index 7badc33..80330ea 100644 --- a/internal/providers/news/lobsters.go +++ b/internal/providers/news/lobsters.go @@ -4,9 +4,10 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/httpjson" ) // SourceIDLobsters is the registry ID the Lobsters provider stamps on every @@ -89,27 +90,13 @@ func (p *Lobsters) TopStories(ctx context.Context, limit int) ([]Story, error) { if base == "" { base = "https://lobste.rs" } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/hottest.json", nil) - if err != nil { - return nil, fmt.Errorf("lobsters: build request: %w", err) - } + headers := map[string]string{"Accept": "application/json"} if p.UserAgent != "" { - req.Header.Set("User-Agent", p.UserAgent) - } - req.Header.Set("Accept", "application/json") - - resp, err := p.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("lobsters: fetch: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("lobsters: status %d: %s", resp.StatusCode, body) + headers["User-Agent"] = p.UserAgent } var items []lobItem - if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { - return nil, fmt.Errorf("lobsters: decode: %w", err) + if err := httpjson.Get(ctx, p.HTTPClient, base+"/hottest.json", &items, headers); err != nil { + return nil, fmt.Errorf("lobsters: %w", err) } if len(items) > limit { items = items[:limit] diff --git a/internal/providers/routing/openrouteservice_test.go b/internal/providers/routing/openrouteservice_test.go index fe194f5..131740c 100644 --- a/internal/providers/routing/openrouteservice_test.go +++ b/internal/providers/routing/openrouteservice_test.go @@ -152,7 +152,10 @@ func TestRouteNon200(t *testing.T) { } func TestModeLabel(t *testing.T) { - cases := []struct{ in Mode; want string }{ + cases := []struct { + in Mode + want string + }{ {ModeDriving, "drive"}, {ModeWalking, "walk"}, {ModeCycling, "cycle"}, diff --git a/internal/providers/weather/nws.go b/internal/providers/weather/nws.go index baee602..e707191 100644 --- a/internal/providers/weather/nws.go +++ b/internal/providers/weather/nws.go @@ -2,26 +2,26 @@ package weather import ( "context" - "encoding/json" "fmt" - "io" "net/http" "sort" "strings" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/httpjson" ) // Alert is one active National Weather Service alert at a lat/lon. Rendered // on the lobby's Alerts destination + as a header strip during severe events, // with a future opportunity to broadcast over the wall pipe. type Alert struct { - ID string // NWS feature id; stable for the lifetime of the alert - Event string // "Severe Thunderstorm Warning", "Tornado Watch", etc. - Severity string // "Extreme" / "Severe" / "Moderate" / "Minor" / "Unknown" - Headline string // one-line summary - Description string // full text, multi-line - Area string // free-form area description - Sender string // issuing office + ID string // NWS feature id; stable for the lifetime of the alert + Event string // "Severe Thunderstorm Warning", "Tornado Watch", etc. + Severity string // "Extreme" / "Severe" / "Moderate" / "Minor" / "Unknown" + Headline string // one-line summary + Description string // full text, multi-line + Area string // free-form area description + Sender string // issuing office Effective time.Time Expires time.Time URL string @@ -81,26 +81,13 @@ type nwsResponse struct { // failed" via the error path, not "no alerts". func (n *NWSAlerts) Alerts(ctx context.Context, lat, lon float64) ([]Alert, error) { url := fmt.Sprintf("https://api.weather.gov/alerts/active?point=%.4f,%.4f", lat, lon) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("nws: build request: %w", err) - } - req.Header.Set("Accept", "application/geo+json") - req.Header.Set("User-Agent", n.UserAgent) - - resp, err := n.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("nws: fetch: %w", err) + headers := map[string]string{ + "Accept": "application/geo+json", + "User-Agent": n.UserAgent, } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("nws: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - var payload nwsResponse - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, fmt.Errorf("nws: decode: %w", err) + if err := httpjson.Get(ctx, n.HTTP, url, &payload, headers); err != nil { + return nil, fmt.Errorf("nws: %w", err) } out := make([]Alert, 0, len(payload.Features)) diff --git a/internal/providers/weather/openmeteo.go b/internal/providers/weather/openmeteo.go index 2db137b..54b65fe 100644 --- a/internal/providers/weather/openmeteo.go +++ b/internal/providers/weather/openmeteo.go @@ -2,12 +2,13 @@ package weather import ( "context" - "encoding/json" "fmt" "net/http" "net/url" "strconv" "time" + + "github.com/nickna/ssh.night.ms/internal/providers/httpjson" ) // OpenMeteo fetches forecasts from https://api.open-meteo.com (free, no key). @@ -47,17 +48,17 @@ type openMeteoResponse struct { WindDir int `json:"wind_direction_10m"` } `json:"current"` Hourly struct { - Time []string `json:"time"` - Temperature []float64 `json:"temperature_2m"` - WeatherCode []int `json:"weather_code"` + Time []string `json:"time"` + Temperature []float64 `json:"temperature_2m"` + WeatherCode []int `json:"weather_code"` } `json:"hourly"` Daily struct { - Time []string `json:"time"` - TempMax []float64 `json:"temperature_2m_max"` - TempMin []float64 `json:"temperature_2m_min"` - WeatherCode []int `json:"weather_code"` - Sunrise []string `json:"sunrise"` - Sunset []string `json:"sunset"` + Time []string `json:"time"` + TempMax []float64 `json:"temperature_2m_max"` + TempMin []float64 `json:"temperature_2m_min"` + WeatherCode []int `json:"weather_code"` + Sunrise []string `json:"sunrise"` + Sunset []string `json:"sunset"` } `json:"daily"` } @@ -71,21 +72,9 @@ func (p *OpenMeteo) Forecast(ctx context.Context, lat, lon float64, label string q.Set("timezone", "auto") q.Set("forecast_days", "7") - req, err := http.NewRequestWithContext(ctx, http.MethodGet, openMeteoBase+"?"+q.Encode(), nil) - if err != nil { - return Forecast{}, err - } - resp, err := p.HTTPClient.Do(req) - if err != nil { - return Forecast{}, fmt.Errorf("open-meteo: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return Forecast{}, fmt.Errorf("open-meteo: status %d", resp.StatusCode) - } var raw openMeteoResponse - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return Forecast{}, fmt.Errorf("open-meteo: decode: %w", err) + if err := httpjson.Get(ctx, p.HTTPClient, openMeteoBase+"?"+q.Encode(), &raw, nil); err != nil { + return Forecast{}, fmt.Errorf("open-meteo: %w", err) } loc, _ := time.LoadLocation(raw.Timezone) diff --git a/internal/providers/weather/weather.go b/internal/providers/weather/weather.go index 90388df..59711c1 100644 --- a/internal/providers/weather/weather.go +++ b/internal/providers/weather/weather.go @@ -37,8 +37,8 @@ type DaySlot struct { // Forecast bundles current + hourly + daily into one Provider return. type Forecast struct { - Location string // human label, e.g. "New York" - Latitude float64 + Location string // human label, e.g. "New York" + Latitude float64 Longitude float64 Timezone string Now Conditions diff --git a/internal/reader/reader.go b/internal/reader/reader.go index 6e66a42..c12c17b 100644 --- a/internal/reader/reader.go +++ b/internal/reader/reader.go @@ -24,9 +24,9 @@ import ( type Article struct { Title string Byline string - Host string // hostname of the source URL, for the screen header - Blocks []Block // ordered body content - URL string // source URL, for "open in browser" hints + Host string // hostname of the source URL, for the screen header + Blocks []Block // ordered body content + URL string // source URL, for "open in browser" hints } // Block is the sum type for body content. Defined as a concrete struct (not diff --git a/internal/realtime/envelope.go b/internal/realtime/envelope.go index 29a7e90..1130bde 100644 --- a/internal/realtime/envelope.go +++ b/internal/realtime/envelope.go @@ -30,17 +30,17 @@ const ( // "new message" case. For edit events Body carries the NEW body and EditedAt // carries the mutation timestamp; CreatedAt is preserved across edits. type ChatEvent struct { - Kind ChatEventKind `json:"kind"` - MessageID int64 `json:"message_id"` - ChannelID int64 `json:"channel_id"` - UserID int64 `json:"user_id,omitempty"` - Handle string `json:"handle,omitempty"` - IsSysop bool `json:"is_sysop,omitempty"` - Body string `json:"body,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - EditedAt time.Time `json:"edited_at,omitempty"` - ParentMessageID *int64 `json:"parent_message_id,omitempty"` - Emoji string `json:"emoji,omitempty"` // populated on reaction events + Kind ChatEventKind `json:"kind"` + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id,omitempty"` + Handle string `json:"handle,omitempty"` + IsSysop bool `json:"is_sysop,omitempty"` + Body string `json:"body,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + EditedAt time.Time `json:"edited_at,omitempty"` + ParentMessageID *int64 `json:"parent_message_id,omitempty"` + Emoji string `json:"emoji,omitempty"` // populated on reaction events // IsPinned is the new state for ChatEventPinChanged. Marshaled even when // false so the receiver can distinguish pin → unpin. IsPinned bool `json:"is_pinned,omitempty"` diff --git a/internal/realtime/presence.go b/internal/realtime/presence.go index 3f6d564..78d5597 100644 --- a/internal/realtime/presence.go +++ b/internal/realtime/presence.go @@ -15,9 +15,9 @@ import ( // heartbeat. The TTL grace window (1 minute) lets a brief network blip not // appear as a logout. type PresenceService struct { - Client *redis.Client - Logger *slog.Logger - HeartbeatTTL time.Duration // how long a single heartbeat keeps the user "online" (typical: 60s) + Client *redis.Client + Logger *slog.Logger + HeartbeatTTL time.Duration // how long a single heartbeat keeps the user "online" (typical: 60s) HeartbeatEvery time.Duration // how often the per-session goroutine touches the key (typical: 30s) } diff --git a/internal/realtime/profile.go b/internal/realtime/profile.go index f7fe931..423270e 100644 --- a/internal/realtime/profile.go +++ b/internal/realtime/profile.go @@ -359,4 +359,3 @@ func (s *ProfileService) BatchHasPfp(ctx context.Context, handles []string) (map } return out, nil } - diff --git a/internal/realtime/sessionkick.go b/internal/realtime/sessionkick.go index 87daa20..2b987cd 100644 --- a/internal/realtime/sessionkick.go +++ b/internal/realtime/sessionkick.go @@ -24,9 +24,9 @@ type SessionKicker struct { bus Bus logger *slog.Logger - mu sync.Mutex - next int64 - regs map[int64][]registration // userID -> registrations + mu sync.Mutex + next int64 + regs map[int64][]registration // userID -> registrations } type registration struct { diff --git a/internal/security/audit/events.go b/internal/security/audit/events.go index 0ce5135..3cb6875 100644 --- a/internal/security/audit/events.go +++ b/internal/security/audit/events.go @@ -46,10 +46,10 @@ type AuthSuccess struct { Method string // "password" | "publickey" } -func (e AuthSuccess) EventType() string { return "auth_success" } -func (e AuthSuccess) Severity() string { return SeverityInfo } -func (e AuthSuccess) Subject() (string, string) { return e.Handle, e.IP } -func (e AuthSuccess) Details() any { return map[string]any{"method": e.Method} } +func (e AuthSuccess) EventType() string { return "auth_success" } +func (e AuthSuccess) Severity() string { return SeverityInfo } +func (e AuthSuccess) Subject() (string, string) { return e.Handle, e.IP } +func (e AuthSuccess) Details() any { return map[string]any{"method": e.Method} } // AuthFailure fires on every Refused / RateLimited path on the auth pipeline. // Reason captures the internal cause (kept for log triage); the SSH client diff --git a/internal/security/audit/recorder_test.go b/internal/security/audit/recorder_test.go index bc1f1bd..d5ed5aa 100644 --- a/internal/security/audit/recorder_test.go +++ b/internal/security/audit/recorder_test.go @@ -5,32 +5,10 @@ import ( "context" "log/slog" "strings" - "sync" "testing" "time" ) -// stubRecorder is a minimal Recorder that just collects events. Used to -// verify call sites in upstream packages route events through correctly. -type stubRecorder struct { - mu sync.Mutex - events []Event -} - -func (s *stubRecorder) Record(_ context.Context, ev Event) { - s.mu.Lock() - defer s.mu.Unlock() - s.events = append(s.events, ev) -} - -func (s *stubRecorder) Events() []Event { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]Event, len(s.events)) - copy(out, s.events) - return out -} - // recorderForTest builds a PostgresRecorder with a nil *gen.Queries — fine // because we never call Run() in tests that only exercise Record(); the // buffer fills up locally and we read it back. Any test that calls Run diff --git a/internal/security/netlimit/deadline_conn.go b/internal/security/netlimit/deadline_conn.go index 998ec6f..bd41314 100644 --- a/internal/security/netlimit/deadline_conn.go +++ b/internal/security/netlimit/deadline_conn.go @@ -21,10 +21,10 @@ import ( type DeadlineConn struct { net.Conn - mu sync.Mutex - cleared bool - onClose func() - fired bool + mu sync.Mutex + cleared bool + onClose func() + fired bool } // WrapWithDeadline returns a *DeadlineConn that has SetDeadline(now+grace) diff --git a/internal/security/netlimit/netlimit_test.go b/internal/security/netlimit/netlimit_test.go index 4dbf136..37be1da 100644 --- a/internal/security/netlimit/netlimit_test.go +++ b/internal/security/netlimit/netlimit_test.go @@ -283,7 +283,7 @@ type fakeListener struct { in chan net.Conn } -func newFakeListener() *fakeListener { return &fakeListener{in: make(chan net.Conn, 16)} } +func newFakeListener() *fakeListener { return &fakeListener{in: make(chan net.Conn, 16)} } func (l *fakeListener) Accept() (net.Conn, error) { c, ok := <-l.in if !ok { @@ -305,14 +305,14 @@ type fakeConn struct { closed atomic.Bool } -func (c *fakeConn) Read(b []byte) (int, error) { return 0, io.EOF } -func (c *fakeConn) Write(b []byte) (int, error) { return len(b), nil } -func (c *fakeConn) Close() error { c.closed.Store(true); return nil } -func (c *fakeConn) LocalAddr() net.Addr { return mustAddrPlain("127.0.0.1:0") } -func (c *fakeConn) RemoteAddr() net.Addr { return c.remote } -func (c *fakeConn) SetDeadline(time.Time) error { return nil } -func (c *fakeConn) SetReadDeadline(time.Time) error { return nil } -func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil } +func (c *fakeConn) Read(b []byte) (int, error) { return 0, io.EOF } +func (c *fakeConn) Write(b []byte) (int, error) { return len(b), nil } +func (c *fakeConn) Close() error { c.closed.Store(true); return nil } +func (c *fakeConn) LocalAddr() net.Addr { return mustAddrPlain("127.0.0.1:0") } +func (c *fakeConn) RemoteAddr() net.Addr { return c.remote } +func (c *fakeConn) SetDeadline(time.Time) error { return nil } +func (c *fakeConn) SetReadDeadline(time.Time) error { return nil } +func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil } func TestListener_AcceptDropsRejected(t *testing.T) { tr := NewTracker(Config{MaxConnPerIP: 1}, discardLogger(), nil) diff --git a/internal/transport/server.go b/internal/transport/server.go index 7bb5753..85fcd65 100644 --- a/internal/transport/server.go +++ b/internal/transport/server.go @@ -187,7 +187,6 @@ func NewServer(cfg Config, deps Deps, logger *slog.Logger) (*Server, error) { return &Server{inner: s, logger: logger}, nil } -func (s *Server) ListenAndServe() error { return s.inner.ListenAndServe() } func (s *Server) Shutdown(ctx context.Context) error { return s.inner.Shutdown(ctx) } // ServeWithListener runs the SSH server against a caller-provided listener diff --git a/internal/tui/app.go b/internal/tui/app.go index b4c5c88..c70c550 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -4,7 +4,6 @@ package tui import ( - "context" "strings" "time" @@ -112,7 +111,7 @@ func (m *Root) fetchStatusTempCmd() tea.Cmd { return nil } return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() f, err := provider.Forecast(ctx, lat, lon, label) if err != nil { @@ -323,7 +322,6 @@ func (m *Root) rearmWallCmd() tea.Cmd { return waitWallCmd(ch) } - // route swaps to the screen that owns this destination. // // arg carries the optional NavigateMsg payload (e.g. a handle for the Profile diff --git a/internal/tui/chat/bodyrender.go b/internal/tui/chat/bodyrender.go index 6bc84f2..b8bf613 100644 --- a/internal/tui/chat/bodyrender.go +++ b/internal/tui/chat/bodyrender.go @@ -1,5 +1,5 @@ // Package chat: rendering helpers for chat message bodies. Supports `*bold*`, -// `_italic_`, `` `code` ``, `@mention` (self vs. other), and `:emoji:`. +// `_italic_`, “ `code` “, `@mention` (self vs. other), and `:emoji:`. package chat import ( @@ -51,7 +51,7 @@ type BodyToken struct { // lookbehind. Rules: // - `*foo*` bold — non-space immediately inside both stars, single line // - `_foo_` italic — same shape with underscores -// - `` `foo` `` code — anything except backtick/newline inside +// - “ `foo` “ code — anything except backtick/newline inside // - `@name` mention — name = [A-Za-z0-9][A-Za-z0-9_-]{0,31}; preceded by start or non-alnum/_ func TokenizeBody(body, selfHandle string) ([]BodyToken, bool) { text := SubstituteEmoji(body) @@ -378,4 +378,3 @@ func WrapBodyLines(body, selfHandle string, width int) ([]string, bool) { } return rendered, mentioned } - diff --git a/internal/tui/chat/commands.go b/internal/tui/chat/commands.go index 6de3434..47c8a8a 100644 --- a/internal/tui/chat/commands.go +++ b/internal/tui/chat/commands.go @@ -4,7 +4,6 @@ package chat import ( - "fmt" "strconv" "strings" @@ -269,7 +268,7 @@ func normalizeChannelName(s string) string { } func helpText() string { - return strings.TrimSpace(fmt.Sprintf(` + return strings.TrimSpace(` commands: /help show this list /join #name join (create if needed) and switch to a channel @@ -294,5 +293,5 @@ commands: /quit log out of the BBS press Esc to return to the lobby; PgUp/PgDn to scroll history. -`)) +`) } diff --git a/internal/tui/components/braille.go b/internal/tui/components/braille.go deleted file mode 100644 index 6435eec..0000000 --- a/internal/tui/components/braille.go +++ /dev/null @@ -1,180 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/nickna/ssh.night.ms/internal/tui/theme" -) - -// BrailleCanvas paints monochrome geometry into terminal cells using the -// Unicode Braille block (U+2800–U+28FF). Each cell covers a 2×4 grid of -// subpixels, mapped to the eight braille dots: -// -// 1 4 -// 2 5 -// 3 6 -// 7 8 -// -// Used by the Map screen for road/coastline rendering. Pure compute — no -// SGR, no escape sequences — so the parent screen wraps the output in -// whatever lipgloss style it wants. -type BrailleCanvas struct { - cols, rows int // terminal cells - cells [][]byte // bit mask per cell (8 dots) -} - -// NewBrailleCanvas allocates a `cols × rows` canvas (in TERMINAL CELLS; -// the pixel grid is 2×4 of that). -func NewBrailleCanvas(cols, rows int) *BrailleCanvas { - if cols < 1 { - cols = 1 - } - if rows < 1 { - rows = 1 - } - cells := make([][]byte, rows) - for r := range cells { - cells[r] = make([]byte, cols) - } - return &BrailleCanvas{cols: cols, rows: rows, cells: cells} -} - -// PixelDims returns the canvas size in subpixels (each terminal cell = 2×4). -func (c *BrailleCanvas) PixelDims() (int, int) { return c.cols * 2, c.rows * 4 } - -// Set lights the (x, y) subpixel. OOB calls are silently ignored so callers -// don't have to clip themselves. -func (c *BrailleCanvas) Set(x, y int) { - if x < 0 || y < 0 { - return - } - cell := x / 2 - cy := y / 4 - if cell >= c.cols || cy >= c.rows { - return - } - dx := x % 2 - dy := y % 4 - c.cells[cy][cell] |= dotBit(dx, dy) -} - -// Clear blanks the canvas without reallocating. -func (c *BrailleCanvas) Clear() { - for r := range c.cells { - for i := range c.cells[r] { - c.cells[r][i] = 0 - } - } -} - -// Line draws a straight Bresenham line. Inclusive of both endpoints. -func (c *BrailleCanvas) Line(x0, y0, x1, y1 int) { - dx := abs(x1 - x0) - dy := -abs(y1 - y0) - sx := -1 - if x0 < x1 { - sx = 1 - } - sy := -1 - if y0 < y1 { - sy = 1 - } - err := dx + dy - for { - c.Set(x0, y0) - if x0 == x1 && y0 == y1 { - break - } - e2 := 2 * err - if e2 >= dy { - err += dy - x0 += sx - } - if e2 <= dx { - err += dx - y0 += sy - } - } -} - -// Circle draws a midpoint-algorithm circle of radius r centered at (cx, cy). -func (c *BrailleCanvas) Circle(cx, cy, r int) { - x, y := r, 0 - err := 1 - x - for x >= y { - c.Set(cx+x, cy+y) - c.Set(cx+y, cy+x) - c.Set(cx-y, cy+x) - c.Set(cx-x, cy+y) - c.Set(cx-x, cy-y) - c.Set(cx-y, cy-x) - c.Set(cx+y, cy-x) - c.Set(cx+x, cy-y) - y++ - if err < 0 { - err += 2*y + 1 - } else { - x-- - err += 2*(y-x) + 1 - } - } -} - -// Render emits one string per row joined by newlines. Empty cells become -// U+2800 (the blank braille pattern, NOT a space) so each row has the same -// visual width — keeps the canvas aligned for column-based math elsewhere. -func (c *BrailleCanvas) Render() string { - var b strings.Builder - style := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorCyan)) - for r := 0; r < c.rows; r++ { - var row strings.Builder - for col := 0; col < c.cols; col++ { - row.WriteRune(brailleRune(c.cells[r][col])) - } - b.WriteString(style.Render(row.String())) - if r < c.rows-1 { - b.WriteByte('\n') - } - } - return b.String() -} - -// dotBit maps a (dx ∈ {0,1}, dy ∈ {0,1,2,3}) coordinate to its bit in the -// braille pattern. Standard 8-dot ordering — top-left is bit 0 (dot 1), -// bottom-right is bit 7 (dot 8). -func dotBit(dx, dy int) byte { - switch dy { - case 0: - if dx == 0 { - return 0x01 - } - return 0x08 - case 1: - if dx == 0 { - return 0x02 - } - return 0x10 - case 2: - if dx == 0 { - return 0x04 - } - return 0x20 - case 3: - if dx == 0 { - return 0x40 - } - return 0x80 - } - return 0 -} - -func brailleRune(mask byte) rune { return rune(0x2800) + rune(mask) } - -func abs(x int) int { - if x < 0 { - return -x - } - return x -} diff --git a/internal/tui/components/cardanim.go b/internal/tui/components/cardanim.go index cb68464..8c63ea4 100644 --- a/internal/tui/components/cardanim.go +++ b/internal/tui/components/cardanim.go @@ -2,7 +2,6 @@ // used by card games: // // - DealAnimation: reveals cards one-at-a-time with a short delay between -// - FlipAnimation: 3-frame back→edge→face reveal for the dealer hole card // - PulseAnimation: toggles bold/dim for a few cycles to draw the eye to // winning cards or a freshly-credited payout chip // - CoinShower: spawns '$' glyphs at random columns and floats them @@ -61,47 +60,6 @@ func (a *DealAnimation) Step() { } } -// --------------------------------------------------------------------------- -// Flip animation (dealer hole reveal) -// --------------------------------------------------------------------------- - -type FlipTickMsg struct{} - -type FlipAnimation struct { - Frame int // 0=back, 1=edge (narrow), 2=face - Interval time.Duration -} - -func NewFlipAnimation() FlipAnimation { - return FlipAnimation{Frame: 0, Interval: 120 * time.Millisecond} -} - -func (a FlipAnimation) Done() bool { return a.Frame >= 2 } - -func (a FlipAnimation) Tick() tea.Cmd { - if a.Done() { - return nil - } - return tea.Tick(a.Interval, func(time.Time) tea.Msg { return FlipTickMsg{} }) -} - -func (a *FlipAnimation) Step() { - if a.Frame < 2 { - a.Frame++ - } -} - -// RenderFlipEdge returns a 5-line, 2-column "edge-on" sprite for the -// middle frame of a flip. Caller pads to CardWidth if it needs the slot -// width to stay constant. -func RenderFlipEdge() string { - border := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorAccent)) - top := border.Render("┌┐") - pipe := border.Render("││") - bot := border.Render("└┘") - return strings.Join([]string{top, pipe, pipe, pipe, bot}, "\n") -} - // --------------------------------------------------------------------------- // Pulse (cycle bold/dim a few times) // --------------------------------------------------------------------------- diff --git a/internal/tui/components/chatlog.go b/internal/tui/components/chatlog.go index c2ed5a0..1bc5629 100644 --- a/internal/tui/components/chatlog.go +++ b/internal/tui/components/chatlog.go @@ -770,4 +770,3 @@ func formatReactionCount(n int) string { } return fmt.Sprintf("%d", n) } - diff --git a/internal/tui/components/roulettefelt.go b/internal/tui/components/roulettefelt.go index c7731f0..29ecca7 100644 --- a/internal/tui/components/roulettefelt.go +++ b/internal/tui/components/roulettefelt.go @@ -200,7 +200,7 @@ func RenderRouletteFelt(opts RouletteFeltOpts) string { myBets := opts.MyBets agg := opts.Aggregate - cellWidth := 3 // "NN " (left-padded number + trailing space) + cellWidth := 3 // "NN " (left-padded number + trailing space) zerosWidth := 5 // " 0 " or " 00 " sep := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorMuted)) diff --git a/internal/tui/components/searchablelist.go b/internal/tui/components/searchablelist.go index bd5f859..cadbf7e 100644 --- a/internal/tui/components/searchablelist.go +++ b/internal/tui/components/searchablelist.go @@ -21,7 +21,7 @@ type SearchableList struct { Filter string // current filter text (lowercased on every Update) Cursor int // index into the *filtered* slice (0-based) Focus bool - Width int // render width in cells + Width int // render width in cells visible []int // indices into Items that survive the filter } diff --git a/internal/tui/components/slotsprites.go b/internal/tui/components/slotsprites.go index 7e3f773..3845681 100644 --- a/internal/tui/components/slotsprites.go +++ b/internal/tui/components/slotsprites.go @@ -123,13 +123,13 @@ var slotColors = [SlotSymbolCount]struct { FG *color.NRGBA Bold bool }{ - SlotCherry: {nil, true}, // unused — BuildCherrySprite paints directly - SlotLemon: {SlotPalette.LemonYellow, false}, - SlotOrange: {SlotPalette.OrangeFruit, true}, - SlotPlum: {SlotPalette.PlumMagenta, false}, - SlotBell: {SlotPalette.BellYellow, true}, - SlotBar: {SlotPalette.BarGold, true}, - SlotSeven: {SlotPalette.SevenRed, true}, + SlotCherry: {nil, true}, // unused — BuildCherrySprite paints directly + SlotLemon: {SlotPalette.LemonYellow, false}, + SlotOrange: {SlotPalette.OrangeFruit, true}, + SlotPlum: {SlotPalette.PlumMagenta, false}, + SlotBell: {SlotPalette.BellYellow, true}, + SlotBar: {SlotPalette.BarGold, true}, + SlotSeven: {SlotPalette.SevenRed, true}, } // slotSprites is the baked sprite table. Index by SlotSymbolID. Each diff --git a/internal/tui/screens/alerts.go b/internal/tui/screens/alerts.go index ffb6a6a..f033439 100644 --- a/internal/tui/screens/alerts.go +++ b/internal/tui/screens/alerts.go @@ -33,8 +33,8 @@ type Alerts struct { alerts []weather.Alert cursor int - mode alertsMode - detail *weather.Alert + mode alertsMode + detail *weather.Alert detailWrap int } @@ -66,7 +66,7 @@ func (m *Alerts) loadCmd() tea.Cmd { if provider == nil { return alertsLoadedMsg{err: fmt.Errorf("alerts provider not configured")} } - ctx, cancel := m.sess.CtxWithTimeout(12*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(12 * time.Second) defer cancel() alerts, err := provider.Alerts(ctx, lat, lon) return alertsLoadedMsg{alerts: alerts, err: err} @@ -193,7 +193,7 @@ func (m *Alerts) View() string { row := fmt.Sprintf("%s%s %s", prefix, severityBadge(a.Severity), a.Event) b.WriteString(row) b.WriteString("\n") - b.WriteString(" " + alertsArea.Render(truncateArea(a.Area, m.sess.Width-6))) + b.WriteString(" " + alertsArea.Render(truncateRunes(a.Area, m.sess.Width-6))) b.WriteString("\n") b.WriteString(" " + alertsHint.Render("expires "+m.sess.DisplayPrefs.FormatDayClock(a.Expires))) b.WriteString("\n\n") @@ -272,14 +272,3 @@ func severityBadge(severity string) string { } return alertsMinor.Render("[" + label + "]") } - -func truncateArea(s string, max int) string { - if max <= 1 { - return s - } - if len([]rune(s)) <= max { - return s - } - r := []rune(s) - return string(r[:max-1]) + "…" -} diff --git a/internal/tui/screens/blackjack.go b/internal/tui/screens/blackjack.go index 4f8b930..d67b3f5 100644 --- a/internal/tui/screens/blackjack.go +++ b/internal/tui/screens/blackjack.go @@ -67,7 +67,7 @@ func (m *Blackjack) Init() tea.Cmd { user := m.sess.Identity.UserID svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() w, err := svc.Load(ctx, user) return bjWalletMsg{wallet: w, err: err} @@ -86,7 +86,7 @@ func (m *Blackjack) dealCmd() tea.Cmd { wallet := m.wallet svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() if err := svc.Bet(ctx, &wallet, bet); err != nil { return bjBetErrMsg{err: err} @@ -106,7 +106,7 @@ func (m *Blackjack) doubleCmd() tea.Cmd { wallet := m.wallet svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() if err := svc.Bet(ctx, &wallet, bet); err != nil { return bjBetErrMsg{err: err} @@ -124,7 +124,7 @@ func (m *Blackjack) settleCmd() tea.Cmd { game := m.game svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() outcome := game.Outcome() payout := blackjack.Payout(bet, game.Doubled(), outcome) @@ -153,14 +153,6 @@ func (m *Blackjack) settleCmd() tea.Cmd { } } -func handToStrings(h []cards.Card) []string { - out := make([]string, len(h)) - for i, c := range h { - out[i] = c.String() - } - return out -} - func (m *Blackjack) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case bjWalletMsg: diff --git a/internal/tui/screens/boards.go b/internal/tui/screens/boards.go index 28237c9..84e9a4c 100644 --- a/internal/tui/screens/boards.go +++ b/internal/tui/screens/boards.go @@ -129,7 +129,7 @@ func (m *Boards) loadForums() tea.Cmd { svc := m.sess.Forums userID := m.sess.Identity.UserID return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() fs, err := svc.ListForums(ctx) if err != nil { @@ -149,7 +149,7 @@ func (m *Boards) loadTopics(forum realtime.Forum) tea.Cmd { m.navSeq++ seq := m.navSeq return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() ts, err := svc.RecentTopics(ctx, forum.ID, topicListLimit) if err != nil { @@ -167,7 +167,7 @@ func (m *Boards) touchRead(topicID int64) tea.Cmd { svc := m.sess.Forums userID := m.sess.Identity.UserID return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() return boardsReadTouchedMsg{err: svc.TouchTopicRead(ctx, userID, topicID)} } @@ -178,7 +178,7 @@ func (m *Boards) loadPosts(topic realtime.Topic) tea.Cmd { m.navSeq++ seq := m.navSeq return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() ps, err := svc.Posts(ctx, topic.ID) if err != nil { @@ -451,7 +451,7 @@ func (m *Boards) submitNewTopic(title, body string) tea.Cmd { return nil } return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() topic, err := svc.CreateTopic(ctx, nil, forum.ID, user.UserID, user.Handle, title, body) if err != nil { @@ -471,7 +471,7 @@ func (m *Boards) submitReply(body string) tea.Cmd { topicID := m.activeTopic.ID forumID := m.activeTopic.ForumID return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() if _, err := svc.Reply(ctx, forumID, topicID, user.UserID, body); err != nil { return boardsErrMsg{stage: "reply", err: err} @@ -957,9 +957,9 @@ func (m *Boards) viewThread() string { // renderPostCard wraps one post in theme.PostCard. Layout inside the card: // -// #3 @handle Jan 2 15:04 [OP] [SYSOP] (edited) +// #3 @handle Jan 2 15:04 [OP] [SYSOP] (edited) // -// post body, indented 2 cells, wrapped to fit… +// post body, indented 2 cells, wrapped to fit… // // isOP is true for the topic's root post — gets an OP chip in the header. // isMine highlights the card border in the brighter accent color so the user @@ -1207,4 +1207,3 @@ func wrapToWidth(text string, width int) []string { } return out } - diff --git a/internal/tui/screens/chat.go b/internal/tui/screens/chat.go index ec116ad..5be53f5 100644 --- a/internal/tui/screens/chat.go +++ b/internal/tui/screens/chat.go @@ -53,9 +53,9 @@ type Chat struct { // typing tracks the "X is typing" indicator state per channel. // typing[channelID][handle] = expiresAt. Entries past expiresAt are pruned // on each typingTickMsg. The publisher's throttle is typingPublishedAt. - typing map[int64]map[string]time.Time - typingPublishedAt time.Time - lastInputValue string + typing map[int64]map[string]time.Time + typingPublishedAt time.Time + lastInputValue string // onlineHandles is the right-rail roster of currently-online users. // Refreshed every onlineRefreshInterval via tea.Tick — the call is one @@ -110,36 +110,36 @@ func NewChat(sess *session.Session, chatSvc *realtime.ChatService) tea.Model { // type chatBootstrapMsg struct { - joined []gen.Channel - active chatChannelHandle - sub chatSubBundle - hist []realtime.Message - unread map[int64]int // persisted unread counts at startup - reactions map[int64]map[string]int - replyCounts map[int64]int // per-parent reply totals for hist window - pfpByHandle map[string]bool // lowercased handles → "has pfp" + joined []gen.Channel + active chatChannelHandle + sub chatSubBundle + hist []realtime.Message + unread map[int64]int // persisted unread counts at startup + reactions map[int64]map[string]int + replyCounts map[int64]int // per-parent reply totals for hist window + pfpByHandle map[string]bool // lowercased handles → "has pfp" } // chatRefanMsg replaces the multi-channel subscription after a /join added // a new channel that should now be covered. type chatRefanMsg struct { - joined []gen.Channel - active chatChannelHandle - sub chatSubBundle - hist []realtime.Message - reactions map[int64]map[string]int - replyCounts map[int64]int - pfpByHandle map[string]bool + joined []gen.Channel + active chatChannelHandle + sub chatSubBundle + hist []realtime.Message + reactions map[int64]map[string]int + replyCounts map[int64]int + pfpByHandle map[string]bool } // chatLocalSwitchMsg is a pure UI swap to an already-joined channel; no // subscription teardown, no re-fanout. Loads history on first visit. type chatLocalSwitchMsg struct { - active chatChannelHandle - hist []realtime.Message // nil if log already loaded - reactions map[int64]map[string]int - replyCounts map[int64]int - pfpByHandle map[string]bool + active chatChannelHandle + hist []realtime.Message // nil if log already loaded + reactions map[int64]map[string]int + replyCounts map[int64]int + pfpByHandle map[string]bool } type chatSubBundle struct { @@ -205,14 +205,14 @@ func (m *Chat) bootstrap() tea.Cmd { } replyCounts, pfpMap := m.loadDecorations(ctx, hist) return chatBootstrapMsg{ - joined: joined, - active: chatChannelHandle{ID: ch.ID, Name: ch.Name, Topic: derefTopic(ch.Topic)}, - sub: sub, - hist: hist, - unread: unread, - reactions: reactions, - replyCounts: replyCounts, - pfpByHandle: pfpMap, + joined: joined, + active: chatChannelHandle{ID: ch.ID, Name: ch.Name, Topic: derefTopic(ch.Topic)}, + sub: sub, + hist: hist, + unread: unread, + reactions: reactions, + replyCounts: replyCounts, + pfpByHandle: pfpMap, } } } @@ -275,7 +275,7 @@ func (m *Chat) touchRead(channelID, msgID int64) tea.Cmd { } userID := m.sess.Identity.UserID return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() if err := m.chat.TouchChannelRead(ctx, userID, channelID, msgID); err != nil { m.sess.Logger.Warn("chat: persist read state", "channel_id", channelID, "err", err) @@ -293,7 +293,7 @@ func (m *Chat) touchActiveAtLatest(channelID int64) tea.Cmd { } userID := m.sess.Identity.UserID return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() latest, err := m.chat.LatestMessageID(ctx, channelID) if err != nil { @@ -347,13 +347,13 @@ func (m *Chat) joinChannel(name string) tea.Cmd { } replyCounts, pfpMap := m.loadDecorations(ctx, hist) return chatRefanMsg{ - joined: joined, - active: chatChannelHandle{ID: ch.ID, Name: ch.Name, Topic: derefTopic(ch.Topic)}, - sub: sub, - hist: hist, - reactions: reactions, - replyCounts: replyCounts, - pfpByHandle: pfpMap, + joined: joined, + active: chatChannelHandle{ID: ch.ID, Name: ch.Name, Topic: derefTopic(ch.Topic)}, + sub: sub, + hist: hist, + reactions: reactions, + replyCounts: replyCounts, + pfpByHandle: pfpMap, } } } @@ -376,7 +376,7 @@ func (m *Chat) switchChannel(name string) tea.Cmd { if alreadyLoaded { return chatLocalSwitchMsg{active: handle} } - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() hist, err := m.chat.RecentMessages(ctx, target.ID, 100) if err != nil { @@ -388,11 +388,11 @@ func (m *Chat) switchChannel(name string) tea.Cmd { } replyCounts, pfpMap := m.loadDecorations(ctx, hist) return chatLocalSwitchMsg{ - active: handle, - hist: hist, - reactions: reactions, - replyCounts: replyCounts, - pfpByHandle: pfpMap, + active: handle, + hist: hist, + reactions: reactions, + replyCounts: replyCounts, + pfpByHandle: pfpMap, } } } @@ -416,7 +416,7 @@ func (m *Chat) leaveCurrent() tea.Cmd { // of the merged sub) and switch active back to #lobby. return tea.Sequence( func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() if err := m.chat.LeaveMembership(ctx, currentID, userID); err != nil { return chatErrMsg{stage: "leave", err: err} @@ -475,19 +475,7 @@ func (m *Chat) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.unread = make(map[int64]int) } m.unread[msg.active.ID] = 0 // active channel is being viewed now - log := m.logFor(msg.active.ID) - log.SetSelfHandle(m.sess.Identity.Handle) - // Seed PFP map before AppendAll so the first paint includes ● marks - // for handles we already know have a profile picture. - if len(msg.pfpByHandle) > 0 { - log.SetPfpHandles(msg.pfpByHandle) - } - log.AppendAll(msg.hist) - log.SetReactions(msg.reactions) - if len(msg.replyCounts) > 0 { - log.SetReplyCounts(msg.replyCounts) - } - log.SnapToBottom() + m.seedChannelLog(msg.active.ID, msg.hist, msg.pfpByHandle, msg.reactions, msg.replyCounts, true) // Schedule image fetches for every history message that references one. imgCmds := m.scheduleHistoryImageFetches(msg.active.ID, msg.hist) // Kick off an immediate presence read so the sidebar dots aren't @@ -509,17 +497,7 @@ func (m *Chat) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.unread[msg.active.ID] = 0 // AppendAll is idempotent on ID (the indexByID map dedupes), so // re-fanout into a previously-visited channel doesn't double-paint. - log := m.logFor(msg.active.ID) - log.SetSelfHandle(m.sess.Identity.Handle) - if len(msg.pfpByHandle) > 0 { - log.SetPfpHandles(msg.pfpByHandle) - } - log.AppendAll(msg.hist) - log.SetReactions(msg.reactions) - if len(msg.replyCounts) > 0 { - log.SetReplyCounts(msg.replyCounts) - } - log.SnapToBottom() + m.seedChannelLog(msg.active.ID, msg.hist, msg.pfpByHandle, msg.reactions, msg.replyCounts, true) m.relayout() imgCmds := m.scheduleHistoryImageFetches(msg.active.ID, msg.hist) out := []tea.Cmd{ @@ -533,21 +511,7 @@ func (m *Chat) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case chatLocalSwitchMsg: m.active = msg.active m.unread[msg.active.ID] = 0 - log := m.logFor(msg.active.ID) - log.SetSelfHandle(m.sess.Identity.Handle) - if len(msg.pfpByHandle) > 0 { - log.SetPfpHandles(msg.pfpByHandle) - } - if msg.hist != nil { - log.AppendAll(msg.hist) - } - if msg.reactions != nil { - log.SetReactions(msg.reactions) - } - if len(msg.replyCounts) > 0 { - log.SetReplyCounts(msg.replyCounts) - } - log.SnapToBottom() + m.seedChannelLog(msg.active.ID, msg.hist, msg.pfpByHandle, msg.reactions, msg.replyCounts, false) m.relayout() imgCmds := m.scheduleHistoryImageFetches(msg.active.ID, msg.hist) if len(imgCmds) > 0 { @@ -803,7 +767,6 @@ func (m *Chat) submit() tea.Cmd { return m.sendChat(raw) } - func (m *Chat) notice(text string) tea.Cmd { m.activeLog().AppendSystem(text) m.activeLog().SnapToBottom() @@ -829,6 +792,39 @@ func (m *Chat) logFor(channelID int64) *components.ChatLog { return log } +// seedChannelLog applies a freshly-fetched history payload to channelID's +// log: self handle, PFP marks (set before AppendAll so the first paint +// already carries ● marks), history, reactions, reply counts, then snaps to +// the bottom. Shared by the bootstrap / refan / local-switch handlers. +// +// replaceReactions controls the nil-snapshot case: bootstrap and refan fetch +// a full reaction snapshot per call, so they replace unconditionally (a nil +// payload legitimately clears the overlay); a local switch into an +// already-seeded channel passes false so a nil payload doesn't wipe chips +// that are still live. +func (m *Chat) seedChannelLog( + channelID int64, + hist []realtime.Message, + pfpByHandle map[string]bool, + reactions map[int64]map[string]int, + replyCounts map[int64]int, + replaceReactions bool, +) { + log := m.logFor(channelID) + log.SetSelfHandle(m.sess.Identity.Handle) + if len(pfpByHandle) > 0 { + log.SetPfpHandles(pfpByHandle) + } + log.AppendAll(hist) + if replaceReactions || reactions != nil { + log.SetReactions(reactions) + } + if len(replyCounts) > 0 { + log.SetReplyCounts(replyCounts) + } + log.SnapToBottom() +} + func (m *Chat) relayout() { if l, ok := m.logs[m.active.ID]; ok { l.SetSize(m.bodyWidth(), m.logHeight()) @@ -888,7 +884,6 @@ func (m *Chat) previewVisible() bool { var ( chatHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(theme.ColorAccent)) chatStatusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorRed)) - chatTypingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorAccentDim)).Italic(true) chatPreviewCmd = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorCyan)).Bold(true) chatPreviewMention = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorYellow)).Bold(true) chatPreviewChannel = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorAccent)).Bold(true) diff --git a/internal/tui/screens/finance.go b/internal/tui/screens/finance.go index 6711f63..a8f12ed 100644 --- a/internal/tui/screens/finance.go +++ b/internal/tui/screens/finance.go @@ -430,11 +430,11 @@ func (m *Finance) loadDetail(item gen.UserWatchlistItem) tea.Cmd { ctx, cancel := m.sess.CtxWithTimeout(quoteFanoutTO) defer cancel() var ( - d *finance.Detail - derr error - hl []finance.Headline - tickers []string - wg sync.WaitGroup + d *finance.Detail + derr error + hl []finance.Headline + tickers []string + wg sync.WaitGroup ) if kind == finance.KindStock { tickers = []string{canon} @@ -1149,7 +1149,6 @@ func formatBigUSD(v float64) string { return fmt.Sprintf("$%.2f", v) } - func (m *Finance) viewReader() string { return m.reader.View(m.sess.Width, m.sess.Height, "Finance › Reader") } diff --git a/internal/tui/screens/format.go b/internal/tui/screens/format.go index be0dab3..85f757d 100644 --- a/internal/tui/screens/format.go +++ b/internal/tui/screens/format.go @@ -1,9 +1,10 @@ package screens +import "github.com/nickna/ssh.night.ms/internal/doors/cards" + // Package-level pure helpers shared across screens. Kept here (rather than // buried in whichever screen first needed them) so they're discoverable and -// have a single definition. Screen-specific variants that intentionally differ -// — alerts.truncateArea, sysop.truncateRow — stay in their own files. +// have a single definition. // plural appends "s" unless n == 1. ASCII regular nouns only. func plural(noun string, n int) string { @@ -25,6 +26,30 @@ func truncate(s string, n int) string { return s[:n-1] + "…" } +// truncateRunes is the rune-aware variant of truncate, for content that can +// carry non-ASCII (NWS alert area names, user-supplied text). Counts runes so +// a multi-byte character near the cut point can't be split mid-sequence. +func truncateRunes(s string, max int) string { + if max <= 1 { + return s + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-1]) + "…" +} + +// handToStrings renders a card hand to its display strings, one per card. +// Shared by the casino screens (blackjack, video poker). +func handToStrings(h []cards.Card) []string { + out := make([]string, len(h)) + for i, c := range h { + out[i] = c.String() + } + return out +} + // max0 clamps a negative int up to 0. func max0(n int) int { if n < 0 { diff --git a/internal/tui/screens/holdem.go b/internal/tui/screens/holdem.go index 89b6a6c..34f0b16 100644 --- a/internal/tui/screens/holdem.go +++ b/internal/tui/screens/holdem.go @@ -36,13 +36,13 @@ type Holdem struct { // hole-card deal at hand start and the per-street community-card // deal — distinguished by dealStage so the renderer knows whether // deal.Revealed bounds hole-card visibility or board visibility. - deal components.DealAnimation - dealStage heDealStage - boardRevealFrom int // board index where the in-flight board deal began - pulse components.PulseAnimation - shower components.CoinShower - lastWinAt int64 // hand counter that already triggered shower; prevents re-fire - handCount int64 + deal components.DealAnimation + dealStage heDealStage + boardRevealFrom int // board index where the in-flight board deal began + pulse components.PulseAnimation + shower components.CoinShower + lastWinAt int64 // hand counter that already triggered shower; prevents re-fire + handCount int64 // bestFive holds the 5 indices (into [hole[0], hole[1], board[0..4]]) // of the winner's winning subset. Populated at showdown by advance() @@ -87,7 +87,7 @@ func (m *Holdem) Init() tea.Cmd { user := m.sess.Identity.UserID svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() w, err := svc.Load(ctx, user) return heWalletMsg{wallet: w, err: err} @@ -103,7 +103,7 @@ func (m *Holdem) buyInCmd() tea.Cmd { wallet := m.wallet svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() if err := svc.Bet(ctx, &wallet, bet); err != nil { return heBetMsg{err: err} @@ -123,7 +123,7 @@ func (m *Holdem) cashOutCmd() tea.Cmd { svc := m.sess.Wallet bet := m.bet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() if final > 0 { if err := svc.Credit(ctx, &wallet, int64(final)); err != nil { diff --git a/internal/tui/screens/holdem_mp.go b/internal/tui/screens/holdem_mp.go index 25698ed..96012b7 100644 --- a/internal/tui/screens/holdem_mp.go +++ b/internal/tui/screens/holdem_mp.go @@ -28,8 +28,8 @@ type HoldemMP struct { err string // lobby state - tables []multiplayer.TableInfo - tableCursor int + tables []multiplayer.TableInfo + tableCursor int // table state activeID int64 @@ -78,7 +78,7 @@ func (m *HoldemMP) loadWallet() tea.Cmd { user := m.sess.Identity.UserID svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() w, err := svc.Load(ctx, user) return hmpWalletMsg{wallet: w, err: err} @@ -271,7 +271,7 @@ func (m *HoldemMP) joinTable(coord *multiplayer.Coordinator) (tea.Model, tea.Cmd svc := m.sess.Wallet debitCmd := func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() if err := svc.Bet(ctx, &wallet, bet); err != nil { return hmpBuyInMsg{err: err} @@ -365,7 +365,7 @@ func (m *HoldemMP) leaveTable() (tea.Model, tea.Cmd) { wallet := m.wallet svc := m.sess.Wallet creditCmd := func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() if chips > 0 { _ = svc.Credit(ctx, &wallet, int64(chips)) diff --git a/internal/tui/screens/leaderboards.go b/internal/tui/screens/leaderboards.go index 47abb62..f5686b1 100644 --- a/internal/tui/screens/leaderboards.go +++ b/internal/tui/screens/leaderboards.go @@ -63,7 +63,7 @@ func (m *Leaderboards) Init() tea.Cmd { func (m *Leaderboards) loadCmd(view leaderboardView) tea.Cmd { svc := m.sess.Leaderboards return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(leaderboardLoadTimeoutSecond*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(leaderboardLoadTimeoutSecond * time.Second) defer cancel() if svc == nil { return leaderboardLoadedMsg{view: view, err: errors.New("leaderboard service unavailable")} diff --git a/internal/tui/screens/lobby.go b/internal/tui/screens/lobby.go index 9c45c88..722f926 100644 --- a/internal/tui/screens/lobby.go +++ b/internal/tui/screens/lobby.go @@ -103,7 +103,7 @@ func (m *Lobby) fetchAlerts() tea.Cmd { if provider == nil { return lobbyAlertsMsg{} } - ctx, cancel := m.sess.CtxWithTimeout(8*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(8 * time.Second) defer cancel() alerts, err := provider.Alerts(ctx, lat, lon) if err != nil { @@ -256,4 +256,3 @@ func (m *Lobby) renderAlertStrip(width int) string { } return style.Width(width).Align(lipgloss.Center).Render(strings.TrimSpace(label)) } - diff --git a/internal/tui/screens/profile.go b/internal/tui/screens/profile.go index aa52454..0567926 100644 --- a/internal/tui/screens/profile.go +++ b/internal/tui/screens/profile.go @@ -104,13 +104,13 @@ type Profile struct { // SSH user, plus the device-code flow state while linking a new one. // oauthCreds is hydrated by oauthLoadedMsg; oauthFlow is non-nil only // while modeOAuthDevice is active. - oauthCreds []gen.ListOAuthCredentialsForUserRow - oauthCursor int - oauthErr string - oauthBusy bool - oauthFlow *devicecode.Flow - oauthFlowProvider auth.OAuthProviderKind - oauthFlowStatus string + oauthCreds []gen.ListOAuthCredentialsForUserRow + oauthCursor int + oauthErr string + oauthBusy bool + oauthFlow *devicecode.Flow + oauthFlowProvider auth.OAuthProviderKind + oauthFlowStatus string // Add-key modal inputs. Built fresh in openAddKey so a cancelled attempt // leaves no residue. addKeyFocus: 0=public-key, 1=label. @@ -177,10 +177,10 @@ func NewProfileFinger(sess *session.Session, handle string) tea.Model { // type profileLoadedMsg struct { - snap *realtime.ProfileSnapshot - keys []gen.IdentityCredential - oauth []gen.ListOAuthCredentialsForUserRow - err error + snap *realtime.ProfileSnapshot + keys []gen.IdentityCredential + oauth []gen.ListOAuthCredentialsForUserRow + err error } type profileSavedMsg struct{ err error } @@ -234,7 +234,7 @@ func (m *Profile) loadSelf() tea.Cmd { queries := m.sess.Queries userID := m.sess.Identity.UserID return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() snap, err := svc.GetByID(ctx, userID) if err != nil { @@ -258,7 +258,7 @@ func (m *Profile) loadSelf() tea.Cmd { func (m *Profile) loadFinger(handle string) tea.Cmd { svc := m.sess.Profile return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() snap, err := svc.GetByHandle(ctx, handle) if err != nil { @@ -517,7 +517,7 @@ func (m *Profile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.locRenameID = 0 // Refresh the Session's PrimaryLocation cache so WeatherCoords() // picks up the new state, then reload the list for the modal. - refreshCtx, cancel := m.sess.CtxWithTimeout(2*time.Second) + refreshCtx, cancel := m.sess.CtxWithTimeout(2 * time.Second) if err := m.sess.RefreshPrimaryLocation(refreshCtx); err != nil { m.sess.Logger.Warn("refresh primary location", "err", err) } @@ -698,10 +698,10 @@ var ( profileHintStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color(theme.ColorDim)).Italic(true) - profileNoticeBase = lipgloss.NewStyle().Bold(true) - profileNoticeOk = profileNoticeBase.Foreground(lipgloss.Color(theme.ColorGreen)) - profileNoticeErr = profileNoticeBase.Foreground(lipgloss.Color(theme.ColorRed)) - profileNoticeDim = profileNoticeBase.Foreground(lipgloss.Color(theme.ColorAccentDim)) + profileNoticeBase = lipgloss.NewStyle().Bold(true) + profileNoticeOk = profileNoticeBase.Foreground(lipgloss.Color(theme.ColorGreen)) + profileNoticeErr = profileNoticeBase.Foreground(lipgloss.Color(theme.ColorRed)) + profileNoticeDim = profileNoticeBase.Foreground(lipgloss.Color(theme.ColorAccentDim)) profileSysopBadgeStyle = lipgloss.NewStyle().Bold(true). Background(lipgloss.Color(theme.ColorYellow)). @@ -713,7 +713,7 @@ var ( Foreground(lipgloss.Color(theme.ColorAccentDim)) profileLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorAccentDim)) profileActiveLabelStyle = lipgloss.NewStyle().Bold(true). - Foreground(lipgloss.Color(theme.ColorAccent)) + Foreground(lipgloss.Color(theme.ColorAccent)) profileButtonFocused = lipgloss.NewStyle().Bold(true). Background(lipgloss.Color(theme.ColorSurfaceAlt)). @@ -1346,4 +1346,3 @@ func (m *Profile) renderConfirmModal() string { } return m.confirm.View(w) } - diff --git a/internal/tui/screens/profile_locations.go b/internal/tui/screens/profile_locations.go index 4a20f98..ec725ef 100644 --- a/internal/tui/screens/profile_locations.go +++ b/internal/tui/screens/profile_locations.go @@ -70,7 +70,7 @@ func (m *Profile) reloadLocations() tea.Cmd { svc := m.sess.Locations userID := m.sess.Identity.UserID return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() if svc == nil { return locationsLoadedMsg{err: errors.New("location service unavailable")} @@ -185,7 +185,7 @@ func (m *Profile) dispatchSwap(a, b realtime.SavedLocation) tea.Cmd { userID := m.sess.Identity.UserID m.working = true return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() if svc == nil { return locationMutatedMsg{err: errors.New("location service unavailable")} @@ -211,7 +211,7 @@ func (m *Profile) submitLocationRename() tea.Cmd { id := m.locRenameID m.working = true return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() if svc == nil { return locationMutatedMsg{err: errors.New("location service unavailable")} @@ -306,7 +306,7 @@ func (m *Profile) searchPlace() tea.Cmd { m.locSearching = true m.locErr = "" return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() results, err := svc.Search(ctx, query, 5) return locationSearchMsg{results: results, err: err} @@ -384,7 +384,7 @@ func (m *Profile) deleteLocation(id int64) tea.Cmd { userID := m.sess.Identity.UserID m.working = true return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(5*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(5 * time.Second) defer cancel() if svc == nil { return locationMutatedMsg{err: errors.New("location service unavailable")} diff --git a/internal/tui/screens/register.go b/internal/tui/screens/register.go index bc0cf7c..0a1b933 100644 --- a/internal/tui/screens/register.go +++ b/internal/tui/screens/register.go @@ -121,7 +121,7 @@ func (m *Register) submitCmd() tea.Cmd { return func() tea.Msg { // Argon2id takes ~100ms; give a generous timeout so a slow disk write // for the user row doesn't fail the obvious success path. - ctx, cancel := m.sess.CtxWithTimeout(15*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(15 * time.Second) defer cancel() known, err := auth.CreateAccount(ctx, deps, in) return submitMsg{known: known, err: err} diff --git a/internal/tui/screens/roulette.go b/internal/tui/screens/roulette.go index a045506..5d7a769 100644 --- a/internal/tui/screens/roulette.go +++ b/internal/tui/screens/roulette.go @@ -27,10 +27,10 @@ const rouletteCabinetWidth = 60 // brief ball-landing pause, then optional pulse + coin shower. Total ribbon // motion ≈ 3.75s, comfortably inside the coordinator's 5s Spinning phase. const ( - rouletteSpinFastFrames = 25 + rouletteSpinFastFrames = 25 rouletteSpinFastIntervalMs = 50 rouletteSpinDecelFrames = 25 - rouletteSpinDecelStepMs = 6 // each decel frame adds 6ms to the wait + rouletteSpinDecelStepMs = 6 // each decel frame adds 6ms to the wait rouletteWallClockTickMs = 250 // for countdown refresh ) @@ -66,8 +66,8 @@ type Roulette struct { // Animation state. ribbonScroll int ribbonFrame int - spinning bool // local-animation flag; true while ribbon is decelerating - winnerIdx int // RibbonOrder index of the winning pocket (-1 until known) + spinning bool // local-animation flag; true while ribbon is decelerating + winnerIdx int // RibbonOrder index of the winning pocket (-1 until known) pulse components.PulseAnimation pulseActive bool coins components.CoinShower @@ -393,8 +393,8 @@ func (m *Roulette) placeBetAtCursor() tea.Cmd { // --------------------------------------------------------------------------- var ( - rouletteHint = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorMuted)).Italic(true) - rouletteErr = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorRed)) + rouletteHint = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorMuted)).Italic(true) + rouletteErr = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorRed)) roulettePhaseLabel = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorCyan)).Bold(true) rouletteWinLabel = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorYellow)).Bold(true) ) diff --git a/internal/tui/screens/slots.go b/internal/tui/screens/slots.go index c21d2a7..7ba0f33 100644 --- a/internal/tui/screens/slots.go +++ b/internal/tui/screens/slots.go @@ -115,7 +115,7 @@ func (m *Slots) loadWallet() tea.Cmd { user := m.sess.Identity.UserID svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() w, err := svc.Load(ctx, user) return slotsWalletLoadedMsg{wallet: w, err: err} @@ -136,7 +136,7 @@ func (m *Slots) spinCmd() tea.Cmd { wallet := m.wallet svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() if err := svc.Bet(ctx, &wallet, bet); err != nil { return slotsSpinReadyMsg{err: err} diff --git a/internal/tui/screens/sysop.go b/internal/tui/screens/sysop.go index 79c5d60..d28afb2 100644 --- a/internal/tui/screens/sysop.go +++ b/internal/tui/screens/sysop.go @@ -639,16 +639,16 @@ func writeAudit(ctx context.Context, q *gen.Queries, actorID int64, action, targ // Styles shared across tabs. Severity/source colorization for the Events // tab lives in sysop_events.go since only that tab uses it. var ( - sysopTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(theme.ColorAccent)) - sysopHint = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorMuted)).Italic(true) - sysopHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(theme.ColorAccentDim)) - sysopBan = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorRed)).Bold(true) - sysopFlag = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorYellow)) - sysopMuted = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorDim)) - sysopErr = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorRed)) - sysopTabBar = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorMuted)) - sysopTabOn = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(theme.ColorAccent)).Underline(true) - sysopTabOff = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorDim)) + sysopTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(theme.ColorAccent)) + sysopHint = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorMuted)).Italic(true) + sysopHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(theme.ColorAccentDim)) + sysopBan = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorRed)).Bold(true) + sysopFlag = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorYellow)) + sysopMuted = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorDim)) + sysopErr = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorRed)) + sysopTabBar = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorMuted)) + sysopTabOn = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(theme.ColorAccent)).Underline(true) + sysopTabOff = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorDim)) ) func (m *Sysop) View() string { @@ -787,21 +787,9 @@ func (m *Sysop) renderUsers(w, h int) string { // sysop's display-zone preference. See sysop_time.go. seen = sysopTSMin(u.LastSeenAt.Time) } - line := fmt.Sprintf("%s %-24s %s", flagStr, truncateRow(u.Handle, 24), sysopMuted.Render(seen)) + line := fmt.Sprintf("%s %-24s %s", flagStr, truncate(u.Handle, 24), sysopMuted.Render(seen)) b.WriteString(line) b.WriteString("\n") } return lipgloss.NewStyle().Width(w).Render(b.String()) } - -// truncateRow keeps row text inside the per-column budget. finance.go has -// its own truncate(), hence the differentiated name. -func truncateRow(s string, n int) string { - if len(s) <= n { - return s - } - if n <= 1 { - return s[:n] - } - return s[:n-1] + "…" -} diff --git a/internal/tui/screens/sysop_bans.go b/internal/tui/screens/sysop_bans.go index 9ae5b0e..b2d9741 100644 --- a/internal/tui/screens/sysop_bans.go +++ b/internal/tui/screens/sysop_bans.go @@ -39,10 +39,10 @@ func (m *Sysop) renderBans(w, h int) string { if ban.BannedAt.Valid { bannedAt = sysopTSMin(ban.BannedAt.Time) } - reason := truncateRow(ban.Reason, 40) - creator := truncateRow(ban.CreatedBy, 16) + reason := truncate(ban.Reason, 40) + creator := truncate(ban.CreatedBy, 16) b.WriteString(fmt.Sprintf(" %-40s expires %s by %s\n", - truncateRow(ban.IpAddr, 40), + truncate(ban.IpAddr, 40), sysopMuted.Render(expires), sysopMuted.Render(creator), )) diff --git a/internal/tui/screens/sysop_settings.go b/internal/tui/screens/sysop_settings.go index c6dcfa5..62fc855 100644 --- a/internal/tui/screens/sysop_settings.go +++ b/internal/tui/screens/sysop_settings.go @@ -53,20 +53,20 @@ func (m *Sysop) renderSettings(w, h int) string { source = sysopFlag.Render("custom ") } - curRendered := truncateRow(cur, valW) + curRendered := truncate(cur, valW) if def.Type == settings.TypeBool { if cur == "true" { - curRendered = sysopFlag.Render(truncateRow(cur, valW)) + curRendered = sysopFlag.Render(truncate(cur, valW)) } else { - curRendered = sysopMuted.Render(truncateRow(cur, valW)) + curRendered = sysopMuted.Render(truncate(cur, valW)) } } line := fmt.Sprintf( "%-*s %-*s default %-*s %s %s", - keyW, truncateRow(def.Key, keyW), + keyW, truncate(def.Key, keyW), valW, curRendered, - defW, truncateRow(dflt, defW), + defW, truncate(dflt, defW), source, sysopMuted.Render("("+def.Type+")"), ) diff --git a/internal/tui/screens/videopoker.go b/internal/tui/screens/videopoker.go index e66ad2c..543128f 100644 --- a/internal/tui/screens/videopoker.go +++ b/internal/tui/screens/videopoker.go @@ -18,11 +18,11 @@ import ( ) // VideoPoker drives one hand of 9/6 Jacks or Better. Lifecycle: -// 1. load wallet -// 2. Deal pressed → bet debits, deal 5 cards -// 3. 1-5 toggles hold per card -// 4. Draw → replace non-held cards, evaluate, credit payout, log round -// 5. press Deal again to start the next hand +// 1. load wallet +// 2. Deal pressed → bet debits, deal 5 cards +// 3. 1-5 toggles hold per card +// 4. Draw → replace non-held cards, evaluate, credit payout, log round +// 5. press Deal again to start the next hand type VideoPoker struct { sess *session.Session wallet doors.Wallet @@ -77,7 +77,7 @@ func (m *VideoPoker) Init() tea.Cmd { user := m.sess.Identity.UserID svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() w, err := svc.Load(ctx, user) return vpWalletLoadedMsg{wallet: w, err: err} @@ -93,7 +93,7 @@ func (m *VideoPoker) dealCmd() tea.Cmd { wallet := m.wallet svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() if err := svc.Bet(ctx, &wallet, bet); err != nil { return vpDealtMsg{err: err} @@ -114,7 +114,7 @@ func (m *VideoPoker) drawCmd() tea.Cmd { game := m.game svc := m.sess.Wallet return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(3*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(3 * time.Second) defer cancel() rank, payout := game.Draw(bet) if payout > 0 { @@ -127,7 +127,7 @@ func (m *VideoPoker) drawCmd() tea.Cmd { UserID: user, GameKey: "videopoker", Bet: bet, Payout: payout, Net: payout - bet, Details: map[string]any{ - "hand": handStrings(hand), + "hand": handToStrings(hand[:]), "rank": rank.String(), "payout": payout, }, @@ -136,14 +136,6 @@ func (m *VideoPoker) drawCmd() tea.Cmd { } } -func handStrings(h [5]cards.Card) []string { - out := make([]string, 5) - for i, c := range h { - out[i] = c.String() - } - return out -} - func (m *VideoPoker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case vpWalletLoadedMsg: diff --git a/internal/tui/screens/weather.go b/internal/tui/screens/weather.go index 9bd5911..5c48992 100644 --- a/internal/tui/screens/weather.go +++ b/internal/tui/screens/weather.go @@ -45,7 +45,7 @@ func (m *Weather) fetch() tea.Cmd { return func() tea.Msg { return weatherLoadedMsg{err: errNoLocation} } } return func() tea.Msg { - ctx, cancel := m.sess.CtxWithTimeout(12*time.Second) + ctx, cancel := m.sess.CtxWithTimeout(12 * time.Second) defer cancel() f, err := provider.Forecast(ctx, lat, lon, label) if err != nil { diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index 6c6586c..36c3ed8 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -25,13 +25,13 @@ const ( // spades/clubs → text ink, matching real-world playing cards. Held/winning // reuses gold (Yellow) so a glance reads as "lit up." Felt accents pick a // distinct hue per cabinet so each game still feels its own. - ColorSuitRed = ColorRed - ColorSuitInk = ColorText - ColorCardHeld = ColorYellow - ColorFeltBJ = "#2E7D5B" - ColorFeltVP = "#3A8FB7" - ColorFeltHE = "#9E2B3C" - ColorFeltRoulette = "#7A1F2E" // dark wine red — distinct from Hold'em's brighter burgundy + ColorSuitRed = ColorRed + ColorSuitInk = ColorText + ColorCardHeld = ColorYellow + ColorFeltBJ = "#2E7D5B" + ColorFeltVP = "#3A8FB7" + ColorFeltHE = "#9E2B3C" + ColorFeltRoulette = "#7A1F2E" // dark wine red — distinct from Hold'em's brighter burgundy ) var ( @@ -188,58 +188,3 @@ func KeyChip(s string) string { Background(lipgloss.Color(ColorSurfaceAlt)). Padding(0, 1).Render(s) } - -// BlendHex linearly interpolates two hex-form colors. t=0 → a, t=1 → b. Used -// by the carousel to fade neighbor cards by distance from center. -func BlendHex(aHex, bHex string, t float64) string { - ar, ag, ab := parseHex(aHex) - br, bg, bb := parseHex(bHex) - r := int(float64(ar)*(1-t) + float64(br)*t) - g := int(float64(ag)*(1-t) + float64(bg)*t) - b := int(float64(ab)*(1-t) + float64(bb)*t) - return rgbHex(r, g, b) -} - -func parseHex(h string) (r, g, b int) { - if len(h) != 7 || h[0] != '#' { - return 0, 0, 0 - } - r = (hex(h[1])<<4 | hex(h[2])) - g = (hex(h[3])<<4 | hex(h[4])) - b = (hex(h[5])<<4 | hex(h[6])) - return -} - -func hex(c byte) int { - switch { - case c >= '0' && c <= '9': - return int(c - '0') - case c >= 'a' && c <= 'f': - return int(c - 'a' + 10) - case c >= 'A' && c <= 'F': - return int(c - 'A' + 10) - } - return 0 -} - -func rgbHex(r, g, b int) string { - r = clamp8(r) - g = clamp8(g) - b = clamp8(b) - return "#" + nibble(r>>4) + nibble(r&0xF) + nibble(g>>4) + nibble(g&0xF) + nibble(b>>4) + nibble(b&0xF) -} - -func clamp8(v int) int { - if v < 0 { - return 0 - } - if v > 255 { - return 255 - } - return v -} - -func nibble(n int) string { - const digits = "0123456789ABCDEF" - return string(digits[n&0xF]) -} diff --git a/internal/web/cookies.go b/internal/web/cookies.go new file mode 100644 index 0000000..7ef0e3a --- /dev/null +++ b/internal/web/cookies.go @@ -0,0 +1,31 @@ +package web + +import ( + "net/http" + "time" +) + +// secureCookie builds a cookie with the security attributes every nightms +// cookie shares: Path=/, HttpOnly, SameSite=Lax, and Secure per deployment +// config (off behind Cloudflare Flexible TLS, on for direct HTTPS). Keeping +// the attribute set in one constructor means a future policy change (e.g. +// SameSite=Strict) lands everywhere at once. +func secureCookie(name, value string, secure bool, expires time.Time) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: value, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: expires, + } +} + +// expiredCookie returns a deletion cookie for name (epoch expiry + MaxAge<0, +// the belt-and-suspenders pair that makes every browser drop it). +func expiredCookie(name string, secure bool) *http.Cookie { + c := secureCookie(name, "", secure, time.Unix(0, 0)) + c.MaxAge = -1 + return c +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 7b4b8dc..0a6c025 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -11,7 +11,6 @@ import ( "github.com/jackc/pgx/v5" "github.com/nickna/ssh.night.ms/internal/auth" - "github.com/nickna/ssh.night.ms/internal/data/gen" "github.com/nickna/ssh.night.ms/internal/tui/session" ) @@ -210,7 +209,3 @@ func (h *handlers) logoutAllPost(w http.ResponseWriter, r *http.Request) { h.sessions.Clear(r.Context(), r, w) http.Redirect(w, r, "/", http.StatusSeeOther) } - -// requireQueries is a small helper for handlers that need typed queries; -// keeps the call site terse. -func (h *handlers) requireQueries() *gen.Queries { return h.deps.Queries } diff --git a/internal/web/handlers_avatar.go b/internal/web/handlers_avatar.go index 17b4649..4efbb60 100644 --- a/internal/web/handlers_avatar.go +++ b/internal/web/handlers_avatar.go @@ -44,14 +44,14 @@ func (h *handlers) publicProfile(w http.ResponseWriter, r *http.Request) { IsSysop: user.IsSysop, JoinedAt: user.CreatedAt.Time.Format("January 2006"), } - h.renderProfile(w, "public_profile", data) + h.renderProfile(w, http.StatusOK, "public_profile", data) } // avatar serves the avatar PNG for a handle. Order of precedence: -// 1. Uploaded profile picture (NIGHTMS_PFP_DIR/.png) when -// users.profile_picture_updated_at is set. ETag = upload timestamp so -// a new upload always invalidates browser caches. -// 2. Otherwise the deterministic identicon. ETag = hash of handle|size. +// 1. Uploaded profile picture (NIGHTMS_PFP_DIR/.png) when +// users.profile_picture_updated_at is set. ETag = upload timestamp so +// a new upload always invalidates browser caches. +// 2. Otherwise the deterministic identicon. ETag = hash of handle|size. // // In both cases we serve Cache-Control max-age=86400. Handle rename // keeps the same avatar URL — a renamed user's old avatar can therefore diff --git a/internal/web/handlers_boards.go b/internal/web/handlers_boards.go index 5131efc..03cdae5 100644 --- a/internal/web/handlers_boards.go +++ b/internal/web/handlers_boards.go @@ -123,7 +123,7 @@ func (h *handlers) boardsIndex(w http.ResponseWriter, r *http.Request) { Unread: unread[f.ID], }) } - h.renderProfile(w, "boards_index", boardsIndexData{ + h.renderProfile(w, http.StatusOK, "boards_index", boardsIndexData{ pageData: h.basePage(r, "boards"), Forums: items, }) @@ -203,7 +203,7 @@ func (h *handlers) boardForum(w http.ResponseWriter, r *http.Request) { Unread: unread[t.ID], }) } - h.renderProfile(w, "boards_forum", boardForumData{ + h.renderProfile(w, http.StatusOK, "boards_forum", boardForumData{ pageData: h.basePage(r, forum.Name), Forum: forumHeader{ID: forum.ID, Name: forum.Name, Description: forum.Description}, Topics: items, @@ -318,7 +318,7 @@ func (h *handlers) boardTopic(w http.ResponseWriter, r *http.Request) { notice = "reply too long (4000 characters max)" } - h.renderProfile(w, "boards_topic", boardTopicData{ + h.renderProfile(w, http.StatusOK, "boards_topic", boardTopicData{ pageData: h.basePage(r, topic.Title), Forum: forumHeader{ID: forum.ID, Name: forum.Name}, Topic: topicHeader{ID: topic.ID, ForumID: topic.ForumID, Title: topic.Title}, @@ -361,7 +361,7 @@ func (h *handlers) boardNewGet(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } - h.renderProfile(w, "boards_new", boardNewData{ + h.renderProfile(w, http.StatusOK, "boards_new", boardNewData{ pageData: h.basePage(r, "new topic"), Forum: forumHeader{ID: forum.ID, Name: forum.Name}, }) @@ -396,8 +396,7 @@ func (h *handlers) boardNewPost(w http.ResponseWriter, r *http.Request) { body := strings.TrimSpace(r.PostFormValue("body")) reRender := func(msg string) { - w.WriteHeader(http.StatusBadRequest) - h.renderProfile(w, "boards_new", boardNewData{ + h.renderProfile(w, http.StatusBadRequest, "boards_new", boardNewData{ pageData: h.basePage(r, "new topic"), Forum: forumHeader{ID: forum.ID, Name: forum.Name}, Title: title, diff --git a/internal/web/handlers_chat.go b/internal/web/handlers_chat.go index bf53e95..d0284bb 100644 --- a/internal/web/handlers_chat.go +++ b/internal/web/handlers_chat.go @@ -201,7 +201,7 @@ func (h *handlers) chatIndex(w http.ResponseWriter, r *http.Request) { }) } - h.renderProfile(w, "chat_index", chatIndexData{ + h.renderProfile(w, http.StatusOK, "chat_index", chatIndexData{ pageData: h.basePage(r, "chat"), Channels: public, DMs: dms, @@ -366,7 +366,7 @@ func (h *handlers) chatChannel(w http.ResponseWriter, r *http.Request) { } } - h.renderProfile(w, "chat_channel", chatChannelData{ + h.renderProfile(w, http.StatusOK, "chat_channel", chatChannelData{ pageData: h.basePage(r, chatDisplayName(ch, id.Handle)), Channel: chatChannelHeader(ch, id.Handle), Messages: items, diff --git a/internal/web/handlers_connections.go b/internal/web/handlers_connections.go index 054363b..a7437ca 100644 --- a/internal/web/handlers_connections.go +++ b/internal/web/handlers_connections.go @@ -19,13 +19,13 @@ import ( // can show the right "link Google / Microsoft" CTAs alongside what's // already attached. type connectionsRow struct { - ID int64 - Provider string - Subject string - Label string - CreatedAt time.Time - LastUsedAt *time.Time - IsSSH bool + ID int64 + Provider string + Subject string + Label string + CreatedAt time.Time + LastUsedAt *time.Time + IsSSH bool } // connectionsData is the page view-model. diff --git a/internal/web/handlers_oauth.go b/internal/web/handlers_oauth.go index d256fdd..8ee1218 100644 --- a/internal/web/handlers_oauth.go +++ b/internal/web/handlers_oauth.go @@ -32,9 +32,6 @@ type OAuthProviders struct { Microsoft *auth.OAuthProvider } -// Has returns true if any provider is configured. -func (p OAuthProviders) Has() bool { return p.Google != nil || p.Microsoft != nil } - // providerByKey resolves the URL param ("google" / "microsoft") to the // configured provider, or nil if unconfigured/unknown. func (h *handlers) providerByKey(key string) *auth.OAuthProvider { @@ -72,15 +69,9 @@ func (h *handlers) oauthStart(w http.ResponseWriter, r *http.Request) { Provider: string(provider.Kind), } body, _ := json.Marshal(stateBlob) - http.SetCookie(w, &http.Cookie{ - Name: oauthStateCookieName, - Value: base64.RawURLEncoding.EncodeToString(body), - Path: "/", - HttpOnly: true, - Secure: h.cfg.SecureCookies, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(10 * time.Minute), - }) + http.SetCookie(w, secureCookie(oauthStateCookieName, + base64.RawURLEncoding.EncodeToString(body), + h.cfg.SecureCookies, time.Now().Add(10*time.Minute))) http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusSeeOther) } @@ -251,74 +242,77 @@ func (h *handlers) linkOAuthCredential(ctx context.Context, userID int64, kind a return tx.Commit(ctx) } -// insertOAuthTokenRow is the initial-link token write. Used by -// linkOAuthCredential inside its transaction. -func insertOAuthTokenRow(ctx context.Context, q *gen.Queries, sealer interface { - Seal([]byte) []byte -}, credID int64, tok *oauth2.Token) error { - sealedAccess := sealer.Seal([]byte(tok.AccessToken)) - var sealedRefresh []byte +// sealedTokenFields is the shared serialization step for the insert and +// upsert token writes: seal both tokens, default a missing expiry to now+1h +// and a missing token type to "Bearer", and split the space-separated scope +// string the IdP returns in the token extras. +type sealedTokenFields struct { + access []byte + refresh []byte // nil when the IdP returned no refresh token + expiresAt pgtype.Timestamptz + tokenType string + scopes []string + now pgtype.Timestamptz +} + +func sealTokenFields(sealer interface{ Seal([]byte) []byte }, tok *oauth2.Token) sealedTokenFields { + now := time.Now().UTC() + f := sealedTokenFields{ + access: sealer.Seal([]byte(tok.AccessToken)), + now: pgtype.Timestamptz{Time: now, Valid: true}, + } if tok.RefreshToken != "" { - sealedRefresh = sealer.Seal([]byte(tok.RefreshToken)) + f.refresh = sealer.Seal([]byte(tok.RefreshToken)) } exp := tok.Expiry - now := time.Now().UTC() if exp.IsZero() { exp = now.Add(time.Hour) } - tokenType := tok.TokenType - if tokenType == "" { - tokenType = "Bearer" + f.expiresAt = pgtype.Timestamptz{Time: exp, Valid: true} + f.tokenType = tok.TokenType + if f.tokenType == "" { + f.tokenType = "Bearer" } - var scopes []string if raw, ok := tok.Extra("scope").(string); ok && raw != "" { - scopes = strings.Fields(raw) + f.scopes = strings.Fields(raw) } + return f +} + +// insertOAuthTokenRow is the initial-link token write. Used by +// linkOAuthCredential inside its transaction. +func insertOAuthTokenRow(ctx context.Context, q *gen.Queries, sealer interface { + Seal([]byte) []byte +}, credID int64, tok *oauth2.Token) error { + f := sealTokenFields(sealer, tok) return q.InsertOAuthToken(ctx, gen.InsertOAuthTokenParams{ CredentialID: credID, - EncryptedAccessToken: sealedAccess, - EncryptedRefreshToken: sealedRefresh, - AccessExpiresAt: pgtype.Timestamptz{Time: exp, Valid: true}, - Scopes: scopes, - TokenType: tokenType, - CreatedAt: pgtype.Timestamptz{Time: now, Valid: true}, + EncryptedAccessToken: f.access, + EncryptedRefreshToken: f.refresh, + AccessExpiresAt: f.expiresAt, + Scopes: f.scopes, + TokenType: f.tokenType, + CreatedAt: f.now, }) } // upsertOAuthTokenRow replaces the token row for an existing credential -// (re-auth path). +// (re-auth path) and resets the reauth/failure bookkeeping. func upsertOAuthTokenRow(ctx context.Context, q *gen.Queries, sealer interface { Seal([]byte) []byte }, credID int64, tok *oauth2.Token) error { - sealedAccess := sealer.Seal([]byte(tok.AccessToken)) - var sealedRefresh []byte - if tok.RefreshToken != "" { - sealedRefresh = sealer.Seal([]byte(tok.RefreshToken)) - } - exp := tok.Expiry - now := time.Now().UTC() - if exp.IsZero() { - exp = now.Add(time.Hour) - } - tokenType := tok.TokenType - if tokenType == "" { - tokenType = "Bearer" - } - var scopes []string - if raw, ok := tok.Extra("scope").(string); ok && raw != "" { - scopes = strings.Fields(raw) - } + f := sealTokenFields(sealer, tok) return q.UpsertOAuthToken(ctx, gen.UpsertOAuthTokenParams{ CredentialID: credID, - EncryptedAccessToken: sealedAccess, - EncryptedRefreshToken: sealedRefresh, - AccessExpiresAt: pgtype.Timestamptz{Time: exp, Valid: true}, - Scopes: scopes, - TokenType: tokenType, + EncryptedAccessToken: f.access, + EncryptedRefreshToken: f.refresh, + AccessExpiresAt: f.expiresAt, + Scopes: f.scopes, + TokenType: f.tokenType, NeedsReauth: false, - LastRefreshedAt: pgtype.Timestamptz{Time: now, Valid: true}, + LastRefreshedAt: f.now, RefreshFailureCount: 0, - CreatedAt: pgtype.Timestamptz{Time: now, Valid: true}, + CreatedAt: f.now, }) } @@ -364,31 +358,16 @@ func remoteAddrIP(r *http.Request) string { // clearCookie wipes the named cookie. Used for the short-lived OAuth state // cookie after the callback runs (success or failure). func (h *handlers) clearCookie(w http.ResponseWriter, name string) { - http.SetCookie(w, &http.Cookie{ - Name: name, - Value: "", - Path: "/", - HttpOnly: true, - Secure: h.cfg.SecureCookies, - SameSite: http.SameSiteLaxMode, - Expires: time.Unix(0, 0), - MaxAge: -1, - }) + http.SetCookie(w, expiredCookie(name, h.cfg.SecureCookies)) } // flash writes a one-shot status into a short-lived cookie that the next // page render picks up and clears. Used for "Linked!" / "Failed" feedback // after the OAuth callback redirects back into /profile/connections. func (h *handlers) flash(w http.ResponseWriter, kind, text string) { - http.SetCookie(w, &http.Cookie{ - Name: "nightms_flash", - Value: base64.RawURLEncoding.EncodeToString([]byte(kind + "|" + text)), - Path: "/", - HttpOnly: true, - Secure: h.cfg.SecureCookies, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(60 * time.Second), - }) + http.SetCookie(w, secureCookie("nightms_flash", + base64.RawURLEncoding.EncodeToString([]byte(kind+"|"+text)), + h.cfg.SecureCookies, time.Now().Add(60*time.Second))) } // readFlash consumes the one-shot flash cookie (if any) and returns the diff --git a/internal/web/handlers_profile.go b/internal/web/handlers_profile.go index 43e892d..2418768 100644 --- a/internal/web/handlers_profile.go +++ b/internal/web/handlers_profile.go @@ -72,7 +72,7 @@ func (h *handlers) profileView(w http.ResponseWriter, r *http.Request) { data.Notice = "profile picture cleared — back to identicon" data.NoticeKind = "ok" } - h.renderProfile(w, "profile", data) + h.renderProfile(w, http.StatusOK, "profile", data) } type passwordFormData struct { @@ -85,7 +85,7 @@ func (h *handlers) passwordGet(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - h.renderProfile(w, "profile_password", passwordFormData{pageData: h.basePage(r, "change password")}) + h.renderProfile(w, http.StatusOK, "profile_password", passwordFormData{pageData: h.basePage(r, "change password")}) } func (h *handlers) passwordPost(w http.ResponseWriter, r *http.Request) { @@ -103,8 +103,7 @@ func (h *handlers) passwordPost(w http.ResponseWriter, r *http.Request) { confirm := r.PostFormValue("confirm") render := func(msg string) { - w.WriteHeader(http.StatusUnauthorized) - h.renderProfile(w, "profile_password", passwordFormData{ + h.renderProfile(w, http.StatusUnauthorized, "profile_password", passwordFormData{ pageData: h.basePage(r, "change password"), Error: msg, }) @@ -193,12 +192,12 @@ func (h *handlers) passwordPost(w http.ResponseWriter, r *http.Request) { // type sshKeyView struct { - ID int64 + ID int64 Fingerprint string - Algorithm string - Label string - CreatedAt time.Time - LastUsedAt *time.Time + Algorithm string + Label string + CreatedAt time.Time + LastUsedAt *time.Time } type keysData struct { @@ -238,7 +237,7 @@ func (h *handlers) keysGet(w http.ResponseWriter, r *http.Request) { v.Algorithm = sshAlgorithmFromMetadata(k.Metadata) view = append(view, v) } - h.renderProfile(w, "profile_keys", keysData{ + h.renderProfile(w, http.StatusOK, "profile_keys", keysData{ pageData: h.basePage(r, "ssh keys"), Keys: view, }) @@ -261,7 +260,6 @@ func (h *handlers) keysAdd(w http.ResponseWriter, r *http.Request) { } render := func(msg string) { - w.WriteHeader(http.StatusBadRequest) // Re-render keys page with error AND current list. rows, _ := h.deps.Queries.ListSshCredentialsForUser(r.Context(), id.UserID) view := make([]sshKeyView, 0, len(rows)) @@ -277,7 +275,7 @@ func (h *handlers) keysAdd(w http.ResponseWriter, r *http.Request) { v.Algorithm = sshAlgorithmFromMetadata(k.Metadata) view = append(view, v) } - h.renderProfile(w, "profile_keys", keysData{ + h.renderProfile(w, http.StatusBadRequest, "profile_keys", keysData{ pageData: h.basePage(r, "ssh keys"), Keys: view, Error: msg, @@ -339,7 +337,7 @@ func (h *handlers) pictureGet(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } - h.renderProfile(w, "profile_picture", pictureFormData{ + h.renderProfile(w, http.StatusOK, "profile_picture", pictureFormData{ pageData: h.basePage(r, "profile picture"), HasUpload: user.ProfilePictureUpdatedAt.Valid, }) @@ -357,9 +355,8 @@ func (h *handlers) picturePost(w http.ResponseWriter, r *http.Request) { return } render := func(status int, msg string) { - w.WriteHeader(status) user, _ := h.deps.Queries.GetUserByID(r.Context(), id.UserID) - h.renderProfile(w, "profile_picture", pictureFormData{ + h.renderProfile(w, status, "profile_picture", pictureFormData{ pageData: h.basePage(r, "profile picture"), HasUpload: user.ProfilePictureUpdatedAt.Valid, Error: msg, @@ -480,7 +477,7 @@ func (h *handlers) sessionsView(w http.ResponseWriter, r *http.Request) { Current: row.Current, }) } - h.renderProfile(w, "profile_sessions", view) + h.renderProfile(w, http.StatusOK, "profile_sessions", view) } // sessionsRevoke kills one specific session by SID. Owner-guarded inside @@ -567,13 +564,16 @@ func shortUA(ua string) string { // is typed loosely so the profile-specific data shapes above thread through. // The shared layout template ultimately receives a value that embeds pageData, // which is what the {{.Identity}} / {{.CSRFField}} accesses depend on. -func (h *handlers) renderProfile(w http.ResponseWriter, page string, data any) { +// Owns the full header sequence (Content-Type before WriteHeader) so error +// paths can't accidentally flush the status line before the headers land. +func (h *handlers) renderProfile(w http.ResponseWriter, status int, page string, data any) { tpl, ok := h.templates[page] if !ok { http.Error(w, "template not found", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) if err := tpl.ExecuteTemplate(w, "layout", data); err != nil { h.deps.Logger.Error("web: render profile", "page", page, "err", err) } diff --git a/internal/web/handlers_weather.go b/internal/web/handlers_weather.go index 60ccea8..c96495f 100644 --- a/internal/web/handlers_weather.go +++ b/internal/web/handlers_weather.go @@ -106,7 +106,7 @@ func (h *handlers) weatherIndex(w http.ResponseWriter, r *http.Request) { } if loc == nil { // No saved location — render the "add a location" prompt. - h.renderProfile(w, "weather", weatherPageData{ + h.renderProfile(w, http.StatusOK, "weather", weatherPageData{ pageData: h.basePage(r, "weather"), Located: false, }) @@ -126,7 +126,7 @@ func (h *handlers) weatherIndex(w http.ResponseWriter, r *http.Request) { if err != nil { h.deps.Logger.Error("weather: forecast", "user_id", id.UserID, "err", err) data.FetchError = "could not load the forecast — try again in a moment" - h.renderProfile(w, "weather", data) + h.renderProfile(w, http.StatusOK, "weather", data) return } @@ -189,7 +189,7 @@ func (h *handlers) weatherIndex(w http.ResponseWriter, r *http.Request) { } } - h.renderProfile(w, "weather", data) + h.renderProfile(w, http.StatusOK, "weather", data) } // severityClass maps an NWS severity to a CSS modifier class. Mirrors the diff --git a/internal/web/methods.go b/internal/web/methods.go index 3fbeba7..33aa220 100644 --- a/internal/web/methods.go +++ b/internal/web/methods.go @@ -5,18 +5,9 @@ import ( "net/http" ) -// requestExt is a thin alias that lets us attach methods to *http.Request -// without violating Go's "no methods on other-package types" rule. Used via -// type-conversion at call sites: `(*requestExt)(r).RemoteAddrNetAddr()`. -type requestExt http.Request - -func (r *requestExt) RemoteAddrNetAddr() net.Addr { - return remoteAddrFor((*http.Request)(r)) -} - -// RemoteAddrNetAddr is a package-level helper invoked from handlers.go as -// `r.RemoteAddrNetAddr()`. Since Go won't let us add methods to *http.Request -// from another package, the handlers use a thin reference helper instead. +// remoteAddrNetAddr adapts an *http.Request to the net.Addr the auth-layer +// rate limiter expects. Go won't let us add methods to *http.Request from +// another package, so handlers use this thin helper instead. func remoteAddrNetAddr(r *http.Request) net.Addr { return remoteAddrFor(r) } diff --git a/internal/web/sessions.go b/internal/web/sessions.go index e50cd1b..4037af4 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -37,8 +37,8 @@ var errInvalidSession = errors.New("invalid session") // rows from commit 44a9a18 silently TTL out — existing users re-log // in once after deploy): // -// web:session:{sid} HASH → uid, ua, ip, ts, seen -// web:user:{user_id} SET → {sid, sid, …} +// web:session:{sid} HASH → uid, ua, ip, ts, seen +// web:user:{user_id} SET → {sid, sid, …} // // Both keys carry the session timeout TTL. The per-user index makes // ClearAllForUser cheap and powers the listing page. Sliding-expiration @@ -114,15 +114,7 @@ func (s *sessionStore) Set(ctx context.Context, r *http.Request, w http.Response if _, err := pipe.Exec(ctx); err != nil { return "", fmt.Errorf("session: store: %w", err) } - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: sid, - Path: "/", - HttpOnly: true, - Secure: s.secure, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(s.timeout), - }) + http.SetCookie(w, secureCookie(sessionCookieName, sid, s.secure, time.Now().Add(s.timeout))) return sid, nil } @@ -196,16 +188,7 @@ func (s *sessionStore) Clear(ctx context.Context, r *http.Request, w http.Respon _ = s.redis.Del(ctx, sessionKey(sid)).Err() } } - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: "", - Path: "/", - HttpOnly: true, - Secure: s.secure, - SameSite: http.SameSiteLaxMode, - Expires: time.Unix(0, 0), - MaxAge: -1, - }) + http.SetCookie(w, expiredCookie(sessionCookieName, s.secure)) } // ClearAllForUser revokes every session belonging to userID. Used by the