- 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
239 lines
7.3 KiB
Go
239 lines
7.3 KiB
Go
// Package api holds the golem15.feedback HTTP handlers: the public widget
|
|
// routes (config and submit) and the JWT preference route.
|
|
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"
|
|
)
|
|
|
|
// configBody is FeedbackApiController::config's answer, in PHP's key order.
|
|
type configBody struct {
|
|
Success bool `json:"success"`
|
|
Data configData `json:"data"`
|
|
}
|
|
|
|
type configData struct {
|
|
Position *string `json:"position"`
|
|
AllowHide bool `json:"allowHide"`
|
|
Colors configColors `json:"colors"`
|
|
Labels models.Labels `json:"labels"`
|
|
}
|
|
|
|
type configColors struct {
|
|
Primary *string `json:"primary"`
|
|
Accent *string `json:"accent"`
|
|
}
|
|
|
|
// Config handles GET /_feedback/api/v1/{key}/config: the widget's position,
|
|
// colors and labels (English for ?lang=en, Polish otherwise) once the key
|
|
// and the Origin pass the widget gate.
|
|
func Config(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
settings, ok := gate(w, r, app)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, configBody{
|
|
Success: true,
|
|
Data: configData{
|
|
Position: settings.Position,
|
|
AllowHide: settings.AllowHide,
|
|
Colors: configColors{Primary: settings.ColorPrimary, Accent: settings.ColorAccent},
|
|
Labels: settings.LabelsForLocale(r.URL.Query().Get("lang")),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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").
|
|
func gate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.Settings, bool) {
|
|
gdb := database(app)
|
|
if gdb == nil {
|
|
writeOpaque500(w)
|
|
return nil, false
|
|
}
|
|
settings, err := models.Instance(r.Context(), gdb)
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return nil, false
|
|
}
|
|
if !KeyMatches(settings, r.PathValue("key")) {
|
|
writeError(w, http.StatusNotFound, "Not found")
|
|
return nil, false
|
|
}
|
|
if !OriginAllowed(r.Header.Get("Origin"), settings) {
|
|
writeError(w, http.StatusForbidden, "Origin not allowed")
|
|
return nil, false
|
|
}
|
|
return settings, true
|
|
}
|
|
|
|
// KeyMatches is FeedbackApiController::keyMatches: the widget is enabled,
|
|
// has a key, and key equals it, compared in constant time (hash_equals).
|
|
func KeyMatches(settings *models.Settings, key string) bool {
|
|
if settings == nil || !settings.Enabled || settings.WidgetKey == "" {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(settings.WidgetKey), []byte(key)) == 1
|
|
}
|
|
|
|
// OriginAllowed is FeedbackApiController::isOriginAllowed. No Origin (curl,
|
|
// a server, a same-origin request) passes. An Origin passes only when its
|
|
// URL host, lower-cased, is on the settings' allowed-origins list; an
|
|
// Origin without a host fails, and an empty list fails every Origin, so a
|
|
// widget that was never configured cannot be embedded anywhere.
|
|
func OriginAllowed(origin string, settings *models.Settings) bool {
|
|
if origin == "" {
|
|
return true
|
|
}
|
|
host, ok := models.URLHost(origin)
|
|
if !ok || host == "" {
|
|
return false
|
|
}
|
|
allowed := settings.AllowedOriginHosts()
|
|
if len(allowed) == 0 {
|
|
return false
|
|
}
|
|
host = models.ASCIILower(host)
|
|
for _, a := range allowed {
|
|
if a == host {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func database(app *backpack.App) *gorm.DB {
|
|
if app == nil {
|
|
return nil
|
|
}
|
|
gdb, ok := app.Lookup[*gorm.DB]()
|
|
if !ok || gdb == nil {
|
|
return nil
|
|
}
|
|
return gdb
|
|
}
|