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:
130
README.md
Normal file
130
README.md
Normal 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
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
|
||||
}
|
||||
@@ -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
45
go.mod
@@ -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
24
go.sum
@@ -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=
|
||||
|
||||
34
plugin.go
34
plugin.go
@@ -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
21
routes.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user