Files
sm-faq-plugin/README.md
Jakub Zych 1585f06b3c 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
2026-10-12 17:51:22 +02:00

131 lines
7.4 KiB
Markdown

# 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
```