This commit is contained in:
lfirmin
2026-05-10 18:07:51 +02:00
parent 50af2452b1
commit a5f888b3c1
59 changed files with 6682 additions and 108 deletions
+111
View File
@@ -0,0 +1,111 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/leolionad58/ticketbot/internal/db"
)
// AuditHandler serves GET /audit.
type AuditHandler struct {
AuditRepo *db.AuditLogRepo
Renderer *Renderer
}
// auditActions lists all known audit action constants for the filter dropdown.
var auditActions = []string{
db.AuditCreatePanel,
db.AuditUpdatePanel,
db.AuditDeletePanel,
db.AuditCreateType,
db.AuditUpdateType,
db.AuditDeleteType,
db.AuditReorderTypes,
db.AuditUpdateConvoc,
db.AuditSendPanelDiscord,
db.AuditDeletePanelMsg,
db.AuditRevokeSession,
db.AuditRevokeAllSess,
db.AuditAdminLogin,
db.AuditAdminLoginFail,
db.AuditAdminLocked,
}
type auditData struct {
baseData
Entries []*db.AuditLogEntry
Total int
Page int
Pages int
Actions []string
// Filter values
FAdminID string
FAction string
FFrom string
FTo string
}
// HandleList serves GET /audit.
func (h *AuditHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "audit")
q := r.URL.Query()
fAdminID := q.Get("admin_id")
fAction := q.Get("action")
fFrom := q.Get("from")
fTo := q.Get("to")
page, _ := strconv.Atoi(q.Get("page"))
if page < 1 {
page = 1
}
var adminID int64
if fAdminID != "" {
adminID, _ = strconv.ParseInt(fAdminID, 10, 64)
}
filter := db.AuditFilter{
AdminID: adminID,
Action: fAction,
}
if fFrom != "" {
if t, err := time.Parse("2006-01-02", fFrom); err == nil {
filter.From = t
}
}
if fTo != "" {
if t, err := time.Parse("2006-01-02", fTo); err == nil {
filter.To = t.Add(24*time.Hour - time.Second)
}
}
const pageSize = 50
entries, total, err := h.AuditRepo.List(ctx, filter, page, pageSize)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
pages := total / pageSize
if total%pageSize != 0 {
pages++
}
if pages < 1 {
pages = 1
}
h.Renderer.Page(w, "audit", auditData{
baseData: base,
Entries: entries,
Total: total,
Page: page,
Pages: pages,
Actions: auditActions,
FAdminID: fAdminID,
FAction: fAction,
FFrom: fFrom,
FTo: fTo,
})
}
+256
View File
@@ -0,0 +1,256 @@
package handlers
import (
"context"
"html/template"
"log/slog"
"net/http"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// AuthHandler handles TOTP setup, credential verification, and logout.
type AuthHandler struct {
Admins *db.PanelAdminRepo
Auth *panelauth.Service
AuditLog *db.AuditLogRepo
Issuer string
Renderer *Renderer
}
// HandlePasswordSetupGET serves GET /auth/password-setup (first login).
func (h *AuthHandler) HandlePasswordSetupGET(w http.ResponseWriter, r *http.Request) {
if panelauth.PendingAuthDiscordID(r) == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
h.Renderer.Auth(w, "password_setup", struct{ Error string }{})
}
// HandlePasswordSetupPOST serves POST /auth/password-setup.
func (h *AuthHandler) HandlePasswordSetupPOST(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
renderErr := func(msg string) {
h.Renderer.Auth(w, "password_setup", struct{ Error string }{Error: msg})
}
password := r.FormValue("password")
confirm := r.FormValue("confirm")
if len(password) < 12 {
renderErr("Le mot de passe doit faire au moins 12 caractères.")
return
}
if password != confirm {
renderErr("Les mots de passe ne correspondent pas.")
return
}
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
hash, err := panelauth.HashPassword(password)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := h.Admins.UpdatePassword(ctx, admin.ID, hash); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !admin.TOTPEnabled {
http.Redirect(w, r, "/auth/totp-setup", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/auth/verify", http.StatusSeeOther)
}
// HandleTOTPSetupGET serves GET /auth/totp-setup.
func (h *AuthHandler) HandleTOTPSetupGET(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
qr, secret, err := panelauth.GenerateTOTP(h.Issuer, admin.DiscordUsername)
if err != nil {
slog.Error("totp setup: generate", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
type totpSetupData struct {
QRCodeBase64 template.URL
Secret string
Error string
}
h.Renderer.Auth(w, "totp_setup", totpSetupData{QRCodeBase64: template.URL(qr), Secret: secret})
}
// HandleTOTPSetupPOST serves POST /auth/totp-setup.
func (h *AuthHandler) HandleTOTPSetupPOST(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
secret := r.FormValue("secret")
code := r.FormValue("code")
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
type totpSetupData struct {
QRCodeBase64 template.URL
Secret string
Error string
}
if err := h.Auth.ConfirmTOTP(ctx, admin.ID, secret, code); err != nil {
qr, sec, _ := panelauth.GenerateTOTP(h.Issuer, admin.DiscordUsername)
h.Renderer.Auth(w, "totp_setup", totpSetupData{QRCodeBase64: template.URL(qr), Secret: sec, Error: "Code invalide. Réessaie."})
return
}
slog.Info("totp setup completed", "discord_id", discordID)
http.Redirect(w, r, "/auth/verify", http.StatusSeeOther)
}
// HandleVerifyGET serves GET /auth/verify.
func (h *AuthHandler) HandleVerifyGET(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
h.Renderer.Auth(w, "totp_verify", struct {
Admin *db.PanelAdmin
Error string
}{Admin: admin})
}
// HandleVerifyPOST serves POST /auth/verify.
func (h *AuthHandler) HandleVerifyPOST(w http.ResponseWriter, r *http.Request) {
discordID := panelauth.PendingAuthDiscordID(r)
if discordID == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
admin, err := h.Admins.GetByDiscordID(ctx, discordID)
if err != nil || admin == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
renderErr := func(msg string) {
h.Renderer.Auth(w, "totp_verify", struct {
Admin *db.PanelAdmin
Error string
}{Admin: admin, Error: msg})
}
ip := clientIP(r)
// Rate limit check
if !h.Auth.Allow(discordID) {
h.auditLoginFail(ctx, admin, ip, db.AuditAdminLocked)
renderErr("Trop de tentatives. Réessaie dans 15 minutes.")
return
}
password := r.FormValue("password")
totpCode := r.FormValue("totp")
if !h.Auth.VerifyCredentials(admin, password, totpCode) {
locked := h.Auth.RecordFailure(discordID)
h.auditLoginFail(ctx, admin, ip, db.AuditAdminLoginFail)
if locked {
h.auditLoginFail(ctx, admin, ip, db.AuditAdminLocked)
slog.Warn("admin account locked", "discord_id", discordID, "ip", ip)
}
renderErr("Identifiants invalides.")
return
}
// Success — clear rate limit and pending cookie, create session
h.Auth.ResetLimit(discordID)
panelauth.ClearPendingAuthCookie(w, r)
sess, err := h.Auth.CreateSession(ctx, admin.ID, ip, r.UserAgent())
if err != nil {
slog.Error("verify: create session", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
panelauth.SetSessionCookie(w, r, sess.Token)
h.auditLogin(ctx, admin, ip)
slog.Info("admin logged in", "discord_id", discordID, "ip", clientIP(r))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleLogout serves POST /auth/logout.
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("panel_session"); err == nil {
_ = h.Auth.InvalidateSession(r.Context(), c.Value)
}
http.SetCookie(w, &http.Cookie{Name: "panel_session", MaxAge: -1, Path: "/"})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *AuthHandler) auditLogin(ctx context.Context, admin *db.PanelAdmin, ip string) {
if h.AuditLog == nil {
return
}
_ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{
AdminID: admin.ID,
Action: db.AuditAdminLogin,
EntityType: "admin",
IPAddress: ip,
})
}
func (h *AuthHandler) auditLoginFail(ctx context.Context, admin *db.PanelAdmin, ip, action string) {
if h.AuditLog == nil {
return
}
_ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{
AdminID: admin.ID,
Action: action,
EntityType: "admin",
IPAddress: ip,
})
}
+91
View File
@@ -0,0 +1,91 @@
package handlers
import (
"html/template"
"net/http"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// ParseFunc is the signature of panel.ParseTemplates — injected to avoid import cycles.
type ParseFunc func(names ...string) (*template.Template, error)
// Renderer provides renderAuth and renderPage to handlers without importing the panel package.
type Renderer struct {
parse ParseFunc
getBotStatus func() BotStatus
}
// NewRenderer creates a Renderer backed by the given parse function.
func NewRenderer(f ParseFunc) *Renderer { return &Renderer{parse: f} }
// SetBotStatus wires a live bot-status function so BotOnline is set on every page.
func (rnd *Renderer) SetBotStatus(fn func() BotStatus) { rnd.getBotStatus = fn }
// base builds baseData with BotOnline populated from the live bot status.
func (rnd *Renderer) base(r *http.Request, active string) baseData {
b := newBase(r, active)
if rnd.getBotStatus != nil {
b.BotOnline = rnd.getBotStatus().Online
}
return b
}
func (rnd *Renderer) Auth(w http.ResponseWriter, page string, data any) {
t, err := rnd.parse("auth_layout.html", page+".html")
if err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "auth_layout", data); err != nil {
http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError)
}
}
func (rnd *Renderer) Page(w http.ResponseWriter, page string, data any) {
t, err := rnd.parse("layout.html", page+".html")
if err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError)
}
}
type baseData struct {
CSRFToken string
Admin *db.PanelAdmin
ActivePage string
BotOnline bool
Flash string
Error string
}
func newBase(r *http.Request, active string) baseData {
sess := panelauth.SessionFromContext(r.Context())
admin := panelauth.AdminFromContext(r.Context())
csrf := ""
if sess != nil {
csrf = sess.CSRFToken
}
return baseData{
CSRFToken: csrf,
Admin: admin,
ActivePage: active,
Flash: r.URL.Query().Get("flash"),
}
}
func clientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return ip
}
return r.RemoteAddr
}
+252
View File
@@ -0,0 +1,252 @@
package handlers
import (
"net/http"
"strings"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// ConvocBotService is the subset of BotService needed for convocation operations.
type ConvocBotService interface {
CreateConvocation(guildID, staffDiscordID, targetDiscordID, reason string) (channelID string, err error)
SendConvocPanel(guildID string) error
SendConvocPanelByID(panelID int64) error
}
// ConvocDiscordInfo is the subset of DiscordInfo for guild selection in convocations.
type ConvocDiscordInfo interface {
GetGuildList() []GuildInfo
}
// ConvocationsHandler serves /convocations.
type ConvocationsHandler struct {
TicketRepo *db.TicketRepo
ConvocRepo *db.ConvocationConfigRepo
ConvocPanelRepo *db.ConvocationPanelRepo
GuildID string
BotService ConvocBotService
DiscordInfo ConvocDiscordInfo
Renderer *Renderer
}
type convocationsData struct {
baseData
Config *db.ConvocationConfig
ConvocPanels []*db.ConvocationPanel
Tickets []*db.Ticket
Total int
Page int
Pages int
Guilds []GuildInfo
}
// HandleGET serves GET /convocations.
func (h *ConvocationsHandler) HandleGET(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "convocations")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
cfg, err := h.ConvocRepo.Get(ctx, h.GuildID)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
var convocPanels []*db.ConvocationPanel
if h.ConvocPanelRepo != nil {
convocPanels, _ = h.ConvocPanelRepo.List(ctx, h.GuildID)
}
page := 1
tickets, total, err := h.TicketRepo.ListFiltered(ctx, db.TicketFilter{
Type: "convocation",
Page: page,
PageSize: 20,
})
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
pages := total / 20
if total%20 != 0 {
pages++
}
if pages < 1 {
pages = 1
}
var guilds []GuildInfo
if h.DiscordInfo != nil {
guilds = h.DiscordInfo.GetGuildList()
}
h.Renderer.Page(w, "convocations", convocationsData{
baseData: base,
Config: cfg,
ConvocPanels: convocPanels,
Tickets: tickets,
Total: total,
Page: page,
Pages: pages,
Guilds: guilds,
})
}
// HandlePOST serves POST /convocations — saves the convocation config.
func (h *ConvocationsHandler) HandlePOST(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Load existing config to preserve panel_message_id (not sent by form)
existing, _ := h.ConvocRepo.Get(ctx, h.GuildID)
panelMsgID := ""
panelChannelID := strings.TrimSpace(r.FormValue("panel_channel_id"))
if existing != nil {
panelMsgID = existing.PanelMessageID
// If channel changed, clear stored message ID so SendConvocPanel sends a new message
if existing.PanelChannelID != panelChannelID {
panelMsgID = ""
}
}
embedColor := strings.TrimSpace(r.FormValue("panel_embed_color"))
if embedColor == "" {
embedColor = "#5865f2"
}
cfg := &db.ConvocationConfig{
GuildID: h.GuildID,
CategoryID: strings.TrimSpace(r.FormValue("category_id")),
LogChannelID: strings.TrimSpace(r.FormValue("log_channel_id")),
ModalEnabled: r.FormValue("modal_enabled") == "1",
PanelChannelID: panelChannelID,
PanelMessageID: panelMsgID,
PanelEmbedTitle: strings.TrimSpace(r.FormValue("panel_embed_title")),
PanelEmbedDescription: strings.TrimSpace(r.FormValue("panel_embed_description")),
PanelEmbedColor: embedColor,
}
if err := h.ConvocRepo.Upsert(ctx, cfg); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/convocations?flash=Configuration+sauvegardée", http.StatusSeeOther)
}
// HandleCreatePanel serves POST /convocations/panels/new — creates a new convocation panel entry.
func (h *ConvocationsHandler) HandleCreatePanel(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if h.ConvocPanelRepo == nil {
http.Redirect(w, r, "/convocations?error=Non+configuré", http.StatusSeeOther)
return
}
color := strings.TrimSpace(r.FormValue("embed_color"))
if color == "" {
color = "#5865f2"
}
p := &db.ConvocationPanel{
GuildID: h.GuildID,
EmbedTitle: strings.TrimSpace(r.FormValue("embed_title")),
EmbedDesc: strings.TrimSpace(r.FormValue("embed_description")),
EmbedColor: color,
ChannelID: strings.TrimSpace(r.FormValue("channel_id")),
}
if err := h.ConvocPanelRepo.Create(ctx, p); err != nil {
http.Redirect(w, r, "/convocations?error="+urlEncode(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/convocations?flash=Panel+créé", http.StatusSeeOther)
}
// HandleDeletePanel serves POST /convocations/panels/{id}/delete.
func (h *ConvocationsHandler) HandleDeletePanel(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
if h.ConvocPanelRepo != nil {
h.ConvocPanelRepo.Delete(ctx, id) //nolint
}
http.Redirect(w, r, "/convocations?flash=Panel+supprimé", http.StatusSeeOther)
}
// HandleSendPanel serves POST /convocations/panels/{id}/send.
func (h *ConvocationsHandler) HandleSendPanel(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
if h.BotService == nil {
http.Redirect(w, r, "/convocations?error=Bot+non+disponible", http.StatusSeeOther)
return
}
if err := h.BotService.SendConvocPanelByID(id); err != nil {
http.Redirect(w, r, "/convocations?error="+urlEncode(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/convocations?flash=Panel+envoyé+sur+Discord", http.StatusSeeOther)
}
// HandleCreate serves POST /convocations/create — creates a convocation via Discord.
func (h *ConvocationsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if h.BotService == nil {
http.Error(w, "bot non disponible", http.StatusServiceUnavailable)
return
}
guildID := strings.TrimSpace(r.FormValue("guild_id"))
if guildID == "" {
guildID = h.GuildID
}
targetID := strings.TrimSpace(r.FormValue("target_id"))
reason := strings.TrimSpace(r.FormValue("reason"))
if targetID == "" || reason == "" {
http.Redirect(w, r, "/convocations?error=ID+utilisateur+et+raison+requis", http.StatusSeeOther)
return
}
admin := panelauth.AdminFromContext(r.Context())
staffDiscordID := ""
if admin != nil {
staffDiscordID = admin.DiscordID
}
channelID, err := h.BotService.CreateConvocation(guildID, staffDiscordID, targetID, reason)
if err != nil {
http.Redirect(w, r, "/convocations?error="+urlEncode(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/convocations?flash=Convocation+créée+dans+<#"+channelID+">", http.StatusSeeOther)
}
func urlEncode(s string) string {
var out strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
out.WriteRune(r)
case r == '-' || r == '_' || r == '.' || r == '~':
out.WriteRune(r)
default:
out.WriteString("+" + strings.ReplaceAll(string(r), " ", "+"))
}
}
return out.String()
}
+161
View File
@@ -0,0 +1,161 @@
package handlers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// BotStatus holds live bot state, populated by the BotService.
type BotStatus struct {
Online bool
StartedAt time.Time
LatencyMs int64
GuildName string
}
func (s BotStatus) Uptime() string {
if !s.Online {
return "—"
}
d := time.Since(s.StartedAt).Truncate(time.Second)
h := int(d.Hours())
m := int(d.Minutes()) % 60
sec := int(d.Seconds()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm %ds", m, sec)
}
// DashboardStats holds pre-computed stats for the dashboard.
type DashboardStats struct {
OpenTickets int
ClosedLast30 int
AvgClaimMinutes int
AvgResolutionMinutes int
}
// ChartBar is one bar in the daily chart.
type ChartBar struct {
Day string
Count int
}
// DashboardHandler serves the main dashboard page.
type DashboardHandler struct {
TicketRepo *db.TicketRepo
PanelRepo *db.PanelConfigRepo
GetBotStatus func() BotStatus
Renderer *Renderer
}
func (h *DashboardHandler) Handle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "dashboard")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
stats := h.loadStats(ctx)
chart := h.loadChart(ctx)
staff := h.loadStaff(ctx)
panels, _ := h.PanelRepo.List(ctx)
status := BotStatus{}
if h.GetBotStatus != nil {
status = h.GetBotStatus()
}
h.Renderer.Page(w, "dashboard", struct {
baseData
Stats DashboardStats
Chart []ChartBar
ChartMax int
Staff []db.StaffStat
Panels []*db.PanelConfig
BotOnline bool
Uptime string
LatencyMs int64
GuildName string
}{
baseData: base,
Stats: stats,
Chart: chart,
ChartMax: chartMax(chart),
Staff: staff,
Panels: panels,
BotOnline: status.Online,
Uptime: status.Uptime(),
LatencyMs: status.LatencyMs,
GuildName: status.GuildName,
})
}
func (h *DashboardHandler) loadStats(ctx context.Context) DashboardStats {
var stats DashboardStats
if h.TicketRepo == nil {
return stats
}
open, _ := h.TicketRepo.CountByStatus(ctx, "open")
claimed, _ := h.TicketRepo.CountByStatus(ctx, "claimed")
stats.OpenTickets = open + claimed
since := time.Now().AddDate(0, 0, -30)
stats.ClosedLast30, _ = h.TicketRepo.CountClosedSince(ctx, since)
stats.AvgClaimMinutes, _ = h.TicketRepo.AvgClaimMinutes(ctx, since)
stats.AvgResolutionMinutes, _ = h.TicketRepo.AvgResolutionMinutes(ctx, since)
return stats
}
func (h *DashboardHandler) loadChart(ctx context.Context) []ChartBar {
if h.TicketRepo == nil {
return nil
}
daily, _ := h.TicketRepo.DailyTicketCounts(ctx, 30)
dayMap := make(map[string]int, len(daily))
for _, d := range daily {
dayMap[d.Day] = d.Count
}
bars := make([]ChartBar, 30)
for i := range bars {
t := time.Now().AddDate(0, 0, -29+i)
day := t.Format("2006-01-02")
bars[i] = ChartBar{Day: t.Format("02/01"), Count: dayMap[day]}
}
return bars
}
func (h *DashboardHandler) loadStaff(ctx context.Context) []db.StaffStat {
if h.TicketRepo == nil {
return nil
}
stats, _ := h.TicketRepo.StaffStats(ctx, 30)
return stats
}
func chartMax(bars []ChartBar) int {
m := 1
for _, b := range bars {
if b.Count > m {
m = b.Count
}
}
return m
}
func fmtMinutes(m int) string {
if m <= 0 {
return "—"
}
if m < 60 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dh%dm", m/60, m%60)
}
+110
View File
@@ -0,0 +1,110 @@
package handlers
import (
"log/slog"
"net/http"
"time"
"golang.org/x/oauth2"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// LoginHandler handles the Discord OAuth2 login flow.
type LoginHandler struct {
Admins *db.PanelAdminRepo
Auth *panelauth.Service
OAuthCfg *oauth2.Config
Renderer *Renderer
}
// HandleLogin serves GET /login.
func (h *LoginHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
state, err := panelauth.GenerateState()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/oauth",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode, // Lax to survive the Discord redirect
MaxAge: 60,
})
type loginData struct {
OAuthURL string
Error string
}
h.Renderer.Auth(w, "login", loginData{
OAuthURL: panelauth.AuthURL(h.OAuthCfg, state),
})
}
// HandleCallback serves GET /oauth/callback.
func (h *LoginHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
// Validate state
stateCookie, err := r.Cookie("oauth_state")
if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
type loginData struct{ OAuthURL, Error string }
h.Renderer.Auth(w, "login", loginData{Error: "Session expirée, réessaie."})
return
}
// Clear state cookie
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Path: "/oauth", MaxAge: -1, Expires: time.Unix(0, 0)})
code := r.URL.Query().Get("code")
if code == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
discordUser, err := panelauth.FetchDiscordUser(ctx, h.OAuthCfg, code)
if err != nil {
slog.Warn("oauth callback: fetch discord user", "err", err)
type loginData struct{ OAuthURL, Error string }
h.Renderer.Auth(w, "login", loginData{
OAuthURL: panelauth.AuthURL(h.OAuthCfg, ""),
Error: "Erreur lors de la connexion Discord. Réessaie.",
})
return
}
// Check admin whitelist
admin, err := h.Admins.GetByDiscordID(ctx, discordUser.ID)
if err != nil {
slog.Error("oauth callback: get admin", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if admin == nil {
// Not in whitelist — show denied page
h.Renderer.Auth(w, "denied", nil)
return
}
// Update Discord profile info
_ = h.Admins.UpdateDiscordProfile(ctx, admin.ID, discordUser.Username, discordUser.Avatar)
admin.DiscordUsername = discordUser.Username
admin.DiscordAvatar = discordUser.Avatar
// Store pending auth cookie
panelauth.SetPendingAuthCookie(w, r, admin.DiscordID)
if admin.PasswordHash == "" {
http.Redirect(w, r, "/auth/password-setup", http.StatusSeeOther)
return
}
if !admin.TOTPEnabled {
http.Redirect(w, r, "/auth/totp-setup", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/auth/verify", http.StatusSeeOther)
}
+433
View File
@@ -0,0 +1,433 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// BotSender is the subset of BotService used by PanelsHandler.
type BotSender interface {
SendPanel(panelID int64) error
DeletePanelMessage(panelID int64) error
}
// GuildRole is a Discord role passed to the panel form.
type GuildRole struct {
ID string
Name string
Color string
}
// GuildChannel is a Discord channel/category passed to the panel form.
type GuildChannel struct {
ID string
Name string
Type int // 0=text, 2=voice, 4=category
}
// GuildInfo is a minimal guild descriptor for the guild selector.
type GuildInfo struct {
ID string
Name string
}
// DiscordInfo can fetch guild roles and channels for the form dropdowns.
type DiscordInfo interface {
GetGuildList() []GuildInfo
GetGuildRoles(guildID string) ([]GuildRole, error)
GetGuildChannels(guildID string) ([]GuildChannel, error)
}
// PanelsHandler serves all /panels/* routes.
type PanelsHandler struct {
PanelRepo *db.PanelConfigRepo
AuditLog *db.AuditLogRepo
BotService BotSender
DiscordInfo DiscordInfo
Renderer *Renderer
}
// HandleList serves GET /panels.
func (h *PanelsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
panels, _ := h.PanelRepo.List(ctx)
guildNames := map[string]string{}
if h.DiscordInfo != nil {
for _, g := range h.DiscordInfo.GetGuildList() {
guildNames[g.ID] = g.Name
}
}
h.Renderer.Page(w, "panels_list", struct {
baseData
Panels []*db.PanelConfig
GuildNames map[string]string
}{baseData: base, Panels: panels, GuildNames: guildNames})
}
// HandleNew serves GET /panels/new.
func (h *PanelsHandler) HandleNew(w http.ResponseWriter, r *http.Request) {
base := h.Renderer.base(r, "panels")
guildID := r.URL.Query().Get("guild_id")
if guildID == "" && h.DiscordInfo != nil {
if guilds := h.DiscordInfo.GetGuildList(); len(guilds) > 0 {
guildID = guilds[0].ID
}
}
h.renderForm(w, base, &db.PanelConfig{EmbedColor: "#5865f2", GuildID: guildID}, true, nil)
}
// HandleCreate serves POST /panels/new.
func (h *PanelsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
p, types, errs := parsePanel(r)
if len(errs) > 0 {
p.Types = types
h.renderForm(w, base, p, true, errs)
return
}
if err := h.PanelRepo.Create(ctx, p); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
h.renderForm(w, base, p, true, map[string]string{"name": "Ce nom est déjà utilisé."})
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if len(types) > 0 {
if err := h.PanelRepo.ReplaceTypes(ctx, p.ID, types); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
if r.FormValue("action") == "save_send" && h.BotService != nil {
_ = h.BotService.SendPanel(p.ID)
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleEdit serves GET /panels/{id}/edit.
func (h *PanelsHandler) HandleEdit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
p, err := h.PanelRepo.GetByID(ctx, id)
if err != nil || p == nil {
http.NotFound(w, r)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
h.renderForm(w, base, p, false, nil)
}
// HandleUpdate serves POST /panels/{id}/edit.
func (h *PanelsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
base := h.Renderer.base(r, "panels")
sess := panelauth.SessionFromContext(ctx)
if sess != nil {
base.CSRFToken = sess.CSRFToken
}
p, types, errs := parsePanel(r)
p.ID = id
if len(errs) > 0 {
p.Types = types
h.renderForm(w, base, p, false, errs)
return
}
if err := h.PanelRepo.Update(ctx, p); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := h.PanelRepo.ReplaceTypes(ctx, id, types); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if r.FormValue("action") == "save_send" && h.BotService != nil {
_ = h.BotService.SendPanel(id)
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleDelete serves POST /panels/{id}/delete.
func (h *PanelsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := h.PanelRepo.Delete(ctx, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/panels", http.StatusSeeOther)
}
// HandleDuplicate serves POST /panels/{id}/duplicate.
func (h *PanelsHandler) HandleDuplicate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
src, err := h.PanelRepo.GetByID(ctx, id)
if err != nil || src == nil {
http.NotFound(w, r)
return
}
copyP := &db.PanelConfig{
Name: src.Name + "-copie",
EmbedTitle: src.EmbedTitle,
EmbedDescription: src.EmbedDescription,
EmbedColor: src.EmbedColor,
GuildID: src.GuildID,
}
if err := h.PanelRepo.Create(ctx, copyP); err != nil {
http.Error(w, "duplicate failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Copy types (without IDs so they get fresh ones)
var newTypes []*db.PanelType
for _, t := range src.Types {
newTypes = append(newTypes, &db.PanelType{
Name: t.Name, ButtonLabel: t.ButtonLabel, ButtonColor: t.ButtonColor,
ButtonEmoji: t.ButtonEmoji, EmbedColor: t.EmbedColor, EmbedTitle: t.EmbedTitle,
EmbedText: t.EmbedText, StaffRoleID: t.StaffRoleID, CategoryID: t.CategoryID,
ClaimChannelID: t.ClaimChannelID, LogChannelID: t.LogChannelID,
MaxPerUser: t.MaxPerUser, ClaimMode: t.ClaimMode, CloseRule: t.CloseRule,
ModalEnabled: t.ModalEnabled, SortOrder: t.SortOrder,
})
}
if len(newTypes) > 0 {
_ = h.PanelRepo.ReplaceTypes(ctx, copyP.ID, newTypes)
}
http.Redirect(w, r, fmt.Sprintf("/panels/%d/edit", copyP.ID), http.StatusSeeOther)
}
// HandlePreview serves POST /panels/preview — returns an HTML fragment.
func (h *PanelsHandler) HandlePreview(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
color := r.FormValue("embed_color")
if color == "" {
color = "#5865f2"
}
title := r.FormValue("embed_title")
if title == "" {
title = "Titre du panel"
}
desc := r.FormValue("embed_description")
type btn struct{ Label, Color, Emoji string }
var buttons []btn
for i := 0; i < 25; i++ {
label := r.FormValue(fmt.Sprintf("types[%d][button_label]", i))
if label == "" {
break
}
buttons = append(buttons, btn{
Label: label,
Color: r.FormValue(fmt.Sprintf("types[%d][button_color]", i)),
Emoji: r.FormValue(fmt.Sprintf("types[%d][button_emoji]", i)),
})
}
btnColorMap := map[string]string{
"primary": "bg-indigo-600", "secondary": "bg-gray-600",
"success": "bg-green-600", "danger": "bg-red-600",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="rounded-lg overflow-hidden border-l-4 bg-[#2b2d31] p-4" style="border-left-color:%s">`, color)
fmt.Fprintf(w, `<p class="font-semibold text-white mb-1">%s</p>`, title)
if desc != "" {
fmt.Fprintf(w, `<p class="text-gray-300 text-sm mb-3">%s</p>`, desc)
}
if len(buttons) > 0 {
fmt.Fprint(w, `<div class="flex flex-wrap gap-2 mt-2">`)
for _, b := range buttons {
cls := btnColorMap[b.Color]
if cls == "" {
cls = "bg-indigo-600"
}
fmt.Fprintf(w, `<span class="%s text-white text-sm px-3 py-1.5 rounded">%s %s</span>`, cls, b.Emoji, b.Label)
}
fmt.Fprint(w, `</div>`)
}
fmt.Fprint(w, `</div>`)
}
// HandleReorderTypes serves POST /panels/{id}/types/reorder.
func (h *PanelsHandler) HandleReorderTypes(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var ids []int64
for _, s := range r.Form["ids[]"] {
n, _ := strconv.ParseInt(s, 10, 64)
ids = append(ids, n)
}
if err := h.PanelRepo.ReorderTypes(ctx, id, ids); err != nil {
http.Error(w, "reorder failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PanelsHandler) renderForm(w http.ResponseWriter, base baseData, p *db.PanelConfig, isNew bool, errs map[string]string) {
if errs == nil {
errs = map[string]string{}
}
var roles []GuildRole
var textChans, categories []GuildChannel
var guilds []GuildInfo
if h.DiscordInfo != nil {
guilds = h.DiscordInfo.GetGuildList()
gid := p.GuildID
if gid == "" && len(guilds) > 0 {
gid = guilds[0].ID
p.GuildID = gid
}
if gid != "" {
roles, _ = h.DiscordInfo.GetGuildRoles(gid)
chans, _ := h.DiscordInfo.GetGuildChannels(gid)
for _, c := range chans {
switch c.Type {
case 0:
textChans = append(textChans, c)
case 4:
categories = append(categories, c)
}
}
}
}
h.Renderer.Page(w, "panel_form", struct {
baseData
Panel *db.PanelConfig
IsNew bool
Errors map[string]string
Guilds []GuildInfo
Roles []GuildRole
TextChans []GuildChannel
Categories []GuildChannel
}{
baseData: base,
Panel: p,
IsNew: isNew,
Errors: errs,
Guilds: guilds,
Roles: roles,
TextChans: textChans,
Categories: categories,
})
}
// parsePanel extracts a PanelConfig + types from the posted form.
func parsePanel(r *http.Request) (*db.PanelConfig, []*db.PanelType, map[string]string) {
errs := map[string]string{}
p := &db.PanelConfig{
Name: strings.TrimSpace(r.FormValue("name")),
EmbedTitle: strings.TrimSpace(r.FormValue("embed_title")),
EmbedDescription: db.NullStr(strings.TrimSpace(r.FormValue("embed_description"))),
EmbedColor: strings.TrimSpace(r.FormValue("embed_color")),
EmbedImage: strings.TrimSpace(r.FormValue("embed_image")),
EmbedThumbnail: strings.TrimSpace(r.FormValue("embed_thumbnail")),
GuildID: strings.TrimSpace(r.FormValue("guild_id")),
ChannelID: db.NullStr(strings.TrimSpace(r.FormValue("channel_id"))),
}
if p.Name == "" {
errs["name"] = "Le nom est requis."
}
if p.EmbedTitle == "" {
errs["embed_title"] = "Le titre est requis."
}
if p.EmbedColor == "" {
p.EmbedColor = "#5865f2"
}
var types []*db.PanelType
for i := 0; i < 25; i++ {
prefix := fmt.Sprintf("types[%d]", i)
name := strings.TrimSpace(r.FormValue(prefix + "[name]"))
if name == "" {
break
}
claimMode := 0
if r.FormValue(prefix+"[claim_mode]") == "1" {
claimMode = 1
}
claimReupMinutes, _ := strconv.Atoi(r.FormValue(prefix + "[claim_reup_minutes]"))
modalEnabled := r.FormValue(prefix+"[modal_enabled]") != "0"
closeRule := r.FormValue(prefix + "[close_rule]")
if closeRule == "" {
closeRule = "staff_only"
}
maxPU, _ := strconv.Atoi(r.FormValue(prefix + "[max_per_user]"))
if maxPU <= 0 {
maxPU = 1
}
embedColor := strings.TrimSpace(r.FormValue(prefix + "[embed_color]"))
if embedColor == "" {
embedColor = "#5865f2"
}
buttonColor := strings.TrimSpace(r.FormValue(prefix + "[button_color]"))
if buttonColor == "" {
buttonColor = "primary"
}
types = append(types, &db.PanelType{
Name: name,
ButtonLabel: strings.TrimSpace(r.FormValue(prefix + "[button_label]")),
ButtonColor: buttonColor,
ButtonEmoji: strings.TrimSpace(r.FormValue(prefix + "[button_emoji]")),
EmbedColor: embedColor,
EmbedTitle: strings.TrimSpace(r.FormValue(prefix + "[embed_title]")),
EmbedText: strings.TrimSpace(r.FormValue(prefix + "[embed_text]")),
ThumbnailURL: strings.TrimSpace(r.FormValue(prefix + "[thumbnail_url]")),
ImageURL: strings.TrimSpace(r.FormValue(prefix + "[image_url]")),
StaffRoleID: strings.TrimSpace(r.FormValue(prefix + "[staff_role_id]")),
CategoryID: strings.TrimSpace(r.FormValue(prefix + "[category_id]")),
ClaimChannelID: strings.TrimSpace(r.FormValue(prefix + "[claim_channel_id]")),
LogChannelID: strings.TrimSpace(r.FormValue(prefix + "[log_channel_id]")),
MaxPerUser: maxPU,
ClaimMode: claimMode,
ClaimReupMinutes: claimReupMinutes,
CloseRule: closeRule,
ModalEnabled: modalEnabled,
SortOrder: i,
})
}
p.Types = types
return p, types, errs
}
+131
View File
@@ -0,0 +1,131 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/leolionad58/ticketbot/internal/db"
panelauth "github.com/leolionad58/ticketbot/internal/panel/auth"
)
// SessionsHandler serves /sessions routes (superadmin only).
type SessionsHandler struct {
SessionRepo *db.PanelSessionRepo
AdminRepo *db.PanelAdminRepo
AuditLog *db.AuditLogRepo
Renderer *Renderer
}
// sessionWithAdmin joins a PanelSession with the admin's username.
type sessionWithAdmin struct {
*db.PanelSession
DiscordUsername string
IsCurrent bool
}
type sessionsData struct {
baseData
Sessions []*sessionWithAdmin
}
// HandleList serves GET /sessions.
func (h *SessionsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "sessions")
currentSess := panelauth.SessionFromContext(ctx)
currentToken := ""
if currentSess != nil {
currentToken = currentSess.Token
}
sessions, err := h.SessionRepo.List(ctx)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
var enriched []*sessionWithAdmin
for _, s := range sessions {
swa := &sessionWithAdmin{
PanelSession: s,
IsCurrent: s.Token == currentToken,
}
if admin, err := h.AdminRepo.GetByID(ctx, s.AdminID); err == nil && admin != nil {
swa.DiscordUsername = admin.DiscordUsername
}
enriched = append(enriched, swa)
}
h.Renderer.Page(w, "sessions", sessionsData{
baseData: base,
Sessions: enriched,
})
}
// HandleRevoke serves POST /sessions/{id}/revoke.
func (h *SessionsHandler) HandleRevoke(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := h.SessionRepo.DeleteByID(ctx, id); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
// Audit log
if h.AuditLog != nil {
admin := panelauth.AdminFromContext(ctx)
if admin != nil {
_ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{
AdminID: admin.ID,
Action: db.AuditRevokeSession,
EntityType: "panel_session",
EntityID: sql.NullInt64{Int64: id, Valid: true},
IPAddress: clientIP(r),
})
}
}
http.Redirect(w, r, "/sessions?flash=Session+révoquée", http.StatusSeeOther)
}
// HandleRevokeAll serves POST /sessions/revoke-all.
func (h *SessionsHandler) HandleRevokeAll(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
admin := panelauth.AdminFromContext(ctx)
if admin == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
currentSess := panelauth.SessionFromContext(ctx)
currentToken := ""
if currentSess != nil {
currentToken = currentSess.Token
}
if err := h.SessionRepo.DeleteAllExcept(ctx, admin.ID, currentToken); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
// Audit log
if h.AuditLog != nil {
_ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{
AdminID: admin.ID,
Action: db.AuditRevokeAllSess,
EntityType: "panel_session",
IPAddress: clientIP(r),
})
}
http.Redirect(w, r, "/sessions?flash=Toutes+les+sessions+révoquées", http.StatusSeeOther)
}
+188
View File
@@ -0,0 +1,188 @@
package handlers
import (
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/leolionad58/ticketbot/internal/db"
)
// TranscriptsHandler serves /transcripts routes.
type TranscriptsHandler struct {
TicketRepo *db.TicketRepo
Renderer *Renderer
DiscordInfo DiscordInfo
}
type transcriptsData struct {
baseData
Tickets []*db.Ticket
Total int
Page int
Pages int
Types []string
StaffList []string
Guilds []GuildInfo
GuildNames map[string]string
// Filter values echoed back to the form
FStatus string
FType string
FStaff string
FGuild string
FFrom string
FTo string
FSearch string
}
// HandleList serves GET /transcripts with search/filter/pagination.
func (h *TranscriptsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
base := h.Renderer.base(r, "transcripts")
q := r.URL.Query()
fStatus := q.Get("status")
fType := q.Get("type")
fStaff := q.Get("staff")
fGuild := q.Get("guild")
fFrom := q.Get("from")
fTo := q.Get("to")
fSearch := q.Get("search")
page, _ := strconv.Atoi(q.Get("page"))
if page < 1 {
page = 1
}
filter := db.TicketFilter{
Status: fStatus,
Type: fType,
StaffID: fStaff,
GuildID: fGuild,
Search: fSearch,
Page: page,
PageSize: 20,
}
if fFrom != "" {
if t, err := time.Parse("2006-01-02", fFrom); err == nil {
filter.From = t
}
}
if fTo != "" {
if t, err := time.Parse("2006-01-02", fTo); err == nil {
filter.To = t.Add(24*time.Hour - time.Second)
}
}
tickets, total, err := h.TicketRepo.ListFiltered(ctx, filter)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
pages := total / 20
if total%20 != 0 {
pages++
}
if pages < 1 {
pages = 1
}
types, _ := h.TicketRepo.ListDistinctTypes(ctx)
staff, _ := h.TicketRepo.ListDistinctStaff(ctx)
var guilds []GuildInfo
guildNames := map[string]string{}
if h.DiscordInfo != nil {
guilds = h.DiscordInfo.GetGuildList()
for _, g := range guilds {
guildNames[g.ID] = g.Name
}
}
h.Renderer.Page(w, "transcripts", transcriptsData{
baseData: base,
Tickets: tickets,
Total: total,
Page: page,
Pages: pages,
Types: types,
StaffList: staff,
Guilds: guilds,
GuildNames: guildNames,
FStatus: fStatus,
FType: fType,
FStaff: fStaff,
FGuild: fGuild,
FFrom: fFrom,
FTo: fTo,
FSearch: fSearch,
})
}
// HandleView serves GET /transcripts/view/{id} — serves the HTML transcript file directly.
// This handler must NOT be wrapped with securityHeaders to avoid CSP restrictions.
func (h *TranscriptsHandler) HandleView(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
ticket, err := h.TicketRepo.GetByID(ctx, id)
if err != nil || ticket == nil {
http.NotFound(w, r)
return
}
if !ticket.TranscriptPath.Valid || ticket.TranscriptPath.String == "" {
http.Error(w, "no transcript for this ticket", http.StatusNotFound)
return
}
path := ticket.TranscriptPath.String
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, fmt.Sprintf("transcript file not found: %s", path), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// HandleDownload serves GET /transcripts/download/{id} — serves the HTML transcript as attachment.
func (h *TranscriptsHandler) HandleDownload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
ticket, err := h.TicketRepo.GetByID(ctx, id)
if err != nil || ticket == nil {
http.NotFound(w, r)
return
}
if !ticket.TranscriptPath.Valid || ticket.TranscriptPath.String == "" {
http.Error(w, "no transcript for this ticket", http.StatusNotFound)
return
}
path := ticket.TranscriptPath.String
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, fmt.Sprintf("transcript file not found: %s", path), http.StatusNotFound)
return
}
filename := fmt.Sprintf("transcript_ticket_%d.html", ticket.TicketNumber)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}