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

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.

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.
  • Import. faq:import-winter copies a WinterCMS site's FAQs from the JSON Lines scripts/winter-export.sql produces. See 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.

{
  "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:

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:

    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:

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

go vet ./...
go test -short ./...    # no Docker
go test ./...           # TestFaqAdminSmoke, TestFaqImportSmoke, TestFaqAPISmoke on a Postgres container
Description
No description provided
Readme 108 KiB
Languages
Go 100%