diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1130d9 --- /dev/null +++ b/README.md @@ -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": "
Everything about shipping.
", + "content": [ + { "question": "How long does shipping take?", "answer": "Two working days.
" }, + { "question": "Where do you ship?", "answer": "Everywhere in the EU.
" } + ] + } +} +``` + +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 --rawAbout shipping.
`
+ first := e.insertFAQ(t, "Shipping", "shipping", en, true, &unsafeDesc,
+ `[{"question":"How long?","answer":"Two days.
"},{"question":"Where?","answer":"Everywhere.
"}]`) + 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":"Dwa dni.
"}]`) + 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 != "Two days.
" { + 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 != "Dwa dni.
" { + 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()) + } +} diff --git a/go.mod b/go.mod index 70e6391..ce79cba 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 7a29fb3..bb45479 100644 --- a/go.sum +++ b/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= diff --git a/plugin.go b/plugin.go index a3f3cf9..864d109 100644 --- a/plugin.go +++ b/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{}) } diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..dd4ce0d --- /dev/null +++ b/routes.go @@ -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 +}