diff --git a/README.md b/README.md index 2d061a3..c9cb666 100644 --- a/README.md +++ b/README.md @@ -249,8 +249,8 @@ Key properties: - **Ordered**: Queue entries have monotonically increasing IDs. Messages are always delivered in order within a client's queue. - **No delivery/read receipts** for channel messages. DM receipts are planned. -- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48 - hours. Entries older than this are pruned. +- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`. + Default is 30 days. Entries older than this are pruned. ### Long-Polling @@ -1788,14 +1788,14 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison). ### Data Lifecycle -- **Messages**: Stored indefinitely in the current implementation. Rotation - per `MAX_HISTORY` is planned. -- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is - planned. +- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` + (default 30 days). +- **Client output queue entries**: Pruned automatically when older than + `QUEUE_MAX_AGE` (default 30 days). - **Channels**: Deleted when the last member leaves (ephemeral). - **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 + 30 days) — the server runs a background cleanup loop that parts idle users from all channels, broadcasts QUIT, and releases their nicks. --- @@ -1812,9 +1812,9 @@ directory is also loaded automatically via | `PORT` | int | `8080` | HTTP listen port | | `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.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_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). | +| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | +| `SESSION_IDLE_TIMEOUT` | string | `720h` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. Default is 30 days. | +| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | | `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) | | `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | @@ -1833,7 +1833,7 @@ SERVER_NAME=My NeoIRC Server MOTD=Welcome! Be excellent to each other. DEBUG=false DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL -SESSION_IDLE_TIMEOUT=24h +SESSION_IDLE_TIMEOUT=720h ``` --- @@ -2228,8 +2228,8 @@ GET /api/v1/challenge ### Post-MVP (Planned) - [ ] **Hashcash proof-of-work** for session creation (abuse prevention) -- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` -- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel +- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE` +- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [ ] **User channel modes** — `+o` (operator), `+v` (voice) - [x] **MODE command** — query channel and user modes (set not yet implemented) diff --git a/internal/config/config.go b/internal/config/config.go index 468ca75..855b55a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,8 +38,9 @@ type Config struct { MetricsUsername string Port int SentryDSN string - MaxHistory int + MessageMaxAge string MaxMessageSize int + QueueMaxAge string MOTD string ServerName string FederationKey string @@ -68,12 +69,13 @@ func New( viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") - viper.SetDefault("MAX_HISTORY", "10000") + viper.SetDefault("MESSAGE_MAX_AGE", "720h") viper.SetDefault("MAX_MESSAGE_SIZE", "4096") + viper.SetDefault("QUEUE_MAX_AGE", "720h") viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("SERVER_NAME", "") viper.SetDefault("FEDERATION_KEY", "") - viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h") + viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h") err := viper.ReadInConfig() if err != nil { @@ -92,8 +94,9 @@ func New( MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), - MaxHistory: viper.GetInt("MAX_HISTORY"), + MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), + QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"), MOTD: viper.GetString("MOTD"), ServerName: viper.GetString("SERVER_NAME"), FederationKey: viper.GetString("FEDERATION_KEY"), diff --git a/internal/db/queries.go b/internal/db/queries.go index 15a65c6..50686d3 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1109,3 +1109,45 @@ func (database *Database) GetSessionCreatedAt( return createdAt, nil } + +// PruneOldQueueEntries deletes client output queue entries +// older than cutoff and returns the number of rows removed. +func (database *Database) PruneOldQueueEntries( + ctx context.Context, + cutoff time.Time, +) (int64, error) { + res, err := database.conn.ExecContext(ctx, + "DELETE FROM client_queues WHERE created_at < ?", + cutoff, + ) + if err != nil { + return 0, fmt.Errorf( + "prune old client output queue entries: %w", err, + ) + } + + deleted, _ := res.RowsAffected() + + return deleted, nil +} + +// PruneOldMessages deletes messages older than cutoff and +// returns the number of rows removed. +func (database *Database) PruneOldMessages( + ctx context.Context, + cutoff time.Time, +) (int64, error) { + res, err := database.conn.ExecContext(ctx, + "DELETE FROM messages WHERE created_at < ?", + cutoff, + ) + if err != nil { + return 0, fmt.Errorf( + "prune old messages: %w", err, + ) + } + + deleted, _ := res.RowsAffected() + + return deleted, nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 72ef994..6d9b7cd 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -31,7 +31,7 @@ type Params struct { Healthcheck *healthcheck.Healthcheck } -const defaultIdleTimeout = 24 * time.Hour +const defaultIdleTimeout = 30 * 24 * time.Hour // Handlers manages HTTP request handling. type Handlers struct { @@ -200,4 +200,77 @@ func (hdlr *Handlers) runCleanup( "deleted", deleted, ) } + + hdlr.pruneQueuesAndMessages(ctx) +} + +// parseDurationConfig parses a Go duration string, +// returning zero on empty input and logging on error. +func (hdlr *Handlers) parseDurationConfig( + name, raw string, +) time.Duration { + if raw == "" { + return 0 + } + + dur, err := time.ParseDuration(raw) + if err != nil { + hdlr.log.Error( + "invalid duration config, skipping", + "name", name, "value", raw, "error", err, + ) + + return 0 + } + + return dur +} + +// pruneQueuesAndMessages removes old client output queue +// entries per QUEUE_MAX_AGE and old messages per +// MESSAGE_MAX_AGE. +func (hdlr *Handlers) pruneQueuesAndMessages( + ctx context.Context, +) { + queueMaxAge := hdlr.parseDurationConfig( + "QUEUE_MAX_AGE", + hdlr.params.Config.QueueMaxAge, + ) + if queueMaxAge > 0 { + queueCutoff := time.Now().Add(-queueMaxAge) + + pruned, err := hdlr.params.Database. + PruneOldQueueEntries(ctx, queueCutoff) + if err != nil { + hdlr.log.Error( + "client output queue pruning failed", "error", err, + ) + } else if pruned > 0 { + hdlr.log.Info( + "pruned old client output queue entries", + "deleted", pruned, + ) + } + } + + messageMaxAge := hdlr.parseDurationConfig( + "MESSAGE_MAX_AGE", + hdlr.params.Config.MessageMaxAge, + ) + if messageMaxAge > 0 { + msgCutoff := time.Now().Add(-messageMaxAge) + + pruned, err := hdlr.params.Database. + PruneOldMessages(ctx, msgCutoff) + if err != nil { + hdlr.log.Error( + "message pruning failed", "error", err, + ) + } else if pruned > 0 { + hdlr.log.Info( + "pruned old messages", + "deleted", pruned, + ) + } + } }