Files
scrabble-game/backend/internal/feedback/attachment.go
T
Ilia Denisov 419ea11b14
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
feat(feedback): in-app user feedback with admin review and account roles
User-facing Feedback screen (Settings -> Info, registered accounts only): a
message (<=1024 runes) plus one optional attachment, an anti-spam gate (one
unreviewed message at a time), and the operator's inline reply with a
Settings/Info badge. Server-rendered admin console section (/_gm/feedback):
unread/read/archived queue with per-user search, detail with read/reply/
archive/delete/delete-all, safe attachment serving (nosniff, images inline via
<img>, others download-only). Introduces account_roles, the first per-account
role table; feedback_banned blocks only feedback submission, granted/revoked
from /users and the delete-with-block action.

- migration 00004_feedback (feedback_messages + account_roles) + jetgen
- backend internal/feedback (store+service), internal/account/roles.go
- wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest,
  is_guest via session resolve) -> guest_forbidden before any backend call
- reply push reuses NotificationEvent with a new admin_reply sub-kind
- UI: /feedback route + screen, attachment picker, badge, channel detection, i18n
- tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e
- docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
2026-06-15 12:23:10 +02:00

55 lines
1.9 KiB
Go

package feedback
import (
"path/filepath"
"strings"
)
// maxAttachmentBytes caps a single attachment's raw size. Chosen to fit, with the
// message text and the FlatBuffers framing, under the gateway's 1 MiB edge body
// cap, so the whole submit request passes without weakening that cap.
const maxAttachmentBytes = 1_000_000
// allowedExt is the attachment extension allow-list. It is mirrored on the UI as a
// pre-upload gate; the server re-checks here as the trust boundary (metadata only,
// the file content is never parsed). Images render inline in the console; the rest
// are download-only.
var allowedExt = map[string]bool{
"png": true, "jpg": true, "jpeg": true, "webp": true, "gif": true, // images
"pdf": true, "txt": true, "log": true, "doc": true, "docx": true,
"rtf": true, "zip": true, "gz": true, "7z": true,
}
// imageType maps an image extension to the content-type the console serves it with
// (loaded only via <img>, which never executes, so a renamed non-image is inert).
var imageType = map[string]string{
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "gif": "image/gif",
}
// ext returns name's lower-cased extension without the leading dot.
func ext(name string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
}
// AllowedAttachment reports whether name's extension is on the allow-list.
func AllowedAttachment(name string) bool {
return allowedExt[ext(name)]
}
// IsImage reports whether name is an inline-previewable image by its extension.
func IsImage(name string) bool {
_, ok := imageType[ext(name)]
return ok
}
// ContentType returns the safe content-type the console serves the attachment
// with: the matching image type for an image, else application/octet-stream so a
// non-image is downloaded rather than rendered.
func ContentType(name string) string {
if t, ok := imageType[ext(name)]; ok {
return t
}
return "application/octet-stream"
}