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 5999266..bdf5835 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,21 +23,21 @@ 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 + 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. @@ -61,11 +61,11 @@ 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", "") viper.SetDefault("FEDERATION_KEY", "") + viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h") err := viper.ReadInConfig() if err != nil { @@ -77,21 +77,21 @@ 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"), + 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 { diff --git a/internal/db/queries.go b/internal/db/queries.go index e4d6250..5b209dd 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -779,6 +779,150 @@ 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 +} + +// GetUserCount returns the number of active users. +func (database *Database) GetUserCount( + 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 user 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 +} + +// 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) { + 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 +} + +// 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 7159884..ad31dd3 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1361,20 +1361,139 @@ 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 +// and cleans up the user (session) if no clients remain. +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, + sessionID, clientID, nick, ok := + hdlr.requireAuth(writer, request) + if !ok { + return + } + + ctx := request.Context() + + err := hdlr.params.Database.DeleteClient( + ctx, clientID, + ) + if err != nil { + hdlr.log.Error( + "delete client failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + 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( + ctx, 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( + ctx context.Context, + sessionID int64, + nick string, +) { + 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() +} + +// HandleServerInfo returns server metadata. +func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + users, err := hdlr.params.Database.GetUserCount( + request.Context(), + ) + if err != nil { + hdlr.log.Error( + "get user 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/handlers/handlers.go b/internal/handlers/handlers.go index d6e2014..ed6ef04 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,100 @@ 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 +} + +// 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) +} + +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) + + // 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, + ) + if err != nil { + hdlr.log.Error( + "user cleanup failed", "error", err, + ) + + return + } + + if deleted > 0 { + hdlr.log.Info( + "cleaned up stale users", + "deleted", deleted, + ) + } +} 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(),