3 Commits

Author SHA1 Message Date
e7dca3a021 feat: add session idle timeout cleanup goroutine
Some checks failed
check / check (push) Has been cancelled
- 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 11:00:06 -08:00
7af73b63c4 feat: add SESSION_IDLE_TIMEOUT config
- New env var SESSION_IDLE_TIMEOUT (default 24h)
- Parsed as time.Duration in handlers
2026-02-28 11:00:06 -08:00
01defdc4f2 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 11:00:06 -08:00
5 changed files with 281 additions and 46 deletions

View File

@@ -36,6 +36,7 @@ type Config struct {
MOTD string MOTD string
ServerName string ServerName string
FederationKey string FederationKey string
SessionIdleTimeout string
params *Params params *Params
log *slog.Logger log *slog.Logger
} }
@@ -66,6 +67,7 @@ func New(
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 {
@@ -90,6 +92,7 @@ func New(
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: &params, params: &params,
} }

View File

@@ -779,6 +779,96 @@ 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
}
// GetSessionCount returns the number of active sessions.
func (database *Database) GetSessionCount(
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 session 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
}
// DeleteStaleSessions removes clients not seen since the
// cutoff and cleans up orphaned sessions.
func (database *Database) DeleteStaleSessions(
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
}
// GetSessionChannels returns channels a session // GetSessionChannels returns channels a session
// belongs to. // belongs to.
func (database *Database) GetSessionChannels( func (database *Database) GetSessionChannels(

View File

@@ -1361,20 +1361,71 @@ 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 { func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
type infoResponse struct {
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{ _, clientID, _, ok :=
Name: hdlr.params.Config.ServerName, hdlr.requireAuth(writer, request)
MOTD: hdlr.params.Config.MOTD, if !ok {
return
}
err := hdlr.params.Database.DeleteClient(
request.Context(), clientID,
)
if err != nil {
hdlr.log.Error(
"delete client failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
}
// 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.GetSessionCount(
request.Context(),
)
if err != nil {
hdlr.log.Error(
"get session 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)
} }
} }

View File

@@ -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: &params, params: &params,
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,78 @@ 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
}
func (hdlr *Handlers) startCleanup(ctx context.Context) {
cleanupCtx, cancel := context.WithCancel(ctx)
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)
deleted, err := hdlr.params.Database.DeleteStaleSessions(
ctx, cutoff,
)
if err != nil {
hdlr.log.Error(
"session cleanup failed", "error", err,
)
return
}
if deleted > 0 {
hdlr.log.Info(
"cleaned up stale clients",
"deleted", deleted,
)
}
}

View File

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