// 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" "net/http" "git.golem15.com/golem15/sm-feedback-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "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")), }, }) } } // 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 }