package panel import ( "context" "fmt" "net/http" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/leolionad58/ticketbot/internal/config" "github.com/leolionad58/ticketbot/internal/db" panelauth "github.com/leolionad58/ticketbot/internal/panel/auth" "github.com/leolionad58/ticketbot/internal/panel/handlers" ) // Server is the HTTP panel server. type Server struct { http *http.Server } // NewServer builds the panel HTTP server with all routes wired up. func NewServer( cfg config.PanelWebConfig, sqlDB *db.TicketRepo, adminRepo *db.PanelAdminRepo, sessionRepo *db.PanelSessionRepo, auditRepo *db.AuditLogRepo, panelRepo *db.PanelConfigRepo, convocRepo *db.ConvocationConfigRepo, convocPanelRepo *db.ConvocationPanelRepo, guildID string, bot BotService, ) *Server { authSvc := panelauth.NewService(adminRepo, sessionRepo, cfg.SessionTimeoutMinutes) authMw := panelauth.NewMiddleware(authSvc, adminRepo) oauthCfg := panelauth.NewOAuthConfig( cfg.OAuthClientID, cfg.OAuthClientSecret, cfg.BaseURL+"/oauth/callback", ) rnd := handlers.NewRenderer(ParseTemplates) if bot != nil { rnd.SetBotStatus(bot.GetBotStatus) } loginH := &handlers.LoginHandler{ Admins: adminRepo, Auth: authSvc, OAuthCfg: oauthCfg, Renderer: rnd, } authH := &handlers.AuthHandler{ Admins: adminRepo, Auth: authSvc, AuditLog: auditRepo, Issuer: "TicketBot", Renderer: rnd, } var getBotStatus func() handlers.BotStatus if bot != nil { getBotStatus = bot.GetBotStatus } dashH := &handlers.DashboardHandler{ TicketRepo: sqlDB, PanelRepo: panelRepo, GetBotStatus: getBotStatus, Renderer: rnd, } var discordInfo handlers.DiscordInfo if bot != nil { if di, ok := bot.(handlers.DiscordInfo); ok { discordInfo = di } } panelsH := &handlers.PanelsHandler{ PanelRepo: panelRepo, AuditLog: auditRepo, BotService: bot, DiscordInfo: discordInfo, Renderer: rnd, } var convocBot handlers.ConvocBotService var convocInfo handlers.ConvocDiscordInfo if bot != nil { convocBot = bot if di, ok := bot.(handlers.ConvocDiscordInfo); ok { convocInfo = di } } convocH := &handlers.ConvocationsHandler{ TicketRepo: sqlDB, ConvocRepo: convocRepo, ConvocPanelRepo: convocPanelRepo, GuildID: guildID, BotService: convocBot, DiscordInfo: convocInfo, Renderer: rnd, } transcriptsH := &handlers.TranscriptsHandler{ TicketRepo: sqlDB, Renderer: rnd, DiscordInfo: discordInfo, } auditH := &handlers.AuditHandler{ AuditRepo: auditRepo, Renderer: rnd, } sessH := &handlers.SessionsHandler{ SessionRepo: sessionRepo, AdminRepo: adminRepo, AuditLog: auditRepo, Renderer: rnd, } r := chi.NewRouter() r.Use(chimiddleware.Recoverer) r.Use(securityHeaders) // Static files r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(StaticFS()))) // Transcript view — served without CSP headers (must be outside securityHeaders group) r.Get("/transcripts/view/{id}", transcriptsH.HandleView) // Public auth routes r.Get("/login", loginH.HandleLogin) r.Get("/oauth/callback", loginH.HandleCallback) r.Get("/auth/password-setup", authH.HandlePasswordSetupGET) r.Post("/auth/password-setup", authH.HandlePasswordSetupPOST) r.Get("/auth/totp-setup", authH.HandleTOTPSetupGET) r.Post("/auth/totp-setup", authH.HandleTOTPSetupPOST) r.Get("/auth/verify", authH.HandleVerifyGET) r.Post("/auth/verify", authH.HandleVerifyPOST) r.Post("/auth/logout", authH.HandleLogout) // Protected routes r.Group(func(r chi.Router) { r.Use(authMw.RequireAuth) r.Get("/", dashH.Handle) // Panels CRUD r.Get("/panels", panelsH.HandleList) r.Get("/panels/new", panelsH.HandleNew) r.Post("/panels/new", panelsH.HandleCreate) r.Post("/panels/preview", panelsH.HandlePreview) r.Get("/panels/{id}/edit", panelsH.HandleEdit) r.Post("/panels/{id}/edit", panelsH.HandleUpdate) r.Post("/panels/{id}/delete", panelsH.HandleDelete) r.Post("/panels/{id}/duplicate", panelsH.HandleDuplicate) r.Post("/panels/{id}/types/reorder", panelsH.HandleReorderTypes) // Convocations r.Get("/convocations", convocH.HandleGET) r.Post("/convocations", convocH.HandlePOST) r.Post("/convocations/create", convocH.HandleCreate) r.Post("/convocations/panels/new", convocH.HandleCreatePanel) r.Post("/convocations/panels/{id}/send", convocH.HandleSendPanel) r.Post("/convocations/panels/{id}/delete", convocH.HandleDeletePanel) // Transcripts (list + download only; view is outside securityHeaders) r.Get("/transcripts", transcriptsH.HandleList) r.Get("/transcripts/download/{id}", transcriptsH.HandleDownload) // Audit log r.Get("/audit", auditH.HandleList) // Sessions (superadmin only) r.Group(func(r chi.Router) { r.Use(authMw.RequireSuperadmin) r.Get("/sessions", sessH.HandleList) r.Post("/sessions/{id}/revoke", sessH.HandleRevoke) r.Post("/sessions/revoke-all", sessH.HandleRevokeAll) }) }) addr := fmt.Sprintf(":%d", cfg.Port) return &Server{ http: &http.Server{ Addr: addr, Handler: r, }, } } // Start runs the HTTP server until ctx is cancelled. func (s *Server) Start(ctx context.Context) error { errCh := make(chan error, 1) go func() { errCh <- s.http.ListenAndServe() }() select { case <-ctx.Done(): return s.http.Shutdown(context.Background()) case err := <-errCh: return err } } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.tailwindcss.com https://cdn.jsdelivr.net; "+ "style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+ "img-src 'self' https://cdn.discordapp.com data:; frame-src 'self'") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") next.ServeHTTP(w, r) }) }