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) } }