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
All checks were successful
check / check (push) Successful in 1m58s
Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
73
README.md
73
README.md
@@ -1158,6 +1158,55 @@ curl -s http://localhost:8080/api/v1/channels/general/members \
|
|||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
-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
|
### GET /api/v1/server — Server Info
|
||||||
|
|
||||||
Return server metadata. No authentication required.
|
Return server metadata. No authentication required.
|
||||||
@@ -1166,10 +1215,17 @@ Return server metadata. No authentication required.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My Chat Server",
|
"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
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
Standard health check endpoint. No authentication required.
|
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
|
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
|
||||||
planned.
|
planned.
|
||||||
- **Channels**: Deleted when the last member leaves (ephemeral).
|
- **Channels**: Deleted when the last member leaves (ephemeral).
|
||||||
- **Users/sessions**: Deleted on `QUIT`. Session expiry by `SESSION_TIMEOUT`
|
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
|
||||||
is planned.
|
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`. |
|
| `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) |
|
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
|
||||||
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
|
| `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). |
|
| `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) |
|
| `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) |
|
||||||
@@ -1610,7 +1668,7 @@ SERVER_NAME=My Chat Server
|
|||||||
MOTD=Welcome! Be excellent to each other.
|
MOTD=Welcome! Be excellent to each other.
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
DBURL=file:./data.db?_journal_mode=WAL
|
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] Docker deployment
|
||||||
- [x] Prometheus metrics endpoint
|
- [x] Prometheus metrics endpoint
|
||||||
- [x] Health check 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)
|
### Post-MVP (Planned)
|
||||||
|
|
||||||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
- [ ] **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`
|
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
|
||||||
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
||||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ type Config struct {
|
|||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MaxHistory int
|
MaxHistory int
|
||||||
SessionTimeout int
|
|
||||||
MaxMessageSize int
|
MaxMessageSize int
|
||||||
MOTD string
|
MOTD string
|
||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
|
SessionIdleTimeout string
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -61,11 +61,11 @@ func New(
|
|||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("MAX_HISTORY", "10000")
|
viper.SetDefault("MAX_HISTORY", "10000")
|
||||||
viper.SetDefault("SESSION_TIMEOUT", "86400")
|
|
||||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||||
viper.SetDefault("MOTD", "")
|
viper.SetDefault("MOTD", "")
|
||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,11 +85,11 @@ func New(
|
|||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
||||||
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),
|
|
||||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||||
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"),
|
||||||
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -779,6 +779,150 @@ func (database *Database) DeleteSession(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteClient removes a single client record by ID.
|
||||||
|
func (database *Database) DeleteClient(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID int64,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"DELETE FROM clients WHERE id = ?",
|
||||||
|
clientID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserCount returns the number of active users.
|
||||||
|
func (database *Database) GetUserCount(
|
||||||
|
ctx context.Context,
|
||||||
|
) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM sessions",
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"get user count: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientCountForSession returns the number of clients
|
||||||
|
// belonging to a session.
|
||||||
|
func (database *Database) ClientCountForSession(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT COUNT(*) FROM clients
|
||||||
|
WHERE session_id = ?`,
|
||||||
|
sessionID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"client count for session: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
res, err := database.conn.ExecContext(ctx,
|
||||||
|
"DELETE FROM clients WHERE last_seen < ?",
|
||||||
|
cutoff,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"delete stale clients: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, _ := res.RowsAffected()
|
||||||
|
|
||||||
|
_, err = database.conn.ExecContext(ctx,
|
||||||
|
`DELETE FROM sessions WHERE id NOT IN
|
||||||
|
(SELECT DISTINCT session_id FROM clients)`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return deleted, fmt.Errorf(
|
||||||
|
"delete orphan sessions: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// GetSessionChannels returns channels a session
|
||||||
// belongs to.
|
// belongs to.
|
||||||
func (database *Database) GetSessionChannels(
|
func (database *Database) GetSessionChannels(
|
||||||
|
|||||||
@@ -1361,20 +1361,139 @@ func (hdlr *Handlers) canAccessChannelHistory(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleServerInfo returns server metadata.
|
// HandleLogout deletes the authenticated client's token
|
||||||
func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
// and cleans up the user (session) if no clients remain.
|
||||||
type infoResponse struct {
|
func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
|
||||||
Name string `json:"name"`
|
|
||||||
MOTD string `json:"motd"`
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(
|
return func(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
hdlr.respondJSON(writer, request, &infoResponse{
|
sessionID, clientID, nick, ok :=
|
||||||
Name: hdlr.params.Config.ServerName,
|
hdlr.requireAuth(writer, request)
|
||||||
MOTD: hdlr.params.Config.MOTD,
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
err := hdlr.params.Database.DeleteClient(
|
||||||
|
ctx, clientID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"delete client failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleServerInfo returns server metadata.
|
||||||
|
func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
||||||
|
return func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
users, err := hdlr.params.Database.GetUserCount(
|
||||||
|
request.Context(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"get user count failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
|
"name": hdlr.params.Config.ServerName,
|
||||||
|
"motd": hdlr.params.Config.MOTD,
|
||||||
|
"users": users,
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/broker"
|
"git.eeqj.de/sneak/chat/internal/broker"
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
@@ -30,12 +31,15 @@ type Params struct {
|
|||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultIdleTimeout = 24 * time.Hour
|
||||||
|
|
||||||
// Handlers manages HTTP request handling.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@@ -43,7 +47,7 @@ func New(
|
|||||||
lifecycle fx.Lifecycle,
|
lifecycle fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Handlers, error) {
|
) (*Handlers, error) {
|
||||||
hdlr := &Handlers{
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
@@ -51,10 +55,14 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
|
hdlr.startCleanup(ctx)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
|
hdlr.stopCleanup()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -96,3 +104,100 @@ func (hdlr *Handlers) respondError(
|
|||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) idleTimeout() time.Duration {
|
||||||
|
raw := hdlr.params.Config.SessionIdleTimeout
|
||||||
|
if raw == "" {
|
||||||
|
return defaultIdleTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
dur, err := time.ParseDuration(raw)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"invalid SESSION_IDLE_TIMEOUT, using default",
|
||||||
|
"value", raw, "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return defaultIdleTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
return dur
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) stopCleanup() {
|
||||||
|
if hdlr.cancelCleanup != nil {
|
||||||
|
hdlr.cancelCleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
||||||
|
timeout := hdlr.idleTimeout()
|
||||||
|
|
||||||
|
interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
hdlr.runCleanup(ctx, timeout)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) runCleanup(
|
||||||
|
ctx context.Context,
|
||||||
|
timeout time.Duration,
|
||||||
|
) {
|
||||||
|
cutoff := time.Now().Add(-timeout)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
"user cleanup failed", "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleted > 0 {
|
||||||
|
hdlr.log.Info(
|
||||||
|
"cleaned up stale users",
|
||||||
|
"deleted", deleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ func (srv *Server) setupAPIv1(router chi.Router) {
|
|||||||
"/state",
|
"/state",
|
||||||
srv.handlers.HandleState(),
|
srv.handlers.HandleState(),
|
||||||
)
|
)
|
||||||
|
router.Post(
|
||||||
|
"/logout",
|
||||||
|
srv.handlers.HandleLogout(),
|
||||||
|
)
|
||||||
|
router.Get(
|
||||||
|
"/users/me",
|
||||||
|
srv.handlers.HandleUsersMe(),
|
||||||
|
)
|
||||||
router.Get(
|
router.Get(
|
||||||
"/messages",
|
"/messages",
|
||||||
srv.handlers.HandleGetMessages(),
|
srv.handlers.HandleGetMessages(),
|
||||||
|
|||||||
Reference in New Issue
Block a user