feat(11.1-04): add the Database section and the core Services pages

- docs/database: models, migrations, queries and pagination, relations,
  casts and validation, attachments and transactions (lagoon.Transaction,
  lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase)
- docs/services: configuration, events, routing with auth groups, rate
  limiting, authentication, the OAuth server, mail and localization
- runnable Examples for lagoon, attach, compass, surf, wire, bouncer,
  wristband, postcard, phrasebook and festival; lagoon TestDocs* regions
  run on the package's Postgres harness through DocsDB
- 15 new required pages
This commit is contained in:
Jakub Zych
2026-09-30 22:59:25 +02:00
parent 9d37d56486
commit efb35a2d35
28 changed files with 3138 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
package surf_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
)
// secret signs the example's tokens. A real application reads its JWT
// secret from configuration.
const secret = "example-secret-that-is-long-enough"
// users loads the principal behind a token subject.
type users struct{}
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
return &bouncer.Principal{ID: id}, nil
}
// BlogPlugin is the acme.blog plugin; only its HTTP surface is shown here.
type BlogPlugin struct {
trusted []netip.Prefix
}
func (p *BlogPlugin) ID() string { return "acme.blog" }
func (p *BlogPlugin) Requires() []string { return nil }
// Register reads http.trusted_proxies once, for the plugin's bucket keys.
func (p *BlogPlugin) Register(app *backpack.App) error {
p.trusted = surf.TrustedProxies(app.Config)
return nil
}
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
// Middlewares registers the plugin's named middleware: here, a JWT guard
// that answers 401 when the request has no valid token.
func (p *BlogPlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist())
if err := guards.Register(p.ID(), "acme.auth", guard); err != nil {
panic(err)
}
auth, err := guards.Middleware("acme.auth")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"acme.auth": auth}
}
// Buckets declares a named rate limit, used as throttle:blog.comments.
func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
return map[string]surf.Bucket{
"blog.comments": {
Max: 1,
Decay: time.Minute,
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) },
},
}
}
// Routes is the Go form of the plugin's routes.php.
func (p *BlogPlugin) Routes(r pact.Router) error {
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
g.Get("/posts/{id}", showPost)
g.Where("id", `[0-9]+`)
g.Get("/posts/{status}/list", listPosts)
g.WhereIn("status", "draft", "published")
// An auth group: every route inside needs a signed-in user.
g.Group("", surf.Use("acme.auth"), func(auth pact.Router) {
auth.Get("/me", showMe)
auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
})
})
return nil
}
func showPost(w http.ResponseWriter, r *http.Request) {
id, ok := surf.IntParam(r, "id")
if !ok {
http.NotFound(w, r)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
}
func listPosts(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusOK, map[string]any{"status": r.PathValue("status"), "data": wire.Slice[string](nil)})
}
func showMe(w http.ResponseWriter, r *http.Request) {
user, _ := bouncer.User(r.Context())
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": user.ID})
}
func addComment(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusCreated, map[string]any{"created": true})
}
func ExampleAssemble() {
// The application passes its config; http.body_limits is required there.
app := backpack.New(nil)
plugin := &BlogPlugin{}
if err := plugin.Register(app); err != nil { // the runtime calls Register
fmt.Println(err)
return
}
h, err := surf.Assemble(app, []party.Plugin{plugin})
if err != nil {
fmt.Println(err)
return
}
token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
do := func(method, path string, auth bool) {
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
if auth {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String()))
}
do("GET", "/api/blog/posts/7", false)
do("GET", "/api/blog/posts/seven", false)
do("GET", "/api/blog/posts/draft/list", false)
do("GET", "/api/blog/posts/deleted/list", false)
do("GET", "/api/blog/me", false)
do("GET", "/api/blog/me", true)
do("POST", "/api/blog/posts/7/comments", true)
do("POST", "/api/blog/posts/7/comments", true)
// Output:
// GET /api/blog/posts/7 200 {"id":7}
// GET /api/blog/posts/seven 404 404 page not found
// GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"}
// GET /api/blog/posts/deleted/list 404 404 page not found
// GET /api/blog/me 401 {"error":true,"message":"Token not provided"}
// GET /api/blog/me 200 {"id":42}
// POST /api/blog/posts/7/comments 201 {"created":true}
// POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."}
}
func ExampleBuildRouter() {
r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
for _, rt := range r.Routes() {
fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware)
}
// Output:
// GET /api/blog/posts/{id} acme.blog [throttle:60,1]
// GET /api/blog/posts/{status}/list acme.blog [throttle:60,1]
// GET /api/blog/me acme.blog [throttle:60,1 acme.auth]
// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536]
}
func ExampleClientIP() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"})
trusted := surf.TrustedProxies(cfg)
// Through the load balancer at 10.0.0.5: the forwarded client is used.
viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil)
viaProxy.RemoteAddr = "10.0.0.5:4711"
viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9")
fmt.Println(surf.ClientIP(viaProxy, trusted))
// Straight from the internet: a forged header is ignored.
direct := httptest.NewRequest("GET", "/api/blog/posts", nil)
direct.RemoteAddr = "203.0.113.7:5000"
direct.Header.Set("X-Forwarded-For", "127.0.0.1")
fmt.Println(surf.ClientIP(direct, trusted))
// Output:
// 198.51.100.23
// 203.0.113.7
}
// TestDocsDeclarations checks the plugin declarations the Routing and Rate
// limiting pages show.
func TestDocsDeclarations(t *testing.T) {
p := &BlogPlugin{}
if _, ok := p.Middlewares()["acme.auth"]; !ok {
t.Fatal("Middlewares() has no acme.auth")
}
if b := p.Buckets()["blog.comments"]; b.Max != 1 || b.Decay != time.Minute {
t.Fatalf("Buckets() = %+v", b)
}
}