0946a3f66c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
Turn the gated-off mock banner into a real advertising subsystem (backend + admin half; the UI rotation lands in PR2). - internal/ads: campaigns (percent weight + validity window; a perpetual, undeletable default that fills the remainder up to 100%), 1..N bilingual messages (en+ru), global display timings; ActiveSet computes the window-filtered, default-remainder, GCD-reduced, language-resolved rotation feed. Smooth-weighted-round-robin math is unit-tested. - migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded default campaign + house message + default timings. - eligibility = !paid_account && hint_balance==0 && !no_banner role (new role; guests qualify). The resolved feed rides the profile.get response (no new RPC, works for guests, nothing distinct to filter); language by service_language. - live update: a notify `banner` sub-kind (re-poll signal) published when an operator grants hints or grants/revokes no_banner, so the client shows/hides in place. - admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD with reorder, default protection, clamped timings. - wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it. - docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker (incl. the deferred app.load aggregator note).
301 lines
9.1 KiB
Go
301 lines
9.1 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
|
|
}
|
|
if role == account.RoleNoBanner {
|
|
s.publishBannerChange(id)
|
|
}
|
|
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
|
|
}
|
|
if role == account.RoleNoBanner {
|
|
s.publishBannerChange(id)
|
|
}
|
|
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
|
|
}
|