5 Commits

Author SHA1 Message Date
clawbot
6f3c0b01b0 refactor: use Go duration strings for QUEUE_MAX_AGE and MESSAGE_MAX_AGE
All checks were successful
check / check (push) Successful in 5s
Change config from integer seconds to Go duration strings (e.g. '720h')
parsed via time.ParseDuration, consistent with SESSION_IDLE_TIMEOUT.
Default remains 30 days (720h).
2026-03-10 04:05:22 -07:00
clawbot
8d7a991587 refactor: 30-day defaults for all expiry settings
- QUEUE_MAX_AGE: 48h -> 30 days (per-client queue entry expiry)
- MESSAGE_MAX_AGE: replaces count-based MAX_HISTORY with time-based
  30-day message expiry
- SESSION_IDLE_TIMEOUT: 24h -> 30 days

All expiry is now time-based (30 days) as requested.

Closes #40
2026-03-10 04:05:22 -07:00
clawbot
291be0c701 fix: remove PruneOrphanedMessages to preserve history within MAX_HISTORY
PruneOrphanedMessages deleted messages that lost their client_queues
references after PruneOldQueueEntries ran, even when those messages
were within the MAX_HISTORY limit. This made MAX_HISTORY meaningless
for low-traffic channels.

RotateChannelMessages already caps messages per target. Queue pruning
handles client_queues growth. Orphan cleanup is redundant.

Closes #40
2026-03-10 04:05:22 -07:00
user
d57d5babf0 feat: implement queue pruning and message rotation
Enforce QUEUE_MAX_AGE and MAX_HISTORY config values that previously
existed but were not applied. The existing cleanup loop now also:

- Prunes client_queues entries older than QUEUE_MAX_AGE (default 48h)
- Rotates messages per target (channel/DM) beyond MAX_HISTORY (default 10000)
- Removes orphaned messages no longer referenced by any client queue

