8 Commits

Author SHA1 Message Date
2761ee156a feat: split Dockerfile into dedicated lint stage for faster CI (#32)
All checks were successful
check / check (push) Successful in 3m4s
## Summary

Split the Dockerfile into a dedicated lint stage using the prebuilt `golangci/golangci-lint:v2.1.6` image, so lint failures are reported faster without needing to download/compile golangci-lint first.

## Changes

- **New lint stage** (`AS lint`): Uses the prebuilt `golangci/golangci-lint` image (pinned by sha256). Runs `make fmt-check` and `make lint`.
- **Build stage** (`AS builder`): Runs `make test` + compilation. No longer installs golangci-lint via `go install`.
- **`COPY --from=lint`**: Forces BuildKit to execute the lint stage before proceeding with the build.
- **Runtime stage**: Unchanged.

All base images remain pinned by sha256 hash.

closes #27

<!-- session: agent:sdlc-manager:subagent:76cebdf6-86f0-4383-93e3-ff3e10fbc7a6 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #32
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:05:08 +01:00
cd909d59c4 Merge pull request 'feat: logout, users/me, user count, session timeout' (#24) from feature/mvp-remaining into main
All checks were successful
check / check (push) Successful in 1m58s
Reviewed-on: #24
2026-03-01 15:47:03 +01:00
clawbot
f5cc098b7b docs: update README for new endpoints, fix config name, remove dead field
All checks were successful
check / check (push) Successful in 1m24s
- 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
2026-03-01 06:41:10 -08:00
user
4d7b7618b2 fix: send QUIT notifications for background idle cleanup
All checks were successful
check / check (push) Successful in 2m2s
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.
2026-03-01 06:33:15 -08:00
user
910a5c2606 fix: OnStart ctx bug, rename session→user, full logout cleanup
All checks were successful
check / check (push) Successful in 1m57s
- 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
2026-02-28 11:14:23 -08:00
bdc243224b feat: add session idle timeout cleanup goroutine
All checks were successful
check / check (push) Successful in 1m58s
- 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
2026-02-28 10:59:09 -08:00
5981c750a4 feat: add SESSION_IDLE_TIMEOUT config
- New env var SESSION_IDLE_TIMEOUT (default 24h)
- Parsed as time.Duration in handlers
2026-02-28 10:59:09 -08:00
6cfab21eaa 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
2026-02-28 10:59:09 -08:00
6 changed files with 242 additions and 29 deletions

View File

@@ -1,24 +1,35 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.24-alpine, 2026-02-26
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
WORKDIR /src
RUN apk add --no-cache git build-base make
# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run all checks — build fails if branch is not green
RUN make check
RUN make test
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
# Runtime stage
# alpine:3.21, 2026-02-26
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates \

View File

@@ -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`

View File

@@ -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"),

View File

@@ -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) {
@@ -869,6 +869,60 @@ func (database *Database) DeleteStaleSessions(
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(

View File

@@ -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,77 @@ 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(
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()
@@ -1406,12 +1474,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,

View File

@@ -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,26 @@ func (hdlr *Handlers) runCleanup(
) {
cutoff := time.Now().Add(-timeout)
deleted, err := hdlr.params.Database.DeleteStaleSessions(
// 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(
"session cleanup failed", "error", err,
"user cleanup failed", "error", err,
)
return
@@ -174,7 +196,7 @@ func (hdlr *Handlers) runCleanup(
if deleted > 0 {
hdlr.log.Info(
"cleaned up stale clients",
"cleaned up stale users",
"deleted", deleted,
)
}