// 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 }