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, 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 }