Compare commits
7 Commits
feat/add-c
...
6f3c0b01b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f3c0b01b0 | ||
|
|
8d7a991587 | ||
|
|
291be0c701 | ||
|
|
d57d5babf0 | ||
| b1fd2f1b96 | |||
| c07f94a432 | |||
| a98e0ca349 |
30
README.md
30
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.
|
||||
- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 30
|
||||
days. Entries older than this are pruned.
|
||||
|
||||
### Long-Polling
|
||||
|
||||
@@ -1624,6 +1624,10 @@ authenticity.
|
||||
termination.
|
||||
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
||||
Restrict this in production via reverse proxy configuration if needed.
|
||||
- **Content-Security-Policy**: The server sets a strict CSP header on all
|
||||
responses, restricting resource loading to same-origin and disabling
|
||||
dangerous features (object embeds, framing, base tag injection). The
|
||||
embedded SPA works without `'unsafe-inline'` for scripts or styles.
|
||||
|
||||
---
|
||||
|
||||
@@ -1784,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).
|
||||
- **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.
|
||||
|
||||
---
|
||||
@@ -1808,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 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` |
|
||||
@@ -1829,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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2224,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] **Queue pruning** — delete old 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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
20
internal/db/errors.go
Normal file
20
internal/db/errors.go
Normal 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
|
||||
}
|
||||
@@ -1096,3 +1096,45 @@ func (database *Database) GetSessionCreatedAt(
|
||||
|
||||
return createdAt, nil
|
||||
}
|
||||
|
||||
// PruneOldQueueEntries deletes client_queues rows 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 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
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
@@ -199,7 +200,7 @@ func (hdlr *Handlers) handleCreateSessionError(
|
||||
request *http.Request,
|
||||
err error,
|
||||
) {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
if db.IsUniqueConstraintError(err) {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"nick already taken",
|
||||
@@ -1427,7 +1428,7 @@ func (hdlr *Handlers) executeNickChange(
|
||||
request.Context(), sessionID, newNick,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
if db.IsUniqueConstraintError(err) {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNicknameInUse, nick, []string{newNick},
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
)
|
||||
|
||||
const minPasswordLength = 8
|
||||
@@ -94,7 +96,7 @@ func (hdlr *Handlers) handleRegisterError(
|
||||
request *http.Request,
|
||||
err error,
|
||||
) {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
if db.IsUniqueConstraintError(err) {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"nick already taken",
|
||||
|
||||
@@ -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,76 @@ 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_queues entries
|
||||
// per QUEUE_MAX_AGE and prunes 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(
|
||||
"queue pruning failed", "error", err,
|
||||
)
|
||||
} else if pruned > 0 {
|
||||
hdlr.log.Info(
|
||||
"pruned old 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,20 +142,6 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// Auth returns middleware that performs authentication.
|
||||
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
mware.log.Info("AUTH: before request")
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics returns middleware that records HTTP metrics.
|
||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||
@@ -180,3 +166,36 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// cspPolicy is the Content-Security-Policy header value applied to all
|
||||
// responses. The embedded SPA loads scripts and styles from same-origin
|
||||
// files only (no inline scripts or inline style attributes), so a strict
|
||||
// policy works without 'unsafe-inline'.
|
||||
const cspPolicy = "default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self'; " +
|
||||
"connect-src 'self'; " +
|
||||
"img-src 'self'; " +
|
||||
"font-src 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'"
|
||||
|
||||
// CSP returns middleware that sets the Content-Security-Policy header on
|
||||
// every response for defense-in-depth against XSS.
|
||||
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
writer.Header().Set(
|
||||
"Content-Security-Policy",
|
||||
cspPolicy,
|
||||
)
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,6 @@ import (
|
||||
|
||||
const routeTimeout = 60 * time.Second
|
||||
|
||||
// cspHeader is the Content-Security-Policy applied to the embedded web SPA.
|
||||
// The SPA loads external scripts and stylesheets from the same origin only;
|
||||
// all API communication uses same-origin fetch (no WebSockets).
|
||||
const cspHeader = "default-src 'self'; script-src 'self'; style-src 'self'"
|
||||
|
||||
// SetupRoutes configures the HTTP routes and middleware.
|
||||
func (srv *Server) SetupRoutes() {
|
||||
srv.router = chi.NewRouter()
|
||||
@@ -34,6 +29,7 @@ func (srv *Server) SetupRoutes() {
|
||||
}
|
||||
|
||||
srv.router.Use(srv.mw.CORS())
|
||||
srv.router.Use(srv.mw.CSP())
|
||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||
|
||||
if srv.sentryEnabled {
|
||||
@@ -138,11 +134,6 @@ func (srv *Server) setupSPA() {
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
writer.Header().Set(
|
||||
"Content-Security-Policy",
|
||||
cspHeader,
|
||||
)
|
||||
|
||||
readFS, ok := distFS.(fs.ReadFileFS)
|
||||
if !ok {
|
||||
fileServer.ServeHTTP(writer, request)
|
||||
|
||||
Reference in New Issue
Block a user