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

View File

@@ -4,10 +4,16 @@ package api
import (
"crypto/subtle"
"io"
"log/slog"
"net/http"
"git.golem15.com/golem15/sm-feedback-plugin/classes"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gocloud.dev/blob"
"gorm.io/gorm"
)
@@ -50,6 +56,116 @@ func Config(app *backpack.App) http.HandlerFunc {
}
}
// SubmitRules are FeedbackApiController::submit's validation rules.
var SubmitRules = []lagoon.RequestRule{
{Field: "message", Rules: lagoon.ParseRules("required|string|max:5000")},
{Field: "type", Rules: lagoon.ParseRules("required|in:bug,feature,other")},
{Field: "email", Rules: lagoon.ParseRules("nullable|email|max:255")},
{Field: "page_url", Rules: lagoon.ParseRules("required|string|max:2000")},
{Field: "user_agent", Rules: lagoon.ParseRules("nullable|string|max:500")},
{Field: "console_log", Rules: lagoon.ParseRules("nullable|string|max:20000")},
{Field: "screenshot", Rules: lagoon.ParseRules("nullable|image|mimes:jpg,jpeg,png,gif,webp|max:10240")},
}
// validationBody is the widget routes' 422 envelope.
type validationBody struct {
Error bool `json:"error"`
Message string `json:"message"`
Details any `json:"details"`
}
// Submit handles POST /_feedback/api/v1/{key}/submit (multipart, from the
// widget): after the widget gate it validates the report as PHP does (422
// "Validation failed" with Laravel's errors under details), refuses a
// screenshot whose bytes are not an image, stores the submission with its
// public screenshot and queues the G15Office job in one transaction, and
// answers 202 {"success":true}. The visitor never waits on G15Office.
func Submit(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := gate(w, r, app); !ok {
return
}
input, ok := readInput(w, r)
if !ok {
return
}
ctx := r.Context()
gdb := database(app)
tr, _ := app.Lookup[*phrasebook.Translator]()
errs, err := lagoon.ValidateRequest(ctx, gdb.WithContext(ctx), input, SubmitRules, tr)
if err != nil {
writeOpaque500(w)
return
}
if len(errs) > 0 {
writeJSON(w, http.StatusUnprocessableEntity, validationBody{
Error: true, Message: "Validation failed",
Details: orderedErrors{errs: errs, order: lagoon.ErrorKeys(errs, SubmitRules)},
})
return
}
var shot *classes.Screenshot
if upload, isFile := input["screenshot"].(lagoon.UploadedFile); isFile && upload.Open != nil {
data, err := readUpload(upload)
if err != nil {
writeOpaque500(w)
return
}
if !classes.IsAllowedImage(data) {
writeJSON(w, http.StatusUnprocessableEntity, validationBody{
Error: true, Message: "Validation failed",
Details: map[string][]string{"screenshot": {"The file is not a valid image."}},
})
return
}
shot = &classes.Screenshot{Filename: upload.Filename, Data: data}
}
var bucket *blob.Bucket
if shot != nil {
if bucket, ok = app.Lookup[*blob.Bucket](); !ok || bucket == nil {
slog.Default().Error("golem15.feedback: upload bucket is not configured")
writeOpaque500(w)
return
}
}
sub := &models.Submission{
Message: stringOr(inputString(input, "message")),
Type: stringOr(inputString(input, "type")),
Email: inputString(input, "email"),
PageURL: stringOr(inputString(input, "page_url")),
UserAgent: inputString(input, "user_agent"),
ConsoleLog: inputString(input, "console_log"),
}
if err := classes.StoreSubmission(ctx, app, gdb, bucket, sub, shot); err != nil {
slog.Default().Error("golem15.feedback: storing a submission failed", slog.Any("error", err))
writeOpaque500(w)
return
}
writeJSON(w, http.StatusAccepted, successBody{Success: true})
}
}
type successBody struct {
Success bool `json:"success"`
}
func stringOr(s *string) string {
if s == nil {
return ""
}
return *s
}
// readUpload reads an uploaded file; the max:10240 rule already bounded it.
func readUpload(upload lagoon.UploadedFile) ([]byte, error) {
rc, err := upload.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// gate loads the settings (creating them on first access) and runs the
// widget checks of both public routes in PHP's order: the key (404
// "Not found") then the Origin (403 "Origin not allowed").