55ed87fb11
Store the sender's interface language (lang) and, for a message that arrived through an external connector (Telegram), the bot language (channel_lang) on the feedback row at submit time, so the operator console shows the state as it was rather than the account's current settings (same snapshot discipline as a suspension reason). Added additively in migration 00005. The console detail reads these columns instead of loading the account live.
295 lines
8.9 KiB
Go
295 lines
8.9 KiB
Go
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
|
|
// <img> 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
|
|
}
|