closes #40
2026-03-10 04:05:22 -07:00
b1fd2f1b96 Replace string-matching error detection with typed SQLite errors (closes #39) (#66)
All checks were successful
check / check (push) Successful in 4s
## Summary

Replaces fragile `strings.Contains(err.Error(), "UNIQUE")` checks with typed error detection using `errors.As` and the SQLite driver's `*sqlite.Error` type.

## Changes

- **`internal/db/errors.go`** (new): Adds `IsUniqueConstraintError(err)` helper that uses `errors.As` to unwrap the error into `*sqlite.Error` and checks for `SQLITE_CONSTRAINT_UNIQUE` (code 2067).
- **`internal/handlers/api.go`**: Replaces two `strings.Contains(err.Error(), "UNIQUE")` calls with `db.IsUniqueConstraintError(err)` — in `handleCreateSessionError` and `executeNickChange`.
- **`internal/handlers/auth.go`**: Replaces one `strings.Contains(err.Error(), "UNIQUE")` call with `db.IsUniqueConstraintError(err)` — in `handleRegisterError`.

## Why

String matching on error messages is fragile — if the SQLite driver changes its error message format, the detection silently breaks. Using `errors.As` with the driver's typed error and checking the specific SQLite error code is robust, idiomatic Go, and immune to message format changes.

closes #39

<!-- session: agent:sdlc-manager:subagent:3fb0b8e2-d635-4848-a5bd-131c5033cdb1 -->

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #66
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:54:27 +01:00
6 changed files with 66 additions and 19 deletions

View File

@@ -1812,9 +1812,9 @@ directory is also loaded automatically via
| `PORT` | int | `8080` | HTTP listen port | | `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`. | | `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) | | `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
| `MESSAGE_MAX_AGE` | int | `2592000` | Maximum age of messages in seconds (30 days). Messages older than this are pruned. | | `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. | | `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` | int | `2592000` | Maximum age of client queue entries in seconds (30 days). Entries older than this are pruned. | | `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client 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) | | `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) | | `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` | | `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` |

View File

@@ -38,9 +38,9 @@ type Config struct {
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
MessageMaxAge int MessageMaxAge string
MaxMessageSize int MaxMessageSize int
QueueMaxAge int QueueMaxAge string
MOTD string MOTD string
ServerName string ServerName string
FederationKey string FederationKey string
@@ -69,9 +69,9 @@ func New(
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MESSAGE_MAX_AGE", "2592000") viper.SetDefault("MESSAGE_MAX_AGE", "720h")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("QUEUE_MAX_AGE", "2592000") viper.SetDefault("QUEUE_MAX_AGE", "720h")
viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("FEDERATION_KEY", "")
@@ -94,9 +94,9 @@ func New(
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetInt("MESSAGE_MAX_AGE"), MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetInt("QUEUE_MAX_AGE"), QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),

20
internal/db/errors.go Normal file
View File

@@ -0,0 +1,20 @@
// Package db provides database access and migration management.
package db
import (
"errors"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// IsUniqueConstraintError reports whether err is a SQLite
// unique-constraint violation.
func IsUniqueConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/irc" "git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/go-chi/chi" "github.com/go-chi/chi"
) )
@@ -199,7 +200,7 @@ func (hdlr *Handlers) handleCreateSessionError(
request *http.Request, request *http.Request,
err error, err error,
) { ) {
if strings.Contains(err.Error(), "UNIQUE") { if db.IsUniqueConstraintError(err) {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"nick already taken", "nick already taken",
@@ -1427,7 +1428,7 @@ func (hdlr *Handlers) executeNickChange(
request.Context(), sessionID, newNick, request.Context(), sessionID, newNick,
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if db.IsUniqueConstraintError(err) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrNicknameInUse, nick, []string{newNick}, irc.ErrNicknameInUse, nick, []string{newNick},

View File

@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/internal/db"
) )
const minPasswordLength = 8 const minPasswordLength = 8
@@ -94,7 +96,7 @@ func (hdlr *Handlers) handleRegisterError(
request *http.Request, request *http.Request,
err error, err error,
) { ) {
if strings.Contains(err.Error(), "UNIQUE") { if db.IsUniqueConstraintError(err) {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"nick already taken", "nick already taken",

View File

@@ -204,16 +204,39 @@ func (hdlr *Handlers) runCleanup(
hdlr.pruneQueuesAndMessages(ctx) 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_queues entries // pruneQueuesAndMessages removes old client_queues entries
// per QUEUE_MAX_AGE and prunes messages per MESSAGE_MAX_AGE. // per QUEUE_MAX_AGE and prunes messages per MESSAGE_MAX_AGE.
func (hdlr *Handlers) pruneQueuesAndMessages( func (hdlr *Handlers) pruneQueuesAndMessages(
ctx context.Context, ctx context.Context,
) { ) {
queueMaxAge := hdlr.params.Config.QueueMaxAge queueMaxAge := hdlr.parseDurationConfig(
"QUEUE_MAX_AGE",
hdlr.params.Config.QueueMaxAge,
)
if queueMaxAge > 0 { if queueMaxAge > 0 {
queueCutoff := time.Now().Add( queueCutoff := time.Now().Add(-queueMaxAge)
-time.Duration(queueMaxAge) * time.Second,
)
pruned, err := hdlr.params.Database. pruned, err := hdlr.params.Database.
PruneOldQueueEntries(ctx, queueCutoff) PruneOldQueueEntries(ctx, queueCutoff)
@@ -229,11 +252,12 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
} }
} }
messageMaxAge := hdlr.params.Config.MessageMaxAge messageMaxAge := hdlr.parseDurationConfig(
"MESSAGE_MAX_AGE",
hdlr.params.Config.MessageMaxAge,
)
if messageMaxAge > 0 { if messageMaxAge > 0 {
msgCutoff := time.Now().Add( msgCutoff := time.Now().Add(-messageMaxAge)
-time.Duration(messageMaxAge) * time.Second,
)
pruned, err := hdlr.params.Database. pruned, err := hdlr.params.Database.
PruneOldMessages(ctx, msgCutoff) PruneOldMessages(ctx, msgCutoff)