From 284e4e70807ca9af1f367353a25980566ba9be50 Mon Sep 17 00:00:00 2001 From: Mikhail Romanko Date: Wed, 21 Oct 2026 20:13:27 +0300 Subject: [PATCH] Add cleanup, minor fixes --- .env.example | 5 +++ backend/internal/rest/config.go | 27 +++++++++++++---- backend/internal/rest/handler_jitsi.go | 26 ++++++++++++++-- backend/internal/rest/server.go | 42 ++++++++++++++++++++++++++ backend/internal/user/service.go | 6 ++++ frontend/src/pages/AuthPage.jsx | 2 +- frontend/src/pages/RoomsPage.jsx | 14 +++++---- 7 files changed, 107 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 0aaf3d4..d3f8cb4 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,11 @@ COOKIE_SECURE=true SESSION_SHORT_DURATION=24h SESSION_LONG_DURATION=720h +# How often the backend purges expired sessions, spent/expired invites and +# never-activated users (Go duration string). Set to 0 (or a negative value) to +# disable the background cleanup. +CLEANUP_INTERVAL=1h + # --- jitsi ----------------------------------------------------------------- # Shared secret and app id must match the Jitsi (JaaS) deployment that signs # and verifies room tokens. JITSI_SECRET has no default and must be set for diff --git a/backend/internal/rest/config.go b/backend/internal/rest/config.go index 8debc77..7b8dcb4 100644 --- a/backend/internal/rest/config.go +++ b/backend/internal/rest/config.go @@ -24,6 +24,11 @@ const ( DefaultShortSession = 24 * time.Hour DefaultLongSession = 30 * 24 * time.Hour DefaultTokenSize = 32 + + // DefaultCleanupInterval is how often expired sessions, spent/expired + // invites and never-activated users are purged in the background. A + // non-positive value disables the background cleanup. + DefaultCleanupInterval = 1 * time.Hour ) type Config struct { @@ -33,10 +38,14 @@ type Config struct { // to build absolute links (e.g. the avatar URL embedded in Jitsi JWTs). // Leave empty to fall back to the request's Host. PublicBaseURL string - Jitsi JitsiConfig - Cors CorsConfig - Sessions SessionConfig - Cookies CookieConfig + // CleanupInterval controls the background maintenance loop that purges + // expired sessions, spent/expired invites and never-activated users. + // Non-positive disables it. + CleanupInterval time.Duration + Jitsi JitsiConfig + Cors CorsConfig + Sessions SessionConfig + Cookies CookieConfig } type JitsiConfig struct { @@ -66,8 +75,9 @@ type CookieConfig struct { func DefaultConfig() Config { return Config{ - Addr: DefaultAddr, - DatabasePath: DefaultDatabasePath, + Addr: DefaultAddr, + DatabasePath: DefaultDatabasePath, + CleanupInterval: DefaultCleanupInterval, Jitsi: JitsiConfig{ Domain: "meet.jit.si", }, @@ -133,6 +143,11 @@ func ConfigFromEnvironment() Config { cfg.Sessions.LongDuration = parsed } } + if v := os.Getenv("CLEANUP_INTERVAL"); v != "" { + if parsed, err := time.ParseDuration(v); err == nil { + cfg.CleanupInterval = parsed + } + } cfg.Jitsi.Secret = os.Getenv("JITSI_SECRET") cfg.Jitsi.AppId = os.Getenv("JITSI_APP_ID") diff --git a/backend/internal/rest/handler_jitsi.go b/backend/internal/rest/handler_jitsi.go index 96bdd2a..bf9142c 100644 --- a/backend/internal/rest/handler_jitsi.go +++ b/backend/internal/rest/handler_jitsi.go @@ -6,6 +6,28 @@ import ( "time" ) +// sanitizeRoomName trims a room name and collapses any character that is not +// allowed in a room name into a single dash, stripping leading/trailing dashes. +// Case is preserved because Jitsi room names are case-sensitive. +func sanitizeRoomName(value string) string { + var builder strings.Builder + builder.Grow(len(value)) + previousDash := false + for _, r := range strings.TrimSpace(value) { + allowed := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' + if allowed { + builder.WriteRune(r) + previousDash = false + continue + } + if !previousDash { + builder.WriteByte('-') + previousDash = true + } + } + return strings.Trim(builder.String(), "-") +} + type joinRoomRequest struct { Room string `json:"room"` } @@ -54,7 +76,7 @@ func (h *Handler) joinRoom(w http.ResponseWriter, r *http.Request) { return } - room := strings.TrimSpace(req.Room) + room := sanitizeRoomName(req.Room) if room == "" { writeError(w, http.StatusBadRequest, "room is required") return @@ -93,7 +115,7 @@ func (h *Handler) joinRoom(w http.ResponseWriter, r *http.Request) { func (h *Handler) deleteRecentRoom(w http.ResponseWriter, r *http.Request) { u := userFromRequest(r) - room := strings.TrimSpace(r.PathValue("room")) + room := sanitizeRoomName(r.PathValue("room")) if room == "" { writeError(w, http.StatusBadRequest, "room is required") return diff --git a/backend/internal/rest/server.go b/backend/internal/rest/server.go index 7bad45f..bd02d1c 100644 --- a/backend/internal/rest/server.go +++ b/backend/internal/rest/server.go @@ -149,6 +149,46 @@ func (h *Handler) Close() error { return h.db.Close() } +// Cleanup purges expired sessions, spent/expired invites and never-activated +// users. Each target is cleaned independently so a single failure does not +// prevent the others from running. +func (h *Handler) Cleanup(ctx context.Context) { + if err := h.sessions.CleanupSessions(ctx); err != nil { + slog.Error("cleanup sessions", slog.String("op", "rest.Cleanup"), slog.Any("error", err)) + } + if err := h.invites.CleanupInvites(ctx); err != nil { + slog.Error("cleanup invites", slog.String("op", "rest.Cleanup"), slog.Any("error", err)) + } + if err := h.users.CleanupUsers(ctx); err != nil { + slog.Error("cleanup users", slog.String("op", "rest.Cleanup"), slog.Any("error", err)) + } +} + +// runCleanupLoop runs Cleanup immediately and then on every tick until ctx is +// cancelled. It is a no-op when the configured interval is non-positive. +func (h *Handler) runCleanupLoop(ctx context.Context) { + interval := h.cfg.CleanupInterval + if interval <= 0 { + slog.Info("background cleanup disabled", slog.String("op", "rest.runCleanupLoop")) + return + } + + slog.Info("background cleanup started", slog.String("op", "rest.runCleanupLoop"), slog.Duration("interval", interval)) + h.Cleanup(ctx) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + h.Cleanup(ctx) + } + } +} + func (h *Handler) Router() http.Handler { mux := http.NewServeMux() h.registerRoutes(mux) @@ -156,6 +196,8 @@ func (h *Handler) Router() http.Handler { } func (h *Handler) Serve(ctx context.Context) error { + go h.runCleanupLoop(ctx) + server := &http.Server{ Addr: h.cfg.Addr, Handler: h.Router(), diff --git a/backend/internal/user/service.go b/backend/internal/user/service.go index 38cbee0..d1bbc5f 100644 --- a/backend/internal/user/service.go +++ b/backend/internal/user/service.go @@ -189,3 +189,9 @@ func (s *Service) ActivateUser(ctx context.Context, user *User) (*User, error) { func (s *Service) DeleteUser(ctx context.Context, user *User) error { return s.r.DeleteUser(ctx, user.Id) } + +// CleanupUsers deletes users that were created but never activated, e.g. when +// signup failed after the account row was inserted. +func (s *Service) CleanupUsers(ctx context.Context) error { + return s.r.CleanupUsers(ctx) +} diff --git a/frontend/src/pages/AuthPage.jsx b/frontend/src/pages/AuthPage.jsx index 6e17ec6..ab84887 100644 --- a/frontend/src/pages/AuthPage.jsx +++ b/frontend/src/pages/AuthPage.jsx @@ -33,7 +33,7 @@ export default function AuthPage({ onAuthenticated }) { return (
-

goChat

+

uPortal