package server
import (
"context"
"html/template"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/feedback"
)
// consoleFeedback renders the paginated feedback queue, filtered by state
// (unread/read/archived), optionally pinned to one sender (?user=) and narrowed by
// sender glob masks (?name / ?ext).
func (s *Server) consoleFeedback(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
status := normalizeFeedbackStatus(c.Query("status"))
user, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := feedback.AdminFilter{
Status: status,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
AccountID: user,
}
total, _ := s.feedback.AdminCount(ctx, filter)
rows, err := s.feedback.AdminList(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
q.Set("status", status)
if filter.AccountID != uuid.Nil {
q.Set("user", filter.AccountID.String())
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
view := adminconsole.FeedbackView{
Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask, ExtMask: filter.ExtMask,
FilterQuery: template.URL(q.Encode()),
}
if filter.AccountID != uuid.Nil {
view.UserID = filter.AccountID.String()
}
for _, r := range rows {
view.Items = append(view.Items, adminconsole.FeedbackRow{
ID: r.ID.String(), AccountID: r.AccountID.String(), SenderName: r.SenderName,
Source: r.Source, Channel: r.Channel, HasAttachment: r.HasAttachment,
Read: r.Read, Replied: r.Replied, Archived: r.Archived, CreatedAt: fmtTime(r.CreatedAt),
})
}
s.renderConsole(c, "feedback", "feedback", "Feedback", view)
}
// consoleFeedbackDetail renders one feedback message with its actions. Opening it
// does NOT mark it read (only the explicit actions do).
func (s *Server) consoleFeedbackDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.FeedbackDetailView{
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, BotLanguage: m.ChannelLang,
IP: m.SenderIP, Body: m.Body,
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
}
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
view.Banned = banned
}
s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view)
}
// consoleFeedbackAttachment serves a message's attachment. It is always sent with
// X-Content-Type-Options: nosniff; images are served with their type for an inline
//
preview (which never executes), everything else as an octet-stream download
// (so a non-image — including a renamed one — is downloaded, never rendered).
func (s *Server) consoleFeedbackAttachment(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
name, data, ok, err := s.feedback.Attachment(c.Request.Context(), id)
if err != nil {
s.consoleError(c, err)
return
}
if !ok {
s.renderConsoleMessage(c, "No attachment", "this message has no attachment", "/_gm/feedback/"+id.String())
return
}
ctype := "application/octet-stream"
disposition := `attachment; filename="` + sanitizeFilename(name) + `"`
if feedback.IsImage(name) {
ctype = feedback.ContentType(name)
disposition = "inline"
}
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", disposition)
c.Data(http.StatusOK, ctype, data)
}
// consoleFeedbackRead marks a message dealt-with without any other action.
func (s *Server) consoleFeedbackRead(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
back := "/_gm/feedback/" + id.String()
if err := s.feedback.MarkRead(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Marked read", "the message is marked read", back)
}
// consoleFeedbackReply sends the operator's reply to the player (marking the
// message read and notifying the player's app).
func (s *Server) consoleFeedbackReply(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
back := "/_gm/feedback/" + id.String()
body := trimForm(c, "reply")
if body == "" {
s.renderConsoleMessage(c, "Empty reply", "enter a reply before sending", back)
return
}
if err := s.feedback.Reply(c.Request.Context(), id, body); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Replied", "the reply was sent to the player", back)
}
// consoleFeedbackArchive files a handled message away (marking it read).
func (s *Server) consoleFeedbackArchive(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
if err := s.feedback.Archive(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Archived", "the message was archived", "/_gm/feedback")
}
// consoleFeedbackDelete physically deletes one message, optionally banning the
// sender from feedback (the "block" checkbox).
func (s *Server) consoleFeedbackDelete(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
if err := s.feedback.Delete(ctx, id); err != nil {
s.consoleError(c, err)
return
}
extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "")
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "the message was deleted"+extra, "/_gm/feedback")
}
// consoleFeedbackDeleteAll physically deletes every message from the sender of the
// given message, optionally banning the sender from feedback.
func (s *Server) consoleFeedbackDeleteAll(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
if err := s.feedback.DeleteAllByAccount(ctx, m.AccountID); err != nil {
s.consoleError(c, err)
return
}
extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "")
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "all messages from the player were deleted"+extra, "/_gm/feedback")
}
// maybeBan grants the feedback-ban role to accountID when ban is set, returning a
// suffix for the result message.
func (s *Server) maybeBan(ctx context.Context, accountID uuid.UUID, ban bool) (string, error) {
if !ban {
return "", nil
}
if err := s.accounts.GrantRole(ctx, accountID, account.RoleFeedbackBanned); err != nil {
return "", err
}
return " and the player was banned from feedback", nil
}
// consoleGrantRole grants a known role to an account (the /users role panel).
func (s *Server) consoleGrantRole(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
role := trimForm(c, "role")
if !account.IsKnownRole(role) {
s.renderConsoleMessage(c, "Invalid role", "the selected role is not recognised", back)
return
}
if err := s.accounts.GrantRole(c.Request.Context(), id, role); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Role granted", "granted "+role, back)
}
// consoleRevokeRole removes a role from an account (the /users role panel).
func (s *Server) consoleRevokeRole(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
role := trimForm(c, "role")
if role == "" {
s.renderConsoleMessage(c, "Invalid role", "no role given", back)
return
}
if err := s.accounts.RevokeRole(c.Request.Context(), id, role); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back)
}
// normalizeFeedbackStatus keeps only a recognised feedback status filter, else
// "unread" (the default queue).
func normalizeFeedbackStatus(s string) string {
switch s {
case "read", "archived":
return s
default:
return "unread"
}
}
// sanitizeFilename strips characters unsafe in a Content-Disposition filename
// (control chars, quotes and path separators) to prevent header injection.
func sanitizeFilename(name string) string {
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == '"' || r == '\\' || r == '/' {
return -1
}
return r
}, name)
if name == "" {
return "attachment"
}
return name
}