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