Compare commits
3 Commits
main
...
e7dca3a021
| Author | SHA1 | Date | |
|---|---|---|---|
| e7dca3a021 | |||
| 7af73b63c4 | |||
| 01defdc4f2 |
@@ -23,21 +23,22 @@ type Params struct {
|
|||||||
|
|
||||||
// Config holds all application configuration values.
|
// Config holds all application configuration values.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DBURL string
|
DBURL string
|
||||||
Debug bool
|
Debug bool
|
||||||
MaintenanceMode bool
|
MaintenanceMode bool
|
||||||
MetricsPassword string
|
MetricsPassword string
|
||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MaxHistory int
|
MaxHistory int
|
||||||
SessionTimeout int
|
SessionTimeout int
|
||||||
MaxMessageSize int
|
MaxMessageSize int
|
||||||
MOTD string
|
MOTD string
|
||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
params *Params
|
SessionIdleTimeout string
|
||||||
log *slog.Logger
|
params *Params
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Config by reading from files and environment variables.
|
// New creates a new Config by reading from files and environment variables.
|
||||||
@@ -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 {
|
||||||
@@ -77,21 +79,22 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
DBURL: viper.GetString("DBURL"),
|
DBURL: viper.GetString("DBURL"),
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: viper.GetBool("DEBUG"),
|
||||||
Port: viper.GetInt("PORT"),
|
Port: viper.GetInt("PORT"),
|
||||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
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"),
|
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"),
|
||||||
log: log,
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
params: ¶ms,
|
log: log,
|
||||||
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Debug {
|
if cfg.Debug {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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