feat(17-04): public FAQ API with output sanitizing, throttle bucket and README
- GET /_faq/api/v1/faqs/{slug}?locale=xx, anonymous, faq-public-api bucket (120/min per client IP)
- locale by enabled code or the default locale; active row, lowest id among duplicates
- description and answers passed through seaglass.Sanitize on output; 404 {"error":"FAQ not found"}
- TestFaqAPISmoke; README with API, CORS paths, import workflow and Winter adaptations
- go mod tidy records the test dependencies
This commit is contained in:
148
controllers/api/faqs.go
Normal file
148
controllers/api/faqs.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package api holds the FAQ plugin's public read API.
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"git.golem15.com/golem15/sm-faq-plugin/models"
|
||||
translate "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/seaglass"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// slugPattern is the shape of a FAQ slug the API serves: lowercase letters,
|
||||
// digits and dashes, starting with a letter or digit. Anything else is the
|
||||
// JSON 404, so every miss has the same body.
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9\-]*$`)
|
||||
|
||||
const errNotFound = "FAQ not found"
|
||||
|
||||
// Question is one row of a FAQ's content: a plain-text question and its
|
||||
// answer as sanitized HTML.
|
||||
type Question struct {
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
||||
// FAQ is the data object of GET /_faq/api/v1/faqs/{slug}.
|
||||
type FAQ struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Locale string `json:"locale"`
|
||||
Description *string `json:"description"`
|
||||
Content []Question `json:"content"`
|
||||
}
|
||||
|
||||
// Show is GET /_faq/api/v1/faqs/{slug}?locale=xx, anonymous. The locale is
|
||||
// the enabled translate locale with code ?locale, else the default locale.
|
||||
// It serves the active FAQ with that slug and locale; when several rows
|
||||
// share them, the one with the lowest id. The description and every answer
|
||||
// are passed through seaglass.Sanitize on output, so rows that never went
|
||||
// through the admin save (imports, SQL copies) are served clean too.
|
||||
// Questions are plain strings. An unknown slug, an inactive row and an
|
||||
// unknown or disabled locale are 404 {"error":"FAQ not found"}.
|
||||
func Show(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if !slugPattern.MatchString(slug) {
|
||||
writeError(w, http.StatusNotFound, errNotFound)
|
||||
return
|
||||
}
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
db = db.WithContext(r.Context())
|
||||
locale, err := findLocale(db, r.URL.Query().Get("locale"))
|
||||
if err != nil {
|
||||
slog.Default().Error("faq api: locale", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if locale == nil {
|
||||
writeError(w, http.StatusNotFound, errNotFound)
|
||||
return
|
||||
}
|
||||
var rows []models.FAQ
|
||||
if err := db.Where("slug = ? AND locale_id = ? AND is_active = ?", slug, locale.ID, true).
|
||||
Order("id ASC").Limit(1).Find(&rows).Error; err != nil {
|
||||
slog.Default().Error("faq api: load faq", "slug", slug, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
writeError(w, http.StatusNotFound, errNotFound)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serialize(rows[0], locale.Code)})
|
||||
}
|
||||
}
|
||||
|
||||
// findLocale returns the enabled locale with code, or the enabled default
|
||||
// locale when code is empty; nil when there is none.
|
||||
func findLocale(db *gorm.DB, code string) (*translate.Locale, error) {
|
||||
q := db.Where("is_enabled = ?", true)
|
||||
if code != "" {
|
||||
q = q.Where("code = ?", code)
|
||||
} else {
|
||||
q = q.Where("is_default = ?", true)
|
||||
}
|
||||
var locales []translate.Locale
|
||||
if err := q.Order("id ASC").Limit(1).Find(&locales).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(locales) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &locales[0], nil
|
||||
}
|
||||
|
||||
func serialize(f models.FAQ, locale string) FAQ {
|
||||
out := FAQ{Name: f.Name, Slug: f.Slug, Locale: locale, Content: []Question{}}
|
||||
if f.Description != nil {
|
||||
clean := seaglass.Sanitize(*f.Description)
|
||||
out.Description = &clean
|
||||
}
|
||||
for _, row := range f.Items() {
|
||||
out.Content = append(out.Content, Question{
|
||||
Question: text(row["question"]),
|
||||
Answer: seaglass.Sanitize(text(row["answer"])),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// text is a stored repeater value as a string: "" for null, the value for a
|
||||
// string, its formatting otherwise (PHP rows may hold numbers).
|
||||
func text(v any) string {
|
||||
switch s := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return s
|
||||
default:
|
||||
return fmt.Sprint(s)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
wire.WriteJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
|
||||
func gdb(app *backpack.App) (*gorm.DB, error) {
|
||||
if app == nil {
|
||||
return nil, errors.New("faq api: application is not configured")
|
||||
}
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("faq api: database is not configured")
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
Reference in New Issue
Block a user