oups
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user