- 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
147 lines
3.7 KiB
Go
147 lines
3.7 KiB
Go
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
|
|
}
|