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:
2026-10-12 17:51:22 +02:00
parent 7413c6905d
commit 1585f06b3c
7 changed files with 531 additions and 5 deletions

130
README.md Normal file
View File

@@ -0,0 +1,130 @@
# golem15.faq
The compiled FAQ plugin for SummerCMS applications: question and answer lists per slug and locale, edited in the admin with sanitized rich text and served to frontends as JSON.
```go
import _ "git.golem15.com/golem15/sm-faq-plugin"
```
Module `git.golem15.com/golem15/sm-faq-plugin`, package `faq`, plugin ID `golem15.faq`.
## Overview
The plugin is a compiled SummerCMS plugin, a port of the WinterCMS Golem15.FAQ plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`.
It requires `golem15.translate`: every FAQ belongs to one translate locale through `locale_id`, and a site keeps one row per locale under the same slug, as the PHP plugin did. `MorphName` is the PHP class string `Golem15\FAQ\Models\FAQ`.
## Features
- **Table.** `golem15_faq_faqs` keeps the PHP columns: id, name, slug, locale_id (NOT NULL), is_active (default false), description (text), content (text holding the JSON list of `{question, answer}` rows) and timestamps, with a non-unique index on (slug, locale_id). One gormigrate step creates it; AutoMigrate is not used.
- **Model.** `models.FAQ` stores `content` as `lagoon.Jsonable` (`models.Content`) and belongs to the translate `Locale`. Fillable: name, slug, locale_id, is_active, description, content. A name, a slug and a locale are required.
- **Admin.** A top-level FAQ item (icon `circle-question-mark`, order 500) with the side item FAQs, behind the permission `golem15.faq.faqs` (developer and publisher roles). The list shows id, name, slug, locale (by name, so rows of the same slug are told apart), active and the timestamps. The form has name, slug (preset from name), locale (relation, by name), is_active, description (rich editor) and content (a repeater, "Add question", each row a required question and a rich-editor answer). Rich text is sanitized on save by the framework's policy (`seaglass`), and the save answers with a notice naming each field it cleaned, such as `content.1.answer`.
- **Public API.** `GET /_faq/api/v1/faqs/{slug}` serves an active FAQ in one locale. See [Usage](#usage).
- **Import.** `faq:import-winter` copies a WinterCMS site's FAQs from the JSON Lines `scripts/winter-export.sql` produces. See [CLI commands](#cli-commands).
- **Phrasebooks.** `lang/en/lang.yaml` and `lang/pl/lang.yaml` keep the PHP key names.
## Usage
### Public FAQ API
```
GET /_faq/api/v1/faqs/{slug}?locale=en
```
Anonymous; the route group never attaches the backend middleware. `slug` is lowercase letters, digits and dashes. `locale` is the code of an enabled translate locale; without it the default locale is used.
```json
{
"data": {
"name": "Shipping",
"slug": "shipping",
"locale": "en",
"description": "<p>Everything about shipping.</p>",
"content": [
{ "question": "How long does shipping take?", "answer": "<p>Two working days.</p>" },
{ "question": "Where do you ship?", "answer": "<p>Everywhere in the EU.</p>" }
]
}
}
```
Rules:
- The row served is the active FAQ with that slug and locale; when several rows share them, the one with the lowest id.
- `description` (a string or `null`) and every `answer` are HTML passed through `seaglass.Sanitize` on output, so rows that never went through the admin save (imports, SQL copies) are served clean too. `question` is a plain string. `content` is always an array.
- An unknown slug, an inactive row, a malformed slug and an unknown or disabled locale are `404 {"error":"FAQ not found"}`.
### Throttling and CORS
The API is throttled by the surf bucket `faq-public-api` (`faq.BucketPublicAPI`): 120 requests a minute per client IP, read behind the host's trusted proxies. The 429 body is surf's `{"message":"Too Many Attempts."}`.
The plugin sends no CORS headers. A host whose frontend runs on another origin adds the path to `http.cors.paths` in `config/http.yaml`:
```yaml
cors:
paths: ["_faq/api/*"]
allowed_origins: ["https://www.example.com"]
allowed_methods: ["GET"]
```
### Winter import
1. On the WinterCMS database, check the FAQ and translate locales table names named in the header of `scripts/winter-export.sql`, then run the read-only export:
```sh
mysql -N -B --raw <database> < scripts/winter-export.sql > faqs.jsonl
```
2. In the application, after every locale code the export names exists in the translate locales:
```sh
./bin/acme faq:import-winter --file faqs.jsonl --dry-run
./bin/acme faq:import-winter --file faqs.jsonl
```
## API reference
| Identifier | Description |
|------------|-------------|
| `faq.Plugin` | The `golem15.faq` plugin: migrations, models, phrasebooks, admin controller, permission, navigation, the `faq:import-winter` command, the public route and its throttle bucket. |
| `faq.BucketPublicAPI` | `faq-public-api`, the throttle bucket of the public API. |
| `models.FAQ` | A FAQ on `golem15_faq_faqs`; `Items` returns its stored rows. |
| `models.Content` | The stored question and answer list. |
| `api.Show` | The handler of `GET /_faq/api/v1/faqs/{slug}`. |
| `api.FAQ`, `api.Question` | The data object of the public API and one of its rows. |
| `console.ImportWinter` | The `faq:import-winter` command. |
| `console.ImportWinterFile` | The import itself, for tests and other callers (`console.WinterOptions`, `console.WinterReport`). |
| `controllers.FAQsControllerID` | The admin controller id `golem15.faq.faqs`. |
| `controllers.PermissionFAQs` | The permission `golem15.faq.faqs`. |
## CLI commands
`faq:import-winter --file FILE [--force] [--dry-run]`
- Reads one JSON object per line (`id`, `name`, `slug`, `locale_code`, `is_active`, `description`, `content`, `created_at`, `updated_at`); `content` may be the PHP JSON string or an array.
- `locale_id` is the id of the translate locale with `locale_code`; an unknown code aborts the import before anything is written, naming the code.
- Ids are kept and the id sequence is moved past them with `setval`. One transaction.
- A table that already holds rows is refused unless `--force`, which replaces them in the same transaction. `--dry-run` prints the report and writes nothing. An empty file writes nothing and reports zero.
- HTML is copied as stored; the API sanitizes it on output.
## Winter adaptations
- Form: the PHP `span: none` and `cssClass: col-sm-4` layout becomes left and right spans in one flat form; the locale relation's `select: name` is `nameFrom: name`; a question is required in each content row.
- List: a locale column is added, since several rows share a slug.
- Saves: a FAQ without a locale is refused with 422 `errors.locale_required` (the locale relation is written after the model rules run, so the check follows the write and rolls it back).
- API: the public JSON API replaces the PHP component; rich text is sanitized on save and again on output.
- Timestamps are `TIMESTAMPTZ`.
## Dependencies
- SummerCMS modules: backpack, bonfire, cabana, lagoon, pact, party, seaglass, surf, wire.
- Compiled plugin: `golem15.translate` (`git.golem15.com/golem15/sm-translate-plugin`).
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`. Test-only: `github.com/testcontainers/testcontainers-go` with its Postgres module, `github.com/jackc/pgx/v5`.
## Testing
```sh
go vet ./...
go test -short ./... # no Docker
go test ./... # TestFaqAdminSmoke, TestFaqImportSmoke, TestFaqAPISmoke on a Postgres container
```

