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:
@@ -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").
|
||||
|
||||
66
controllers/api/me_hidden_controller.go
Normal file
66
controllers/api/me_hidden_controller.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/sm-feedback-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
)
|
||||
|
||||
// MeHiddenRules are MeHiddenController::update's validation rules.
|
||||
var MeHiddenRules = []lagoon.RequestRule{
|
||||
{Field: "hidden", Rules: lagoon.ParseRules("required|boolean")},
|
||||
}
|
||||
|
||||
type meHiddenErrors struct {
|
||||
Error string `json:"error"`
|
||||
Errors any `json:"errors"`
|
||||
}
|
||||
|
||||
type meHiddenBody struct {
|
||||
Hidden bool `json:"hidden"`
|
||||
}
|
||||
|
||||
// MeHidden handles PUT /_feedback/api/v1/me/hidden (jwt.auth): the signed-in
|
||||
// user's "hide the widget" preference. hidden must be a Laravel boolean
|
||||
// (true, false, 1, 0, "1", "0"); anything else is 422 {"error":"Validation
|
||||
// failed","errors":...}. Only the JWT principal's row is written, and the
|
||||
// answer is {"hidden":bool}. The value also rides on every user payload
|
||||
// (feedback_widget_hidden), so there is no read route.
|
||||
func MeHidden(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb := database(app)
|
||||
user, ok := bouncer.User(r.Context())
|
||||
if gdb == nil || !ok || user == nil || user.ID == 0 {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
input, ok := readInput(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
tr, _ := app.Lookup[*phrasebook.Translator]()
|
||||
errs, err := lagoon.ValidateRequest(ctx, gdb.WithContext(ctx), input, MeHiddenRules, tr)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, meHiddenErrors{
|
||||
Error: "Validation failed",
|
||||
Errors: orderedErrors{errs: errs, order: lagoon.ErrorKeys(errs, MeHiddenRules)},
|
||||
})
|
||||
return
|
||||
}
|
||||
hidden := laravelTrue(input["hidden"])
|
||||
if err := models.SetWidgetHidden(ctx, gdb, user.ID, hidden); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, meHiddenBody{Hidden: hidden})
|
||||
}
|
||||
}
|
||||
146
controllers/api/request.go
Normal file
146
controllers/api/request.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// errBodyTooLarge is a body over the router's cap.
|
||||
var errBodyTooLarge = errors.New("feedback: request body too large")
|
||||
|
||||
// multipartMemory is how much of a multipart body is kept in memory; the
|
||||
// rest spills to temporary files. The router's body cap bounds the total.
|
||||
const multipartMemory = 32 << 20
|
||||
|
||||
// decodeInput is Laravel's $request->all(): the query string overlaid by
|
||||
// the body. A JSON body decodes with numbers as json.Number; a form or
|
||||
// multipart body gives strings (the last value of a repeated key) and
|
||||
// lagoon.UploadedFile values for files. An unreadable body is an empty bag.
|
||||
func decodeInput(r *http.Request) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
mergeValues(out, r.URL.Query())
|
||||
if r.Body == nil || r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
return out, nil
|
||||
}
|
||||
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
switch mediaType {
|
||||
case "multipart/form-data":
|
||||
if err := r.ParseMultipartForm(multipartMemory); err != nil {
|
||||
if isBodyTooLarge(err) {
|
||||
return nil, errBodyTooLarge
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
mergeValues(out, r.MultipartForm.Value)
|
||||
for key, headers := range r.MultipartForm.File {
|
||||
if len(headers) > 0 {
|
||||
out[key] = lagoon.UploadedFileFromHeader(headers[len(headers)-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case "application/x-www-form-urlencoded":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
if isBodyTooLarge(err) {
|
||||
return nil, errBodyTooLarge
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
mergeValues(out, r.PostForm)
|
||||
return out, nil
|
||||
default:
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
if isBodyTooLarge(err) {
|
||||
return nil, errBodyTooLarge
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return out, nil
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.UseNumber()
|
||||
var body map[string]any
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
// Laravel's json() of an invalid body is an empty bag.
|
||||
return out, nil
|
||||
}
|
||||
for k, v := range body {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
func mergeValues(out map[string]any, values url.Values) {
|
||||
for key, vals := range values {
|
||||
if len(vals) > 0 {
|
||||
out[key] = vals[len(vals)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isBodyTooLarge(err error) bool {
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
return true
|
||||
}
|
||||
// mime/multipart reports the reader error as text, not wrapped.
|
||||
return err != nil && strings.Contains(err.Error(), "request body too large")
|
||||
}
|
||||
|
||||
// readInput decodes the request; a body over the router's cap is a 413.
|
||||
func readInput(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
|
||||
input, err := decodeInput(r)
|
||||
if errors.Is(err, errBodyTooLarge) {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "Payload too large"})
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
return input, true
|
||||
}
|
||||
|
||||
// inputString is $request->input($key) for a validated string field: nil
|
||||
// when absent or null, else the string as sent.
|
||||
func inputString(input map[string]any, key string) *string {
|
||||
switch v := input[key].(type) {
|
||||
case string:
|
||||
return &v
|
||||
case json.Number:
|
||||
s := v.String()
|
||||
return &s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// laravelTrue is (bool) of a value the boolean rule accepted (true, false,
|
||||
// 1, 0, "1", "0"): true for true, 1 and "1".
|
||||
func laravelTrue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
return err == nil && f == 1
|
||||
case float64:
|
||||
return t == 1
|
||||
case int:
|
||||
return t == 1
|
||||
case string:
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
|
||||
return err == nil && n == 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user