From a5f888b3c16cb569ae9a03dc960bb4dde0a4511a Mon Sep 17 00:00:00 2001 From: lfirmin Date: Sun, 10 May 2026 18:07:51 +0200 Subject: [PATCH] oups --- cmd/bot/admin.go | 157 +++++++ cmd/bot/main.go | 79 +++- config.yaml | 30 +- go.mod | 5 + go.sum | 14 + internal/claim/manager.go | 34 +- internal/config/config.go | 26 +- internal/db/audit_log.go | 114 +++++ internal/db/convocation_config.go | 88 ++++ internal/db/convocation_panels.go | 103 +++++ internal/db/db.go | 122 ++++++ internal/db/panel_admin.go | 131 ++++++ internal/db/panel_config.go | 282 ++++++++++++ internal/db/panel_session.go | 102 +++++ internal/db/tickets.go | 251 ++++++++++- internal/db/user_blacklist.go | 55 +++ internal/discord/bot.go | 59 ++- internal/discord/bot_service.go | 403 +++++++++++++++++ internal/discord/components/convocation.go | 253 +++++++++++ internal/discord/components/panel.go | 57 ++- internal/discord/components/ticket.go | 4 +- internal/discord/router.go | 24 +- internal/logger/discord.go | 25 +- internal/panel/auth/middleware.go | 173 ++++++++ internal/panel/auth/oauth.go | 74 ++++ internal/panel/auth/rate_limiter.go | 92 ++++ internal/panel/auth/rate_limiter_test.go | 82 ++++ internal/panel/auth/service.go | 192 ++++++++ internal/panel/auth/service_test.go | 70 +++ internal/panel/bot_service.go | 14 + internal/panel/embed.go | 90 ++++ internal/panel/handlers/audit.go | 111 +++++ internal/panel/handlers/auth.go | 256 +++++++++++ internal/panel/handlers/common.go | 91 ++++ internal/panel/handlers/convocations.go | 252 +++++++++++ internal/panel/handlers/dashboard.go | 161 +++++++ internal/panel/handlers/login.go | 110 +++++ internal/panel/handlers/panels.go | 433 +++++++++++++++++++ internal/panel/handlers/sessions.go | 131 ++++++ internal/panel/handlers/transcripts.go | 188 ++++++++ internal/panel/migrate.go | 71 +++ internal/panel/server.go | 208 +++++++++ internal/panel/static/app.css | 23 + internal/panel/templates/audit.html | 103 +++++ internal/panel/templates/auth_layout.html | 28 ++ internal/panel/templates/convocations.html | 158 +++++++ internal/panel/templates/dashboard.html | 140 ++++++ internal/panel/templates/denied.html | 13 + internal/panel/templates/layout.html | 125 ++++++ internal/panel/templates/login.html | 20 + internal/panel/templates/panel_form.html | 431 ++++++++++++++++++ internal/panel/templates/panels_list.html | 91 ++++ internal/panel/templates/password_setup.html | 30 ++ internal/panel/templates/sessions.html | 90 ++++ internal/panel/templates/totp_setup.html | 35 ++ internal/panel/templates/totp_verify.html | 41 ++ internal/panel/templates/transcripts.html | 170 ++++++++ internal/tickets/auth.go | 4 + internal/tickets/service.go | 71 ++- 59 files changed, 6682 insertions(+), 108 deletions(-) create mode 100644 cmd/bot/admin.go create mode 100644 internal/db/audit_log.go create mode 100644 internal/db/convocation_config.go create mode 100644 internal/db/convocation_panels.go create mode 100644 internal/db/panel_admin.go create mode 100644 internal/db/panel_config.go create mode 100644 internal/db/panel_session.go create mode 100644 internal/db/user_blacklist.go create mode 100644 internal/discord/bot_service.go create mode 100644 internal/discord/components/convocation.go create mode 100644 internal/panel/auth/middleware.go create mode 100644 internal/panel/auth/oauth.go create mode 100644 internal/panel/auth/rate_limiter.go create mode 100644 internal/panel/auth/rate_limiter_test.go create mode 100644 internal/panel/auth/service.go create mode 100644 internal/panel/auth/service_test.go create mode 100644 internal/panel/bot_service.go create mode 100644 internal/panel/embed.go create mode 100644 internal/panel/handlers/audit.go create mode 100644 internal/panel/handlers/auth.go create mode 100644 internal/panel/handlers/common.go create mode 100644 internal/panel/handlers/convocations.go create mode 100644 internal/panel/handlers/dashboard.go create mode 100644 internal/panel/handlers/login.go create mode 100644 internal/panel/handlers/panels.go create mode 100644 internal/panel/handlers/sessions.go create mode 100644 internal/panel/handlers/transcripts.go create mode 100644 internal/panel/migrate.go create mode 100644 internal/panel/server.go create mode 100644 internal/panel/static/app.css create mode 100644 internal/panel/templates/audit.html create mode 100644 internal/panel/templates/auth_layout.html create mode 100644 internal/panel/templates/convocations.html create mode 100644 internal/panel/templates/dashboard.html create mode 100644 internal/panel/templates/denied.html create mode 100644 internal/panel/templates/layout.html create mode 100644 internal/panel/templates/login.html create mode 100644 internal/panel/templates/panel_form.html create mode 100644 internal/panel/templates/panels_list.html create mode 100644 internal/panel/templates/password_setup.html create mode 100644 internal/panel/templates/sessions.html create mode 100644 internal/panel/templates/totp_setup.html create mode 100644 internal/panel/templates/totp_verify.html create mode 100644 internal/panel/templates/transcripts.html diff --git a/cmd/bot/admin.go b/cmd/bot/admin.go new file mode 100644 index 0000000..933b844 --- /dev/null +++ b/cmd/bot/admin.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + + "github.com/leolionad58/ticketbot/internal/db" + panelauth "github.com/leolionad58/ticketbot/internal/panel/auth" +) + +// handleAdminCLI processes "ticketbot admin " and exits. +// Returns true if an admin subcommand was handled. +func handleAdminCLI(sqldb *sql.DB) bool { + if len(os.Args) < 2 || os.Args[1] != "admin" { + return false + } + + repo := db.NewPanelAdminRepo(sqldb) + ctx := context.Background() + + if len(os.Args) < 3 { + printAdminUsage() + os.Exit(1) + } + + switch os.Args[2] { + case "add": + cliAddAdmin(ctx, repo) + case "remove": + cliRemoveAdmin(ctx, repo) + case "list": + cliListAdmins(ctx, repo) + case "reset-totp": + cliResetTOTP(ctx, repo) + case "reset-password": + cliResetPassword(ctx, repo) + default: + fmt.Fprintf(os.Stderr, "Unknown admin subcommand: %s\n", os.Args[2]) + printAdminUsage() + os.Exit(1) + } + return true +} + +func cliAddAdmin(ctx context.Context, repo *db.PanelAdminRepo) { + args := os.Args[3:] + discordID := flagValue(args, "--discord-id") + password := flagValue(args, "--password") + superadmin := containsFlag(args, "--superadmin") + if discordID == "" { + fmt.Fprintln(os.Stderr, "Usage: admin add --discord-id [--password ] [--superadmin]") + os.Exit(1) + } + var hash string + if password != "" { + var err error + hash, err = panelauth.HashPassword(password) + must(err, "hash password") + } + a, err := repo.Create(ctx, discordID, "", hash, superadmin) + must(err, "create admin") + fmt.Printf("Admin added: discord_id=%s id=%d superadmin=%v\n", a.DiscordID, a.ID, a.IsSuperadmin) +} + +func cliRemoveAdmin(ctx context.Context, repo *db.PanelAdminRepo) { + discordID := flagValue(os.Args[3:], "--discord-id") + if discordID == "" { + fmt.Fprintln(os.Stderr, "Usage: admin remove --discord-id ") + os.Exit(1) + } + must(repo.Delete(ctx, discordID), "remove admin") + fmt.Printf("Admin removed: %s\n", discordID) +} + +func cliListAdmins(ctx context.Context, repo *db.PanelAdminRepo) { + admins, err := repo.List(ctx) + must(err, "list admins") + if len(admins) == 0 { + fmt.Println("No admins configured.") + return + } + fmt.Printf("%-20s %-30s %-12s %-12s\n", "Discord ID", "Username", "Superadmin", "TOTP") + for _, a := range admins { + fmt.Printf("%-20s %-30s %-12v %-12v\n", a.DiscordID, a.DiscordUsername, a.IsSuperadmin, a.TOTPEnabled) + } +} + +func cliResetTOTP(ctx context.Context, repo *db.PanelAdminRepo) { + discordID := flagValue(os.Args[3:], "--discord-id") + if discordID == "" { + fmt.Fprintln(os.Stderr, "Usage: admin reset-totp --discord-id ") + os.Exit(1) + } + a, err := repo.GetByDiscordID(ctx, discordID) + must(err, "get admin") + if a == nil { + fmt.Fprintf(os.Stderr, "Admin not found: %s\n", discordID) + os.Exit(1) + } + must(repo.ResetTOTP(ctx, a.ID), "reset totp") + fmt.Printf("TOTP reset for %s — admin must re-setup TOTP on next login.\n", discordID) +} + +func cliResetPassword(ctx context.Context, repo *db.PanelAdminRepo) { + args := os.Args[3:] + discordID := flagValue(args, "--discord-id") + password := flagValue(args, "--password") + if discordID == "" || password == "" { + fmt.Fprintln(os.Stderr, "Usage: admin reset-password --discord-id --password ") + os.Exit(1) + } + a, err := repo.GetByDiscordID(ctx, discordID) + must(err, "get admin") + if a == nil { + fmt.Fprintf(os.Stderr, "Admin not found: %s\n", discordID) + os.Exit(1) + } + hash, err := panelauth.HashPassword(password) + must(err, "hash password") + must(repo.UpdatePassword(ctx, a.ID, hash), "update password") + fmt.Printf("Password updated for %s\n", discordID) +} + +func flagValue(args []string, flag string) string { + for i := 0; i < len(args)-1; i++ { + if args[i] == flag { + return args[i+1] + } + } + return "" +} + +func containsFlag(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + return false +} + +func printAdminUsage() { + fmt.Fprintln(os.Stderr, `Usage: ticketbot admin [flags] + +Subcommands: + add --discord-id --password [--superadmin] + remove --discord-id + list + reset-totp --discord-id + reset-password --discord-id --password `) +} + +func newAdminRepo(sqldb *sql.DB) *db.PanelAdminRepo { + return db.NewPanelAdminRepo(sqldb) +} diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 887aa25..ff8108a 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log/slog" "os" "os/signal" @@ -15,6 +16,7 @@ import ( "github.com/leolionad58/ticketbot/internal/discord/components" "github.com/leolionad58/ticketbot/internal/discord/events" "github.com/leolionad58/ticketbot/internal/logger" + "github.com/leolionad58/ticketbot/internal/panel" "github.com/leolionad58/ticketbot/internal/tickets" "github.com/leolionad58/ticketbot/internal/transcript" ) @@ -34,8 +36,23 @@ func main() { must(err, "open db") defer sqldb.Close() + // CLI admin subcommand — handle and exit before starting the bot + if handleAdminCLI(sqldb) { + return + } + + // Migrate YAML panels to DB on first boot + panelRepo := db.NewPanelConfigRepo(sqldb) + if err := panel.MigrateYAMLPanels(context.Background(), panelRepo, cfg, os.Getenv("GUILD_ID")); err != nil { + slog.Warn("YAML panel migration failed", "err", err) + } + ticketRepo := db.NewTicketRepo(sqldb) claimRepo := db.NewClaimMessageRepo(sqldb) + convocRepo := db.NewConvocationConfigRepo(sqldb) + adminRepo := db.NewPanelAdminRepo(sqldb) + sessionRepo := db.NewPanelSessionRepo(sqldb) + auditRepo := db.NewAuditLogRepo(sqldb) token := os.Getenv("DISCORD_TOKEN") if token == "" { @@ -45,12 +62,27 @@ func main() { appID := os.Getenv("DISCORD_APP_ID") guildID := os.Getenv("GUILD_ID") - bot, err := discordbot.New(token, cfgProvider, ticketRepo, claimRepo, guildID) + // Build guild allowlist: config yaml takes priority, env GUILD_ID is always included + allowedGuilds := append([]string{}, cfg.Bot.AllowedGuilds...) + if guildID != "" { + found := false + for _, id := range allowedGuilds { + if id == guildID { + found = true + break + } + } + if !found { + allowedGuilds = append(allowedGuilds, guildID) + } + } + + bot, err := discordbot.New(token, cfgProvider, ticketRepo, claimRepo, guildID, allowedGuilds) must(err, "create bot") // Services auth := tickets.NewAuthService(cfgProvider) - ticketSvc := tickets.NewService(ticketRepo, auth, bot.Session, cfgProvider) + ticketSvc := tickets.NewService(ticketRepo, panelRepo, auth, bot.Session, cfgProvider) claimMgr := claim.NewManager(cfgProvider, ticketRepo, claimRepo) logSvc := logger.NewDiscordLogger(bot.Session, cfgProvider) transcriptGen := transcript.NewGenerator("transcripts", bot.Session) @@ -76,6 +108,7 @@ func main() { panelComp := &components.PanelComponent{ TicketSvc: ticketSvc, TicketRepo: ticketRepo, + PanelRepo: panelRepo, ClaimMgr: claimMgr, LogSvc: logSvc, Config: cfgProvider, @@ -88,14 +121,22 @@ func main() { Transcript: transcriptGen, Auth: auth, } + convocComp := &components.ConvocationComponent{ + ConvocRepo: convocRepo, + TicketRepo: ticketRepo, + Auth: auth, + LogSvc: logSvc, + } // Router router := &discordbot.Router{ - PanelCmd: panelCmd, - TicketCmd: ticketCmd, - ConvocCmd: convocCmd, - PanelComp: panelComp, - TicketComp: ticketComp, + PanelCmd: panelCmd, + TicketCmd: ticketCmd, + ConvocCmd: convocCmd, + PanelComp: panelComp, + TicketComp: ticketComp, + ConvocComp: convocComp, + IsGuildAllowed: bot.IsGuildAllowed, } bot.Session.AddHandler(router.Handle) @@ -123,6 +164,30 @@ func main() { slog.Warn("config watcher failed to start", "err", err) } + // Start panel HTTP server if configured + if cfg.Panel.BaseURL != "" || cfg.Panel.Port > 0 { + botSvc := discordbot.NewBotService(bot.Session, panelRepo, ticketRepo, convocRepo, guildID, allowedGuilds) + panelSrv := panel.NewServer( + cfg.Panel, + ticketRepo, + adminRepo, + sessionRepo, + auditRepo, + panelRepo, + convocRepo, + guildID, + botSvc, + ) + ctx, cancelPanel := context.WithCancel(context.Background()) + go func() { + slog.Info("panel server starting", "port", cfg.Panel.Port) + if err := panelSrv.Start(ctx); err != nil { + slog.Error("panel server stopped", "err", err) + } + }() + defer cancelPanel() + } + slog.Info("ticketbot ready, Ctrl+C to stop") stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) diff --git a/config.yaml b/config.yaml index a3f7363..768b1c0 100644 --- a/config.yaml +++ b/config.yaml @@ -1,22 +1,14 @@ bot: - admin_role: "1499923403761647689" # role pouvant lancer /panel_send + admin_role: "1499923403761647689" # role pouvant lancer /panel_send logs_channel: "1499924634655658004" # channel de logs (open/claim/close) - claim_channel: "1499924671884431380" # channel où défilent les tickets à claim - convocation_category: "1499924426358263950" # catégorie pour /convocation - claim_reup_minutes: 1 # délai avant re-up et ping staff + claim_channel: "1499924671884431380" # channel où défilent les tickets à claim + convocation_category: "1499924426358263950" # catégorie pour /convocation + claim_reup_minutes: 1 + allowed_guilds: ["1346947332536795180", "1498425866437529761"] # délai avant re-up et ping staff -panels: - support_panel: - embed_title: "Ouvrir un ticket" - embed_description: "Choisis le type de ticket ci-dessous." - embed_color: "#2b2d31" - types: - support: - button_label: "Support" - button_color: "primary" # primary / secondary / success / danger - button_emoji: "🛠️" # optionnel - embed_color: "#5865f2" - embed_title: "Ticket support" - embed_text: "Décris ton problème, un staff va te répondre." - staff_role: "1499923512683659314" - category: "1499924426358263950" \ No newline at end of file +panel: + port: 8080 + base_url: "http://localhost:8080" # ton domaine en prod (ex: https://panel.tonsite.com) + oauth_client_id: "1415152714559782922" # Discord app → OAuth2 → Client ID + oauth_client_secret: "H5HvZ1AYvhT3VirKFLQmGvgRYuxzvy4m" # Discord app → OAuth2 → Client Secret + session_timeout_minutes: 30 \ No newline at end of file diff --git a/go.mod b/go.mod index 240fd37..f36499b 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,21 @@ module github.com/leolionad58/ticketbot go 1.26.2 require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bwmarrin/discordgo v0.29.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.10.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.0 // indirect diff --git a/go.sum b/go.sum index eb790e8..3b9058b 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,14 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= @@ -14,11 +19,20 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/claim/manager.go b/internal/claim/manager.go index 6ca41f6..f02cabd 100644 --- a/internal/claim/manager.go +++ b/internal/claim/manager.go @@ -63,8 +63,12 @@ func (m *Manager) DeleteClaimMessage(ctx context.Context, s *discordgo.Session, if err != nil || cm == nil { return } - cfg := m.cfg.Get() - if err := s.ChannelMessageDelete(cfg.Bot.ClaimChannel, cm.MessageID); err != nil { + // Resolve claim channel: look up ticket for per-ticket channel, fall back to config. + claimChannelID := m.cfg.Get().Bot.ClaimChannel + if t, err := m.repo.GetByID(ctx, ticketID); err == nil && t != nil && t.ClaimChannelID != "" { + claimChannelID = t.ClaimChannelID + } + if err := s.ChannelMessageDelete(claimChannelID, cm.MessageID); err != nil { slog.Warn("claim: delete message", "ticket_id", ticketID, "err", err) } m.claims.Delete(ctx, ticketID) //nolint @@ -80,6 +84,9 @@ func (m *Manager) MarkClaimed(ctx context.Context, s *discordgo.Session, ticket } cfg := m.cfg.Get() claimChannelID := cfg.Bot.ClaimChannel + if ticket.ClaimChannelID != "" { + claimChannelID = ticket.ClaimChannelID + } if claimChannelID == "" { m.claims.Delete(ctx, ticket.ID) //nolint return @@ -131,7 +138,11 @@ func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReu }() cfg := m.cfg.Get() - interval := time.Duration(cfg.Bot.ClaimReupMinutes) * time.Minute + reupMins := cfg.Bot.ClaimReupMinutes + if ticket.ClaimReupMinutes > 0 { + reupMins = ticket.ClaimReupMinutes + } + interval := time.Duration(reupMins) * time.Minute elapsed := time.Since(lastReupAt) next := interval - elapsed if next <= 0 { @@ -175,7 +186,11 @@ func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReu // Re-read config in case of hot reload cfg = m.cfg.Get() - interval = time.Duration(cfg.Bot.ClaimReupMinutes) * time.Minute + reupMins = cfg.Bot.ClaimReupMinutes + if ticket.ClaimReupMinutes > 0 { + reupMins = ticket.ClaimReupMinutes + } + interval = time.Duration(reupMins) * time.Minute timer.Reset(interval) } } @@ -185,19 +200,24 @@ func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReu func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *db.Ticket, withPing bool) (string, error) { cfg := m.cfg.Get() claimChannelID := cfg.Bot.ClaimChannel + if ticket.ClaimChannelID != "" { + claimChannelID = ticket.ClaimChannelID + } if claimChannelID == "" { return "", fmt.Errorf("claim_channel not configured") } - // Determine embed color from type config, fallback to blurple + // Determine embed color and staff role — prefer ticket fields (set at open time for DB panels) embedColor := 0x5865f2 - var staffRoleID string + staffRoleID := ticket.StaffRoleID if panel, ok := cfg.Panels[ticket.Panel]; ok { if t, ok := panel.Types[ticket.Type]; ok { if c := parseHexColor(t.EmbedColor); c != 0 { embedColor = c } - staffRoleID = t.StaffRole + if staffRoleID == "" { + staffRoleID = t.StaffRole + } } } diff --git a/internal/config/config.go b/internal/config/config.go index 4d88faf..c133c44 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,15 +36,25 @@ type Panel struct { } type Bot struct { - AdminRole string `yaml:"admin_role"` - LogsChannel string `yaml:"logs_channel"` - ClaimChannel string `yaml:"claim_channel"` - ConvocationCategory string `yaml:"convocation_category"` - ClaimReupMinutes int `yaml:"claim_reup_minutes"` + AdminRole string `yaml:"admin_role"` + LogsChannel string `yaml:"logs_channel"` + ClaimChannel string `yaml:"claim_channel"` + ConvocationCategory string `yaml:"convocation_category"` + ClaimReupMinutes int `yaml:"claim_reup_minutes"` + AllowedGuilds []string `yaml:"allowed_guilds"` // whitelist; empty = use GUILD_ID env only +} + +type PanelWebConfig struct { + Port int `yaml:"port"` + BaseURL string `yaml:"base_url"` + OAuthClientID string `yaml:"oauth_client_id"` + OAuthClientSecret string `yaml:"oauth_client_secret"` + SessionTimeoutMinutes int `yaml:"session_timeout_minutes"` } type Config struct { Bot Bot `yaml:"bot"` + Panel PanelWebConfig `yaml:"panel"` Panels map[string]Panel `yaml:"panels"` } @@ -55,6 +65,12 @@ func (c *Config) Validate() error { if c.Bot.ClaimReupMinutes <= 0 { c.Bot.ClaimReupMinutes = 30 } + if c.Panel.Port <= 0 { + c.Panel.Port = 8080 + } + if c.Panel.SessionTimeoutMinutes <= 0 { + c.Panel.SessionTimeoutMinutes = 30 + } return nil } diff --git a/internal/db/audit_log.go b/internal/db/audit_log.go new file mode 100644 index 0000000..a6a96d0 --- /dev/null +++ b/internal/db/audit_log.go @@ -0,0 +1,114 @@ +package db + +import ( + "context" + "database/sql" + "time" +) + +type AuditLogEntry struct { + ID int64 + AdminID int64 + Action string + EntityType string + EntityID sql.NullInt64 + OldValue sql.NullString + NewValue sql.NullString + IPAddress string + CreatedAt time.Time +} + +// Audit action constants +const ( + AuditCreatePanel = "create_panel" + AuditUpdatePanel = "update_panel" + AuditDeletePanel = "delete_panel" + AuditCreateType = "create_type" + AuditUpdateType = "update_type" + AuditDeleteType = "delete_type" + AuditReorderTypes = "reorder_types" + AuditUpdateConvoc = "update_convocation" + AuditSendPanelDiscord = "send_panel_discord" + AuditDeletePanelMsg = "delete_panel_discord" + AuditRevokeSession = "revoke_session" + AuditRevokeAllSess = "revoke_all_sessions" + AuditAdminLogin = "admin_login" + AuditAdminLoginFail = "admin_login_failed" + AuditAdminLocked = "admin_locked" +) + +type AuditFilter struct { + AdminID int64 + Action string + EntityType string + From, To time.Time +} + +type AuditLogRepo struct{ db *sql.DB } + +func NewAuditLogRepo(db *sql.DB) *AuditLogRepo { return &AuditLogRepo{db: db} } + +func (r *AuditLogRepo) Insert(ctx context.Context, e *AuditLogEntry) error { + e.CreatedAt = time.Now().UTC() + _, err := r.db.ExecContext(ctx, + `INSERT INTO audit_log(admin_id,action,entity_type,entity_id,old_value,new_value,ip_address,created_at) + VALUES(?,?,?,?,?,?,?,?)`, + e.AdminID, e.Action, e.EntityType, e.EntityID, e.OldValue, e.NewValue, e.IPAddress, e.CreatedAt) + return err +} + +func (r *AuditLogRepo) List(ctx context.Context, f AuditFilter, page, pageSize int) ([]*AuditLogEntry, int, error) { + where := "WHERE 1=1" + args := []any{} + if f.AdminID != 0 { + where += " AND admin_id=?" + args = append(args, f.AdminID) + } + if f.Action != "" { + where += " AND action=?" + args = append(args, f.Action) + } + if f.EntityType != "" { + where += " AND entity_type=?" + args = append(args, f.EntityType) + } + if !f.From.IsZero() { + where += " AND created_at>=?" + args = append(args, f.From) + } + if !f.To.IsZero() { + where += " AND created_at<=?" + args = append(args, f.To) + } + + var total int + countArgs := make([]any, len(args)) + copy(countArgs, args) + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log `+where, countArgs...).Scan(&total); err != nil { + return nil, 0, err + } + + if page < 1 { + page = 1 + } + offset := (page - 1) * pageSize + args = append(args, pageSize, offset) + rows, err := r.db.QueryContext(ctx, + `SELECT id,admin_id,action,entity_type,entity_id,old_value,new_value,ip_address,created_at + FROM audit_log `+where+` ORDER BY created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var out []*AuditLogEntry + for rows.Next() { + var e AuditLogEntry + if err := rows.Scan(&e.ID, &e.AdminID, &e.Action, &e.EntityType, &e.EntityID, + &e.OldValue, &e.NewValue, &e.IPAddress, &e.CreatedAt); err != nil { + return nil, 0, err + } + out = append(out, &e) + } + return out, total, rows.Err() +} diff --git a/internal/db/convocation_config.go b/internal/db/convocation_config.go new file mode 100644 index 0000000..cd79923 --- /dev/null +++ b/internal/db/convocation_config.go @@ -0,0 +1,88 @@ +package db + +import ( + "context" + "database/sql" + "time" +) + +// ConvocationConfig holds per-guild convocation configuration. +type ConvocationConfig struct { + ID int64 + GuildID string + CategoryID string + LogChannelID string + ModalEnabled bool + UpdatedAt time.Time + PanelChannelID string + PanelMessageID string + PanelEmbedTitle string + PanelEmbedDescription string + PanelEmbedColor string +} + +// ConvocationConfigRepo handles CRUD for convocation_config. +type ConvocationConfigRepo struct{ db *sql.DB } + +// NewConvocationConfigRepo creates a ConvocationConfigRepo. +func NewConvocationConfigRepo(db *sql.DB) *ConvocationConfigRepo { + return &ConvocationConfigRepo{db: db} +} + +// Get returns the convocation config for the given guild. +// Returns an empty (zero-value) config without error if none exists yet. +func (r *ConvocationConfigRepo) Get(ctx context.Context, guildID string) (*ConvocationConfig, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id,guild_id,category_id,log_channel_id,modal_enabled,updated_at, + panel_channel_id,panel_message_id,panel_embed_title,panel_embed_description,panel_embed_color + FROM convocation_config WHERE guild_id=?`, guildID) + var c ConvocationConfig + var modalEnabled int + err := row.Scan(&c.ID, &c.GuildID, &c.CategoryID, &c.LogChannelID, &modalEnabled, &c.UpdatedAt, + &c.PanelChannelID, &c.PanelMessageID, &c.PanelEmbedTitle, &c.PanelEmbedDescription, &c.PanelEmbedColor) + if err == sql.ErrNoRows { + c.GuildID = guildID + c.ModalEnabled = true + c.PanelEmbedTitle = "Créer une convocation" + c.PanelEmbedColor = "#5865f2" + return &c, nil + } + if err != nil { + return nil, err + } + c.ModalEnabled = modalEnabled != 0 + return &c, nil +} + +// Upsert inserts or replaces the convocation config for a guild. +func (r *ConvocationConfigRepo) Upsert(ctx context.Context, c *ConvocationConfig) error { + c.UpdatedAt = time.Now().UTC() + modalInt := 0 + if c.ModalEnabled { + modalInt = 1 + } + _, err := r.db.ExecContext(ctx, + `INSERT INTO convocation_config(guild_id,category_id,log_channel_id,modal_enabled,updated_at, + panel_channel_id,panel_message_id,panel_embed_title,panel_embed_description,panel_embed_color) + VALUES(?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(guild_id) DO UPDATE SET + category_id=excluded.category_id, + log_channel_id=excluded.log_channel_id, + modal_enabled=excluded.modal_enabled, + updated_at=excluded.updated_at, + panel_channel_id=excluded.panel_channel_id, + panel_embed_title=excluded.panel_embed_title, + panel_embed_description=excluded.panel_embed_description, + panel_embed_color=excluded.panel_embed_color`, + c.GuildID, c.CategoryID, c.LogChannelID, modalInt, c.UpdatedAt, + c.PanelChannelID, c.PanelMessageID, c.PanelEmbedTitle, c.PanelEmbedDescription, c.PanelEmbedColor) + return err +} + +// SetConvocPanelMessage stores the Discord message ID for the convocation panel. +func (r *ConvocationConfigRepo) SetConvocPanelMessage(ctx context.Context, guildID, channelID, messageID string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE convocation_config SET panel_channel_id=?, panel_message_id=?, updated_at=? WHERE guild_id=?`, + channelID, messageID, time.Now().UTC(), guildID) + return err +} diff --git a/internal/db/convocation_panels.go b/internal/db/convocation_panels.go new file mode 100644 index 0000000..b37f0fe --- /dev/null +++ b/internal/db/convocation_panels.go @@ -0,0 +1,103 @@ +package db + +import ( + "context" + "database/sql" + "time" +) + +// ConvocationPanel is a single convocation panel embed+button entry. +type ConvocationPanel struct { + ID int64 + GuildID string + EmbedTitle string + EmbedDesc string + EmbedColor string + ChannelID string + MessageID string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ConvocationPanelRepo struct{ db *sql.DB } + +func NewConvocationPanelRepo(db *sql.DB) *ConvocationPanelRepo { + return &ConvocationPanelRepo{db: db} +} + +func (r *ConvocationPanelRepo) List(ctx context.Context, guildID string) ([]*ConvocationPanel, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id,guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at + FROM convocation_panels WHERE guild_id=? ORDER BY id`, guildID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*ConvocationPanel + for rows.Next() { + p, err := scanConvocPanel(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (r *ConvocationPanelRepo) GetByID(ctx context.Context, id int64) (*ConvocationPanel, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id,guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at + FROM convocation_panels WHERE id=?`, id) + var p ConvocationPanel + err := row.Scan(&p.ID, &p.GuildID, &p.EmbedTitle, &p.EmbedDesc, &p.EmbedColor, + &p.ChannelID, &p.MessageID, &p.CreatedAt, &p.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &p, err +} + +func (r *ConvocationPanelRepo) Create(ctx context.Context, p *ConvocationPanel) error { + now := time.Now().UTC() + res, err := r.db.ExecContext(ctx, + `INSERT INTO convocation_panels(guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`, + p.GuildID, p.EmbedTitle, p.EmbedDesc, p.EmbedColor, p.ChannelID, p.MessageID, now, now) + if err != nil { + return err + } + p.ID, _ = res.LastInsertId() + p.CreatedAt = now + p.UpdatedAt = now + return nil +} + +func (r *ConvocationPanelRepo) Update(ctx context.Context, p *ConvocationPanel) error { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, + `UPDATE convocation_panels SET embed_title=?,embed_description=?,embed_color=?,channel_id=?,updated_at=? WHERE id=?`, + p.EmbedTitle, p.EmbedDesc, p.EmbedColor, p.ChannelID, now, p.ID) + if err == nil { + p.UpdatedAt = now + } + return err +} + +func (r *ConvocationPanelRepo) SetMessage(ctx context.Context, id int64, messageID string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE convocation_panels SET message_id=?,updated_at=? WHERE id=?`, + messageID, time.Now().UTC(), id) + return err +} + +func (r *ConvocationPanelRepo) Delete(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM convocation_panels WHERE id=?`, id) + return err +} + +func scanConvocPanel(rows *sql.Rows) (*ConvocationPanel, error) { + var p ConvocationPanel + err := rows.Scan(&p.ID, &p.GuildID, &p.EmbedTitle, &p.EmbedDesc, &p.EmbedColor, + &p.ChannelID, &p.MessageID, &p.CreatedAt, &p.UpdatedAt) + return &p, err +} diff --git a/internal/db/db.go b/internal/db/db.go index 09ed907..ce91613 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -63,8 +63,130 @@ func migrate(db *sql.DB) error { // v2: user-supplied ticket title and description (from modal) `ALTER TABLE tickets ADD COLUMN ticket_title TEXT; ALTER TABLE tickets ADD COLUMN ticket_description TEXT;`, + // v3: panel web admin tables + `CREATE TABLE IF NOT EXISTS panel_admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT NOT NULL UNIQUE, + discord_username TEXT NOT NULL DEFAULT '', + discord_avatar TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + totp_secret TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + is_superadmin INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS panel_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + csrf_token TEXT NOT NULL, + admin_id INTEGER NOT NULL REFERENCES panel_admins(id) ON DELETE CASCADE, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + last_activity DATETIME NOT NULL, + created_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_token ON panel_sessions(token); + CREATE TABLE IF NOT EXISTS panel_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + embed_title TEXT NOT NULL DEFAULT '', + embed_description TEXT, + embed_color TEXT NOT NULL DEFAULT '#5865f2', + guild_id TEXT NOT NULL DEFAULT '', + channel_id TEXT, + message_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS panel_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + panel_id INTEGER NOT NULL REFERENCES panel_configs(id) ON DELETE CASCADE, + name TEXT NOT NULL, + button_label TEXT NOT NULL DEFAULT '', + button_color TEXT NOT NULL DEFAULT 'primary', + button_emoji TEXT NOT NULL DEFAULT '', + embed_color TEXT NOT NULL DEFAULT '#5865f2', + embed_title TEXT NOT NULL DEFAULT '', + embed_text TEXT NOT NULL DEFAULT '', + staff_role_id TEXT NOT NULL DEFAULT '', + category_id TEXT NOT NULL DEFAULT '', + claim_channel_id TEXT NOT NULL DEFAULT '', + log_channel_id TEXT NOT NULL DEFAULT '', + max_per_user INTEGER NOT NULL DEFAULT 1, + claim_mode INTEGER NOT NULL DEFAULT 1, + close_rule TEXT NOT NULL DEFAULT 'staff_only', + modal_enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE(panel_id, name) + ); + CREATE TABLE IF NOT EXISTS convocation_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL UNIQUE, + category_id TEXT NOT NULL DEFAULT '', + log_channel_id TEXT NOT NULL DEFAULT '', + modal_enabled INTEGER NOT NULL DEFAULT 1, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id INTEGER NOT NULL REFERENCES panel_admins(id), + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + old_value TEXT, + new_value TEXT, + ip_address TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_audit_admin ON audit_log(admin_id); + CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id); + CREATE TABLE IF NOT EXISTS user_blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT NOT NULL UNIQUE, + reason TEXT, + blacklisted_by TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL + );`, } + migrations = append(migrations, + // v4: embed image fields on panel_types + `ALTER TABLE panel_types ADD COLUMN thumbnail_url TEXT NOT NULL DEFAULT ''; + ALTER TABLE panel_types ADD COLUMN image_url TEXT NOT NULL DEFAULT '';`, + // v5: embed image/thumbnail on panel_configs (panel-level) + `ALTER TABLE panel_configs ADD COLUMN embed_image TEXT NOT NULL DEFAULT ''; + ALTER TABLE panel_configs ADD COLUMN embed_thumbnail TEXT NOT NULL DEFAULT '';`, + // v6: per-ticket guild + channel routing + convoc panel config + `ALTER TABLE tickets ADD COLUMN guild_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE tickets ADD COLUMN claim_channel_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE tickets ADD COLUMN log_channel_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE convocation_config ADD COLUMN panel_channel_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE convocation_config ADD COLUMN panel_message_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE convocation_config ADD COLUMN panel_embed_title TEXT NOT NULL DEFAULT 'Créer une convocation'; + ALTER TABLE convocation_config ADD COLUMN panel_embed_description TEXT NOT NULL DEFAULT ''; + ALTER TABLE convocation_config ADD COLUMN panel_embed_color TEXT NOT NULL DEFAULT '#5865f2';`, + // v7: per-ticket staff role + reup time, per-type reup time, convocation panels table + `ALTER TABLE tickets ADD COLUMN staff_role_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE tickets ADD COLUMN claim_reup_minutes INTEGER NOT NULL DEFAULT 0; + ALTER TABLE panel_types ADD COLUMN claim_reup_minutes INTEGER NOT NULL DEFAULT 0; + CREATE TABLE IF NOT EXISTS convocation_panels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + embed_title TEXT NOT NULL DEFAULT 'Créer une convocation', + embed_description TEXT NOT NULL DEFAULT '', + embed_color TEXT NOT NULL DEFAULT '#5865f2', + channel_id TEXT NOT NULL DEFAULT '', + message_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_convoc_panels_guild ON convocation_panels(guild_id);`, + ) + for i, m := range migrations { v := i + 1 if v <= version { diff --git a/internal/db/panel_admin.go b/internal/db/panel_admin.go new file mode 100644 index 0000000..3f2a8a7 --- /dev/null +++ b/internal/db/panel_admin.go @@ -0,0 +1,131 @@ +package db + +import ( + "context" + "database/sql" + "time" +) + +type PanelAdmin struct { + ID int64 + DiscordID string + DiscordUsername string + DiscordAvatar string + PasswordHash string + TOTPSecret sql.NullString + TOTPEnabled bool + IsSuperadmin bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type PanelAdminRepo struct{ db *sql.DB } + +func NewPanelAdminRepo(db *sql.DB) *PanelAdminRepo { return &PanelAdminRepo{db: db} } + +const adminCols = `id,discord_id,discord_username,discord_avatar,password_hash,totp_secret,totp_enabled,is_superadmin,created_at,updated_at` + +func (r *PanelAdminRepo) Create(ctx context.Context, discordID, username, passwordHash string, isSuperadmin bool) (*PanelAdmin, error) { + now := time.Now().UTC() + res, err := r.db.ExecContext(ctx, + `INSERT INTO panel_admins(discord_id,discord_username,password_hash,is_superadmin,created_at,updated_at) + VALUES(?,?,?,?,?,?)`, + discordID, username, passwordHash, boolInt(isSuperadmin), now, now) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return r.GetByID(ctx, id) +} + +func (r *PanelAdminRepo) GetByID(ctx context.Context, id int64) (*PanelAdmin, error) { + row := r.db.QueryRowContext(ctx, `SELECT `+adminCols+` FROM panel_admins WHERE id=?`, id) + return scanAdmin(row) +} + +func (r *PanelAdminRepo) GetByDiscordID(ctx context.Context, discordID string) (*PanelAdmin, error) { + row := r.db.QueryRowContext(ctx, `SELECT `+adminCols+` FROM panel_admins WHERE discord_id=?`, discordID) + return scanAdmin(row) +} + +func (r *PanelAdminRepo) List(ctx context.Context) ([]*PanelAdmin, error) { + rows, err := r.db.QueryContext(ctx, `SELECT `+adminCols+` FROM panel_admins ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*PanelAdmin + for rows.Next() { + a, err := scanAdminRows(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (r *PanelAdminRepo) UpdateDiscordProfile(ctx context.Context, id int64, username, avatar string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE panel_admins SET discord_username=?,discord_avatar=?,updated_at=? WHERE id=?`, + username, avatar, time.Now().UTC(), id) + return err +} + +func (r *PanelAdminRepo) UpdatePassword(ctx context.Context, id int64, hash string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE panel_admins SET password_hash=?,updated_at=? WHERE id=?`, + hash, time.Now().UTC(), id) + return err +} + +func (r *PanelAdminRepo) UpdateTOTP(ctx context.Context, id int64, secret string, enabled bool) error { + _, err := r.db.ExecContext(ctx, + `UPDATE panel_admins SET totp_secret=?,totp_enabled=?,updated_at=? WHERE id=?`, + secret, boolInt(enabled), time.Now().UTC(), id) + return err +} + +func (r *PanelAdminRepo) ResetTOTP(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, + `UPDATE panel_admins SET totp_secret=NULL,totp_enabled=0,updated_at=? WHERE id=?`, + time.Now().UTC(), id) + return err +} + +func (r *PanelAdminRepo) Delete(ctx context.Context, discordID string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM panel_admins WHERE discord_id=?`, discordID) + return err +} + +func scanAdmin(row *sql.Row) (*PanelAdmin, error) { + a, err := scanAdminFields(func(dest ...any) error { return row.Scan(dest...) }) + if err == sql.ErrNoRows { + return nil, nil + } + return a, err +} + +func scanAdminRows(rows *sql.Rows) (*PanelAdmin, error) { + return scanAdminFields(func(dest ...any) error { return rows.Scan(dest...) }) +} + +func scanAdminFields(scan func(...any) error) (*PanelAdmin, error) { + var a PanelAdmin + var totpEnabled, isSuperadmin int + err := scan(&a.ID, &a.DiscordID, &a.DiscordUsername, &a.DiscordAvatar, + &a.PasswordHash, &a.TOTPSecret, &totpEnabled, &isSuperadmin, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, err + } + a.TOTPEnabled = totpEnabled != 0 + a.IsSuperadmin = isSuperadmin != 0 + return &a, nil +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/db/panel_config.go b/internal/db/panel_config.go new file mode 100644 index 0000000..233c3ef --- /dev/null +++ b/internal/db/panel_config.go @@ -0,0 +1,282 @@ +package db + +import ( + "context" + "database/sql" + "time" +) + +type PanelConfig struct { + ID int64 + Name string + EmbedTitle string + EmbedDescription sql.NullString + EmbedColor string + EmbedImage string + EmbedThumbnail string + GuildID string + ChannelID sql.NullString + MessageID sql.NullString + SortOrder int + CreatedAt time.Time + UpdatedAt time.Time + Types []*PanelType +} + +type PanelType struct { + ID int64 + PanelID int64 + Name string + ButtonLabel string + ButtonColor string + ButtonEmoji string + EmbedColor string + EmbedTitle string + EmbedText string + ThumbnailURL string + ImageURL string + StaffRoleID string + CategoryID string + ClaimChannelID string + LogChannelID string + MaxPerUser int + ClaimMode int + ClaimReupMinutes int + CloseRule string + ModalEnabled bool + SortOrder int + CreatedAt time.Time + UpdatedAt time.Time +} + +type PanelConfigRepo struct{ db *sql.DB } + +func NewPanelConfigRepo(db *sql.DB) *PanelConfigRepo { return &PanelConfigRepo{db: db} } + +const panelCols = `id,name,embed_title,embed_description,embed_color,embed_image,embed_thumbnail,guild_id,channel_id,message_id,sort_order,created_at,updated_at` + +const typeCols = `id,panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url,` + + `staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at` + +func (r *PanelConfigRepo) List(ctx context.Context) ([]*PanelConfig, error) { + rows, err := r.db.QueryContext(ctx, `SELECT `+panelCols+` FROM panel_configs ORDER BY sort_order,name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*PanelConfig + for rows.Next() { + p, err := scanPanelConfig(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + // Load types for each panel in one extra pass + for _, p := range out { + p.Types, _ = r.ListTypes(ctx, p.ID) + } + return out, nil +} + +func (r *PanelConfigRepo) GetByID(ctx context.Context, id int64) (*PanelConfig, error) { + row := r.db.QueryRowContext(ctx, `SELECT `+panelCols+` FROM panel_configs WHERE id=?`, id) + var p PanelConfig + err := row.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor, + &p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + types, err := r.ListTypes(ctx, id) + if err != nil { + return nil, err + } + p.Types = types + return &p, nil +} + +func (r *PanelConfigRepo) Create(ctx context.Context, p *PanelConfig) error { + now := time.Now().UTC() + res, err := r.db.ExecContext(ctx, + `INSERT INTO panel_configs(name,embed_title,embed_description,embed_color,embed_image,embed_thumbnail,guild_id,channel_id,sort_order,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + p.Name, p.EmbedTitle, p.EmbedDescription, p.EmbedColor, p.EmbedImage, p.EmbedThumbnail, + p.GuildID, p.ChannelID, p.SortOrder, now, now) + if err != nil { + return err + } + p.ID, _ = res.LastInsertId() + p.CreatedAt = now + p.UpdatedAt = now + return nil +} + +func (r *PanelConfigRepo) Update(ctx context.Context, p *PanelConfig) error { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, + `UPDATE panel_configs SET name=?,embed_title=?,embed_description=?,embed_color=?,embed_image=?,embed_thumbnail=?, + guild_id=?,channel_id=?,sort_order=?,updated_at=? WHERE id=?`, + p.Name, p.EmbedTitle, p.EmbedDescription, p.EmbedColor, p.EmbedImage, p.EmbedThumbnail, + p.GuildID, p.ChannelID, p.SortOrder, now, p.ID) + if err == nil { + p.UpdatedAt = now + } + return err +} + +func (r *PanelConfigRepo) Delete(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM panel_configs WHERE id=?`, id) + return err +} + +// ReplaceTypes deletes all types for a panel and inserts the provided list. +func (r *PanelConfigRepo) ReplaceTypes(ctx context.Context, panelID int64, types []*PanelType) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() //nolint + if _, err := tx.ExecContext(ctx, `DELETE FROM panel_types WHERE panel_id=?`, panelID); err != nil { + return err + } + now := time.Now().UTC() + for i, t := range types { + t.PanelID = panelID + t.SortOrder = i + res, err := tx.ExecContext(ctx, + `INSERT INTO panel_types(panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url, + staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + t.PanelID, t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL, + t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes, + t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, now) + if err != nil { + return err + } + t.ID, _ = res.LastInsertId() + } + return tx.Commit() +} + +// ReorderTypes updates sort_order for the given type IDs in order. +func (r *PanelConfigRepo) ReorderTypes(ctx context.Context, panelID int64, typeIDs []int64) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() //nolint + for i, id := range typeIDs { + if _, err := tx.ExecContext(ctx, + `UPDATE panel_types SET sort_order=? WHERE id=? AND panel_id=?`, i, id, panelID); err != nil { + return err + } + } + return tx.Commit() +} + +func (r *PanelConfigRepo) SetDiscordMessage(ctx context.Context, id int64, channelID, messageID string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE panel_configs SET channel_id=?,message_id=?,updated_at=? WHERE id=?`, + channelID, messageID, time.Now().UTC(), id) + return err +} + +func (r *PanelConfigRepo) GetByName(ctx context.Context, name string) (*PanelConfig, error) { + row := r.db.QueryRowContext(ctx, `SELECT `+panelCols+` FROM panel_configs WHERE name=?`, name) + var p PanelConfig + err := row.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor, + &p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + p.Types, _ = r.ListTypes(ctx, p.ID) + return &p, nil +} + +func (r *PanelConfigRepo) IsEmpty(ctx context.Context) (bool, error) { + var count int + err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM panel_configs`).Scan(&count) + return count == 0, err +} + +// --- Types --- + +func (r *PanelConfigRepo) ListTypes(ctx context.Context, panelID int64) ([]*PanelType, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+typeCols+` FROM panel_types WHERE panel_id=? ORDER BY sort_order,name`, panelID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*PanelType + for rows.Next() { + t, err := scanPanelType(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (r *PanelConfigRepo) CreateType(ctx context.Context, t *PanelType) error { + now := time.Now().UTC() + res, err := r.db.ExecContext(ctx, + `INSERT INTO panel_types(panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url, + staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + t.PanelID, t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL, + t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes, + t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, now) + if err != nil { + return err + } + t.ID, _ = res.LastInsertId() + t.CreatedAt = now + t.UpdatedAt = now + return nil +} + +func (r *PanelConfigRepo) UpdateType(ctx context.Context, t *PanelType) error { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, + `UPDATE panel_types SET name=?,button_label=?,button_color=?,button_emoji=?,embed_color=?,embed_title=?,embed_text=?,thumbnail_url=?,image_url=?, + staff_role_id=?,category_id=?,claim_channel_id=?,log_channel_id=?,max_per_user=?,claim_mode=?,claim_reup_minutes=?,close_rule=?,modal_enabled=?,sort_order=?,updated_at=? + WHERE id=?`, + t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL, + t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes, + t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, t.ID) + return err +} + +func (r *PanelConfigRepo) DeleteType(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM panel_types WHERE id=?`, id) + return err +} + +func scanPanelConfig(rows *sql.Rows) (*PanelConfig, error) { + var p PanelConfig + err := rows.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor, + &p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt) + return &p, err +} + +func scanPanelType(rows *sql.Rows) (*PanelType, error) { + var t PanelType + var modalEnabled int + err := rows.Scan(&t.ID, &t.PanelID, &t.Name, &t.ButtonLabel, &t.ButtonColor, &t.ButtonEmoji, + &t.EmbedColor, &t.EmbedTitle, &t.EmbedText, &t.ThumbnailURL, &t.ImageURL, + &t.StaffRoleID, &t.CategoryID, &t.ClaimChannelID, &t.LogChannelID, + &t.MaxPerUser, &t.ClaimMode, &t.ClaimReupMinutes, &t.CloseRule, &modalEnabled, &t.SortOrder, &t.CreatedAt, &t.UpdatedAt) + t.ModalEnabled = modalEnabled != 0 + return &t, err +} diff --git a/internal/db/panel_session.go b/internal/db/panel_session.go new file mode 100644 index 0000000..f66b060 --- /dev/null +++ b/internal/db/panel_session.go @@ -0,0 +1,102 @@ +package db + +import ( + "context" + "database/sql" + "time" +) + +type PanelSession struct { + ID int64 + Token string + CSRFToken string + AdminID int64 + IPAddress string + UserAgent string + LastActivity time.Time + CreatedAt time.Time +} + +type PanelSessionRepo struct{ db *sql.DB } + +func NewPanelSessionRepo(db *sql.DB) *PanelSessionRepo { return &PanelSessionRepo{db: db} } + +func (r *PanelSessionRepo) Create(ctx context.Context, s *PanelSession) error { + res, err := r.db.ExecContext(ctx, + `INSERT INTO panel_sessions(token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at) + VALUES(?,?,?,?,?,?,?)`, + s.Token, s.CSRFToken, s.AdminID, s.IPAddress, s.UserAgent, s.LastActivity, s.CreatedAt) + if err != nil { + return err + } + s.ID, _ = res.LastInsertId() + return nil +} + +func (r *PanelSessionRepo) GetByToken(ctx context.Context, token string) (*PanelSession, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id,token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at + FROM panel_sessions WHERE token=?`, token) + var s PanelSession + err := row.Scan(&s.ID, &s.Token, &s.CSRFToken, &s.AdminID, + &s.IPAddress, &s.UserAgent, &s.LastActivity, &s.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *PanelSessionRepo) UpdateLastActivity(ctx context.Context, token string, t time.Time) error { + _, err := r.db.ExecContext(ctx, + `UPDATE panel_sessions SET last_activity=? WHERE token=?`, t, token) + return err +} + +func (r *PanelSessionRepo) DeleteByToken(ctx context.Context, token string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE token=?`, token) + return err +} + +func (r *PanelSessionRepo) DeleteByID(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE id=?`, id) + return err +} + +// DeleteAllExcept deletes all sessions for adminID except the one with exceptToken. +func (r *PanelSessionRepo) DeleteAllExcept(ctx context.Context, adminID int64, exceptToken string) error { + _, err := r.db.ExecContext(ctx, + `DELETE FROM panel_sessions WHERE admin_id=? AND token!=?`, adminID, exceptToken) + return err +} + +func (r *PanelSessionRepo) List(ctx context.Context) ([]*PanelSession, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id,token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at + FROM panel_sessions ORDER BY last_activity DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSessions(rows) +} + +func (r *PanelSessionRepo) DeleteExpiredBefore(ctx context.Context, before time.Time) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE last_activity=?`, since).Scan(&n) + return n, err +} + +// AvgClaimMinutes returns the average minutes between opened_at and claimed_at for tickets claimed since `since`. +func (r *TicketRepo) AvgClaimMinutes(ctx context.Context, since time.Time) (int, error) { + var avg sql.NullFloat64 + err := r.db.QueryRowContext(ctx, + `SELECT AVG((julianday(claimed_at)-julianday(opened_at))*1440) + FROM tickets WHERE claimed_at IS NOT NULL AND claimed_at>=?`, since).Scan(&avg) + if err != nil || !avg.Valid { + return 0, err + } + return int(avg.Float64), nil +} + +// AvgResolutionMinutes returns the average minutes between opened_at and closed_at for tickets closed since `since`. +func (r *TicketRepo) AvgResolutionMinutes(ctx context.Context, since time.Time) (int, error) { + var avg sql.NullFloat64 + err := r.db.QueryRowContext(ctx, + `SELECT AVG((julianday(closed_at)-julianday(opened_at))*1440) + FROM tickets WHERE closed_at IS NOT NULL AND closed_at>=?`, since).Scan(&avg) + if err != nil || !avg.Valid { + return 0, err + } + return int(avg.Float64), nil +} + func (r *TicketRepo) SetClaimed(ctx context.Context, id int64, staffID string, at time.Time) error { _, err := r.db.ExecContext(ctx, `UPDATE tickets SET status='claimed', claimed_by=?, claimed_at=? WHERE id=?`, @@ -136,7 +179,7 @@ func (r *TicketRepo) SetClosedByChannel(ctx context.Context, channelID, reason s func (r *TicketRepo) ListOpen(ctx context.Context) ([]*Ticket, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, + SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at, claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE status='open'`) if err != nil { @@ -164,6 +207,188 @@ func (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) { return n, tx.Commit() } +// DailyCount holds the ticket count for one day. +type DailyCount struct { + Day string // "2006-01-02" + Count int +} + +// DailyTicketCounts returns the count of tickets opened per day for the last n days. +func (r *TicketRepo) DailyTicketCounts(ctx context.Context, days int) ([]DailyCount, error) { + since := time.Now().AddDate(0, 0, -days+1).Truncate(24 * time.Hour) + rows, err := r.db.QueryContext(ctx, ` + SELECT strftime('%Y-%m-%d', opened_at) AS day, COUNT(*) AS cnt + FROM tickets + WHERE opened_at >= ? + GROUP BY day + ORDER BY day`, since.UTC()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []DailyCount + for rows.Next() { + var dc DailyCount + if err := rows.Scan(&dc.Day, &dc.Count); err != nil { + return nil, err + } + out = append(out, dc) + } + return out, rows.Err() +} + +// StaffStat holds per-staff ticket stats. +type StaffStat struct { + StaffID string + Claimed int + Closed int + AvgClaimMinutes int + AvgResolutionMinutes int +} + +// StaffStats returns per-staff statistics for tickets claimed in the last n days. +func (r *TicketRepo) StaffStats(ctx context.Context, days int) ([]StaffStat, error) { + since := time.Now().AddDate(0, 0, -days) + rows, err := r.db.QueryContext(ctx, ` + SELECT + claimed_by, + COUNT(*) AS claimed, + SUM(CASE WHEN status='closed' THEN 1 ELSE 0 END) AS closed, + CAST(AVG(CASE WHEN claimed_at IS NOT NULL + THEN (julianday(claimed_at) - julianday(opened_at)) * 1440 END) AS INTEGER) AS avg_claim, + CAST(AVG(CASE WHEN closed_at IS NOT NULL AND claimed_at IS NOT NULL + THEN (julianday(closed_at) - julianday(opened_at)) * 1440 END) AS INTEGER) AS avg_resolution + FROM tickets + WHERE claimed_by IS NOT NULL AND claimed_at >= ? + GROUP BY claimed_by + ORDER BY claimed DESC`, since.UTC()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []StaffStat + for rows.Next() { + var s StaffStat + var avgClaim, avgRes sql.NullInt64 + if err := rows.Scan(&s.StaffID, &s.Claimed, &s.Closed, &avgClaim, &avgRes); err != nil { + return nil, err + } + s.AvgClaimMinutes = int(avgClaim.Int64) + s.AvgResolutionMinutes = int(avgRes.Int64) + out = append(out, s) + } + return out, rows.Err() +} + +// ListFiltered returns tickets matching optional filters with pagination. +type TicketFilter struct { + Status string + Type string + StaffID string + GuildID string + Search string + From, To time.Time + Page int + PageSize int +} + +func (r *TicketRepo) ListFiltered(ctx context.Context, f TicketFilter) ([]*Ticket, int, error) { + if f.PageSize <= 0 { + f.PageSize = 20 + } + if f.Page <= 0 { + f.Page = 1 + } + offset := (f.Page - 1) * f.PageSize + + args := []any{} + where := "1=1" + if f.Status != "" { + where += " AND status=?" + args = append(args, f.Status) + } + if f.Type != "" { + where += " AND type=?" + args = append(args, f.Type) + } + if f.StaffID != "" { + where += " AND claimed_by=?" + args = append(args, f.StaffID) + } + if f.GuildID != "" { + where += " AND guild_id=?" + args = append(args, f.GuildID) + } + if f.Search != "" { + where += " AND (ticket_title LIKE ? OR user_id LIKE ?)" + args = append(args, "%"+f.Search+"%", "%"+f.Search+"%") + } + if !f.From.IsZero() { + where += " AND opened_at >= ?" + args = append(args, f.From.UTC()) + } + if !f.To.IsZero() { + where += " AND opened_at <= ?" + args = append(args, f.To.UTC()) + } + + var total int + countArgs := make([]any, len(args)) + copy(countArgs, args) + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE "+where, countArgs...).Scan(&total); err != nil { + return nil, 0, err + } + + args = append(args, f.PageSize, offset) + rows, err := r.db.QueryContext(ctx, ` + SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at, + claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description + FROM tickets WHERE `+where+` ORDER BY opened_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + list, err := scanTickets(rows) + return list, total, err +} + +// ListDistinctTypes returns all distinct ticket types present in the tickets table. +func (r *TicketRepo) ListDistinctTypes(ctx context.Context) ([]string, error) { + rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT type FROM tickets ORDER BY type`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var t string + if err := rows.Scan(&t); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// ListDistinctStaff returns all distinct staff IDs (claimed_by) from the tickets table. +func (r *TicketRepo) ListDistinctStaff(ctx context.Context) ([]string, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT DISTINCT claimed_by FROM tickets WHERE claimed_by IS NOT NULL ORDER BY claimed_by`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + // NullStr returns a valid NullString for non-empty strings, invalid (NULL) for empty. func NullStr(s string) sql.NullString { return sql.NullString{String: s, Valid: s != ""} @@ -180,6 +405,7 @@ func (r *TicketRepo) SetTitleDescription(ctx context.Context, id int64, title, d func scanTicket(row *sql.Row) (*Ticket, error) { var t Ticket err := row.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, + &t.GuildID, &t.ClaimChannelID, &t.LogChannelID, &t.StaffRoleID, &t.ClaimReupMinutes, &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, &t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription) if err != nil { @@ -193,6 +419,7 @@ func scanTickets(rows *sql.Rows) ([]*Ticket, error) { for rows.Next() { var t Ticket if err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, + &t.GuildID, &t.ClaimChannelID, &t.LogChannelID, &t.StaffRoleID, &t.ClaimReupMinutes, &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, &t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription); err != nil { return nil, err diff --git a/internal/db/user_blacklist.go b/internal/db/user_blacklist.go new file mode 100644 index 0000000..89041db --- /dev/null +++ b/internal/db/user_blacklist.go @@ -0,0 +1,55 @@ +package db + +import ( + "context" + "database/sql" + "time" +) + +type BlacklistedUser struct { + ID int64 + DiscordID string + Reason sql.NullString + BlacklistedBy string + CreatedAt time.Time +} + +type UserBlacklistRepo struct{ db *sql.DB } + +func NewUserBlacklistRepo(db *sql.DB) *UserBlacklistRepo { return &UserBlacklistRepo{db: db} } + +func (r *UserBlacklistRepo) Add(ctx context.Context, discordID, reason, blacklistedBy string) error { + _, err := r.db.ExecContext(ctx, + `INSERT OR REPLACE INTO user_blacklist(discord_id,reason,blacklisted_by,created_at) VALUES(?,?,?,?)`, + discordID, NullStr(reason), blacklistedBy, time.Now().UTC()) + return err +} + +func (r *UserBlacklistRepo) Remove(ctx context.Context, discordID string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM user_blacklist WHERE discord_id=?`, discordID) + return err +} + +func (r *UserBlacklistRepo) IsBlacklisted(ctx context.Context, discordID string) (bool, error) { + var count int + err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_blacklist WHERE discord_id=?`, discordID).Scan(&count) + return count > 0, err +} + +func (r *UserBlacklistRepo) List(ctx context.Context) ([]*BlacklistedUser, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id,discord_id,reason,blacklisted_by,created_at FROM user_blacklist ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*BlacklistedUser + for rows.Next() { + var u BlacklistedUser + if err := rows.Scan(&u.ID, &u.DiscordID, &u.Reason, &u.BlacklistedBy, &u.CreatedAt); err != nil { + return nil, err + } + out = append(out, &u) + } + return out, rows.Err() +} diff --git a/internal/discord/bot.go b/internal/discord/bot.go index 1dbf3be..1ad6c30 100644 --- a/internal/discord/bot.go +++ b/internal/discord/bot.go @@ -10,14 +10,15 @@ import ( ) type Bot struct { - Session *discordgo.Session - Config *config.Provider - Tickets *db.TicketRepo - Claims *db.ClaimMessageRepo - GuildID string + Session *discordgo.Session + Config *config.Provider + Tickets *db.TicketRepo + Claims *db.ClaimMessageRepo + GuildID string + AllowedGuilds map[string]bool // set of allowed guild IDs; if empty, all guilds allowed } -func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.ClaimMessageRepo, guildID string) (*Bot, error) { +func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.ClaimMessageRepo, guildID string, allowedGuilds []string) (*Bot, error) { s, err := discordgo.New("Bot " + token) if err != nil { return nil, fmt.Errorf("create session: %w", err) @@ -27,15 +28,29 @@ func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db. discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent + allowed := make(map[string]bool, len(allowedGuilds)) + for _, id := range allowedGuilds { + if id != "" { + allowed[id] = true + } + } return &Bot{ - Session: s, - Config: cfg, - Tickets: tickets, - Claims: claims, - GuildID: guildID, + Session: s, + Config: cfg, + Tickets: tickets, + Claims: claims, + GuildID: guildID, + AllowedGuilds: allowed, }, nil } +func (b *Bot) IsGuildAllowed(guildID string) bool { + if len(b.AllowedGuilds) == 0 { + return true + } + return b.AllowedGuilds[guildID] +} + func (b *Bot) Open() error { return b.Session.Open() } @@ -46,12 +61,24 @@ func (b *Bot) Close() { func (b *Bot) RegisterCommands(appID string) error { cmds := ApplicationCommands() - for _, cmd := range cmds { - _, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd) - if err != nil { - return fmt.Errorf("register %s: %w", cmd.Name, err) + for guildID := range b.AllowedGuilds { + for _, cmd := range cmds { + _, err := b.Session.ApplicationCommandCreate(appID, guildID, cmd) + if err != nil { + return fmt.Errorf("register %s in guild %s: %w", cmd.Name, guildID, err) + } + slog.Info("registered command", "name", cmd.Name, "guild_id", guildID) + } + } + // If no allowed guilds configured, register globally (or in default guild) + if len(b.AllowedGuilds) == 0 { + for _, cmd := range cmds { + _, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd) + if err != nil { + return fmt.Errorf("register %s: %w", cmd.Name, err) + } + slog.Info("registered command", "name", cmd.Name) } - slog.Info("registered command", "name", cmd.Name) } return nil } diff --git a/internal/discord/bot_service.go b/internal/discord/bot_service.go new file mode 100644 index 0000000..a16aa12 --- /dev/null +++ b/internal/discord/bot_service.go @@ -0,0 +1,403 @@ +package discord + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + + "github.com/leolionad58/ticketbot/internal/db" + "github.com/leolionad58/ticketbot/internal/panel/handlers" +) + +// BotServiceImpl implements panel.BotService and handlers.DiscordInfo. +type BotServiceImpl struct { + session *discordgo.Session + panelRepo *db.PanelConfigRepo + ticketRepo *db.TicketRepo + convocRepo *db.ConvocationConfigRepo + guildID string + allowedGuilds []string + startedAt time.Time +} + +func NewBotService( + s *discordgo.Session, + panelRepo *db.PanelConfigRepo, + ticketRepo *db.TicketRepo, + convocRepo *db.ConvocationConfigRepo, + guildID string, + allowedGuilds []string, +) *BotServiceImpl { + return &BotServiceImpl{ + session: s, + panelRepo: panelRepo, + ticketRepo: ticketRepo, + convocRepo: convocRepo, + guildID: guildID, + allowedGuilds: allowedGuilds, + startedAt: time.Now(), + } +} + +// GetBotStatus returns live bot metrics. +func (b *BotServiceImpl) GetBotStatus() handlers.BotStatus { + guildName := "" + if g, err := b.session.State.Guild(b.guildID); err == nil && g != nil { + guildName = g.Name + } + return handlers.BotStatus{ + Online: true, + StartedAt: b.startedAt, + LatencyMs: b.session.HeartbeatLatency().Milliseconds(), + GuildName: guildName, + } +} + +// GetGuildList returns all whitelisted guilds by name. +func (b *BotServiceImpl) GetGuildList() []handlers.GuildInfo { + seen := map[string]bool{} + ids := append([]string{}, b.allowedGuilds...) + if b.guildID != "" && !seen[b.guildID] { + ids = append(ids, b.guildID) + } + out := make([]handlers.GuildInfo, 0, len(ids)) + for _, id := range ids { + if seen[id] || id == "" { + continue + } + seen[id] = true + name := id + if g, err := b.session.State.Guild(id); err == nil && g != nil { + name = g.Name + } + out = append(out, handlers.GuildInfo{ID: id, Name: name}) + } + return out +} + +// SendPanel sends or updates the Discord panel message for the given panel ID. +func (b *BotServiceImpl) SendPanel(panelID int64) error { + ctx := context.Background() + p, err := b.panelRepo.GetByID(ctx, panelID) + if err != nil { + return fmt.Errorf("get panel: %w", err) + } + if p == nil { + return fmt.Errorf("panel %d not found", panelID) + } + if !p.ChannelID.Valid || p.ChannelID.String == "" { + return fmt.Errorf("panel %d has no channel configured", panelID) + } + + embed := &discordgo.MessageEmbed{ + Title: p.EmbedTitle, + Description: p.EmbedDescription.String, + Color: parseHexColor(p.EmbedColor), + } + if p.EmbedThumbnail != "" { + embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: p.EmbedThumbnail} + } + if p.EmbedImage != "" { + embed.Image = &discordgo.MessageEmbedImage{URL: p.EmbedImage} + } + + // Build action rows (max 5 buttons per row) + var components []discordgo.MessageComponent + var row []discordgo.MessageComponent + for i, t := range p.Types { + btn := discordgo.Button{ + Label: t.ButtonLabel, + Style: buttonStyle(t.ButtonColor), + CustomID: fmt.Sprintf("panel:open:%s:%s", p.Name, t.Name), + } + if t.ButtonEmoji != "" { + emoji := discordgo.ComponentEmoji{Name: t.ButtonEmoji} + btn.Emoji = &emoji + } + row = append(row, btn) + if len(row) == 5 || i == len(p.Types)-1 { + components = append(components, discordgo.ActionsRow{Components: row}) + row = nil + } + } + + channelID := p.ChannelID.String + if p.MessageID.Valid && p.MessageID.String != "" { + _, editErr := b.session.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: channelID, + ID: p.MessageID.String, + Embeds: &[]*discordgo.MessageEmbed{embed}, + Components: &components, + }) + if editErr == nil { + return nil + } + // Message no longer exists — clear the stored ID and fall through to send new. + _ = b.panelRepo.SetDiscordMessage(ctx, panelID, channelID, "") + } + + msg, err := b.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{embed}, + Components: components, + }) + if err != nil { + return fmt.Errorf("send panel message: %w", err) + } + return b.panelRepo.SetDiscordMessage(ctx, panelID, channelID, msg.ID) +} + +// DeletePanelMessage deletes the Discord panel message. +func (b *BotServiceImpl) DeletePanelMessage(panelID int64) error { + ctx := context.Background() + p, err := b.panelRepo.GetByID(ctx, panelID) + if err != nil || p == nil { + return nil + } + if p.MessageID.Valid && p.ChannelID.Valid && p.MessageID.String != "" { + _ = b.session.ChannelMessageDelete(p.ChannelID.String, p.MessageID.String) + _ = b.panelRepo.SetDiscordMessage(ctx, panelID, "", "") + } + return nil +} + +// GetGuildRoles returns all roles for the given guild. +func (b *BotServiceImpl) GetGuildRoles(guildID string) ([]handlers.GuildRole, error) { + roles, err := b.session.GuildRoles(guildID) + if err != nil { + return nil, err + } + out := make([]handlers.GuildRole, 0, len(roles)) + for _, r := range roles { + if r.Name == "@everyone" { + continue + } + color := "#99aab5" + if r.Color != 0 { + color = fmt.Sprintf("#%06x", r.Color) + } + out = append(out, handlers.GuildRole{ID: r.ID, Name: r.Name, Color: color}) + } + return out, nil +} + +// GetGuildChannels returns all channels for the given guild. +func (b *BotServiceImpl) GetGuildChannels(guildID string) ([]handlers.GuildChannel, error) { + channels, err := b.session.GuildChannels(guildID) + if err != nil { + return nil, err + } + out := make([]handlers.GuildChannel, 0, len(channels)) + for _, c := range channels { + out = append(out, handlers.GuildChannel{ + ID: c.ID, + Name: c.Name, + Type: int(c.Type), + }) + } + return out, nil +} + +// CreateConvocation creates a convocation channel on Discord and records it in the DB. +// staffDiscordID is the Discord ID of the admin creating the convocation. +func (b *BotServiceImpl) CreateConvocation(guildID, staffDiscordID, targetDiscordID, reason string) (string, error) { + ctx := context.Background() + + // Get convocation category from DB config + cfg, err := b.convocRepo.Get(ctx, guildID) + if err != nil { + return "", fmt.Errorf("get convoc config: %w", err) + } + categoryID := "" + if cfg != nil { + categoryID = cfg.CategoryID + } + if categoryID == "" { + return "", fmt.Errorf("aucune catégorie de convocation configurée pour cette guild") + } + + // Get target member info + target, err := b.session.GuildMember(guildID, targetDiscordID) + if err != nil { + return "", fmt.Errorf("utilisateur introuvable dans la guild : %w", err) + } + + n, err := b.ticketRepo.NextConvocationNumber(ctx) + if err != nil { + return "", fmt.Errorf("next convocation number: %w", err) + } + + username := target.User.Username + channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocUsername(username), n) + if len(channelName) > 100 { + channelName = channelName[:100] + } + + ch, err := b.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ + Name: channelName, + Type: discordgo.ChannelTypeGuildText, + ParentID: categoryID, + PermissionOverwrites: []*discordgo.PermissionOverwrite{ + { + ID: guildID, + Type: discordgo.PermissionOverwriteTypeRole, + Deny: discordgo.PermissionViewChannel, + }, + { + ID: staffDiscordID, + Type: discordgo.PermissionOverwriteTypeMember, + Allow: discordgo.PermissionViewChannel | + discordgo.PermissionSendMessages | + discordgo.PermissionReadMessageHistory | + discordgo.PermissionAttachFiles, + }, + { + ID: targetDiscordID, + Type: discordgo.PermissionOverwriteTypeMember, + Allow: discordgo.PermissionViewChannel | + discordgo.PermissionSendMessages | + discordgo.PermissionReadMessageHistory, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("create channel: %w", err) + } + + deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffDiscordID) + if len(deleteCustomID) > 100 { + deleteCustomID = "convoc:delete:" + ch.ID + } + + b.session.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint + Content: fmt.Sprintf("<@%s>", targetDiscordID), + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Convocation", + Description: fmt.Sprintf("Tu as été convoqué par <@%s>.\n\n**Raison :** %s", staffDiscordID, reason), + Color: 0x5865f2, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Supprimer", + Style: discordgo.DangerButton, + CustomID: deleteCustomID, + }, + }}, + }, + }) + + ticket := &db.Ticket{ + UserID: targetDiscordID, + Panel: "convocation", + Type: "convocation", + ChannelID: ch.ID, + OpenedAt: time.Now(), + Status: "open", + TicketNumber: n, + } + _ = b.ticketRepo.InsertWithNumber(ctx, ticket) + + return ch.ID, nil +} + +// SendConvocPanel sends (or updates) the convocation panel embed+button in the configured Discord channel. +func (b *BotServiceImpl) SendConvocPanel(guildID string) error { + ctx := context.Background() + cfg, err := b.convocRepo.Get(ctx, guildID) + if err != nil { + return fmt.Errorf("get convoc config: %w", err) + } + if cfg == nil || cfg.PanelChannelID == "" { + return fmt.Errorf("aucun salon de panel convocation configuré") + } + + title := cfg.PanelEmbedTitle + if title == "" { + title = "Créer une convocation" + } + color := parseHexColor(cfg.PanelEmbedColor) + if color == 0 { + color = 0x5865f2 + } + + embed := &discordgo.MessageEmbed{ + Title: title, + Description: cfg.PanelEmbedDescription, + Color: color, + } + components := []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Créer une convocation", + Style: discordgo.PrimaryButton, + CustomID: "convoc:panel:" + guildID, + }, + }}, + } + + if cfg.PanelMessageID != "" { + _, editErr := b.session.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: cfg.PanelChannelID, + ID: cfg.PanelMessageID, + Embeds: &[]*discordgo.MessageEmbed{embed}, + Components: &components, + }) + if editErr == nil { + return nil + } + // Message deleted — fall through to send new + _ = b.convocRepo.SetConvocPanelMessage(ctx, guildID, cfg.PanelChannelID, "") + } + + msg, err := b.session.ChannelMessageSendComplex(cfg.PanelChannelID, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{embed}, + Components: components, + }) + if err != nil { + return fmt.Errorf("send convoc panel: %w", err) + } + return b.convocRepo.SetConvocPanelMessage(ctx, guildID, cfg.PanelChannelID, msg.ID) +} + +func sanitizeConvocUsername(username string) string { + var out []byte + for _, b := range []byte(username) { + if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' { + out = append(out, b) + } else if b >= 'A' && b <= 'Z' { + out = append(out, b+32) + } + } + if len(out) == 0 { + return "user" + } + if len(out) > 20 { + out = out[:20] + } + return string(out) +} + +func parseHexColor(s string) int { + s = strings.TrimPrefix(s, "#") + n, _ := strconv.ParseInt(s, 16, 64) + return int(n) +} + +func buttonStyle(color string) discordgo.ButtonStyle { + switch color { + case "secondary": + return discordgo.SecondaryButton + case "success": + return discordgo.SuccessButton + case "danger": + return discordgo.DangerButton + default: + return discordgo.PrimaryButton + } +} diff --git a/internal/discord/components/convocation.go b/internal/discord/components/convocation.go new file mode 100644 index 0000000..bcdca70 --- /dev/null +++ b/internal/discord/components/convocation.go @@ -0,0 +1,253 @@ +package components + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/leolionad58/ticketbot/internal/db" + "github.com/leolionad58/ticketbot/internal/logger" + "github.com/leolionad58/ticketbot/internal/tickets" +) + +// ConvocationComponent handles the convocation panel button and modal. +type ConvocationComponent struct { + ConvocRepo *db.ConvocationConfigRepo + TicketRepo *db.TicketRepo + Auth *tickets.AuthService + LogSvc *logger.DiscordLogger +} + +// HandlePanel handles clicks on convoc:panel: buttons. +// Shows a modal asking for target user ID and reason. +func (c *ConvocationComponent) HandlePanel(s *discordgo.Session, i *discordgo.InteractionCreate) { + staffID := interactionUserID(i) + memberRoles := memberRoleIDs(i) + + if !c.Auth.Can(memberRoles, tickets.ActionConvocation, nil) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + slog.Warn("convoc panel: denied", "user_id", staffID) + return + } + + guildID := interactionGuildID(i) + parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 3) + if len(parts) == 3 && parts[2] != "" { + guildID = parts[2] + } + + modalID := "modal:convoc:" + guildID + if len(modalID) > 100 { + modalID = modalID[:100] + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: modalID, + Title: "Créer une convocation", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "target", + Label: "ID Discord de l'utilisateur", + Style: discordgo.TextInputShort, + Required: true, + MaxLength: 50, + Placeholder: "123456789012345678", + }, + }}, + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "reason", + Label: "Raison de la convocation", + Style: discordgo.TextInputParagraph, + Required: true, + MaxLength: 500, + Placeholder: "Motif de la convocation...", + }, + }}, + }, + }, + }) +} + +// HandleModalSubmit handles modal:convoc: submissions. +func (c *ConvocationComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) { + parts := strings.SplitN(i.ModalSubmitData().CustomID, ":", 3) + if len(parts) != 3 { + return + } + guildID := parts[2] + if guildID == "" { + guildID = interactionGuildID(i) + } + + var targetID, reason string + for _, row := range i.ModalSubmitData().Components { + if ar, ok := row.(*discordgo.ActionsRow); ok { + for _, comp := range ar.Components { + if ti, ok := comp.(*discordgo.TextInput); ok { + switch ti.CustomID { + case "target": + targetID = strings.TrimSpace(ti.Value) + case "reason": + reason = strings.TrimSpace(ti.Value) + } + } + } + } + } + + if targetID == "" || reason == "" { + ephemeral(s, i, "L'ID utilisateur et la raison sont requis.") + return + } + // Strip mention format <@ID> or <@!ID> + targetID = strings.TrimPrefix(targetID, "<@!") + targetID = strings.TrimPrefix(targetID, "<@") + targetID = strings.TrimSuffix(targetID, ">") + targetID = strings.TrimSpace(targetID) + + staffID := interactionUserID(i) + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cfg, err := c.ConvocRepo.Get(ctx, guildID) + if err != nil || cfg == nil || cfg.CategoryID == "" { + followup(s, i, "La catégorie de convocation n'est pas configurée.") + return + } + + target, err := s.GuildMember(guildID, targetID) + if err != nil { + followup(s, i, fmt.Sprintf("Utilisateur introuvable dans ce serveur : %s", targetID)) + return + } + if target.User.Bot { + followup(s, i, "Tu ne peux pas convoquer un bot.") + return + } + if target.User.ID == staffID { + followup(s, i, "Tu ne peux pas te convoquer toi-même.") + return + } + + n, err := c.TicketRepo.NextConvocationNumber(ctx) + if err != nil { + slog.Error("convoc modal: next number", "err", err) + followup(s, i, "Erreur interne.") + return + } + + username := target.User.Username + channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocN(username), n) + if len(channelName) > 100 { + channelName = channelName[:100] + } + + ch, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ + Name: channelName, + Type: discordgo.ChannelTypeGuildText, + ParentID: cfg.CategoryID, + PermissionOverwrites: []*discordgo.PermissionOverwrite{ + { + ID: guildID, + Type: discordgo.PermissionOverwriteTypeRole, + Deny: discordgo.PermissionViewChannel, + }, + { + ID: staffID, + Type: discordgo.PermissionOverwriteTypeMember, + Allow: discordgo.PermissionViewChannel | + discordgo.PermissionSendMessages | + discordgo.PermissionReadMessageHistory | + discordgo.PermissionAttachFiles, + }, + { + ID: target.User.ID, + Type: discordgo.PermissionOverwriteTypeMember, + Allow: discordgo.PermissionViewChannel | + discordgo.PermissionSendMessages | + discordgo.PermissionReadMessageHistory, + }, + }, + }) + if err != nil { + slog.Error("convoc modal: create channel", "err", err) + followup(s, i, "Erreur lors de la création du salon.") + return + } + + deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffID) + if len(deleteCustomID) > 100 { + deleteCustomID = "convoc:delete:" + ch.ID + } + + s.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint + Content: fmt.Sprintf("<@%s>", target.User.ID), + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Convocation", + Description: fmt.Sprintf("Tu as été convoqué par <@%s>.\n\n**Raison :** %s", staffID, reason), + Color: 0x5865f2, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Supprimer", + Style: discordgo.DangerButton, + CustomID: deleteCustomID, + }, + }}, + }, + }) + + ticket := &db.Ticket{ + UserID: target.User.ID, + Panel: "convocation", + Type: "convocation", + ChannelID: ch.ID, + GuildID: guildID, + LogChannelID: cfg.LogChannelID, + OpenedAt: time.Now(), + Status: "open", + TicketNumber: n, + } + if err := c.TicketRepo.InsertWithNumber(ctx, ticket); err != nil { + slog.Error("convoc modal: insert DB", "err", err) + } + + c.LogSvc.LogConvocationOpened(staffID, target.User.ID, reason, ch.ID) + slog.Info("convocation created via panel", "channel_id", ch.ID, "staff_id", staffID, "target_id", target.User.ID) + + followup(s, i, fmt.Sprintf("Convocation créée : <#%s>", ch.ID)) +} + +func sanitizeConvocN(username string) string { + var out []byte + for _, b := range []byte(username) { + if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' { + out = append(out, b) + } else if b >= 'A' && b <= 'Z' { + out = append(out, b+32) + } + } + if len(out) == 0 { + return "user" + } + if len(out) > 20 { + out = out[:20] + } + return string(out) +} diff --git a/internal/discord/components/panel.go b/internal/discord/components/panel.go index c97e977..c06ea06 100644 --- a/internal/discord/components/panel.go +++ b/internal/discord/components/panel.go @@ -19,6 +19,7 @@ import ( type PanelComponent struct { TicketSvc *tickets.Service TicketRepo *db.TicketRepo + PanelRepo *db.PanelConfigRepo ClaimMgr *claim.Manager LogSvc *logger.DiscordLogger Config *config.Provider @@ -123,26 +124,48 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In ticket.TicketTitle = db.NullStr(ticketTitle) ticket.TicketDescription = db.NullStr(ticketDescription) - cfg := c.Config.Get() - var embedTitle, embedText, embedColor string - if panel, ok := cfg.Panels[panelName]; ok { + var embedTitle, embedText, embedColor, thumbnailURL, imageURL string + yamlCfg := c.Config.Get() + if panel, ok := yamlCfg.Panels[panelName]; ok { if typeCfg, ok := panel.Types[ticketType]; ok { embedTitle = typeCfg.EmbedTitle embedText = typeCfg.EmbedText embedColor = typeCfg.EmbedColor } + } else if c.PanelRepo != nil { + if dbPanel, err := c.PanelRepo.GetByName(context.Background(), panelName); err == nil && dbPanel != nil { + for _, t := range dbPanel.Types { + if t.Name == ticketType { + embedTitle = t.EmbedTitle + embedText = t.EmbedText + embedColor = t.EmbedColor + thumbnailURL = t.ThumbnailURL + imageURL = t.ImageURL + break + } + } + } } // a) Welcome embed (bot identity, no webhook) + welcomeEmbed := &discordgo.MessageEmbed{ + Title: embedTitle, + Description: embedText, + Color: parseColor(embedColor), + } + if thumbnailURL != "" { + welcomeEmbed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: thumbnailURL} + } + if imageURL != "" { + welcomeEmbed.Image = &discordgo.MessageEmbedImage{URL: imageURL} + } deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{ - Embeds: []*discordgo.MessageEmbed{ - {Title: embedTitle, Description: embedText, Color: parseColor(embedColor)}, - }, + Embeds: []*discordgo.MessageEmbed{welcomeEmbed}, Components: []discordgo.MessageComponent{ discordgo.ActionsRow{Components: []discordgo.MessageComponent{ discordgo.Button{ - Label: "Supprimer le ticket", + Label: "Fermer le ticket", Style: discordgo.DangerButton, CustomID: deleteCustomID, }, @@ -155,8 +178,21 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In // b) Webhook impersonation message postImpersonationMessage(s, ticket, uID, ticketTitle, ticketDescription, i.GuildID) - // c) Claim channel message (includes title + description) - c.ClaimMgr.StartTicket(ctx, s, ticket) + // c) Claim channel message — only if claim mode is enabled for this panel type + claimEnabled := true + if c.PanelRepo != nil { + if dbPanel, err2 := c.PanelRepo.GetByName(context.Background(), panelName); err2 == nil && dbPanel != nil { + for _, t := range dbPanel.Types { + if t.Name == ticketType { + claimEnabled = t.ClaimMode != 0 + break + } + } + } + } + if claimEnabled { + c.ClaimMgr.StartTicket(ctx, s, ticket) + } c.LogSvc.LogTicketOpened(ticket, uID) slog.Info("ticket opened via modal", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID) @@ -175,6 +211,9 @@ func postImpersonationMessage(s *discordgo.Session, ticket *db.Ticket, userID, t user := member.User avatarURL := user.AvatarURL("128") displayName := user.Username + if user.GlobalName != "" { + displayName = user.GlobalName + } if member.Nick != "" { displayName = member.Nick } diff --git a/internal/discord/components/ticket.go b/internal/discord/components/ticket.go index 2c421cf..7cb7a64 100644 --- a/internal/discord/components/ticket.go +++ b/internal/discord/components/ticket.go @@ -60,11 +60,11 @@ func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: "Es-tu sûr de vouloir supprimer ce ticket ? Cette action est irréversible.", + Content: "Es-tu sûr de vouloir fermer ce ticket ?", Flags: discordgo.MessageFlagsEphemeral, Components: []discordgo.MessageComponent{ discordgo.ActionsRow{Components: []discordgo.MessageComponent{ - discordgo.Button{Label: "Oui, supprimer", Style: discordgo.DangerButton, CustomID: yesID}, + discordgo.Button{Label: "Oui, fermer", Style: discordgo.DangerButton, CustomID: yesID}, discordgo.Button{Label: "Annuler", Style: discordgo.SecondaryButton, CustomID: cancelID}, }}, }, diff --git a/internal/discord/router.go b/internal/discord/router.go index 7930ee3..1ea7563 100644 --- a/internal/discord/router.go +++ b/internal/discord/router.go @@ -11,14 +11,20 @@ import ( // Router dispatches Discord interactions to the appropriate handler. type Router struct { - PanelCmd *commands.PanelCommand - TicketCmd *commands.TicketCommand - ConvocCmd *commands.ConvocationCommand - PanelComp *components.PanelComponent - TicketComp *components.TicketComponent + PanelCmd *commands.PanelCommand + TicketCmd *commands.TicketCommand + ConvocCmd *commands.ConvocationCommand + PanelComp *components.PanelComponent + TicketComp *components.TicketComponent + ConvocComp *components.ConvocationComponent + IsGuildAllowed func(guildID string) bool } func (r *Router) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + if r.IsGuildAllowed != nil && !r.IsGuildAllowed(i.GuildID) { + slog.Debug("ignoring interaction from unlisted guild", "guild_id", i.GuildID) + return + } switch i.Type { case discordgo.InteractionApplicationCommand: r.routeCommand(s, i) @@ -61,6 +67,10 @@ func (r *Router) routeComponent(s *discordgo.Session, i *discordgo.InteractionCr r.TicketComp.HandleDeleteCancel(s, i) case strings.HasPrefix(customID, "convoc:delete:"): r.TicketComp.HandleConvocationDelete(s, i) + case strings.HasPrefix(customID, "convoc:panel:"): + if r.ConvocComp != nil { + r.ConvocComp.HandlePanel(s, i) + } default: slog.Warn("unknown component", "custom_id", customID) } @@ -72,6 +82,10 @@ func (r *Router) routeModal(s *discordgo.Session, i *discordgo.InteractionCreate switch { case strings.HasPrefix(customID, "modal:ticket:"): r.PanelComp.HandleModalSubmit(s, i) + case strings.HasPrefix(customID, "modal:convoc:"): + if r.ConvocComp != nil { + r.ConvocComp.HandleModalSubmit(s, i) + } default: slog.Warn("unknown modal", "custom_id", customID) } diff --git a/internal/logger/discord.go b/internal/logger/discord.go index 7b99b3a..31c7f48 100644 --- a/internal/logger/discord.go +++ b/internal/logger/discord.go @@ -23,13 +23,27 @@ func (l *DiscordLogger) send(embed *discordgo.MessageEmbed) { if cfg.Bot.LogsChannel == "" { return } - if _, err := l.Session.ChannelMessageSendEmbed(cfg.Bot.LogsChannel, embed); err != nil { + l.sendTo(cfg.Bot.LogsChannel, embed) +} + +func (l *DiscordLogger) sendTo(channelID string, embed *discordgo.MessageEmbed) { + if channelID == "" { + return + } + if _, err := l.Session.ChannelMessageSendEmbed(channelID, embed); err != nil { slog.Error("discord log: send", "err", err) } } +func (l *DiscordLogger) logChannel(ticket *db.Ticket) string { + if ticket.LogChannelID != "" { + return ticket.LogChannelID + } + return l.Config.Get().Bot.LogsChannel +} + func (l *DiscordLogger) LogTicketOpened(ticket *db.Ticket, userID string) { - l.send(&discordgo.MessageEmbed{ + l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{ Title: "Ticket ouvert", Color: 0x57f287, Fields: []*discordgo.MessageEmbedField{ @@ -42,7 +56,7 @@ func (l *DiscordLogger) LogTicketOpened(ticket *db.Ticket, userID string) { } func (l *DiscordLogger) LogTicketClaimed(ticket *db.Ticket, staffID string) { - l.send(&discordgo.MessageEmbed{ + l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{ Title: "Ticket claim", Color: 0xfee75c, Fields: []*discordgo.MessageEmbedField{ @@ -53,7 +67,7 @@ func (l *DiscordLogger) LogTicketClaimed(ticket *db.Ticket, staffID string) { } func (l *DiscordLogger) LogTicketClosed(ticket *db.Ticket, staffID, reason, transcriptPath string) { - embed := &discordgo.MessageEmbed{ + l.sendTo(l.logChannel(ticket), &discordgo.MessageEmbed{ Title: "Ticket fermé", Color: 0xed4245, Fields: []*discordgo.MessageEmbedField{ @@ -61,8 +75,7 @@ func (l *DiscordLogger) LogTicketClosed(ticket *db.Ticket, staffID, reason, tran {Name: "Ticket", Value: fmt.Sprintf("%s-%04d", ticket.Type, ticket.TicketNumber), Inline: true}, {Name: "Raison", Value: reason}, }, - } - l.send(embed) + }) } func (l *DiscordLogger) LogConvocationOpened(staffID, targetID, reason, channelID string) { diff --git a/internal/panel/auth/middleware.go b/internal/panel/auth/middleware.go new file mode 100644 index 0000000..27787c5 --- /dev/null +++ b/internal/panel/auth/middleware.go @@ -0,0 +1,173 @@ +package auth + +import ( + "context" + "net/http" + "time" + + "github.com/leolionad58/ticketbot/internal/db" +) + +type ctxKey int + +const ( + ctxAdmin ctxKey = iota + ctxSession ctxKey = iota +) + +// WithAdmin injects an admin into the request context. +func WithAdmin(r *http.Request, admin *db.PanelAdmin) *http.Request { + return r.WithContext(context.WithValue(r.Context(), ctxAdmin, admin)) +} + +// AdminFromContext retrieves the admin from the request context (nil if not set). +func AdminFromContext(ctx context.Context) *db.PanelAdmin { + v, _ := ctx.Value(ctxAdmin).(*db.PanelAdmin) + return v +} + +// SessionFromContext retrieves the session from the request context. +func SessionFromContext(ctx context.Context) *db.PanelSession { + v, _ := ctx.Value(ctxSession).(*db.PanelSession) + return v +} + +// Middleware wires authentication and CSRF checking onto protected routes. +type Middleware struct { + svc *Service + admins *db.PanelAdminRepo +} + +func NewMiddleware(svc *Service, admins *db.PanelAdminRepo) *Middleware { + return &Middleware{svc: svc, admins: admins} +} + +// RequireAuth is an HTTP middleware that validates the session cookie and injects +// the admin into the request context. Redirects to /login if not authenticated. +func (m *Middleware) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("panel_session") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + ctx := r.Context() + sess, err := m.svc.ValidateSession(ctx, cookie.Value) + if err != nil || sess == nil { + clearSessionCookie(w, r) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // CSRF check for state-changing methods + if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete { + csrfHeader := r.Header.Get("X-CSRF-Token") + if csrfHeader == "" { + // Also accept form field for non-HTMX posts + csrfHeader = r.FormValue("csrf_token") + } + if csrfHeader != sess.CSRFToken { + http.Error(w, "CSRF token invalid", http.StatusForbidden) + return + } + } + + // Touch session (update last_activity) + _ = m.svc.TouchSession(ctx, cookie.Value) + + // Load admin + admin, err := m.admins.GetByID(ctx, sess.AdminID) + if err != nil || admin == nil { + clearSessionCookie(w, r) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + ctx = context.WithValue(ctx, ctxAdmin, admin) + ctx = context.WithValue(ctx, ctxSession, sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireSuperadmin wraps RequireAuth and additionally checks is_superadmin. +func (m *Middleware) RequireSuperadmin(next http.Handler) http.Handler { + return m.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + admin := AdminFromContext(r.Context()) + if admin == nil || !admin.IsSuperadmin { + http.Error(w, "Accès refusé", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + })) +} + +// isSecure returns true if the request arrived over HTTPS (direct TLS or behind a proxy). +func isSecure(r *http.Request) bool { + return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" +} + +// SetSessionCookie writes the main session cookie. +func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string) { + http.SetCookie(w, &http.Cookie{ + Name: "panel_session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: isSecure(r), + SameSite: http.SameSiteStrictMode, + }) +} + +// SetPendingAuthCookie stores the Discord ID between OAuth callback and password verification. +func SetPendingAuthCookie(w http.ResponseWriter, r *http.Request, discordID string) { + http.SetCookie(w, &http.Cookie{ + Name: "panel_pending_auth", + Value: discordID, + Path: "/auth", + HttpOnly: true, + Secure: isSecure(r), + SameSite: http.SameSiteLaxMode, // Lax: survives the redirect from /oauth/callback to /auth/* + MaxAge: 300, // 5 minutes + }) +} + +// PendingAuthDiscordID reads the pending auth cookie. +func PendingAuthDiscordID(r *http.Request) string { + c, err := r.Cookie("panel_pending_auth") + if err != nil { + return "" + } + return c.Value +} + +func clearSessionCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "panel_session", + Value: "", + Path: "/", + HttpOnly: true, + Secure: isSecure(r), + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) +} + +func clearPendingAuthCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "panel_pending_auth", + Value: "", + Path: "/auth", + HttpOnly: true, + Secure: isSecure(r), + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) +} + +// ClearPendingAuthCookie is exported for use by handlers. +func ClearPendingAuthCookie(w http.ResponseWriter, r *http.Request) { + clearPendingAuthCookie(w, r) +} diff --git a/internal/panel/auth/oauth.go b/internal/panel/auth/oauth.go new file mode 100644 index 0000000..ac8634b --- /dev/null +++ b/internal/panel/auth/oauth.go @@ -0,0 +1,74 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "golang.org/x/oauth2" +) + +// DiscordEndpoint is the Discord OAuth2 endpoint. +var DiscordEndpoint = oauth2.Endpoint{ + AuthURL: "https://discord.com/api/oauth2/authorize", + TokenURL: "https://discord.com/api/oauth2/token", +} + +// DiscordUser holds the data returned by /users/@me. +type DiscordUser struct { + ID string `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +// AvatarURL returns the full Discord CDN URL for the user's avatar. +func (u *DiscordUser) AvatarURL() string { + if u.Avatar == "" { + return "" + } + return fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png?size=128", u.ID, u.Avatar) +} + +// NewOAuthConfig builds an oauth2.Config for Discord. +func NewOAuthConfig(clientID, clientSecret, callbackURL string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: callbackURL, + Scopes: []string{"identify"}, + Endpoint: DiscordEndpoint, + } +} + +// AuthURL returns the Discord authorization URL with the given state. +func AuthURL(cfg *oauth2.Config, state string) string { + return cfg.AuthCodeURL(state, oauth2.AccessTypeOnline) +} + +// FetchDiscordUser exchanges the authorization code for a token, then calls /users/@me. +func FetchDiscordUser(ctx context.Context, cfg *oauth2.Config, code string) (*DiscordUser, error) { + token, err := cfg.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("exchange oauth code: %w", err) + } + + client := cfg.Client(ctx, token) + resp, err := client.Get("https://discord.com/api/v10/users/@me") + if err != nil { + return nil, fmt.Errorf("fetch discord user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("discord api %d: %s", resp.StatusCode, body) + } + + var user DiscordUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("decode discord user: %w", err) + } + return &user, nil +} diff --git a/internal/panel/auth/rate_limiter.go b/internal/panel/auth/rate_limiter.go new file mode 100644 index 0000000..4c48682 --- /dev/null +++ b/internal/panel/auth/rate_limiter.go @@ -0,0 +1,92 @@ +package auth + +import ( + "sync" + "time" +) + +const ( + maxAttempts = 5 + windowMinutes = 15 + lockMinutes = 15 +) + +type entry struct { + count int + firstAt time.Time + lockedUntil time.Time +} + +// RateLimiter tracks failed login attempts per Discord ID in memory. +// Resets on bot restart (by design — avoids DB writes on every auth failure). +type RateLimiter struct { + mu sync.Mutex + entries map[string]*entry +} + +func NewRateLimiter() *RateLimiter { + return &RateLimiter{entries: make(map[string]*entry)} +} + +// Allow returns true if the given ID is not currently rate-limited. +func (r *RateLimiter) Allow(id string) bool { + r.mu.Lock() + defer r.mu.Unlock() + e, ok := r.entries[id] + if !ok { + return true + } + if !e.lockedUntil.IsZero() { + if time.Now().Before(e.lockedUntil) { + return false + } + // Lock expired — reset + delete(r.entries, id) + } + return true +} + +// Record records a failed attempt. Returns true if the account is now locked. +func (r *RateLimiter) Record(id string) bool { + r.mu.Lock() + defer r.mu.Unlock() + e, ok := r.entries[id] + if !ok { + e = &entry{firstAt: time.Now()} + r.entries[id] = e + } + // Reset window if outside window duration + if time.Since(e.firstAt) > windowMinutes*time.Minute { + e.count = 0 + e.firstAt = time.Now() + e.lockedUntil = time.Time{} + } + e.count++ + if e.count >= maxAttempts { + e.lockedUntil = time.Now().Add(lockMinutes * time.Minute) + return true + } + return false +} + +// Reset clears the rate limit entry for a Discord ID (call on successful login). +func (r *RateLimiter) Reset(id string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.entries, id) +} + +// RemainingLock returns the remaining lock duration (zero if not locked). +func (r *RateLimiter) RemainingLock(id string) time.Duration { + r.mu.Lock() + defer r.mu.Unlock() + e, ok := r.entries[id] + if !ok || e.lockedUntil.IsZero() { + return 0 + } + remaining := time.Until(e.lockedUntil) + if remaining < 0 { + return 0 + } + return remaining +} diff --git a/internal/panel/auth/rate_limiter_test.go b/internal/panel/auth/rate_limiter_test.go new file mode 100644 index 0000000..08bd027 --- /dev/null +++ b/internal/panel/auth/rate_limiter_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "testing" + "time" +) + +func TestRateLimiterAllow(t *testing.T) { + r := NewRateLimiter() + if !r.Allow("user1") { + t.Error("fresh user should be allowed") + } +} + +func TestRateLimiterLockAfterMaxAttempts(t *testing.T) { + r := NewRateLimiter() + id := "user2" + for i := 0; i < maxAttempts-1; i++ { + locked := r.Record(id) + if locked { + t.Fatalf("should not be locked before max attempts (attempt %d)", i+1) + } + if !r.Allow(id) { + t.Fatalf("should be allowed before lock (attempt %d)", i+1) + } + } + locked := r.Record(id) + if !locked { + t.Error("should be locked after max attempts") + } + if r.Allow(id) { + t.Error("locked user should not be allowed") + } +} + +func TestRateLimiterReset(t *testing.T) { + r := NewRateLimiter() + id := "user3" + for i := 0; i < maxAttempts; i++ { + r.Record(id) //nolint + } + if r.Allow(id) { + t.Error("should be locked before reset") + } + r.Reset(id) + if !r.Allow(id) { + t.Error("should be allowed after reset") + } +} + +func TestRateLimiterWindowReset(t *testing.T) { + r := NewRateLimiter() + id := "user4" + // Simulate 4 attempts within window then manually expire the window + for i := 0; i < maxAttempts-1; i++ { + r.Record(id) //nolint + } + // Manually expire the window + r.mu.Lock() + r.entries[id].firstAt = time.Now().Add(-windowMinutes*time.Minute - time.Second) + r.mu.Unlock() + + // Next attempt should reset the window, not lock + locked := r.Record(id) + if locked { + t.Error("should not lock after window expired") + } +} + +func TestRateLimiterRemainingLock(t *testing.T) { + r := NewRateLimiter() + id := "user5" + if r.RemainingLock(id) != 0 { + t.Error("no lock expected for fresh user") + } + for i := 0; i < maxAttempts; i++ { + r.Record(id) //nolint + } + if r.RemainingLock(id) == 0 { + t.Error("lock expected after max attempts") + } +} diff --git a/internal/panel/auth/service.go b/internal/panel/auth/service.go new file mode 100644 index 0000000..1862b23 --- /dev/null +++ b/internal/panel/auth/service.go @@ -0,0 +1,192 @@ +package auth + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + qrcode "github.com/skip2/go-qrcode" + "golang.org/x/crypto/bcrypt" + + "github.com/leolionad58/ticketbot/internal/db" +) + +const bcryptCost = 12 + +// Service handles authentication operations: password, TOTP, sessions. +type Service struct { + admins *db.PanelAdminRepo + sessions *db.PanelSessionRepo + limiter *RateLimiter + ttl time.Duration +} + +func NewService(admins *db.PanelAdminRepo, sessions *db.PanelSessionRepo, ttlMinutes int) *Service { + return &Service{ + admins: admins, + sessions: sessions, + limiter: NewRateLimiter(), + ttl: time.Duration(ttlMinutes) * time.Minute, + } +} + +// HashPassword returns a bcrypt hash of the password. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// VerifyPassword checks a plaintext password against a bcrypt hash. +func VerifyPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// Allow reports whether the Discord ID is not currently rate-limited. +func (s *Service) Allow(discordID string) bool { return s.limiter.Allow(discordID) } + +// RecordFailure records a failed login attempt and returns true if now locked. +func (s *Service) RecordFailure(discordID string) bool { return s.limiter.Record(discordID) } + +// ResetLimit clears the rate limit for the Discord ID. +func (s *Service) ResetLimit(discordID string) { s.limiter.Reset(discordID) } + +// VerifyCredentials checks password and TOTP code for the given admin. +// Returns a unified error ("invalid credentials") regardless of which check fails +// to prevent information leakage. +func (s *Service) VerifyCredentials(admin *db.PanelAdmin, password, totpCode string) bool { + if !VerifyPassword(admin.PasswordHash, password) { + return false + } + if admin.TOTPEnabled { + if !admin.TOTPSecret.Valid { + return false + } + if !totp.Validate(totpCode, admin.TOTPSecret.String) { + return false + } + } + return true +} + +// GenerateTOTP generates a new TOTP key for the admin and returns the QR code as a base64 PNG +// and the raw secret for manual entry. +func GenerateTOTP(issuer, accountName string) (qrBase64, secret string, err error) { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: issuer, + AccountName: accountName, + }) + if err != nil { + return "", "", fmt.Errorf("generate totp key: %w", err) + } + + img, err := key.Image(256, 256) + if err != nil { + // Fallback: use skip2/go-qrcode + var pngBytes []byte + pngBytes, err = qrcode.Encode(key.URL(), qrcode.Medium, 256) + if err != nil { + return "", "", fmt.Errorf("generate qr code: %w", err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes), key.Secret(), nil + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return "", "", fmt.Errorf("encode qr png: %w", err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), key.Secret(), nil +} + +// ValidateTOTPCode validates a TOTP code against a secret. +func ValidateTOTPCode(code, secret string) bool { + return totp.Validate(code, secret) +} + +// ConfirmTOTP saves the TOTP secret to the admin record and enables TOTP. +func (s *Service) ConfirmTOTP(ctx context.Context, adminID int64, secret, code string) error { + if !totp.Validate(code, secret) { + return fmt.Errorf("invalid totp code") + } + return s.admins.UpdateTOTP(ctx, adminID, secret, true) +} + +// CreateSession generates a new session for the given admin. +func (s *Service) CreateSession(ctx context.Context, adminID int64, ip, ua string) (*db.PanelSession, error) { + token, err := randomHex(64) + if err != nil { + return nil, err + } + csrf, err := randomHex(32) + if err != nil { + return nil, err + } + now := time.Now().UTC() + sess := &db.PanelSession{ + Token: token, + CSRFToken: csrf, + AdminID: adminID, + IPAddress: ip, + UserAgent: ua, + LastActivity: now, + CreatedAt: now, + } + if err := s.sessions.Create(ctx, sess); err != nil { + return nil, err + } + return sess, nil +} + +// ValidateSession looks up a session by token and checks it hasn't timed out. +// Returns nil if not found or expired (and deletes it in that case). +func (s *Service) ValidateSession(ctx context.Context, token string) (*db.PanelSession, error) { + sess, err := s.sessions.GetByToken(ctx, token) + if err != nil || sess == nil { + return nil, err + } + if time.Since(sess.LastActivity) > s.ttl { + _ = s.sessions.DeleteByToken(ctx, token) + return nil, nil + } + return sess, nil +} + +// TouchSession updates the last_activity timestamp. +func (s *Service) TouchSession(ctx context.Context, token string) error { + return s.sessions.UpdateLastActivity(ctx, token, time.Now().UTC()) +} + +// InvalidateSession deletes a session. +func (s *Service) InvalidateSession(ctx context.Context, token string) error { + return s.sessions.DeleteByToken(ctx, token) +} + +// InvalidateAllExcept deletes all sessions for adminID except exceptToken. +func (s *Service) InvalidateAllExcept(ctx context.Context, adminID int64, exceptToken string) error { + return s.sessions.DeleteAllExcept(ctx, adminID, exceptToken) +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// GenerateState generates a random state string for OAuth2 CSRF protection. +func GenerateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/panel/auth/service_test.go b/internal/panel/auth/service_test.go new file mode 100644 index 0000000..3767d79 --- /dev/null +++ b/internal/panel/auth/service_test.go @@ -0,0 +1,70 @@ +package auth + +import ( + "testing" + "time" + + "github.com/pquerna/otp/totp" +) + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("correct-horse-battery-staple") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if !VerifyPassword(hash, "correct-horse-battery-staple") { + t.Error("VerifyPassword should return true for correct password") + } + if VerifyPassword(hash, "wrong-password") { + t.Error("VerifyPassword should return false for wrong password") + } +} + +func TestGenerateTOTP(t *testing.T) { + qr, secret, err := GenerateTOTP("TestApp", "user@test") + if err != nil { + t.Fatalf("GenerateTOTP: %v", err) + } + if secret == "" { + t.Error("secret should not be empty") + } + if qr == "" { + t.Error("qr should not be empty") + } +} + +func TestValidateTOTPCode(t *testing.T) { + _, secret, err := GenerateTOTP("TestApp", "user@test") + if err != nil { + t.Fatalf("GenerateTOTP: %v", err) + } + + // Generate a valid code + code, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + if !ValidateTOTPCode(code, secret) { + t.Error("ValidateTOTPCode should return true for valid code") + } + if ValidateTOTPCode("000000", secret) { + t.Error("ValidateTOTPCode should return false for invalid code") + } +} + +func TestGenerateState(t *testing.T) { + s1, err := GenerateState() + if err != nil { + t.Fatalf("GenerateState: %v", err) + } + s2, err := GenerateState() + if err != nil { + t.Fatalf("GenerateState: %v", err) + } + if s1 == s2 { + t.Error("GenerateState should return unique values") + } + if len(s1) != 32 { // 16 bytes → 32 hex chars + t.Errorf("GenerateState length = %d, want 32", len(s1)) + } +} diff --git a/internal/panel/bot_service.go b/internal/panel/bot_service.go new file mode 100644 index 0000000..e563cf1 --- /dev/null +++ b/internal/panel/bot_service.go @@ -0,0 +1,14 @@ +package panel + +import ( + "github.com/leolionad58/ticketbot/internal/panel/handlers" +) + +// BotService is the interface the panel calls into the live bot. +type BotService interface { + GetBotStatus() handlers.BotStatus + SendPanel(panelID int64) error + DeletePanelMessage(panelID int64) error + CreateConvocation(guildID, staffDiscordID, targetDiscordID, reason string) (channelID string, err error) + SendConvocPanel(guildID string) error +} diff --git a/internal/panel/embed.go b/internal/panel/embed.go new file mode 100644 index 0000000..5c7378d --- /dev/null +++ b/internal/panel/embed.go @@ -0,0 +1,90 @@ +package panel + +import ( + "embed" + "fmt" + "html/template" + "io/fs" + "net/http" + "os" +) + +//go:embed templates static +var embeddedFS embed.FS + +var templateFuncs = template.FuncMap{ + "percent": func(count, max int) int { + if max <= 0 { + return 0 + } + v := count * 100 / max + if v > 100 { + return 100 + } + return v + }, + "fmtMin": func(m int) string { + if m <= 0 { + return "—" + } + if m < 60 { + return fmt.Sprintf("%dm", m) + } + return fmt.Sprintf("%dh%dm", m/60, m%60) + }, + "add": func(a, b int) int { return a + b }, + // dict builds a map[string]any for passing multiple values to a sub-template. + "dict": func(pairs ...any) map[string]any { + m := make(map[string]any, len(pairs)/2) + for i := 0; i+1 < len(pairs); i += 2 { + k, _ := pairs[i].(string) + m[k] = pairs[i+1] + } + return m + }, + "buttonClass": func(color string) string { + switch color { + case "secondary": + return "bg-gray-600" + case "success": + return "bg-green-600" + case "danger": + return "bg-red-600" + default: + return "bg-indigo-600" + } + }, + "not": func(v bool) bool { return !v }, +} + +// StaticFS returns the file system to use for /static/* routes. +// In dev mode (PANEL_DEV=true), serves from disk for hot reload. +func StaticFS() http.FileSystem { + if os.Getenv("PANEL_DEV") == "true" { + return http.Dir("internal/panel/static") + } + sub, _ := fs.Sub(embeddedFS, "static") + return http.FS(sub) +} + +// ParseTemplates parses a set of template files by name with global template functions. +// In dev mode templates are loaded from disk on every call (hot reload). +func ParseTemplates(names ...string) (*template.Template, error) { + var paths []string + for _, n := range names { + paths = append(paths, "templates/"+n) + } + base := template.New("").Funcs(templateFuncs) + if os.Getenv("PANEL_DEV") == "true" { + return base.ParseFiles(prependDir("internal/panel/", paths)...) + } + return base.ParseFS(embeddedFS, paths...) +} + +func prependDir(dir string, paths []string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = dir + p + } + return out +} diff --git a/internal/panel/handlers/audit.go b/internal/panel/handlers/audit.go new file mode 100644 index 0000000..a201966 --- /dev/null +++ b/internal/panel/handlers/audit.go @@ -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, + }) +} diff --git a/internal/panel/handlers/auth.go b/internal/panel/handlers/auth.go new file mode 100644 index 0000000..52d26dd --- /dev/null +++ b/internal/panel/handlers/auth.go @@ -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, + }) +} diff --git a/internal/panel/handlers/common.go b/internal/panel/handlers/common.go new file mode 100644 index 0000000..e86cbf8 --- /dev/null +++ b/internal/panel/handlers/common.go @@ -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 +} diff --git a/internal/panel/handlers/convocations.go b/internal/panel/handlers/convocations.go new file mode 100644 index 0000000..b3ab6d6 --- /dev/null +++ b/internal/panel/handlers/convocations.go @@ -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() +} diff --git a/internal/panel/handlers/dashboard.go b/internal/panel/handlers/dashboard.go new file mode 100644 index 0000000..aba25c1 --- /dev/null +++ b/internal/panel/handlers/dashboard.go @@ -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) +} diff --git a/internal/panel/handlers/login.go b/internal/panel/handlers/login.go new file mode 100644 index 0000000..e9572d5 --- /dev/null +++ b/internal/panel/handlers/login.go @@ -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) +} diff --git a/internal/panel/handlers/panels.go b/internal/panel/handlers/panels.go new file mode 100644 index 0000000..a788452 --- /dev/null +++ b/internal/panel/handlers/panels.go @@ -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, `
`, color) + fmt.Fprintf(w, `