148
controllers/api/faqs.go Normal file
View 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
}

View File

@@ -364,3 +364,137 @@ func TestFaqImportSmoke(t *testing.T) {
t.Fatalf("empty file changed the table: %d rows", n)
}
}
// publicGet sends an anonymous GET to a public path from remote, or from a
// fresh client address when remote is "".
func (e *faqEnv) publicGet(t *testing.T, path, remote string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Host = "api.example.test"
if remote == "" {
remote = fmt.Sprintf("203.0.113.%d:5000", adminSeq.Add(1)%250+1)
}
req.RemoteAddr = remote
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// insertFAQ writes a row with raw SQL, past the admin save gate, and returns
// its id.
func (e *faqEnv) insertFAQ(t *testing.T, name, slug string, localeID uint, active bool, description *string, content string) uint {
t.Helper()
var id uint
if err := e.gdb.Raw(`INSERT INTO golem15_faq_faqs (name, slug, locale_id, is_active, description, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) RETURNING id`,
name, slug, localeID, active, description, content).Scan(&id).Error; err != nil || id == 0 {
t.Fatalf("insert faq %s: id=%d err=%v", slug, id, err)
}
return id
}
type faqBody struct {
Data struct {
Name string `json:"name"`
Slug string `json:"slug"`
Locale string `json:"locale"`
Description *string `json:"description"`
Content []struct {
Question string `json:"question"`
Answer string `json:"answer"`
} `json:"content"`
} `json:"data"`
}
func (e *faqEnv) getFAQ(t *testing.T, path string) faqBody {
t.Helper()
rec := e.publicGet(t, path, "")
var body faqBody
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
t.Fatalf("GET %s: %d %s", path, rec.Code, rec.Body.String())
}
return body
}
// TestFaqAPISmoke reads FAQs through the anonymous public API: the default
// locale without ?locale, the requested locale with it, 404 JSON for an
// inactive row, an unknown or disabled locale and an unknown slug, rows
// written with raw SQL served sanitized, the lowest id among duplicate slugs
// and the per-client throttle.
func TestFaqAPISmoke(t *testing.T) {
e := bootFAQ(t)
en, pl := e.localeID(t, "en"), e.localeID(t, "pl")
var de uint
if err := e.gdb.Raw(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES ('de', 'Deutsch', FALSE, FALSE, 3) RETURNING id`).Scan(&de).Error; err != nil || de == 0 {
t.Fatalf("locale de: %d %v", de, err)
}
unsafeDesc := `<p>About shipping.</p><img src="/x.png" onerror="alert(1)">`
first := e.insertFAQ(t, "Shipping", "shipping", en, true, &unsafeDesc,
`[{"question":"How long?","answer":"<p>Two days.</p><script>alert(1)</script>"},{"question":"Where?","answer":"<p onclick=\"steal()\">Everywhere.</p>"}]`)
dup := e.insertFAQ(t, "Shipping copy", "shipping", en, true, nil, `[]`)
if dup <= first {
t.Fatalf("duplicate id %d not after %d", dup, first)
}
e.insertFAQ(t, "Wysyłka", "shipping", pl, true, nil, `[{"question":"Jak długo?","answer":"<p>Dwa dni.</p>"}]`)
e.insertFAQ(t, "Versand", "shipping", de, true, nil, `[]`)
e.insertFAQ(t, "Returns", "returns", en, false, nil, `[]`)
// Without ?locale the default locale (en) is served; duplicates serve
// the lowest id; HTML is sanitized on output.
for _, path := range []string{"/_faq/api/v1/faqs/shipping", "/_faq/api/v1/faqs/shipping?locale=en"} {
got := e.getFAQ(t, path)
d := got.Data
if d.Name != "Shipping" || d.Slug != "shipping" || d.Locale != "en" || len(d.Content) != 2 {
t.Fatalf("%s = %+v", path, d)
}
if d.Description == nil || strings.Contains(*d.Description, "onerror") || !strings.Contains(*d.Description, "About shipping.") {
t.Fatalf("%s description = %v", path, d.Description)
}
if d.Content[0].Question != "How long?" || d.Content[0].Answer != "<p>Two days.</p>" {
t.Fatalf("%s first row = %+v", path, d.Content[0])
}
if strings.Contains(d.Content[1].Answer, "onclick") || !strings.Contains(d.Content[1].Answer, "Everywhere.") {
t.Fatalf("%s second answer = %q", path, d.Content[1].Answer)
}
}
// ?locale=pl serves the pl row; a null description stays null.
got := e.getFAQ(t, "/_faq/api/v1/faqs/shipping?locale=pl")
if got.Data.Name != "Wysyłka" || got.Data.Locale != "pl" || got.Data.Description != nil || len(got.Data.Content) != 1 || got.Data.Content[0].Answer != "<p>Dwa dni.</p>" {
t.Fatalf("pl = %+v", got.Data)
}
rec := e.publicGet(t, "/_faq/api/v1/faqs/shipping?locale=pl", "")
if !strings.Contains(rec.Body.String(), `"description":null`) {
t.Fatalf("null description not encoded as null: %s", rec.Body.String())
}
// Misses are 404 JSON: an inactive row, an unknown slug, a malformed
// slug, an unknown locale and a disabled locale.
for _, path := range []string{
"/_faq/api/v1/faqs/returns",
"/_faq/api/v1/faqs/nope",
"/_faq/api/v1/faqs/Shipping",
"/_faq/api/v1/faqs/shipping?locale=xx",
"/_faq/api/v1/faqs/shipping?locale=de",
} {
rec := e.publicGet(t, path, "")
if rec.Code != http.StatusNotFound || rec.Body.String() != `{"error":"FAQ not found"}` {
t.Errorf("GET %s: %d %s", path, rec.Code, rec.Body.String())
}
}
// One client gets 120 requests a minute, then surf's 429 body.
const client = "192.0.2.77:6000"
for i := 0; i < 120; i++ {
if rec := e.publicGet(t, "/_faq/api/v1/faqs/nope", client); rec.Code != http.StatusNotFound {
t.Fatalf("request %d: %d %s", i+1, rec.Code, rec.Body.String())
}
}
rec = e.publicGet(t, "/_faq/api/v1/faqs/shipping", client)
if rec.Code != http.StatusTooManyRequests || !strings.Contains(rec.Body.String(), "Too Many Attempts.") {
t.Fatalf("121st request: %d %s", rec.Code, rec.Body.String())
}
if rec := e.publicGet(t, "/_faq/api/v1/faqs/shipping", ""); rec.Code != http.StatusOK {
t.Fatalf("another client after the limit: %d %s", rec.Code, rec.Body.String())
}
}

45
go.mod
View File

@@ -6,17 +6,35 @@ require (
git.golem15.com/golem15/sm-translate-plugin v0.1.0
git.golem15.com/golem15/summercms v0.1.23
github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/jackc/pgx/v5 v5.10.0
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
gorm.io/gorm v1.31.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.4 // indirect
@@ -29,10 +47,10 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect
github.com/knadh/koanf/providers/confmap v1.0.1 // indirect
@@ -41,21 +59,46 @@ require (
github.com/knadh/koanf/v2 v2.3.6 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/riverqueue/river v0.47.0 // indirect
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
github.com/riverqueue/river/rivershared v0.47.0 // indirect
github.com/riverqueue/river/rivertype v0.47.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/wneessen/go-mail v0.8.1 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect

24
go.sum
View File

@@ -18,6 +18,10 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
git.golem15.com/golem15/sm-translate-plugin v0.1.0 h1:7aq8xAui45QC8wNVjUljHkxf7fwipWFL4Zk6huWj5Kw=
git.golem15.com/golem15/sm-translate-plugin v0.1.0/go.mod h1:ttZxm6hPSmNAVpQxsRTDJu2wS9PbycBYO3cAW5dnC/c=
git.golem15.com/golem15/sm-user-plugin v0.1.0 h1:93GlLxXip8yFNeTTdLd/2r9mL0duZ08AabU0jLEpY6I=
git.golem15.com/golem15/sm-user-plugin v0.1.0/go.mod h1:LuSHLHtXFj5qe1lSoswXGKiU5g1l2nRWgstNcw+69d4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
@@ -87,6 +91,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
@@ -118,6 +124,7 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -190,6 +197,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
@@ -237,6 +246,8 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
@@ -249,6 +260,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
@@ -257,10 +270,13 @@ github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGyc
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM=
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
@@ -313,6 +329,10 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
@@ -346,3 +366,7 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

View File

@@ -4,6 +4,9 @@
package faq
import (
"net/http"
"time"
"git.golem15.com/golem15/sm-faq-plugin/console"
"git.golem15.com/golem15/sm-faq-plugin/models"
"git.golem15.com/golem15/sm-faq-plugin/updates"
@@ -11,14 +14,17 @@ import (
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
)
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
_ party.Plugin = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
_ pact.HasRoutes = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
)
// Plugin is the golem15.faq plugin.
@@ -50,6 +56,26 @@ func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
func (p *Plugin) Models() []any { return models.All() }
// Buckets registers faq-public-api: 120 requests a minute per client IP
// (behind the configured trusted proxies). 429 bodies are surf's
// {"message":"Too Many Attempts."}.
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p.app != nil {
trusted = surf.TrustedProxies(p.app.Config)
}
return map[string]surf.Bucket{
"faq-public-api": {
Name: BucketPublicAPI,
Max: 120,
Decay: time.Minute,
Key: func(r *http.Request) string {
return "faq-public|" + surf.ClientIP(r, trusted)
},
},
}
}
func init() {
party.Register(&Plugin{})
}

21
routes.go Normal file
View File

@@ -0,0 +1,21 @@
package faq
import (
apicontrollers "git.golem15.com/golem15/sm-faq-plugin/controllers/api"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/surf"
)
// BucketPublicAPI is the throttle bucket of the public FAQ API.
const BucketPublicAPI = "faq-public-api"
// Routes mounts the anonymous read API under /_faq/api/v1 (D-17). The group
// never attaches the backend middleware: anonymous callers would get 401.
// The slug is checked in the handler, so every miss is the JSON 404. CORS is
// the host's: it adds _faq/api/* to cors.paths.
func (p *Plugin) Routes(r pact.Router) error {
r.Group("/_faq/api/v1", surf.Use("throttle:"+BucketPublicAPI), func(g pact.Router) {
g.Get("/faqs/{slug}", apicontrollers.Show(p.app))
})
return nil
}