Compare commits
3 Commits
e7dca3a021
...
2774be06d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2774be06d4 | |||
| a619059f10 | |||
| fd6429a9a5 |
@@ -23,21 +23,22 @@ 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
|
||||
SessionTimeout 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.
|
||||
@@ -66,6 +67,7 @@ func New(
|
||||
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 +79,22 @@ 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"),
|
||||
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"),
|
||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
if cfg.Debug {
|
||||
|
||||
@@ -779,6 +779,96 @@ 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// belongs to.
|
||||
func (database *Database) GetSessionChannels(
|
||||
|
||||
@@ -1361,20 +1361,71 @@ 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.
|
||||
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,
|
||||
_, clientID, _, ok :=
|
||||
hdlr.requireAuth(writer, request)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,78 @@ 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
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,48 +59,55 @@ func (srv *Server) SetupRoutes() {
|
||||
}
|
||||
|
||||
// API v1.
|
||||
srv.router.Route(
|
||||
"/api/v1",
|
||||
func(router chi.Router) {
|
||||
router.Get(
|
||||
"/server",
|
||||
srv.handlers.HandleServerInfo(),
|
||||
)
|
||||
router.Post(
|
||||
"/session",
|
||||
srv.handlers.HandleCreateSession(),
|
||||
)
|
||||
router.Get(
|
||||
"/state",
|
||||
srv.handlers.HandleState(),
|
||||
)
|
||||
router.Get(
|
||||
"/messages",
|
||||
srv.handlers.HandleGetMessages(),
|
||||
)
|
||||
router.Post(
|
||||
"/messages",
|
||||
srv.handlers.HandleSendCommand(),
|
||||
)
|
||||
router.Get(
|
||||
"/history",
|
||||
srv.handlers.HandleGetHistory(),
|
||||
)
|
||||
router.Get(
|
||||
"/channels",
|
||||
srv.handlers.HandleListAllChannels(),
|
||||
)
|
||||
router.Get(
|
||||
"/channels/{channel}/members",
|
||||
srv.handlers.HandleChannelMembers(),
|
||||
)
|
||||
},
|
||||
)
|
||||
srv.router.Route("/api/v1", srv.setupAPIv1)
|
||||
|
||||
// Serve embedded SPA.
|
||||
srv.setupSPA()
|
||||
}
|
||||
|
||||
func (srv *Server) setupAPIv1(router chi.Router) {
|
||||
router.Get(
|
||||
"/server",
|
||||
srv.handlers.HandleServerInfo(),
|
||||
)
|
||||
router.Post(
|
||||
"/session",
|
||||
srv.handlers.HandleCreateSession(),
|
||||
)
|
||||
router.Get(
|
||||
"/state",
|
||||
srv.handlers.HandleState(),
|
||||
)
|
||||
router.Post(
|
||||
"/logout",
|
||||
srv.handlers.HandleLogout(),
|
||||
)
|
||||
router.Get(
|
||||
"/users/me",
|
||||
srv.handlers.HandleUsersMe(),
|
||||
)
|
||||
router.Get(
|
||||
"/messages",
|
||||
srv.handlers.HandleGetMessages(),
|
||||
)
|
||||
router.Post(
|
||||
"/messages",
|
||||
srv.handlers.HandleSendCommand(),
|
||||
)
|
||||
router.Get(
|
||||
"/history",
|
||||
srv.handlers.HandleGetHistory(),
|
||||
)
|
||||
router.Get(
|
||||
"/channels",
|
||||
srv.handlers.HandleListAllChannels(),
|
||||
)
|
||||
router.Get(
|
||||
"/channels/{channel}/members",
|
||||
srv.handlers.HandleChannelMembers(),
|
||||
)
|
||||
}
|
||||
|
||||
func (srv *Server) setupSPA() {
|
||||
distFS, err := fs.Sub(web.Dist, "dist")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user