209 lines
5.8 KiB
Go
209 lines
5.8 KiB
Go
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,
|
|
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,
|
|
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/send-panel", convocH.HandleSendPanel)
|
|
|
|
// 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)
|
|
})
|
|
}
|