feat: feedback submissions with screenshots, the hide-widget preference and its user payload key

- POST /_feedback/api/v1/{key}/submit validates as PHP, refuses non-image screenshots (ImageContentGuard copy) and stores the submission, its public screenshot and the G15Office job in one transaction
- PUT /_feedback/api/v1/me/hidden (jwt.auth) stores the caller's preference; feedback_widget_hidden joins every golem15.user payload
- preflights stay with the framework CORS layer
This commit is contained in:
Jakub Zych
2026-10-04 00:10:15 +02:00
parent b4b4135ad3
commit 92e1b4e09c
12 changed files with 1073 additions and 6 deletions

78
classes/classes_test.go Normal file
View File

@@ -0,0 +1,78 @@
package classes_test
import (
"bytes"
"encoding/hex"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"testing"
"git.golem15.com/golem15/sm-feedback-plugin/classes"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
)
func mustHex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatal(err)
}
return b
}
func encoded(t *testing.T, enc func(*bytes.Buffer, image.Image) error) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
img.Set(1, 1, color.RGBA{R: 200, A: 255})
var buf bytes.Buffer
if err := enc(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// TestImageGuardCopy pins the feedback copy of ImageContentGuard: the
// shared vectors (the PHP-recorded screenshot fixtures among them) get the
// verdicts PHP gave and the framework's own guard (attach.IsAllowedImage)
// gives, and the sniffed type of each accepted image is one of the four.
func TestImageGuardCopy(t *testing.T) {
cases := []struct {
name string
data []byte
want bool
}{
{"png", encoded(t, func(b *bytes.Buffer, i image.Image) error { return png.Encode(b, i) }), true},
{"jpeg", encoded(t, func(b *bytes.Buffer, i image.Image) error { return jpeg.Encode(b, i, nil) }), true},
{"gif", encoded(t, func(b *bytes.Buffer, i image.Image) error { return gif.Encode(b, i, nil) }), true},
// The parity fixtures: a PNG and a WebP PHP accepted, and a JPEG
// header without an image PHP refused ("The file is not a valid
// image.").
{"fixture-png", mustHex(t, "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc0000001949444154789c626160a8906360c0442c20021b189c128001005509023d9970f43c0000000049454e44ae426082"), true},
{"fixture-webp", mustHex(t, "524946464800000057454250565038203c0000005003009d012a180010003e6d2c9245a422a19804004006c4a009d3284600fc000108a000feec35853fff497f283d1d7ff3f3396879687ee0b7790000"), true},
{"fixture-spoofed-jpeg", mustHex(t, "ffd8ffe000104a46494600010100000100010000706f6c79676c6f743a2061204a5045472068656164657220776974686f757420616e7920696d616765ffd9"), false},
{"text", []byte("plain text, not a picture"), false},
{"empty", nil, false},
{"svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>`), false},
{"bmp", append([]byte("BM"), make([]byte, 60)...), false},
{"png-header-only", mustHex(t, "89504e470d0a1a0a"), false},
}
for _, c := range cases {
got := classes.IsAllowedImage(c.data)
if got != c.want {
t.Errorf("%s: IsAllowedImage = %v, want %v", c.name, got, c.want)
}
if fw := attach.IsAllowedImage(c.data); fw != got {
t.Errorf("%s: the framework guard says %v, the copy %v", c.name, fw, got)
}
if got {
switch classes.SniffImageMIME(c.data) {
case "image/png", "image/jpeg", "image/gif", "image/webp":
default:
t.Errorf("%s: sniffed %q", c.name, classes.SniffImageMIME(c.data))
}
}
}
}

59
classes/image_guard.go Normal file
View File

@@ -0,0 +1,59 @@
// Package classes holds the golem15.feedback services: the screenshot
// content guard, the G15Office client and the job that files a submission
// as a G15Office task.
package classes
import (
"bytes"
"image"
"net/http"
// The decoders image.DecodeConfig needs for the allowed formats; the
// webp decoder is registered by lagoon/attach.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
_ "git.golem15.com/golem15/summercms/modules/lagoon/attach"
)
// AllowedImageMIMEs is ImageContentGuard::ALLOWED_IMAGE_MIMES.
var AllowedImageMIMEs = []string{"image/jpeg", "image/png", "image/gif", "image/webp"}
// SniffImageMIME is the finfo magic-byte sniff of ImageContentGuard: the
// content type the bytes themselves declare, independent of any file name
// or client header.
func SniffImageMIME(data []byte) string {
return http.DetectContentType(data)
}
// IsAllowedImage ports ImageContentGuard::isAllowedImage: the bytes must
// sniff as JPEG, PNG, GIF or WebP and must decode as that image
// (getimagesize), which rejects a polyglot whose header alone looks right.
// It fails closed: empty or unreadable content is refused. The WinterCMS
// plugins keep one copy of the guard each, and so do their ports.
func IsAllowedImage(data []byte) bool {
if len(data) == 0 {
return false
}
sniffed := SniffImageMIME(data)
allowed := false
for _, m := range AllowedImageMIMEs {
if sniffed == m {
allowed = true
break
}
}
if !allowed {
return false
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return false
}
switch format {
case "jpeg", "png", "gif", "webp":
return true
}
return false
}

123
classes/submission_store.go Normal file
View File

@@ -0,0 +1,123 @@
package classes
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"mime"
"path/filepath"
"strconv"
"strings"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// Screenshot is a validated screenshot upload: the client's file name and
// the bytes, which passed IsAllowedImage.
type Screenshot struct {
Filename string
Data []byte
}
// StoreSubmission saves a new submission (status pending), queues its
// G15Office job (SyncG15OfficeArgs, a summer_jobs row labelled
// SyncG15OfficeLabel) and, when there is one, stores the screenshot as a
// public file attached to the submission's screenshot relation, all in one
// transaction: when any step fails nothing is kept, no job is queued and
// the screenshot's blob is deleted again.
func StoreSubmission(ctx context.Context, app *backpack.App, gdb *gorm.DB, bucket *blob.Bucket, sub *models.Submission, shot *Screenshot) error {
if gdb == nil {
return errors.New("feedback: database is not configured")
}
if shot != nil && bucket == nil {
return errors.New("feedback: upload bucket is not configured")
}
jobs, err := conga.From(app)
if err != nil {
return err
}
sub.Status = models.StatusPending
var keys []string
err = lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(sub).Error; err != nil {
return err
}
if _, err := jobs.Dispatch(ctx, tx.Session(&gorm.Session{NewDB: true, Context: ctx}), SyncG15OfficeArgs{SubmissionID: sub.ID}, conga.DispatchOpts{
Label: SyncG15OfficeLabel,
Queue: SyncG15OfficeQueue,
MaxAttempts: SyncG15OfficeAttempts,
}); err != nil {
return err
}
if shot == nil {
return nil
}
f, err := storeScreenshot(ctx, tx.Session(&gorm.Session{NewDB: true, Context: ctx}), bucket, sub.ID, shot)
if f != nil {
keys = attach.BlobKeys(*f)
}
return err
})
if err != nil && len(keys) > 0 {
_ = attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys)
}
return err
}
// storeScreenshot writes the screenshot under a fresh Winter-style disk
// name (22 hex characters plus the client file's extension, lower-cased)
// and inserts its system_files row attached to the submission, public and
// sorted by its own id as WinterCMS's Sortable trait does. The returned
// file is set once the blob is written, so a failed row can be cleaned up.
func storeScreenshot(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, submissionID uint, shot *Screenshot) (*attach.File, error) {
raw := make([]byte, 11)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
diskName := hex.EncodeToString(raw)
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(shot.Filename), ".")); ext != "" {
diskName += "." + ext
}
contentType := SniffImageMIME(shot.Data)
if mt, _, err := mime.ParseMediaType(contentType); err == nil {
contentType = mt
}
if err := bucket.WriteAll(ctx, attach.BlobKey(diskName), shot.Data, &blob.WriterOptions{ContentType: contentType}); err != nil {
return nil, err
}
public := true
f := &attach.File{
DiskName: diskName,
FileName: shot.Filename,
FileSize: int64(len(shot.Data)),
ContentType: contentType,
IsPublic: &public,
Field: models.ScreenshotField,
AttachmentType: models.Submission{}.MorphName(),
AttachmentID: strconv.FormatUint(uint64(submissionID), 10),
}
if err := db.Create(f).Error; err != nil {
return f, err
}
f.SortOrder = int(f.ID)
return f, db.Model(&attach.File{}).Where("id = ?", f.ID).Update("sort_order", f.SortOrder).Error
}
// ScreenshotOf returns the submission's attached screenshot, or nil.
func ScreenshotOf(ctx context.Context, db *gorm.DB, submissionID uint) (*attach.File, error) {
var files []attach.File
err := db.WithContext(ctx).
Where("attachment_type = ? AND attachment_id = ? AND field = ?", models.Submission{}.MorphName(), strconv.FormatUint(uint64(submissionID), 10), models.ScreenshotField).
Order("sort_order, id").Limit(1).Find(&files).Error
if err != nil || len(files) == 0 {
return nil, err
}
return &files[0], nil
}

22
classes/sync_g15office.go Normal file
View File

@@ -0,0 +1,22 @@
package classes
// The G15Office sync job's contract: SubmitFeedback queues one per stored
// submission, the worker files it (SyncFeedbackToG15Office).
const (
// SyncG15OfficeKind is the River kind of the job.
SyncG15OfficeKind = "golem15.feedback.sync_g15office"
// SyncG15OfficeQueue is the job's queue.
SyncG15OfficeQueue = "feedback"
// SyncG15OfficeLabel is the summer_jobs label, the PHP job class.
SyncG15OfficeLabel = `Golem15\Feedback\Jobs\SyncFeedbackToG15Office`
// SyncG15OfficeAttempts is SyncFeedbackToG15Office::$tries.
SyncG15OfficeAttempts = 3
)
// SyncG15OfficeArgs is the job's argument: the submission to file.
type SyncG15OfficeArgs struct {
SubmissionID uint `json:"submission_id"`
}
// Kind is the River job kind.
func (SyncG15OfficeArgs) Kind() string { return SyncG15OfficeKind }