16 Commits

Author SHA1 Message Date
clawbot
3b42620749 feat: split Dockerfile into dedicated lint stage
All checks were successful
check / check (push) Successful in 6s
Use pre-built golangci/golangci-lint:v2.1.6 image for fast lint feedback
instead of installing golangci-lint from source on every build.

- Lint stage: runs fmt-check and lint using pre-built image
- Build stage: runs tests and compiles binaries
- COPY --from=lint forces BuildKit to execute the lint stage
- All images pinned by sha256 digest
- Runtime stage unchanged
2026-03-02 00:03:04 -08: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
4a0ed57fc0 Merge pull request 'feat: password-based registration and login (closes #1)' (#23) from feature/auth-passwords into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #23
Reviewed-by: Jeffrey Paul <sneak@noreply.example.org>
2026-02-28 19:57:40 +01:00
user
52c85724a7 fix: remove unused //nolint:gosec directives on password fields
All checks were successful
check / check (push) Successful in 2m4s
2026-02-28 10:33:59 -08:00
clawbot
69c9550bb2 consolidate password_hash into 001 migration
Some checks failed
check / check (push) Failing after 1m27s
Pre-1.0, no installed base — merge 002_add_passwords.sql into
001_initial.sql and remove the separate migration file.
2026-02-28 07:59:01 -08:00
7047167dc8 Add tests for register and login endpoints
Some checks failed
check / check (push) Failing after 1m39s
2026-02-27 05:00:51 -08:00
3cd942ffa5 Add /api/v1/register and /api/v1/login routes 2026-02-27 04:55:40 -08:00
b8794c2587 Add register and login HTTP handlers 2026-02-27 04:55:31 -08:00
70aa15e758 Add RegisterUser and LoginUser DB functions with bcrypt 2026-02-27 04:55:06 -08:00
5e26e53187 Add migration 002: add password_hash column to sessions 2026-02-27 04:54:31 -08:00
14 changed files with 1101 additions and 52 deletions

View File

@@ -1,18 +1,28 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6
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 — tests and compilation
# 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 by creating a stage dependency
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

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`

13
go.mod
View File

@@ -4,14 +4,18 @@ go 1.24.0
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/gdamore/tcell/v2 v2.13.8
github.com/getsentry/sentry-go v0.42.0
github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/rivo/tview v0.42.0
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0
golang.org/x/crypto v0.48.0
modernc.org/sqlite v1.45.0
)
@@ -21,9 +25,7 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.13.8 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -33,7 +35,6 @@ require (
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/tview v0.42.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
@@ -47,9 +48,9 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect

30
go.sum
View File

@@ -113,12 +113,14 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -126,8 +128,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -135,30 +137,26 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=

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

161
internal/db/auth.go Normal file
View File

@@ -0,0 +1,161 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const bcryptCost = bcrypt.DefaultCost
var errNoPassword = errors.New(
"account has no password set",
)
// RegisterUser creates a session with a hashed password
// and returns session ID, client ID, and token.
func (database *Database) RegisterUser(
ctx context.Context,
nick, password string,
) (int64, int64, string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
if err != nil {
return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
}
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return 0, 0, "", err
}
now := time.Now()
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, 0, "", fmt.Errorf(
"begin tx: %w", err,
)
}
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, password_hash,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
sessionUUID, nick, string(hash), now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create session: %w", err,
)
}
sessionID, _ := res.LastInsertId()
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create client: %w", err,
)
}
clientID, _ := clientRes.LastInsertId()
err = transaction.Commit()
if err != nil {
return 0, 0, "", fmt.Errorf(
"commit registration: %w", err,
)
}
return sessionID, clientID, token, nil
}
// LoginUser verifies a nick/password and creates a new
// client token.
func (database *Database) LoginUser(
ctx context.Context,
nick, password string,
) (int64, int64, string, error) {
var (
sessionID int64
passwordHash string
)
err := database.conn.QueryRowContext(
ctx,
`SELECT id, password_hash
FROM sessions WHERE nick = ?`,
nick,
).Scan(&sessionID, &passwordHash)
if err != nil {
return 0, 0, "", fmt.Errorf(
"get session for login: %w", err,
)
}
if passwordHash == "" {
return 0, 0, "", fmt.Errorf(
"login: %w", errNoPassword,
)
}
err = bcrypt.CompareHashAndPassword(
[]byte(passwordHash), []byte(password),
)
if err != nil {
return 0, 0, "", fmt.Errorf(
"verify password: %w", err,
)
}
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return 0, 0, "", err
}
now := time.Now()
res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
if err != nil {
return 0, 0, "", fmt.Errorf(
"create login client: %w", err,
)
}
clientID, _ := res.LastInsertId()
_, _ = database.conn.ExecContext(
ctx,
"UPDATE sessions SET last_seen = ? WHERE id = ?",
now, sessionID,
)
return sessionID, clientID, token, nil
}

178
internal/db/auth_test.go Normal file
View File

@@ -0,0 +1,178 @@
package db_test
import (
"testing"
_ "modernc.org/sqlite"
)
func TestRegisterUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, clientID, token, err :=
database.RegisterUser(ctx, "reguser", "password123")
if err != nil {
t.Fatal(err)
}
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
// Verify session works via token lookup.
sid, cid, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if sid != sessionID || cid != clientID {
t.Fatal("session/client id mismatch")
}
if nick != "reguser" {
t.Fatalf("expected reguser, got %s", nick)
}
}
func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "dupnick", "password123")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
}
_ = dupSID
_ = dupCID
_ = dupToken
}
func TestLoginUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
// Verify the new token works.
_, _, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "loginuser" {
t.Fatalf("expected loginuser, got %s", nick)
}
}
func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNoPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
// Create anonymous session (no password).
anonSID, anonCID, anonToken, err :=
database.CreateSession(ctx, "anon")
if err != nil {
t.Fatal(err)
}
_ = anonSID
_ = anonCID
_ = anonToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "anon", "anything1")
if loginErr == nil {
t.Fatal(
"expected error for login on passwordless account",
)
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNonexistent(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "ghost", "password123")
if err == nil {
t.Fatal("expected error for nonexistent user")
}
_ = loginSID
_ = loginCID
_ = loginToken
}

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

@@ -1,11 +1,12 @@
-- Chat server schema (pre-1.0 consolidated)
PRAGMA foreign_keys = ON;
-- Sessions: IRC-style sessions (no passwords, nick + optional signing key)
-- Sessions: each session is a user identity (nick + optional password + signing key)
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
nick TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP

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

@@ -1469,6 +1469,310 @@ func TestHealthcheck(t *testing.T) {
}
}
func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "reguser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf(
"expected 201, got %d: %s",
resp.StatusCode, respBody,
)
}
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
if result["nick"] != "reguser" {
t.Fatalf(
"expected reguser, got %v", result["nick"],
)
}
}
func TestRegisterDuplicate(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "dupuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp2.StatusCode)
}
}
func postJSONExpectStatus(
t *testing.T,
tserver *testServer,
path string,
payload map[string]string,
expectedStatus int,
) {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(path),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != expectedStatus {
t.Fatalf(
"expected %d, got %d",
expectedStatus, resp.StatusCode,
)
}
}
func TestRegisterShortPassword(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "shortpw", "password": "short",
},
http.StatusBadRequest,
)
}
func TestRegisterInvalidNick(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "bad nick!",
"password": "password123",
},
http.StatusBadRequest,
)
}
func TestLoginValid(t *testing.T) {
tserver := newTestServer(t)
// Register first.
regBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
// Login.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body)
t.Fatalf(
"expected 200, got %d: %s",
resp2.StatusCode, respBody,
)
}
var result map[string]any
_ = json.NewDecoder(resp2.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
// Verify token works.
token, ok := result["token"].(string)
if !ok {
t.Fatal("token not a string")
}
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if state["nick"] != "loginuser" {
t.Fatalf(
"expected loginuser, got %v",
state["nick"],
)
}
}
func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t)
regBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "correctpass1",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
loginBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "wrongpass12",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf(
"expected 401, got %d", resp2.StatusCode,
)
}
}
func TestLoginNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
"nick": "ghostuser",
"password": "password123",
},
http.StatusUnauthorized,
)
}
func TestSessionStillWorks(t *testing.T) {
tserver := newTestServer(t)
// Verify anonymous session creation still works.
token := tserver.createSession("anon_user")
if token == "" {
t.Fatal("expected token for anonymous session")
}
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if state["nick"] != "anon_user" {
t.Fatalf(
"expected anon_user, got %v",
state["nick"],
)
}
}
func TestNickBroadcastToChannels(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("nick_a")

186
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,186 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
)
const minPasswordLength = 8
// HandleRegister creates a new user with a password.
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleRegister(writer, request)
}
}
func (hdlr *Handlers) handleRegister(
writer http.ResponseWriter,
request *http.Request,
) {
type registerRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload registerRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
"password must be at least 8 characters",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.handleRegisterError(
writer, request, err,
)
return
}
hdlr.deliverMOTD(request, clientID, sessionID)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
func (hdlr *Handlers) handleRegisterError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"register user failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleLogin(writer, request)
}
}
func (hdlr *Handlers) handleLogin(
writer http.ResponseWriter,
request *http.Request,
) {
type loginRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload loginRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if payload.Nick == "" || payload.Password == "" {
hdlr.respondError(
writer, request,
"nick and password required",
http.StatusBadRequest,
)
return
}
sessionID, _, token, err :=
hdlr.params.Database.LoginUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid credentials",
http.StatusUnauthorized,
)
return
}
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusOK)
}

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,
)
}

View File

@@ -74,6 +74,14 @@ func (srv *Server) setupAPIv1(router chi.Router) {
"/session",
srv.handlers.HandleCreateSession(),
)
router.Post(
"/register",
srv.handlers.HandleRegister(),
)
router.Post(
"/login",
srv.handlers.HandleLogin(),
)
router.Get(
"/state",
srv.handlers.HandleState(),