From 6cfab21eaa3c2bfa3869a2473fea1f5162302854 Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 27 Feb 2026 05:06:56 -0800 Subject: [PATCH 1/6] feat: add logout endpoint and users/me endpoint - POST /api/v1/logout: deletes client token, returns {status: ok} - GET /api/v1/users/me: returns session info (delegates to HandleState) - Add DeleteClient, GetSessionCount, ClientCountForSession, DeleteStaleSessions to db layer - Add user count to GET /api/v1/server response - Extract setupAPIv1 to fix funlen lint issue --- internal/db/queries.go | 90 +++++++++++++++++++++++++++++++++++++++ internal/handlers/api.go | 71 +++++++++++++++++++++++++----- internal/server/routes.go | 8 ++++ 3 files changed, 159 insertions(+), 10 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index e4d6250..60e409e 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -779,6 +779,96 @@ func (database *Database) DeleteSession( return nil } +// DeleteClient removes a single client record by ID. +func (database *Database) DeleteClient( + ctx context.Context, + clientID int64, +) error { + _, err := database.conn.ExecContext( + ctx, + "DELETE FROM clients WHERE id = ?", + clientID, + ) + if err != nil { + return fmt.Errorf("delete client: %w", err) + } + + return nil +} + +// GetSessionCount returns the number of active sessions. +func (database *Database) GetSessionCount( + ctx context.Context, +) (int64, error) { + var count int64 + + err := database.conn.QueryRowContext( + ctx, + "SELECT COUNT(*) FROM sessions", + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf( + "get session count: %w", err, + ) + } + + return count, nil +} + +// ClientCountForSession returns the number of clients +// belonging to a session. +func (database *Database) ClientCountForSession( + ctx context.Context, + sessionID int64, +) (int64, error) { + var count int64 + + err := database.conn.QueryRowContext( + ctx, + `SELECT COUNT(*) FROM clients + WHERE session_id = ?`, + sessionID, + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf( + "client count for session: %w", err, + ) + } + + return count, nil +} + +// DeleteStaleSessions removes clients not seen since the +// cutoff and cleans up orphaned sessions. +func (database *Database) DeleteStaleSessions( + ctx context.Context, + cutoff time.Time, +) (int64, error) { + res, err := database.conn.ExecContext(ctx, + "DELETE FROM clients WHERE last_seen < ?", + cutoff, + ) + if err != nil { + return 0, fmt.Errorf( + "delete stale clients: %w", err, + ) + } + + deleted, _ := res.RowsAffected() + + _, err = database.conn.ExecContext(ctx, + `DELETE FROM sessions WHERE id NOT IN + (SELECT DISTINCT session_id FROM clients)`, + ) + if err != nil { + return deleted, fmt.Errorf( + "delete orphan sessions: %w", err, + ) + } + + return deleted, nil +} + // GetSessionChannels returns channels a session // belongs to. func (database *Database) GetSessionChannels( diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 7159884..47923f4 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1361,20 +1361,71 @@ func (hdlr *Handlers) canAccessChannelHistory( return true } -// HandleServerInfo returns server metadata. -func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { - type infoResponse struct { - Name string `json:"name"` - MOTD string `json:"motd"` - } - +// HandleLogout deletes the authenticated client's token. +func (hdlr *Handlers) HandleLogout() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { - hdlr.respondJSON(writer, request, &infoResponse{ - Name: hdlr.params.Config.ServerName, - MOTD: hdlr.params.Config.MOTD, + _, clientID, _, ok := + hdlr.requireAuth(writer, request) + if !ok { + return + } + + err := hdlr.params.Database.DeleteClient( + request.Context(), clientID, + ) + if err != nil { + hdlr.log.Error( + "delete client failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) + } +} + +// HandleUsersMe returns the current user's session info. +func (hdlr *Handlers) HandleUsersMe() http.HandlerFunc { + return hdlr.HandleState() +} + +// HandleServerInfo returns server metadata. +func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + users, err := hdlr.params.Database.GetSessionCount( + request.Context(), + ) + if err != nil { + hdlr.log.Error( + "get session count failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + hdlr.respondJSON(writer, request, map[string]any{ + "name": hdlr.params.Config.ServerName, + "motd": hdlr.params.Config.MOTD, + "users": users, }, http.StatusOK) } } diff --git a/internal/server/routes.go b/internal/server/routes.go index e7b632e..9e945e7 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -86,6 +86,14 @@ func (srv *Server) setupAPIv1(router chi.Router) { "/state", srv.handlers.HandleState(), ) + router.Post( + "/logout", + srv.handlers.HandleLogout(), + ) + router.Get( + "/users/me", + srv.handlers.HandleUsersMe(), + ) router.Get( "/messages", srv.handlers.HandleGetMessages(), From 5981c750a462b72af2e3006c15c928b70626bc2d Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 27 Feb 2026 05:06:56 -0800 Subject: [PATCH 2/6] feat: add SESSION_IDLE_TIMEOUT config - New env var SESSION_IDLE_TIMEOUT (default 24h) - Parsed as time.Duration in handlers --- internal/config/config.go | 63 ++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5999266..3117e02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,21 +23,22 @@ type Params struct { // Config holds all application configuration values. type Config struct { - DBURL string - Debug bool - MaintenanceMode bool - MetricsPassword string - MetricsUsername string - Port int - SentryDSN string - MaxHistory int - SessionTimeout int - MaxMessageSize int - MOTD string - ServerName string - FederationKey string - params *Params - log *slog.Logger + DBURL string + Debug bool + MaintenanceMode bool + MetricsPassword string + MetricsUsername string + Port int + SentryDSN string + MaxHistory int + SessionTimeout int + MaxMessageSize int + MOTD string + ServerName string + FederationKey string + SessionIdleTimeout string + params *Params + log *slog.Logger } // New creates a new Config by reading from files and environment variables. @@ -66,6 +67,7 @@ func New( viper.SetDefault("MOTD", "") viper.SetDefault("SERVER_NAME", "") viper.SetDefault("FEDERATION_KEY", "") + viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h") err := viper.ReadInConfig() if err != nil { @@ -77,21 +79,22 @@ func New( } cfg := &Config{ - DBURL: viper.GetString("DBURL"), - Debug: viper.GetBool("DEBUG"), - Port: viper.GetInt("PORT"), - SentryDSN: viper.GetString("SENTRY_DSN"), - MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), - MetricsUsername: viper.GetString("METRICS_USERNAME"), - MetricsPassword: viper.GetString("METRICS_PASSWORD"), - MaxHistory: viper.GetInt("MAX_HISTORY"), - SessionTimeout: viper.GetInt("SESSION_TIMEOUT"), - MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), - MOTD: viper.GetString("MOTD"), - ServerName: viper.GetString("SERVER_NAME"), - FederationKey: viper.GetString("FEDERATION_KEY"), - log: log, - params: ¶ms, + DBURL: viper.GetString("DBURL"), + Debug: viper.GetBool("DEBUG"), + Port: viper.GetInt("PORT"), + SentryDSN: viper.GetString("SENTRY_DSN"), + MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), + MetricsUsername: viper.GetString("METRICS_USERNAME"), + MetricsPassword: viper.GetString("METRICS_PASSWORD"), + MaxHistory: viper.GetInt("MAX_HISTORY"), + SessionTimeout: viper.GetInt("SESSION_TIMEOUT"), + MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), + MOTD: viper.GetString("MOTD"), + ServerName: viper.GetString("SERVER_NAME"), + FederationKey: viper.GetString("FEDERATION_KEY"), + SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), + log: log, + params: ¶ms, } if cfg.Debug { From bdc243224bd0d8aaf1771a8adc818f588939224d Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 27 Feb 2026 05:06:56 -0800 Subject: [PATCH 3/6] feat: add session idle timeout cleanup goroutine - Periodic cleanup loop deletes stale clients based on SESSION_IDLE_TIMEOUT - Orphaned sessions (no clients) are cleaned up automatically - last_seen already updated on each authenticated request via GetSessionByToken --- internal/handlers/handlers.go | 95 ++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index d6e2014..763edf1 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -7,6 +7,7 @@ import ( "errors" "log/slog" "net/http" + "time" "git.eeqj.de/sneak/chat/internal/broker" "git.eeqj.de/sneak/chat/internal/config" @@ -30,12 +31,15 @@ type Params struct { Healthcheck *healthcheck.Healthcheck } +const defaultIdleTimeout = 24 * time.Hour + // Handlers manages HTTP request handling. type Handlers struct { - params *Params - log *slog.Logger - hc *healthcheck.Healthcheck - broker *broker.Broker + params *Params + log *slog.Logger + hc *healthcheck.Healthcheck + broker *broker.Broker + cancelCleanup context.CancelFunc } // New creates a new Handlers instance. @@ -43,7 +47,7 @@ func New( lifecycle fx.Lifecycle, params Params, ) (*Handlers, error) { - hdlr := &Handlers{ + hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup params: ¶ms, log: params.Logger.Get(), hc: params.Healthcheck, @@ -51,10 +55,14 @@ func New( } lifecycle.Append(fx.Hook{ - OnStart: func(_ context.Context) error { + OnStart: func(ctx context.Context) error { + hdlr.startCleanup(ctx) + return nil }, OnStop: func(_ context.Context) error { + hdlr.stopCleanup() + return nil }, }) @@ -96,3 +104,78 @@ func (hdlr *Handlers) respondError( status, ) } + +func (hdlr *Handlers) idleTimeout() time.Duration { + raw := hdlr.params.Config.SessionIdleTimeout + if raw == "" { + return defaultIdleTimeout + } + + dur, err := time.ParseDuration(raw) + if err != nil { + hdlr.log.Error( + "invalid SESSION_IDLE_TIMEOUT, using default", + "value", raw, "error", err, + ) + + return defaultIdleTimeout + } + + return dur +} + +func (hdlr *Handlers) startCleanup(ctx context.Context) { + cleanupCtx, cancel := context.WithCancel(ctx) + hdlr.cancelCleanup = cancel + + go hdlr.cleanupLoop(cleanupCtx) +} + +func (hdlr *Handlers) stopCleanup() { + if hdlr.cancelCleanup != nil { + hdlr.cancelCleanup() + } +} + +func (hdlr *Handlers) cleanupLoop(ctx context.Context) { + timeout := hdlr.idleTimeout() + + interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + hdlr.runCleanup(ctx, timeout) + case <-ctx.Done(): + return + } + } +} + +func (hdlr *Handlers) runCleanup( + ctx context.Context, + timeout time.Duration, +) { + cutoff := time.Now().Add(-timeout) + + deleted, err := hdlr.params.Database.DeleteStaleSessions( + ctx, cutoff, + ) + if err != nil { + hdlr.log.Error( + "session cleanup failed", "error", err, + ) + + return + } + + if deleted > 0 { + hdlr.log.Info( + "cleaned up stale clients", + "deleted", deleted, + ) + } +} From 910a5c260683a3ef1d6c94de0e5f435c9a864088 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 28 Feb 2026 11:14:23 -0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20OnStart=20ctx=20bug,=20rename=20sess?= =?UTF-8?q?ion=E2=86=92user,=20full=20logout=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use context.Background() for cleanup goroutine instead of OnStart ctx which is cancelled after startup completes - Rename GetSessionCount→GetUserCount, DeleteStaleSessions→ DeleteStaleUsers to reflect that sessions represent users - HandleLogout now fully cleans up when last client disconnects: parts all channels (notifying members via QUIT), removes empty channels, and deletes the session/user record - docker build passes, all tests green, 0 lint issues --- internal/db/queries.go | 12 +++--- internal/handlers/api.go | 80 ++++++++++++++++++++++++++++++++--- internal/handlers/handlers.go | 18 +++++--- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index 60e409e..d6fbb91 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -796,8 +796,8 @@ func (database *Database) DeleteClient( return nil } -// GetSessionCount returns the number of active sessions. -func (database *Database) GetSessionCount( +// GetUserCount returns the number of active users. +func (database *Database) GetUserCount( ctx context.Context, ) (int64, error) { var count int64 @@ -808,7 +808,7 @@ func (database *Database) GetSessionCount( ).Scan(&count) if err != nil { return 0, fmt.Errorf( - "get session count: %w", err, + "get user count: %w", err, ) } @@ -838,9 +838,9 @@ func (database *Database) ClientCountForSession( return count, nil } -// DeleteStaleSessions removes clients not seen since the -// cutoff and cleans up orphaned sessions. -func (database *Database) DeleteStaleSessions( +// DeleteStaleUsers removes clients not seen since the +// cutoff and cleans up orphaned users (sessions). +func (database *Database) DeleteStaleUsers( ctx context.Context, cutoff time.Time, ) (int64, error) { diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 47923f4..b963f9b 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1361,20 +1361,23 @@ func (hdlr *Handlers) canAccessChannelHistory( return true } -// HandleLogout deletes the authenticated client's token. +// HandleLogout deletes the authenticated client's token +// and cleans up the user (session) if no clients remain. func (hdlr *Handlers) HandleLogout() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { - _, clientID, _, ok := + sessionID, clientID, nick, ok := hdlr.requireAuth(writer, request) if !ok { return } + ctx := request.Context() + err := hdlr.params.Database.DeleteClient( - request.Context(), clientID, + ctx, clientID, ) if err != nil { hdlr.log.Error( @@ -1389,12 +1392,79 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc { return } + // If no clients remain, clean up the user fully: + // part all channels (notifying members) and + // delete the session. + remaining, err := hdlr.params.Database. + ClientCountForSession(ctx, sessionID) + if err != nil { + hdlr.log.Error( + "client count check failed", "error", err, + ) + } + + if remaining == 0 { + hdlr.cleanupUser( + request, sessionID, nick, + ) + } + hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } } +// cleanupUser parts the user from all channels (notifying +// members) and deletes the session. +func (hdlr *Handlers) cleanupUser( + request *http.Request, + sessionID int64, + nick string, +) { + ctx := request.Context() + + channels, _ := hdlr.params.Database. + GetSessionChannels(ctx, sessionID) + + notified := map[int64]bool{} + + var quitDBID int64 + + if len(channels) > 0 { + quitDBID, _, _ = hdlr.params.Database.InsertMessage( + ctx, "QUIT", nick, "", nil, nil, + ) + } + + for _, chanInfo := range channels { + memberIDs, _ := hdlr.params.Database. + GetChannelMemberIDs(ctx, chanInfo.ID) + + for _, mid := range memberIDs { + if mid != sessionID && !notified[mid] { + notified[mid] = true + + _ = hdlr.params.Database.EnqueueToSession( + ctx, mid, quitDBID, + ) + + hdlr.broker.Notify(mid) + } + } + + _ = hdlr.params.Database.PartChannel( + ctx, chanInfo.ID, sessionID, + ) + + _ = hdlr.params.Database.DeleteChannelIfEmpty( + ctx, chanInfo.ID, + ) + } + + _ = hdlr.params.Database.DeleteSession(ctx, sessionID) +} + // HandleUsersMe returns the current user's session info. func (hdlr *Handlers) HandleUsersMe() http.HandlerFunc { return hdlr.HandleState() @@ -1406,12 +1476,12 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { writer http.ResponseWriter, request *http.Request, ) { - users, err := hdlr.params.Database.GetSessionCount( + users, err := hdlr.params.Database.GetUserCount( request.Context(), ) if err != nil { hdlr.log.Error( - "get session count failed", "error", err, + "get user count failed", "error", err, ) hdlr.respondError( writer, request, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 763edf1..7ce952e 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -124,8 +124,16 @@ func (hdlr *Handlers) idleTimeout() time.Duration { return dur } -func (hdlr *Handlers) startCleanup(ctx context.Context) { - cleanupCtx, cancel := context.WithCancel(ctx) +// startCleanup launches the idle-user cleanup goroutine. +// We use context.Background rather than the OnStart ctx +// because the OnStart context is startup-scoped and would +// cancel the goroutine once all start hooks complete. +// +//nolint:contextcheck // intentional Background ctx +func (hdlr *Handlers) startCleanup(_ context.Context) { + cleanupCtx, cancel := context.WithCancel( + context.Background(), + ) hdlr.cancelCleanup = cancel go hdlr.cleanupLoop(cleanupCtx) @@ -161,12 +169,12 @@ func (hdlr *Handlers) runCleanup( ) { cutoff := time.Now().Add(-timeout) - deleted, err := hdlr.params.Database.DeleteStaleSessions( + deleted, err := hdlr.params.Database.DeleteStaleUsers( ctx, cutoff, ) if err != nil { hdlr.log.Error( - "session cleanup failed", "error", err, + "user cleanup failed", "error", err, ) return @@ -174,7 +182,7 @@ func (hdlr *Handlers) runCleanup( if deleted > 0 { hdlr.log.Info( - "cleaned up stale clients", + "cleaned up stale users", "deleted", deleted, ) } From 4d7b7618b22ca9bdc5742b42b60ddce0c2ef64d1 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Mar 2026 06:33:15 -0800 Subject: [PATCH 5/6] fix: send QUIT notifications for background idle cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background idle cleanup (DeleteStaleUsers) was removing stale clients/sessions directly via SQL without sending QUIT notifications to channel members. This caused timed-out users to silently disappear from channels. Now runCleanup identifies sessions that will be orphaned by the stale client deletion and calls cleanupUser for each one first, ensuring QUIT messages are sent to all channel members — matching the explicit logout behavior. Also refactored cleanupUser to accept context.Context instead of *http.Request so it can be called from both HTTP handlers and the background cleanup goroutine. --- internal/db/queries.go | 54 +++++++++++++++++++++++++++++++++++ internal/handlers/api.go | 6 ++-- internal/handlers/handlers.go | 14 +++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index d6fbb91..5b209dd 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -869,6 +869,60 @@ func (database *Database) DeleteStaleUsers( return deleted, nil } +// StaleSession holds the id and nick of a session +// whose clients are all stale. +type StaleSession struct { + ID int64 + Nick string +} + +// GetStaleOrphanSessions returns sessions where every +// client has a last_seen before cutoff. +func (database *Database) GetStaleOrphanSessions( + ctx context.Context, + cutoff time.Time, +) ([]StaleSession, error) { + rows, err := database.conn.QueryContext(ctx, + `SELECT s.id, s.nick + FROM sessions s + WHERE s.id IN ( + SELECT DISTINCT session_id FROM clients + WHERE last_seen < ? + ) + AND s.id NOT IN ( + SELECT DISTINCT session_id FROM clients + WHERE last_seen >= ? + )`, cutoff, cutoff) + if err != nil { + return nil, fmt.Errorf( + "get stale orphan sessions: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + var result []StaleSession + + for rows.Next() { + var stale StaleSession + if err := rows.Scan(&stale.ID, &stale.Nick); err != nil { + return nil, fmt.Errorf( + "scan stale session: %w", err, + ) + } + + result = append(result, stale) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "iterate stale sessions: %w", err, + ) + } + + return result, nil +} + // GetSessionChannels returns channels a session // belongs to. func (database *Database) GetSessionChannels( diff --git a/internal/handlers/api.go b/internal/handlers/api.go index b963f9b..ad31dd3 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1405,7 +1405,7 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc { if remaining == 0 { hdlr.cleanupUser( - request, sessionID, nick, + ctx, sessionID, nick, ) } @@ -1418,12 +1418,10 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc { // cleanupUser parts the user from all channels (notifying // members) and deletes the session. func (hdlr *Handlers) cleanupUser( - request *http.Request, + ctx context.Context, sessionID int64, nick string, ) { - ctx := request.Context() - channels, _ := hdlr.params.Database. GetSessionChannels(ctx, sessionID) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 7ce952e..ed6ef04 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -169,6 +169,20 @@ func (hdlr *Handlers) runCleanup( ) { cutoff := time.Now().Add(-timeout) + // Find sessions that will be orphaned so we can send + // QUIT notifications before deleting anything. + stale, err := hdlr.params.Database. + GetStaleOrphanSessions(ctx, cutoff) + if err != nil { + hdlr.log.Error( + "stale session lookup failed", "error", err, + ) + } + + for _, ss := range stale { + hdlr.cleanupUser(ctx, ss.ID, ss.Nick) + } + deleted, err := hdlr.params.Database.DeleteStaleUsers( ctx, cutoff, ) From f5cc098b7b1ba5c215db5f441115cba720e810bf Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 1 Mar 2026 06:41:10 -0800 Subject: [PATCH 6/6] docs: update README for new endpoints, fix config name, remove dead field - Document POST /api/v1/logout endpoint - Document GET /api/v1/users/me endpoint - Add 'users' field to GET /api/v1/server response docs - Fix config: SESSION_TIMEOUT -> SESSION_IDLE_TIMEOUT - Update storage section: session expiry is implemented - Update roadmap: move session expiry to implemented - Remove dead SessionTimeout config field from Go code --- README.md | 73 +++++++++++++++++++++++++++++++++++---- internal/config/config.go | 3 -- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4ba7b4c..04d019f 100644 --- a/README.md +++ b/README.md @@ -1158,6 +1158,55 @@ curl -s http://localhost:8080/api/v1/channels/general/members \ -H "Authorization: Bearer $TOKEN" | jq . ``` +### POST /api/v1/logout — Logout + +Destroy the current client's auth token. If no other clients remain on the +session, the user is fully cleaned up: parted from all channels (with QUIT +broadcast to members), session deleted, nick released. + +**Request:** No body. Requires auth. + +**Response:** `200 OK` +```json +{"status": "ok"} +``` + +**Errors:** + +| Status | Error | When | +|--------|-------|------| +| 401 | `unauthorized` | Missing or invalid auth token | + +**curl example:** +```bash +curl -s -X POST http://localhost:8080/api/v1/logout \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +### GET /api/v1/users/me — Current User Info + +Return the current user's session state. This is an alias for +`GET /api/v1/state`. + +**Request:** No body. Requires auth. + +**Response:** `200 OK` +```json +{ + "id": 1, + "nick": "alice", + "channels": [ + {"id": 1, "name": "#general", "topic": "Welcome!"} + ] +} +``` + +**curl example:** +```bash +curl -s http://localhost:8080/api/v1/users/me \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + ### GET /api/v1/server — Server Info Return server metadata. No authentication required. @@ -1166,10 +1215,17 @@ Return server metadata. No authentication required. ```json { "name": "My Chat Server", - "motd": "Welcome! Be nice." + "motd": "Welcome! Be nice.", + "users": 42 } ``` +| Field | Type | Description | +|---------|---------|-------------| +| `name` | string | Server display name | +| `motd` | string | Message of the day | +| `users` | integer | Number of currently active user sessions | + ### GET /.well-known/healthcheck.json — Health Check Standard health check endpoint. No authentication required. @@ -1572,8 +1628,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison). - **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is planned. - **Channels**: Deleted when the last member leaves (ephemeral). -- **Users/sessions**: Deleted on `QUIT`. Session expiry by `SESSION_TIMEOUT` - is planned. +- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle + sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default + 24h) — the server runs a background cleanup loop that parts idle users + from all channels, broadcasts QUIT, and releases their nicks. --- @@ -1590,7 +1648,7 @@ directory is also loaded automatically via | `DBURL` | string | `file:./data.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:./path.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | | `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | | `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) | -| `SESSION_TIMEOUT` | int | `86400` | Session idle timeout in seconds (planned). Sessions with no activity for this long are expired and the nick is released. | +| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. | | `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). | | `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | | `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | @@ -1610,7 +1668,7 @@ SERVER_NAME=My Chat Server MOTD=Welcome! Be excellent to each other. DEBUG=false DBURL=file:./data.db?_journal_mode=WAL -SESSION_TIMEOUT=86400 +SESSION_IDLE_TIMEOUT=24h ``` --- @@ -2008,11 +2066,14 @@ GET /api/v1/challenge - [x] Docker deployment - [x] Prometheus metrics endpoint - [x] Health check endpoint +- [x] Session expiry — auto-expire idle sessions, release nicks +- [x] Logout endpoint (`POST /api/v1/logout`) +- [x] Current user endpoint (`GET /api/v1/users/me`) +- [x] User count in server info (`GET /api/v1/server`) ### Post-MVP (Planned) - [ ] **Hashcash proof-of-work** for session creation (abuse prevention) -- [ ] **Session expiry** — auto-expire idle sessions, release nicks - [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` - [ ] **Message rotation** — enforce `MAX_HISTORY` per channel - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` diff --git a/internal/config/config.go b/internal/config/config.go index 3117e02..bdf5835 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,7 +31,6 @@ type Config struct { Port int SentryDSN string MaxHistory int - SessionTimeout int MaxMessageSize int MOTD string ServerName string @@ -62,7 +61,6 @@ func New( viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("MAX_HISTORY", "10000") - viper.SetDefault("SESSION_TIMEOUT", "86400") viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MOTD", "") viper.SetDefault("SERVER_NAME", "") @@ -87,7 +85,6 @@ func New( MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), MaxHistory: viper.GetInt("MAX_HISTORY"), - SessionTimeout: viper.GetInt("SESSION_TIMEOUT"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), MOTD: viper.GetString("MOTD"), ServerName: viper.GetString("SERVER_NAME"),