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 is contained in:
commit
cd909d59c4
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 .
|
||||
```
|
||||
|
||||
### 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`
|
||||
|
||||
@ -23,21 +23,21 @@ type Params struct {
|
||||
|
||||
// Config holds all application configuration values.
|
||||
type Config struct {
|
||||
DBURL string
|
||||
Debug bool
|
||||
MaintenanceMode bool
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
Port int
|
||||
SentryDSN string
|
||||
MaxHistory int
|
||||
SessionTimeout int
|
||||
MaxMessageSize int
|
||||
MOTD string
|
||||
ServerName string
|
||||
FederationKey string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
DBURL string
|
||||
Debug bool
|
||||
MaintenanceMode bool
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
Port int
|
||||
SentryDSN string
|
||||
MaxHistory int
|
||||
MaxMessageSize int
|
||||
MOTD string
|
||||
ServerName string
|
||||
FederationKey string
|
||||
SessionIdleTimeout string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Config by reading from files and environment variables.
|
||||
@ -61,11 +61,11 @@ 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", "")
|
||||
viper.SetDefault("FEDERATION_KEY", "")
|
||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
@ -77,21 +77,21 @@ func New(
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
DBURL: viper.GetString("DBURL"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
Port: viper.GetInt("PORT"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
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"),
|
||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
DBURL: viper.GetString("DBURL"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
Port: viper.GetInt("PORT"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||
MOTD: viper.GetString("MOTD"),
|
||||
ServerName: viper.GetString("SERVER_NAME"),
|
||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
if cfg.Debug {
|
||||
|
||||
@ -779,6 +779,150 @@ func (database *Database) DeleteSession(
|
||||
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
|
||||
// belongs to.
|
||||
func (database *Database) GetSessionChannels(
|
||||
|
||||
@ -1361,20 +1361,139 @@ func (hdlr *Handlers) canAccessChannelHistory(
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleServerInfo returns server metadata.
|
||||
func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
||||
type infoResponse struct {
|
||||
Name string `json:"name"`
|
||||
MOTD string `json:"motd"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
) {
|
||||
hdlr.respondJSON(writer, request, &infoResponse{
|
||||
Name: hdlr.params.Config.ServerName,
|
||||
MOTD: hdlr.params.Config.MOTD,
|
||||
sessionID, clientID, nick, ok :=
|
||||
hdlr.requireAuth(writer, request)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/broker"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
@ -30,12 +31,15 @@ type Params struct {
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
}
|
||||
|
||||
const defaultIdleTimeout = 24 * time.Hour
|
||||
|
||||
// Handlers manages HTTP request handling.
|
||||
type Handlers struct {
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
@ -43,7 +47,7 @@ func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Handlers, error) {
|
||||
hdlr := &Handlers{
|
||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
@ -51,10 +55,14 @@ func New(
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
OnStart: func(ctx context.Context) error {
|
||||
hdlr.startCleanup(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
hdlr.stopCleanup()
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
@ -96,3 +104,100 @@ func (hdlr *Handlers) respondError(
|
||||
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",
|
||||
srv.handlers.HandleState(),
|
||||
)
|
||||
router.Post(
|
||||
"/logout",
|
||||
srv.handlers.HandleLogout(),
|
||||
)
|
||||
router.Get(
|
||||
"/users/me",
|
||||
srv.handlers.HandleUsersMe(),
|
||||
)
|
||||
router.Get(
|
||||
"/messages",
|
||||
srv.handlers.HandleGetMessages(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user