diff --git a/docs/console/setup-and-maintenance.md b/docs/console/setup-and-maintenance.md index d3d272f..3e786e3 100644 --- a/docs/console/setup-and-maintenance.md +++ b/docs/console/setup-and-maintenance.md @@ -55,8 +55,8 @@ Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the | Command | Flags | Purpose | |---------|-------|---------| -| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. | -| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag. | +| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, binds the address, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, or `admin: off` with the steps to add an admin controller. | +| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. | ```sh ./bin/acme route:list @@ -83,6 +83,8 @@ Both commands prompt for the password without echoing it. In a script, pipe it o From the application directory, `summer admin:create --email admin@example.com --superuser` runs the same command on the built binary, prompt included. +When the admin is mounted and the `backend_users` table is empty, `serve` prints a warning at start-up naming `summer admin:create`. + ## Queues and the scheduler | Command | Arguments and flags | Purpose | diff --git a/docs/services/routing.md b/docs/services/routing.md index ae60091..7e1f593 100644 --- a/docs/services/routing.md +++ b/docs/services/routing.md @@ -189,6 +189,8 @@ do("POST", "/api/blog/posts/7/comments", true) ./bin/acme route:list ``` +The table is followed by a line saying whether the admin is mounted and under which prefix, for example `admin: mounted at /backend`. + `surf.BuildRouter` returns the same information to Go code through `surf.Router.Routes`: ```go src=modules/surf/example_test.go#ExampleBuildRouter diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 9cc481b..581171a 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -178,7 +178,7 @@ func (p *Plugin) AdminControllers() []pact.AdminController { func (p *Plugin) AdminFS() fs.FS { return adminFS } ``` -`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`. +`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. `surf.Router.AdminPrefix` reports which case applies, and the `serve` and `route:list` commands print it as an admin status line. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`. ## API reference diff --git a/modules/surf/README.md b/modules/surf/README.md index 42c035f..3103dab 100644 --- a/modules/surf/README.md +++ b/modules/surf/README.md @@ -22,7 +22,7 @@ surf turns the routes that plugins declare through `pact.HasRoutes` into one `ht - Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`. - Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay. - `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale. -- A read-only route table (`surf.Router.Routes`) and the `serve` and `route:list` commands. +- A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands. ## Usage @@ -108,6 +108,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur | `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. | | `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. | | `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. | +| `surf.Router.AdminPrefix` | The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller. | | `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. | | `surf.Use` | Builds a middleware name list for a group. | | `surf.Constraint` | A compiled path-parameter restriction built by `surf.Regex` or `surf.Enum`. | @@ -162,8 +163,8 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md | Command | Flags | Description | |---------|-------|-------------| -| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. | -| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route. | +| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, with a warning naming `summer admin:create` when no administrator exists yet, or `admin: off` with the steps to add an admin controller. | +| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at ` or `admin: off`). | ## Dependencies diff --git a/modules/surf/admin_prefix_test.go b/modules/surf/admin_prefix_test.go index 0cd9588..9ed764a 100644 --- a/modules/surf/admin_prefix_test.go +++ b/modules/surf/admin_prefix_test.go @@ -1,6 +1,7 @@ package surf import ( + "bytes" "io/fs" "net/http" "os" @@ -10,6 +11,7 @@ import ( "testing/fstest" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" @@ -177,3 +179,57 @@ func (p prefixRoutePlugin) Routes(r pact.Router) error { }) return nil } + +// TestRouterAdminPrefix reports the admin mount path BuildRouter used. +func TestRouterAdminPrefix(t *testing.T) { + for _, tc := range []struct { + name string + app *backpack.App + plugins []party.Plugin + prefix string + mounted bool + }{ + {"configured prefix", adminPrefixApp(t), []party.Plugin{adminPrefixPlugin{}}, "/acme-admin", true}, + {"default prefix", adminPrefixAppWith(t, ""), []party.Plugin{adminPrefixPlugin{}}, "/backend", true}, + {"no admin controllers", adminPrefixApp(t), []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/hello"}}, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + r, err := BuildRouter(tc.app, tc.plugins) + if err != nil { + t.Fatal(err) + } + prefix, mounted := r.AdminPrefix() + if prefix != tc.prefix || mounted != tc.mounted { + t.Fatalf("AdminPrefix() = (%q, %v), want (%q, %v)", prefix, mounted, tc.prefix, tc.mounted) + } + }) + } + if prefix, mounted := (*Router)(nil).AdminPrefix(); prefix != "" || mounted { + t.Fatalf("nil router AdminPrefix() = (%q, %v)", prefix, mounted) + } +} + +// TestRouteListAdminStatus ends the route table with the admin status line. +func TestRouteListAdminStatus(t *testing.T) { + for _, tc := range []struct { + name string + app *backpack.App + plugins []party.Plugin + want string + }{ + {"mounted", adminPrefixApp(t), []party.Plugin{adminPrefixPlugin{}}, "admin: mounted at /acme-admin"}, + {"off", adminPrefixApp(t), []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/hello"}}, "admin: off — no plugin registers an admin controller"}, + } { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) + if err := RouteListCommand(tc.app, tc.plugins).Run(t.Context(), nil, out); err != nil { + t.Fatal(err) + } + got := strings.TrimRight(buf.String(), "\n") + if !strings.HasSuffix(got, tc.want) { + t.Fatalf("route:list output does not end with %q:\n%s", tc.want, got) + } + }) + } +} diff --git a/modules/surf/routelist_command.go b/modules/surf/routelist_command.go index 323f3d3..f2806b3 100644 --- a/modules/surf/routelist_command.go +++ b/modules/surf/routelist_command.go @@ -11,7 +11,8 @@ import ( ) // RouteListCommand builds the router the same way ServeCommand does, then -// renders Routes() as a table instead of serving. +// renders Routes() as a table instead of serving, followed by the admin +// status line. func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command { return bonfire.Command{ Name: "route:list", @@ -33,6 +34,11 @@ func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command }) } out.Table([]string{"Method", "Pattern", "Plugin", "Middleware", "Raw"}, rows) + if prefix, mounted := r.AdminPrefix(); mounted { + out.Info("admin: mounted at " + prefix) + } else { + out.Info(adminOffText) + } return nil }, } diff --git a/modules/surf/router.go b/modules/surf/router.go index e639e6e..2e1706e 100644 --- a/modules/surf/router.go +++ b/modules/surf/router.go @@ -60,6 +60,8 @@ type Router struct { uploadBytes int64 built map[string]pact.Middleware app *backpack.App + adminPrefix string + adminMounted bool } var ( @@ -556,6 +558,8 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { if err := r.checkAdminPrefix(admin.Prefix); err != nil { return nil, err } + r.adminPrefix = admin.Prefix + r.adminMounted = true } for _, rt := range r.routes { if _, err := r.wrap(rt); err != nil { diff --git a/modules/surf/routetable.go b/modules/surf/routetable.go index 0cc1935..b0db223 100644 --- a/modules/surf/routetable.go +++ b/modules/surf/routetable.go @@ -10,6 +10,15 @@ type RouteInfo struct { Raw bool } +// AdminPrefix reports the cabana admin mount path set by BuildRouter. False +// means no plugin registers an admin controller, so no admin routes exist. +func (r *Router) AdminPrefix() (string, bool) { + if r == nil || !r.adminMounted { + return "", false + } + return r.adminPrefix, true +} + // Routes returns a defensive copy of every registered route. func (r *Router) Routes() []RouteInfo { if r == nil { diff --git a/modules/surf/serve.go b/modules/surf/serve.go index ff67482..e5c067d 100644 --- a/modules/surf/serve.go +++ b/modules/surf/serve.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "net/netip" "os" "os/signal" "strings" @@ -13,11 +14,13 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/party" "gocloud.dev/blob" + "gorm.io/gorm" ) // ServeCommand starts a signal-aware HTTP server on the assembled router. @@ -50,22 +53,36 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command { return err } defer bucket.Close() - h, err := Assemble(app, plugins) + r, err := BuildRouter(app, plugins) + if err != nil { + return err + } + h, err := r.compile() + if err != nil { + return err + } + ln, err := net.Listen("tcp", addr) if err != nil { return err } worker, err := conga.StartServeWorker(ctx, app, plugins) if err != nil { + _ = ln.Close() return err } if worker != nil { out.Info(fmt.Sprintf("job worker started on queues: %s", strings.Join(worker.Queues(), ", "))) } + prefix, mounted := r.AdminPrefix() + admins := adminsUnknown + if mounted { + admins = countAdmins(ctx, gdb) + } + printBanner(out, serveBanner(addr, prefix, mounted, admins)) srv := &http.Server{Addr: addr, Handler: h, BaseContext: func(net.Listener) context.Context { return ctx }} errCh := make(chan error, 1) go func() { - out.Info(fmt.Sprintf("listening on %s", addr)) - errCh <- srv.ListenAndServe() + errCh <- srv.Serve(ln) }() shutdownCtx := func() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 10*time.Second) @@ -121,3 +138,119 @@ func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error } return bucket, nil } + +// adminCreateCommand is the command the startup hints name for creating the +// first backend administrator. +const adminCreateCommand = "summer admin:create --email you@example.com --superuser" + +// adminOffText is the admin status line serve and route:list print when no +// plugin registers an admin controller. +const adminOffText = "admin: off — no plugin registers an admin controller" + +// serveURL turns a listen address into a URL a browser can open. An empty or +// unspecified host (0.0.0.0, ::) becomes 127.0.0.1, port 80 is left out and +// an IPv6 host is bracketed. An address that does not parse gives "". +func serveURL(addr string) string { + host, port, err := net.SplitHostPort(addr) + if err != nil || port == "" { + return "" + } + if host == "" { + host = "127.0.0.1" + } else if ip, err := netip.ParseAddr(host); err == nil && ip.IsUnspecified() { + host = "127.0.0.1" + } + hostport := net.JoinHostPort(host, port) + if port == "80" { + hostport = host + if strings.Contains(host, ":") { + hostport = "[" + host + "]" + } + } + return "http://" + hostport + "/" +} + +// adminPresence is what serve could learn about backend administrators. +type adminPresence int + +const ( + adminsUnknown adminPresence = iota + adminsMissingTable + adminsNone + adminsPresent +) + +// countAdmins checks whether backend_users exists and holds a row. It is +// bounded by a 2 second timeout and maps every failure to adminsUnknown, so +// it never fails or blocks boot. HasTable queries information_schema, so a +// missing table does not reach the GORM error log. +func countAdmins(ctx context.Context, gdb *gorm.DB) adminPresence { + if gdb == nil { + return adminsUnknown + } + c, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + db := gdb.WithContext(c) + if !db.Migrator().HasTable(&cabana.BackendUser{}) { + if c.Err() != nil { + return adminsUnknown + } + return adminsMissingTable + } + var n int64 + if err := db.Model(&cabana.BackendUser{}).Count(&n).Error; err != nil { + return adminsUnknown + } + if n == 0 { + return adminsNone + } + return adminsPresent +} + +// bannerLine is one startup line; warn lines print with the warning symbol. +type bannerLine struct { + warn bool + text string +} + +// serveBanner builds the lines serve prints once the port is bound: the +// listen address with a browser URL, then the admin status. +func serveBanner(addr, adminPrefix string, mounted bool, admins adminPresence) []bannerLine { + url := serveURL(addr) + listen := "listening on " + addr + if url != "" { + listen += ", open " + url + } + lines := []bannerLine{{text: listen}} + if !mounted { + return append(lines, + bannerLine{text: adminOffText}, + bannerLine{text: "to add one, run summer make:admin-controller , then summer build, summer migrate and " + adminCreateCommand + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"}, + ) + } + if url != "" { + lines = append(lines, bannerLine{text: "admin: " + strings.TrimSuffix(url, "/") + adminPrefix}) + } else { + lines = append(lines, bannerLine{text: "admin: mounted at " + adminPrefix}) + } + switch admins { + case adminsNone: + lines = append(lines, bannerLine{warn: true, text: "no administrators yet; create one with " + adminCreateCommand}) + case adminsMissingTable: + lines = append(lines, bannerLine{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + adminCreateCommand}) + case adminsUnknown: + lines = append(lines, bannerLine{text: "sign in with an administrator; " + adminCreateCommand + " creates one"}) + } + return lines +} + +// printBanner writes banner lines as info or warning output. +func printBanner(out bonfire.Output, lines []bannerLine) { + for _, l := range lines { + if l.warn { + out.Warning(l.text) + continue + } + out.Info(l.text) + } +} diff --git a/modules/surf/serve_test.go b/modules/surf/serve_test.go index d508719..3f615d2 100644 --- a/modules/surf/serve_test.go +++ b/modules/surf/serve_test.go @@ -1,12 +1,15 @@ package surf import ( + "bytes" "os" "path/filepath" + "slices" "strings" "testing" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/compass" "gocloud.dev/blob" ) @@ -51,3 +54,71 @@ func serveTestApp(t *testing.T, storageYAML string) *backpack.App { } return backpack.New(cfg) } + +func TestServeURL(t *testing.T) { + for _, tc := range []struct{ addr, want string }{ + {":8123", "http://127.0.0.1:8123/"}, + {"127.0.0.1:9000", "http://127.0.0.1:9000/"}, + {"[::1]:8080", "http://[::1]:8080/"}, + {"0.0.0.0:80", "http://127.0.0.1/"}, + {"[::]:8080", "http://127.0.0.1:8080/"}, + {"localhost:3000", "http://localhost:3000/"}, + {"8080", ""}, + {"", ""}, + {"127.0.0.1:", ""}, + } { + if got := serveURL(tc.addr); got != tc.want { + t.Errorf("serveURL(%q) = %q, want %q", tc.addr, got, tc.want) + } + } +} + +func TestServeBanner(t *testing.T) { + listen := bannerLine{text: "listening on :8123, open http://127.0.0.1:8123/"} + mounted := bannerLine{text: "admin: http://127.0.0.1:8123/backend"} + create := "summer admin:create --email you@example.com --superuser" + for _, tc := range []struct { + name string + addr string + prefix string + mounted bool + admins adminPresence + want []bannerLine + }{ + {"present", ":8123", "/backend", true, adminsPresent, []bannerLine{listen, mounted}}, + {"none", ":8123", "/backend", true, adminsNone, []bannerLine{listen, mounted, + {warn: true, text: "no administrators yet; create one with " + create}}}, + {"missing table", ":8123", "/backend", true, adminsMissingTable, []bannerLine{listen, mounted, + {warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + create}}}, + {"unknown", ":8123", "/backend", true, adminsUnknown, []bannerLine{listen, mounted, + {text: "sign in with an administrator; " + create + " creates one"}}}, + {"off", ":8123", "", false, adminsUnknown, []bannerLine{listen, + {text: "admin: off — no plugin registers an admin controller"}, + {text: "to add one, run summer make:admin-controller , then summer build, summer migrate and " + create + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"}}}, + {"unparsable address", "8080", "/backend", true, adminsPresent, []bannerLine{ + {text: "listening on 8080"}, {text: "admin: mounted at /backend"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + got := serveBanner(tc.addr, tc.prefix, tc.mounted, tc.admins) + if !slices.Equal(got, tc.want) { + t.Fatalf("serveBanner:\n got %#v\nwant %#v", got, tc.want) + } + }) + } +} + +func TestPrintBanner(t *testing.T) { + var buf bytes.Buffer + out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) + printBanner(out, []bannerLine{{text: "plain"}, {warn: true, text: "careful"}}) + got := buf.String() + if !strings.Contains(got, "ℹ plain") || !strings.Contains(got, "⚠ careful") { + t.Fatalf("printBanner output = %q", got) + } +} + +func TestCountAdminsNilDB(t *testing.T) { + if got := countAdmins(t.Context(), nil); got != adminsUnknown { + t.Fatalf("countAdmins(nil) = %v, want adminsUnknown", got) + } +}