%s

`, title) + if desc != "" { + fmt.Fprintf(w, `

%s

`, desc) + } + if len(buttons) > 0 { + fmt.Fprint(w, `
`) + for _, b := range buttons { + cls := btnColorMap[b.Color] + if cls == "" { + cls = "bg-indigo-600" + } + fmt.Fprintf(w, `%s %s`, cls, b.Emoji, b.Label) + } + fmt.Fprint(w, `
`) + } + fmt.Fprint(w, `
`) +} + +// 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 +} diff --git a/internal/panel/handlers/sessions.go b/internal/panel/handlers/sessions.go new file mode 100644 index 0000000..309e37c --- /dev/null +++ b/internal/panel/handlers/sessions.go @@ -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) +} diff --git a/internal/panel/handlers/transcripts.go b/internal/panel/handlers/transcripts.go new file mode 100644 index 0000000..76f6225 --- /dev/null +++ b/internal/panel/handlers/transcripts.go @@ -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) +} diff --git a/internal/panel/migrate.go b/internal/panel/migrate.go new file mode 100644 index 0000000..64e283c --- /dev/null +++ b/internal/panel/migrate.go @@ -0,0 +1,71 @@ +package panel + +import ( + "context" + "log/slog" + "time" + + "github.com/leolionad58/ticketbot/internal/config" + "github.com/leolionad58/ticketbot/internal/db" +) + +// MigrateYAMLPanels copies panel configuration from the YAML config into panel_configs and +// panel_types tables. It runs only once: if panel_configs is already non-empty it is a no-op. +func MigrateYAMLPanels(ctx context.Context, repo *db.PanelConfigRepo, cfg *config.Config, guildID string) error { + empty, err := repo.IsEmpty(ctx) + if err != nil { + return err + } + if !empty || len(cfg.Panels) == 0 { + return nil + } + + slog.Info("migrating YAML panels to DB", "count", len(cfg.Panels)) + order := 0 + for panelName, panel := range cfg.Panels { + pc := &db.PanelConfig{ + Name: panelName, + EmbedTitle: panel.EmbedTitle, + EmbedColor: panel.EmbedColor, + GuildID: guildID, + SortOrder: order, + } + if panel.EmbedDescription != "" { + pc.EmbedDescription.String = panel.EmbedDescription + pc.EmbedDescription.Valid = true + } + if err := repo.Create(ctx, pc); err != nil { + return err + } + + typeOrder := 0 + for typeName, t := range panel.Types { + pt := &db.PanelType{ + PanelID: pc.ID, + Name: typeName, + ButtonLabel: t.ButtonLabel, + ButtonColor: string(t.ButtonColor), + ButtonEmoji: t.ButtonEmoji, + EmbedColor: t.EmbedColor, + EmbedTitle: t.EmbedTitle, + EmbedText: t.EmbedText, + StaffRoleID: t.StaffRole, + CategoryID: t.Category, + MaxPerUser: 1, + ClaimMode: 1, + CloseRule: "staff_only", + ModalEnabled: true, + SortOrder: typeOrder, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := repo.CreateType(ctx, pt); err != nil { + return err + } + typeOrder++ + } + order++ + } + slog.Info("YAML panels migrated to DB", "panels", len(cfg.Panels)) + return nil +} diff --git a/internal/panel/server.go b/internal/panel/server.go new file mode 100644 index 0000000..0f5f9a9 --- /dev/null +++ b/internal/panel/server.go @@ -0,0 +1,208 @@ +package panel + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + + "github.com/leolionad58/ticketbot/internal/config" + "github.com/leolionad58/ticketbot/internal/db" + panelauth "github.com/leolionad58/ticketbot/internal/panel/auth" + "github.com/leolionad58/ticketbot/internal/panel/handlers" +) + +// Server is the HTTP panel server. +type Server struct { + http *http.Server +} + +// NewServer builds the panel HTTP server with all routes wired up. +func NewServer( + cfg config.PanelWebConfig, + sqlDB *db.TicketRepo, + adminRepo *db.PanelAdminRepo, + sessionRepo *db.PanelSessionRepo, + auditRepo *db.AuditLogRepo, + panelRepo *db.PanelConfigRepo, + convocRepo *db.ConvocationConfigRepo, + guildID string, + bot BotService, +) *Server { + authSvc := panelauth.NewService(adminRepo, sessionRepo, cfg.SessionTimeoutMinutes) + authMw := panelauth.NewMiddleware(authSvc, adminRepo) + + oauthCfg := panelauth.NewOAuthConfig( + cfg.OAuthClientID, + cfg.OAuthClientSecret, + cfg.BaseURL+"/oauth/callback", + ) + + rnd := handlers.NewRenderer(ParseTemplates) + if bot != nil { + rnd.SetBotStatus(bot.GetBotStatus) + } + + loginH := &handlers.LoginHandler{ + Admins: adminRepo, + Auth: authSvc, + OAuthCfg: oauthCfg, + Renderer: rnd, + } + authH := &handlers.AuthHandler{ + Admins: adminRepo, + Auth: authSvc, + AuditLog: auditRepo, + Issuer: "TicketBot", + Renderer: rnd, + } + var getBotStatus func() handlers.BotStatus + if bot != nil { + getBotStatus = bot.GetBotStatus + } + dashH := &handlers.DashboardHandler{ + TicketRepo: sqlDB, + PanelRepo: panelRepo, + GetBotStatus: getBotStatus, + Renderer: rnd, + } + var discordInfo handlers.DiscordInfo + if bot != nil { + if di, ok := bot.(handlers.DiscordInfo); ok { + discordInfo = di + } + } + panelsH := &handlers.PanelsHandler{ + PanelRepo: panelRepo, + AuditLog: auditRepo, + BotService: bot, + DiscordInfo: discordInfo, + Renderer: rnd, + } + var convocBot handlers.ConvocBotService + var convocInfo handlers.ConvocDiscordInfo + if bot != nil { + convocBot = bot + if di, ok := bot.(handlers.ConvocDiscordInfo); ok { + convocInfo = di + } + } + convocH := &handlers.ConvocationsHandler{ + TicketRepo: sqlDB, + ConvocRepo: convocRepo, + GuildID: guildID, + BotService: convocBot, + DiscordInfo: convocInfo, + Renderer: rnd, + } + transcriptsH := &handlers.TranscriptsHandler{ + TicketRepo: sqlDB, + Renderer: rnd, + DiscordInfo: discordInfo, + } + auditH := &handlers.AuditHandler{ + AuditRepo: auditRepo, + Renderer: rnd, + } + sessH := &handlers.SessionsHandler{ + SessionRepo: sessionRepo, + AdminRepo: adminRepo, + AuditLog: auditRepo, + Renderer: rnd, + } + + r := chi.NewRouter() + r.Use(chimiddleware.Recoverer) + r.Use(securityHeaders) + + // Static files + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(StaticFS()))) + + // Transcript view — served without CSP headers (must be outside securityHeaders group) + r.Get("/transcripts/view/{id}", transcriptsH.HandleView) + + // Public auth routes + r.Get("/login", loginH.HandleLogin) + r.Get("/oauth/callback", loginH.HandleCallback) + r.Get("/auth/password-setup", authH.HandlePasswordSetupGET) + r.Post("/auth/password-setup", authH.HandlePasswordSetupPOST) + r.Get("/auth/totp-setup", authH.HandleTOTPSetupGET) + r.Post("/auth/totp-setup", authH.HandleTOTPSetupPOST) + r.Get("/auth/verify", authH.HandleVerifyGET) + r.Post("/auth/verify", authH.HandleVerifyPOST) + r.Post("/auth/logout", authH.HandleLogout) + + // Protected routes + r.Group(func(r chi.Router) { + r.Use(authMw.RequireAuth) + r.Get("/", dashH.Handle) + + // Panels CRUD + r.Get("/panels", panelsH.HandleList) + r.Get("/panels/new", panelsH.HandleNew) + r.Post("/panels/new", panelsH.HandleCreate) + r.Post("/panels/preview", panelsH.HandlePreview) + r.Get("/panels/{id}/edit", panelsH.HandleEdit) + r.Post("/panels/{id}/edit", panelsH.HandleUpdate) + r.Post("/panels/{id}/delete", panelsH.HandleDelete) + r.Post("/panels/{id}/duplicate", panelsH.HandleDuplicate) + r.Post("/panels/{id}/types/reorder", panelsH.HandleReorderTypes) + + // Convocations + r.Get("/convocations", convocH.HandleGET) + r.Post("/convocations", convocH.HandlePOST) + r.Post("/convocations/send-panel", convocH.HandleSendPanel) + + // Transcripts (list + download only; view is outside securityHeaders) + r.Get("/transcripts", transcriptsH.HandleList) + r.Get("/transcripts/download/{id}", transcriptsH.HandleDownload) + + // Audit log + r.Get("/audit", auditH.HandleList) + + // Sessions (superadmin only) + r.Group(func(r chi.Router) { + r.Use(authMw.RequireSuperadmin) + r.Get("/sessions", sessH.HandleList) + r.Post("/sessions/{id}/revoke", sessH.HandleRevoke) + r.Post("/sessions/revoke-all", sessH.HandleRevokeAll) + }) + }) + + addr := fmt.Sprintf(":%d", cfg.Port) + return &Server{ + http: &http.Server{ + Addr: addr, + Handler: r, + }, + } +} + +// Start runs the HTTP server until ctx is cancelled. +func (s *Server) Start(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { errCh <- s.http.ListenAndServe() }() + select { + case <-ctx.Done(): + return s.http.Shutdown(context.Background()) + case err := <-errCh: + return err + } +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.tailwindcss.com https://cdn.jsdelivr.net; "+ + "style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+ + "img-src 'self' https://cdn.discordapp.com data:; frame-src 'self'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "0") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/panel/static/app.css b/internal/panel/static/app.css new file mode 100644 index 0000000..f104b36 --- /dev/null +++ b/internal/panel/static/app.css @@ -0,0 +1,23 @@ +/* Custom styles — Tailwind handles most of the heavy lifting */ + +/* Scrollbar — Discord-dark style */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: #1e1f22; } +::-webkit-scrollbar-thumb { background: #3f4147; border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: #5865f2; } + +/* HTMX loading indicator */ +.htmx-indicator { opacity: 0; transition: opacity 150ms; } +.htmx-request .htmx-indicator { opacity: 1; } + +/* Color picker alignment */ +input[type="color"] { padding: 2px; cursor: pointer; } + +/* Toast animations */ +@keyframes slideIn { from { transform: translateX(120%); } to { transform: translateX(0); } } +@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } +.toast-enter { animation: slideIn 0.2s ease-out; } +.toast-leave { animation: fadeOut 0.3s ease-in forwards; } + +/* Sidebar active link */ +.sidebar-link-active { background-color: #404249; color: #fff; } diff --git a/internal/panel/templates/audit.html b/internal/panel/templates/audit.html new file mode 100644 index 0000000..2ec9a92 --- /dev/null +++ b/internal/panel/templates/audit.html @@ -0,0 +1,103 @@ +{{define "title"}}Audit Log{{end}} +{{define "header"}}Audit Log{{end}} +{{define "content"}} + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + Réinitialiser + + {{.Total}} entrée{{if gt .Total 1}}s{{end}} +
+
+ + +
+ {{if .Entries}} +
+ + + + + + + + + + + + + {{range .Entries}} + + + + + + + + + {{end}} + +
DateAdmin IDActionEntitéEntité IDIP
{{.CreatedAt.Format "02/01/2006 15:04:05"}}{{.AdminID}} + {{.Action}} + {{.EntityType}} + {{if .EntityID.Valid}}{{.EntityID.Int64}}{{else}}—{{end}} + {{.IPAddress}}
+
+ + {{if gt .Pages 1}} +
+ {{if gt .Page 1}} + ← Précédent + {{end}} + Page {{.Page}} / {{.Pages}} + {{if lt .Page .Pages}} + Suivant → + {{end}} +
+ {{end}} + + {{else}} +
+ Aucune entrée d'audit pour ces critères. +
+ {{end}} +
+{{end}} diff --git a/internal/panel/templates/auth_layout.html b/internal/panel/templates/auth_layout.html new file mode 100644 index 0000000..0092361 --- /dev/null +++ b/internal/panel/templates/auth_layout.html @@ -0,0 +1,28 @@ +{{define "auth_layout"}} + + + + + + {{block "title" .}}TicketBot Admin{{end}} + + + + + +
+
+

TicketBot

+

Panel d'administration

+
+
+ {{block "content" .}}{{end}} +
+
+ + +{{end}} diff --git a/internal/panel/templates/convocations.html b/internal/panel/templates/convocations.html new file mode 100644 index 0000000..75e49c6 --- /dev/null +++ b/internal/panel/templates/convocations.html @@ -0,0 +1,158 @@ +{{define "title"}}Convocations{{end}} +{{define "header"}}Convocations{{end}} +{{define "content"}} + + +
+

Configuration des convocations

+
+ + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+

Panel Discord (bouton convocation)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + + {{if and .Config .Config.PanelChannelID}} +
+
+ + +
+ {{if and .Config .Config.PanelMessageID}} + Panel envoyé + {{end}} +
+ {{end}} +
+ + +
+
+

Convocations passées

+ {{.Total}} au total +
+ + {{if .Tickets}} +
+ + + + + + + + + + + + + + {{range .Tickets}} + + + + + + + + + + {{end}} + +
#UtilisateurTitreStaffStatutOuvert leFermé le
{{.TicketNumber}}{{.UserID}} + {{if .TicketTitle.Valid}}{{.TicketTitle.String}}{{else}}{{end}} + + {{if .ClaimedBy.Valid}}{{.ClaimedBy.String}}{{else}}—{{end}} + + {{if eq .Status "open"}} + Ouvert + {{else if eq .Status "claimed"}} + Claimé + {{else}} + Fermé + {{end}} + {{.OpenedAt.Format "02/01/2006 15:04"}} + {{if .ClosedAt.Valid}}{{.ClosedAt.Time.Format "02/01/2006 15:04"}}{{else}}—{{end}} +
+
+ + {{if gt .Pages 1}} +
+ {{if gt .Page 1}} + ← Précédent + {{end}} + Page {{.Page}} / {{.Pages}} + {{if lt .Page .Pages}} + Suivant → + {{end}} +
+ {{end}} + + {{else}} +
+ Aucune convocation enregistrée. +
+ {{end}} +
+{{end}} diff --git a/internal/panel/templates/dashboard.html b/internal/panel/templates/dashboard.html new file mode 100644 index 0000000..80be746 --- /dev/null +++ b/internal/panel/templates/dashboard.html @@ -0,0 +1,140 @@ +{{define "title"}}Dashboard{{end}} +{{define "header"}}Dashboard{{end}} +{{define "content"}} + +
+
+

Tickets ouverts

+

{{.Stats.OpenTickets}}

+
+
+

Fermés (30j)

+

{{.Stats.ClosedLast30}}

+
+
+

Moy. claim (30j)

+

+ {{if .Stats.AvgClaimMinutes}}{{.Stats.AvgClaimMinutes}}min{{else}}—{{end}} +

+
+
+

Moy. résolution (30j)

+

+ {{if .Stats.AvgResolutionMinutes}}{{.Stats.AvgResolutionMinutes}}min{{else}}—{{end}} +

+
+
+ + +
+ +
+

Tickets ouverts — 30 derniers jours

+ {{if .Chart}} +
+ {{range .Chart}} +
+
+
+ {{.Day}} + {{.Count}} +
+ {{end}} +
+ {{else}} +

Pas encore de données.

+ {{end}} +
+ + +
+

Statut du bot

+
+
+
Connexion
+
+ + {{if .BotOnline}}En ligne{{else}}Hors ligne{{end}} +
+
+
+
Uptime
+
{{.Uptime}}
+
+
+
Latence
+
{{if .LatencyMs}}{{.LatencyMs}}ms{{else}}—{{end}}
+
+ {{if .GuildName}} +
+
Serveur
+
{{.GuildName}}
+
+ {{end}} +
+
+
+ + +
+ +
+
+

Tickets par staff (30j)

+
+ {{if .Staff}} + + + + + + + + + + + + {{range .Staff}} + + + + + + + + {{end}} + +
StaffClaimésFermésMoy. claimMoy. résol.
{{.StaffID}}{{.Claimed}}{{.Closed}}{{fmtMin .AvgClaimMinutes}}{{fmtMin .AvgResolutionMinutes}}
+ {{else}} +

Aucun ticket claimé sur les 30 derniers jours.

+ {{end}} +
+ + +
+
+

Panels

+ + Créer +
+ {{if .Panels}} +
    + {{range .Panels}} +
  • + + + {{.Name}} + {{len .Types}} + + Éditer → +
  • + {{end}} +
+ {{else}} +

+ Créer un panel → +

+ {{end}} +
+
+{{end}} diff --git a/internal/panel/templates/denied.html b/internal/panel/templates/denied.html new file mode 100644 index 0000000..86fff0c --- /dev/null +++ b/internal/panel/templates/denied.html @@ -0,0 +1,13 @@ +{{define "content"}} +
+
🚫
+

Accès refusé

+

+ Ton compte Discord n'est pas autorisé à accéder à ce panel.
+ Contacte un administrateur pour obtenir l'accès. +

+ + ← Retour à la connexion + +
+{{end}} diff --git a/internal/panel/templates/layout.html b/internal/panel/templates/layout.html new file mode 100644 index 0000000..179d87f --- /dev/null +++ b/internal/panel/templates/layout.html @@ -0,0 +1,125 @@ +{{define "layout"}} + + + + + + {{if .CSRFToken}}{{end}} + {{block "title" .}}Dashboard{{end}} — TicketBot Admin + + + + + + + + + + + +
+ +
+

{{block "header" .}}Dashboard{{end}}

+
+ {{if .BotOnline}} + + Bot en ligne + + {{else}} + + Bot hors ligne + + {{end}} +
+
+ + +
+ {{if .Flash}} +
+ {{.Flash}} +
+ {{end}} + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + {{block "content" .}}{{end}} +
+
+ + +
+ + +{{end}} diff --git a/internal/panel/templates/login.html b/internal/panel/templates/login.html new file mode 100644 index 0000000..25d2434 --- /dev/null +++ b/internal/panel/templates/login.html @@ -0,0 +1,20 @@ +{{define "content"}} +
+

Connexion

+

Connecte-toi avec ton compte Discord pour accéder au panel.

+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + + + + + + Se connecter avec Discord + +
+{{end}} diff --git a/internal/panel/templates/panel_form.html b/internal/panel/templates/panel_form.html new file mode 100644 index 0000000..be35931 --- /dev/null +++ b/internal/panel/templates/panel_form.html @@ -0,0 +1,431 @@ +{{define "title"}}{{if .IsNew}}Nouveau panel{{else}}Éditer — {{.Panel.Name}}{{end}}{{end}} +{{define "header"}}{{if .IsNew}}Créer un panel{{else}}Éditer {{.Panel.Name}}{{end}}{{end}} +{{define "content"}} +
+ + + {{if and .IsNew .Guilds}} +
+ + +

Les rôles et channels chargés correspondent au serveur sélectionné.

+
+ {{end}} + + + +
+ +
+ + +
+

Informations du panel

+
+
+ + + {{if index .Errors "name"}}

{{index .Errors "name"}}

{{end}} +
+
+ + + {{if index .Errors "embed_title"}}

{{index .Errors "embed_title"}}

{{end}} +
+
+ + +
+
+ +
+ + {{.Panel.EmbedColor}} +
+
+
+ + +
+
+ + {{if .TextChans}} + + {{else}} + + {{end}} +
+
+
+ + +
+
+

Types de tickets

+ +
+
+ {{range $i, $t := .Panel.Types}} + {{template "type_block" dict "Type" $t "Index" $i "CSRFToken" $.CSRFToken "Roles" $.Roles "TextChans" $.TextChans "Categories" $.Categories}} + {{end}} +
+ {{if eq (len .Panel.Types) 0}} +

Aucun type. Cliquez sur "+ Ajouter un type".

+ {{end}} +
+ +
+ + +
+
+

Aperçu

+
+
+

{{if .Panel.EmbedTitle}}{{.Panel.EmbedTitle}}{{else}}Titre du panel{{end}}

+ {{if .Panel.EmbedDescription.Valid}} +

{{.Panel.EmbedDescription.String}}

+ {{end}} + {{if .Panel.Types}} +
+ {{range .Panel.Types}} + {{.ButtonEmoji}} {{.ButtonLabel}} + {{end}} +
+ {{end}} +
+
+
+ +
+ + + + Annuler + +
+
+
+
+ + + + + + + + + +{{end}} + +{{define "type_block"}} +
+
+ + + Type {{add .Index 1}} + + +
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+ + {{if .Roles}} + + {{else}} + + {{end}} +
+
+ + {{if .Categories}} + + {{else}} + + {{end}} +
+
+ + {{if .TextChans}} + + {{else}} + + {{end}} +
+
+ + {{if .TextChans}} + + {{else}} + + {{end}} +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+{{end}} diff --git a/internal/panel/templates/panels_list.html b/internal/panel/templates/panels_list.html new file mode 100644 index 0000000..7782413 --- /dev/null +++ b/internal/panel/templates/panels_list.html @@ -0,0 +1,91 @@ +{{define "title"}}Panels{{end}} +{{define "header"}}Panels{{end}} +{{define "content"}} +
+

{{len .Panels}} panel{{if gt (len .Panels) 1}}s{{end}} configuré{{if gt (len .Panels) 1}}s{{end}}

+ + + Créer un panel + +
+ +{{if .Panels}} +
+ {{range .Panels}} +
+ +
+ +
+
+ {{.Name}} + {{len .Types}} type{{if gt (len .Types) 1}}s{{end}} + {{if .MessageID.Valid}}Envoyé{{end}} + {{if .GuildID}} + {{$gname := index $.GuildNames .GuildID}} + {{if $gname}}{{$gname}}{{else}}{{.GuildID}}{{end}} + {{end}} +
+

{{.EmbedTitle}}

+ {{if .ChannelID.Valid}}

Channel : {{.ChannelID.String}}

{{end}} +
+ +
+ + Éditer + +
+ + +
+ +
+
+ {{end}} +
+{{else}} +
+

Aucun panel configuré.

+ + Créer votre premier panel + +
+{{end}} + + + + + +{{end}} diff --git a/internal/panel/templates/password_setup.html b/internal/panel/templates/password_setup.html new file mode 100644 index 0000000..4af0097 --- /dev/null +++ b/internal/panel/templates/password_setup.html @@ -0,0 +1,30 @@ +{{define "content"}} +

Créer un mot de passe

+

+ Choisissez un mot de passe sécurisé (12 caractères minimum). Il vous sera demandé à chaque connexion. +

+ +{{if .Error}} +
+ {{.Error}} +
+{{end}} + +
+
+ + +
+
+ + +
+ +
+{{end}} diff --git a/internal/panel/templates/sessions.html b/internal/panel/templates/sessions.html new file mode 100644 index 0000000..812f0b9 --- /dev/null +++ b/internal/panel/templates/sessions.html @@ -0,0 +1,90 @@ +{{define "title"}}Sessions{{end}} +{{define "header"}}Sessions actives{{end}} +{{define "content"}} + +
+

{{len .Sessions}} session{{if gt (len .Sessions) 1}}s{{end}} active{{if gt (len .Sessions) 1}}s{{end}}

+ +
+ +
+ {{if .Sessions}} +
+ + + + + + + + + + + + + {{range .Sessions}} + + + + + + + + + {{end}} + +
AdminIPUser AgentDernière activitéCréée leAction
+ {{.DiscordUsername}} + {{if .IsCurrent}}(vous){{end}} + {{.IPAddress}}{{.UserAgent}}{{.LastActivity.Format "02/01/2006 15:04:05"}}{{.CreatedAt.Format "02/01/2006 15:04:05"}} + {{if not .IsCurrent}} +
+ + +
+ {{else}} + Session courante + {{end}} +
+
+ {{else}} +
+ Aucune session active. +
+ {{end}} +
+ + + + + +{{end}} diff --git a/internal/panel/templates/totp_setup.html b/internal/panel/templates/totp_setup.html new file mode 100644 index 0000000..fc6c079 --- /dev/null +++ b/internal/panel/templates/totp_setup.html @@ -0,0 +1,35 @@ +{{define "content"}} +

Configuration TOTP

+

+ Scanne le QR code avec Google Authenticator ou une app compatible, puis saisis le code pour valider. +

+ +{{if .Error}} +
+ {{.Error}} +
+{{end}} + +
+ QR Code TOTP +
+ +
+

Secret manuel :

+ {{.Secret}} +
+ +
+ +
+ + +
+ +
+{{end}} diff --git a/internal/panel/templates/totp_verify.html b/internal/panel/templates/totp_verify.html new file mode 100644 index 0000000..20a39f4 --- /dev/null +++ b/internal/panel/templates/totp_verify.html @@ -0,0 +1,41 @@ +{{define "content"}} +

Vérification

+ +{{if .Admin}} +
+ {{if .Admin.DiscordAvatar}} + + {{end}} +
+

Connecté en tant que

+

{{.Admin.DiscordUsername}}

+
+
+{{end}} + +{{if .Error}} +
+ {{.Error}} +
+{{end}} + +
+
+ + +
+
+ + +
+ +
+{{end}} diff --git a/internal/panel/templates/transcripts.html b/internal/panel/templates/transcripts.html new file mode 100644 index 0000000..e4fff9c --- /dev/null +++ b/internal/panel/templates/transcripts.html @@ -0,0 +1,170 @@ +{{define "title"}}Transcripts{{end}} +{{define "header"}}Transcripts{{end}} +{{define "content"}} + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {{if .Guilds}} +
+ + +
+ {{end}} + +
+ + +
+ +
+ + +
+
+ +
+ + + Réinitialiser + + {{.Total}} résultat{{if gt .Total 1}}s{{end}} +
+
+ + +
+ {{if .Tickets}} +
+ + + + + + + + + + + + + + + + + {{range .Tickets}} + + + + + + + + + + + + + {{end}} + +
#ServeurTypeUtilisateurStaffTitreStatutOuvert leFermé leActions
{{.TicketNumber}} + {{if .GuildID}}{{$gn := index $.GuildNames .GuildID}}{{if $gn}}{{$gn}}{{else}}{{.GuildID}}{{end}}{{else}}{{end}} + {{.Type}}{{.UserID}} + {{if .ClaimedBy.Valid}}{{.ClaimedBy.String}}{{else}}—{{end}} + + {{if .TicketTitle.Valid}}{{.TicketTitle.String}}{{else}}{{end}} + + {{if eq .Status "open"}} + Ouvert + {{else if eq .Status "claimed"}} + Claimé + {{else}} + Fermé + {{end}} + {{.OpenedAt.Format "02/01/06 15:04"}} + {{if .ClosedAt.Valid}}{{.ClosedAt.Time.Format "02/01/06 15:04"}}{{else}}—{{end}} + +
+ {{if .TranscriptPath.Valid}} + + Voir + + + ↓ + + {{else}} + + {{end}} +
+
+
+ + {{if gt .Pages 1}} +
+ {{if gt .Page 1}} + ← Précédent + {{end}} + Page {{.Page}} / {{.Pages}} + {{if lt .Page .Pages}} + Suivant → + {{end}} +
+ {{end}} + + {{else}} +
+ Aucun ticket trouvé pour ces critères. +
+ {{end}} +
+{{end}} diff --git a/internal/tickets/auth.go b/internal/tickets/auth.go index 606effd..2498473 100644 --- a/internal/tickets/auth.go +++ b/internal/tickets/auth.go @@ -60,6 +60,10 @@ func (a *AuthService) Can(memberRoles []string, action Action, ticket *db.Ticket if ticket == nil { return "" } + // Prefer role stored on the ticket itself (populated at open time for DB panels) + if ticket.StaffRoleID != "" { + return ticket.StaffRoleID + } if p, ok := cfg.Panels[ticket.Panel]; ok { if t, ok := p.Types[ticket.Type]; ok { return t.StaffRole diff --git a/internal/tickets/service.go b/internal/tickets/service.go index dd1d53a..6342f12 100644 --- a/internal/tickets/service.go +++ b/internal/tickets/service.go @@ -15,15 +15,16 @@ import ( ) type Service struct { - db *db.TicketRepo - auth *AuthService - session *discordgo.Session - cfg *config.Provider - opening sync.Map // key: userID+":"+ticketType prevents double-click + db *db.TicketRepo + panelRepo *db.PanelConfigRepo + auth *AuthService + session *discordgo.Session + cfg *config.Provider + opening sync.Map // key: userID+":"+ticketType prevents double-click } -func NewService(repo *db.TicketRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service { - return &Service{db: repo, auth: auth, session: s, cfg: cfg} +func NewService(repo *db.TicketRepo, panelRepo *db.PanelConfigRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service { + return &Service{db: repo, panelRepo: panelRepo, auth: auth, session: s, cfg: cfg} } var ErrAlreadyOpen = fmt.Errorf("already_open") @@ -45,23 +46,53 @@ func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticket return existing, fmt.Errorf("%w:%s", ErrAlreadyOpen, existing.ChannelID) } - cfg := svc.cfg.Get() - panel, ok := cfg.Panels[panelName] - if !ok { + var categoryID, claimChannelID, logChannelID, staffRoleID string + var claimReupMinutes int + yamlCfg := svc.cfg.Get() + if panel, ok := yamlCfg.Panels[panelName]; ok { + typeCfg, ok := panel.Types[ticketType] + if !ok { + return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName) + } + categoryID = typeCfg.Category + staffRoleID = typeCfg.StaffRole + // YAML panels have no per-type claim/log routing; fall back to global config at claim/log time + } else if svc.panelRepo != nil { + dbPanel, err := svc.panelRepo.GetByName(ctx, panelName) + if err != nil || dbPanel == nil { + return nil, fmt.Errorf("panel %q not found", panelName) + } + var dbType *db.PanelType + for _, t := range dbPanel.Types { + if t.Name == ticketType { + dbType = t + break + } + } + if dbType == nil { + return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName) + } + categoryID = dbType.CategoryID + claimChannelID = dbType.ClaimChannelID + logChannelID = dbType.LogChannelID + staffRoleID = dbType.StaffRoleID + claimReupMinutes = dbType.ClaimReupMinutes + } else { return nil, fmt.Errorf("panel %q not found", panelName) } - typeCfg, ok := panel.Types[ticketType] - if !ok { - return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName) - } // Reserve a placeholder to get the ticket number atomically ticket := &db.Ticket{ - UserID: userID, - Panel: panelName, - Type: ticketType, - ChannelID: fmt.Sprintf("pending-%d", time.Now().UnixNano()), - OpenedAt: time.Now(), + UserID: userID, + Panel: panelName, + Type: ticketType, + ChannelID: fmt.Sprintf("pending-%d", time.Now().UnixNano()), + GuildID: guildID, + ClaimChannelID: claimChannelID, + LogChannelID: logChannelID, + StaffRoleID: staffRoleID, + ClaimReupMinutes: claimReupMinutes, + OpenedAt: time.Now(), } if err := svc.db.Insert(ctx, ticket); err != nil { return nil, fmt.Errorf("insert ticket: %w", err) @@ -71,7 +102,7 @@ func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticket ch, err := svc.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ Name: channelName, Type: discordgo.ChannelTypeGuildText, - ParentID: typeCfg.Category, + ParentID: categoryID, PermissionOverwrites: []*discordgo.PermissionOverwrite{ { ID: guildID,