feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links
- docs/backend: admin controllers, forms, lists and filters, relation manager, users and permissions, settings, partials and widgets, admin SPA - docs/services: storage, outbound HTTP, realtime, Web Push, search, parity testing and the Frontend and AJAX (not provided) page - Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse and beachcomber TestDocs* regions run on their Postgres harnesses - concept map rows link their guide pages and the not-provided rows the Frontend and AJAX page; index lists Backend, Database and Services - TestDocsRequiredPages asserts the D-08 section order
This commit is contained in:
@@ -109,11 +109,43 @@ var requiredPages = []string{
|
|||||||
"services/oauth-server",
|
"services/oauth-server",
|
||||||
"services/mail",
|
"services/mail",
|
||||||
"services/localization",
|
"services/localization",
|
||||||
|
"backend/admin-controllers",
|
||||||
|
"backend/forms",
|
||||||
|
"backend/lists-and-filters",
|
||||||
|
"backend/relation-manager",
|
||||||
|
"backend/users-and-permissions",
|
||||||
|
"backend/settings",
|
||||||
|
"backend/partials-and-widgets",
|
||||||
|
"backend/admin-spa",
|
||||||
|
"services/storage",
|
||||||
|
"services/outbound-http",
|
||||||
|
"services/realtime",
|
||||||
|
"services/push",
|
||||||
|
"services/search",
|
||||||
|
"services/parity-testing",
|
||||||
|
"services/frontend-and-ajax",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sectionOrder is the D-08 sidebar order: WinterCMS's documentation order,
|
||||||
|
// with the API reference last.
|
||||||
|
var sectionOrder = []string{"setup", "architecture", "plugins", "backend", "database", "services", "console", "api"}
|
||||||
|
|
||||||
// TestDocsRequiredPages asserts that every required page is in the loaded
|
// TestDocsRequiredPages asserts that every required page is in the loaded
|
||||||
// tree and is built as both .html and .md on the real tree.
|
// tree and is built as both .html and .md on the real tree, and that
|
||||||
|
// site.yaml lists the sections in sectionOrder.
|
||||||
func TestDocsRequiredPages(t *testing.T) {
|
func TestDocsRequiredPages(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(repoRoot, "docs", "site.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sections []string
|
||||||
|
for _, m := range regexp.MustCompile(`(?m)^ - name: (\S+)$`).FindAllStringSubmatch(string(raw), -1) {
|
||||||
|
sections = append(sections, m[1])
|
||||||
|
}
|
||||||
|
if !slices.Equal(sections, sectionOrder) {
|
||||||
|
t.Errorf("site.yaml sections = %v, want %v", sections, sectionOrder)
|
||||||
|
}
|
||||||
|
|
||||||
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot, Commands: docsCommands()})
|
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot, Commands: docsCommands()})
|
||||||
if err != nil || len(problems) > 0 {
|
if err != nil || len(problems) > 0 {
|
||||||
t.Fatalf("Pages: %v %v", err, problems)
|
t.Fatalf("Pages: %v %v", err, problems)
|
||||||
|
|||||||
224
docs/backend/admin-controllers.md
Normal file
224
docs/backend/admin-controllers.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
title: Admin controllers
|
||||||
|
description: Declare admin controllers with pact.AdminController and WinterCMS-shaped YAML, and let the generic JSON admin API list, show, create, update and delete records.
|
||||||
|
section: backend
|
||||||
|
order: 10
|
||||||
|
---
|
||||||
|
# Admin controllers
|
||||||
|
|
||||||
|
A WinterCMS backend controller extends `Backend\Classes\Controller`, implements the List, Form and Relation behaviours, and describes its screens in `config_list.yaml`, `config_form.yaml` and the model's `columns.yaml` and `fields.yaml`. SummerCMS keeps the YAML, and drops the controller class: [cabana](../../modules/cabana/README.md) compiles the YAML at boot and serves one generic JSON admin API for every controller, and the admin SPA renders the screens from the compiled schemas.
|
||||||
|
|
||||||
|
## Declaring a controller
|
||||||
|
|
||||||
|
A controller is a small Go type that implements `pact.AdminController`: its ID, the model name that `modelClass` in the YAML must match, and the directory that holds its YAML. It usually also implements `pact.AdminRecordSource`, which returns the GORM model to query, and `pact.AdminPermissioned`, the permissions an administrator needs. The plugin returns its controllers from `pact.HasAdminControllers` and its embedded YAML tree from `pact.AdminAssets`:
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_controller_test.go
|
||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Post is the model behind the acme.blog posts controller.
|
||||||
|
type Post struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Title string `gorm:"column:title"`
|
||||||
|
Slug string `gorm:"column:slug"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
Published bool `gorm:"column:published"`
|
||||||
|
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||||
|
Content string `gorm:"column:content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Post) TableName() string { return "acme_blog_posts" }
|
||||||
|
|
||||||
|
// Fillable lists the columns the admin form may write.
|
||||||
|
func (Post) Fillable() []string {
|
||||||
|
return []string{"title", "slug", "status", "published", "content"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsController is the admin controller for posts: the Go form of a
|
||||||
|
// WinterCMS controller with the List and Form behaviours.
|
||||||
|
type PostsController struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminController = PostsController{}
|
||||||
|
_ pact.AdminRecordSource = PostsController{}
|
||||||
|
_ pact.AdminPermissioned = PostsController{}
|
||||||
|
_ pact.HasAdminControllers = (*BlogPlugin)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the controller ID; its admin API path is /acme/blog/posts.
|
||||||
|
func (PostsController) ID() string { return "acme.blog.posts" }
|
||||||
|
|
||||||
|
// ModelName must equal modelClass in the YAML.
|
||||||
|
func (PostsController) ModelName() string { return "Post" }
|
||||||
|
|
||||||
|
// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
|
||||||
|
func (PostsController) ConfigDir() string { return "controllers/posts" }
|
||||||
|
|
||||||
|
// NewRecord returns the model the generic admin handlers query.
|
||||||
|
func (PostsController) NewRecord() any { return &Post{} }
|
||||||
|
|
||||||
|
// RequiredPermissions are checked before any schema or query.
|
||||||
|
func (PostsController) RequiredPermissions() []string {
|
||||||
|
return []string{"acme.blog.access_posts"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlogSettings is the singleton row behind the plugin's settings page.
|
||||||
|
type BlogSettings struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
PostsPerPage int `gorm:"column:posts_per_page"`
|
||||||
|
CommentsEnabled bool `gorm:"column:comments_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BlogSettings) TableName() string { return "acme_blog_settings" }
|
||||||
|
|
||||||
|
func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }
|
||||||
|
|
||||||
|
// Rules validates a settings save, as a WinterCMS settings model's $rules.
|
||||||
|
func (BlogSettings) Rules() map[string]string {
|
||||||
|
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
|
||||||
|
type BlogPlugin struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminAssets = (*BlogPlugin)(nil)
|
||||||
|
_ pact.HasPermissions = (*BlogPlugin)(nil)
|
||||||
|
_ pact.HasNavigation = (*BlogPlugin)(nil)
|
||||||
|
_ pact.HasSettings = (*BlogPlugin)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *BlogPlugin) ID() string { return "acme.blog" }
|
||||||
|
func (p *BlogPlugin) Requires() []string { return nil }
|
||||||
|
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
|
||||||
|
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
|
||||||
|
|
||||||
|
// AdminControllers registers the plugin's admin controllers.
|
||||||
|
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
|
||||||
|
return []pact.AdminController{PostsController{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
|
||||||
|
// plugin returns an embed.FS; the example reads the same files from testdata.
|
||||||
|
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }
|
||||||
|
|
||||||
|
// Permissions replaces registerPermissions().
|
||||||
|
func (p *BlogPlugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{
|
||||||
|
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
|
||||||
|
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation replaces registerNavigation().
|
||||||
|
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
|
||||||
|
return []pact.NavigationItem{{
|
||||||
|
Code: "blog",
|
||||||
|
Label: "acme.blog::lang.plugin.name",
|
||||||
|
Icon: "icon-pencil",
|
||||||
|
Permissions: []string{"acme.blog.access_posts"},
|
||||||
|
Controller: "acme.blog.posts",
|
||||||
|
SideMenu: []pact.NavigationItem{
|
||||||
|
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings replaces registerSettings().
|
||||||
|
func (p *BlogPlugin) Settings() []pact.SettingsItem {
|
||||||
|
return []pact.SettingsItem{{
|
||||||
|
Code: "blog",
|
||||||
|
Label: "acme.blog::lang.settings.label",
|
||||||
|
Description: "acme.blog::lang.settings.description",
|
||||||
|
Category: "acme.blog::lang.plugin.name",
|
||||||
|
Icon: "icon-pencil",
|
||||||
|
Model: "BlogSettings",
|
||||||
|
Permissions: []string{"acme.blog.access_settings"},
|
||||||
|
Form: "models/settings/fields.yaml",
|
||||||
|
NewModel: func() any { return &BlogSettings{} },
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summer make:admin-controller` writes the controller type and its four YAML files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
summer make:admin-controller acme.blog Posts
|
||||||
|
```
|
||||||
|
|
||||||
|
The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md).
|
||||||
|
|
||||||
|
## Compilation at boot
|
||||||
|
|
||||||
|
At start-up `cabana.Activate` compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a `modelClass` that does not match `pact.AdminController.ModelName`, a list column the model does not have, an unknown field type. Nothing is parsed per request. The example below activates the admin for the plugin above:
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_test.go#ExampleActivate
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
|
||||||
|
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
|
||||||
|
_ = cfg.Set("backend.uri", "/admin")
|
||||||
|
|
||||||
|
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(routes.Prefix)
|
||||||
|
|
||||||
|
editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
|
||||||
|
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
|
||||||
|
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
|
||||||
|
// Output:
|
||||||
|
// /admin
|
||||||
|
// true
|
||||||
|
// false
|
||||||
|
```
|
||||||
|
|
||||||
|
`admin.jwt.secret` is required as soon as any plugin registers an admin controller. With no admin controllers, no admin routes exist and no secret is needed.
|
||||||
|
|
||||||
|
## The admin API
|
||||||
|
|
||||||
|
Every controller gets the same routes, relative to `<prefix>/api/v1/{vendor}/{plugin}/{controller}`:
|
||||||
|
|
||||||
|
| Method and path | Does |
|
||||||
|
|-----------------|------|
|
||||||
|
| `GET /schema/list`, `GET /schema/form` | The list and form schemas, translated into the request locale. |
|
||||||
|
| `GET /` | Lists records with search, sort, filters and pagination, allowed only on columns the schema declares. |
|
||||||
|
| `POST /` | Creates a record. |
|
||||||
|
| `GET /{id}`, `PUT /{id}`, `DELETE /{id}` | Shows, updates and deletes a record. |
|
||||||
|
| `POST /bulk-delete` | Deletes a set of records in one transaction. |
|
||||||
|
|
||||||
|
The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (`cabana.Envelope`), and a validation failure is a 422 `validation_failed` error with messages per field.
|
||||||
|
|
||||||
|
Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through `lagoon.Fill` and validated through `lagoon.Validate` in a transaction.
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
Behaviour overrides such as `formBeforeCreate` or `listExtendQuery` become optional interfaces on the controller. cabana checks for each one and calls it at the matching point:
|
||||||
|
|
||||||
|
| Interface | Runs |
|
||||||
|
|-----------|------|
|
||||||
|
| `pact.ListExtendQuery` | Scopes every list query, for example to the administrator's own records. |
|
||||||
|
| `pact.FormExtendQuery` | Scopes every show, update and delete lookup, so a record outside the scope is a 404. |
|
||||||
|
| `pact.FormBeforeCreate`, `pact.FormAfterCreate` | Around a create, inside its transaction. |
|
||||||
|
| `pact.FormBeforeUpdate`, `pact.FormAfterUpdate` | Around an update, inside its transaction. |
|
||||||
|
| `pact.FormBeforeDelete`, `pact.FormAfterDelete` | Around a delete, inside its transaction. |
|
||||||
|
| `pact.DropdownOptionsProvider` | Supplies the options of a `dropdown` field that names a method. |
|
||||||
|
|
||||||
|
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
|
||||||
|
|
||||||
|
## Toolbar actions
|
||||||
|
|
||||||
|
`toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points.
|
||||||
38
docs/backend/admin-spa.md
Normal file
38
docs/backend/admin-spa.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Admin SPA
|
||||||
|
description: How boardwalk serves the embedded Vue admin SPA under backend.uri, how the SPA talks to the admin API, and how its TypeScript types come from OpenAPI.
|
||||||
|
section: backend
|
||||||
|
order: 80
|
||||||
|
---
|
||||||
|
# Admin SPA
|
||||||
|
|
||||||
|
WinterCMS renders its backend on the server with layouts, partials and the AJAX framework. SummerCMS replaces that with one Vue 3 single-page app, built once and embedded in the binary by [boardwalk](../../modules/boardwalk/README.md). Plugins do not ship admin pages: they ship YAML and, when they need them, partials and scripts, and the SPA renders every controller from the schemas the admin API serves.
|
||||||
|
|
||||||
|
## Serving
|
||||||
|
|
||||||
|
cabana mounts the SPA under the admin prefix, `backend.uri` (`/backend` by default; one or more lowercase path segments). `boardwalk.Handler` serves the build:
|
||||||
|
|
||||||
|
- Any path under the prefix that is not a file and not under `api/` returns `index.html`, so the SPA's own routes work on reload.
|
||||||
|
- Paths under `api/` that no API route matches return the admin API's JSON `not_found` error, never the SPA.
|
||||||
|
- Hashed files under `assets/` are cached for a long time; `index.html` is never cached.
|
||||||
|
- Every response carries a restrictive Content-Security-Policy, frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`.
|
||||||
|
|
||||||
|
The build is path-agnostic: `index.html` holds a placeholder (`boardwalk.BaseToken`) that `boardwalk.RewriteIndex` replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up.
|
||||||
|
|
||||||
|
A plugin route under the admin prefix also fails the start-up: the SPA and the admin API own that whole path.
|
||||||
|
|
||||||
|
## How the SPA talks to the server
|
||||||
|
|
||||||
|
The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on [Users and permissions](users-and-permissions.md). For each screen it loads the controller's localized schema (`schema/list`, `schema/form`), then the records, and renders the fields and columns the schema names. Strings come from `GET <prefix>/api/v1/lang`, the `backend::lang` bundle in the request locale, with CLDR plural forms.
|
||||||
|
|
||||||
|
## Types from OpenAPI
|
||||||
|
|
||||||
|
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/check-admin-openapi.sh --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building the SPA
|
||||||
|
|
||||||
|
The SPA's source is the `admin/` Vite project. `npm --prefix admin run build` type-checks it and writes the build to `modules/boardwalk/dist`, which the next `go build` embeds. An application that only uses the framework never builds the SPA: the build is committed with the framework.
|
||||||
105
docs/backend/forms.md
Normal file
105
docs/backend/forms.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
title: Forms
|
||||||
|
description: Describe admin forms in config_form.yaml and fields.yaml, with the supported field types, spans, tabs, dropdown options and create or update contexts.
|
||||||
|
section: backend
|
||||||
|
order: 20
|
||||||
|
---
|
||||||
|
# Forms
|
||||||
|
|
||||||
|
The Form behaviour's `config_form.yaml` and the model's `fields.yaml` keep their WinterCMS shape. [cabana](../../modules/cabana/README.md) compiles them strictly at boot: an unknown key, field type, span or size stops the start-up with an error naming the file and the field, so a WinterCMS option that SummerCMS does not implement is never ignored silently.
|
||||||
|
|
||||||
|
## config_form.yaml
|
||||||
|
|
||||||
|
The controller's form configuration names the fields file with a WinterCMS path, the model class, and where the SPA goes after a save:
|
||||||
|
|
||||||
|
```yaml src=modules/cabana/testdata/docs/controllers/posts/config_form.yaml
|
||||||
|
name: acme.blog::lang.posts.form
|
||||||
|
form: ~/plugins/acme/blog/models/post/fields.yaml
|
||||||
|
modelClass: Post
|
||||||
|
defaultRedirect: acme/blog/posts
|
||||||
|
create:
|
||||||
|
redirect: acme/blog/posts/update/:id
|
||||||
|
redirectClose: acme/blog/posts
|
||||||
|
update:
|
||||||
|
redirect: acme/blog/posts
|
||||||
|
redirectClose: acme/blog/posts
|
||||||
|
```
|
||||||
|
|
||||||
|
`modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins/<vendor>/<plugin>/` prefix points into the plugin's own embedded tree.
|
||||||
|
|
||||||
|
## fields.yaml
|
||||||
|
|
||||||
|
```yaml src=modules/cabana/testdata/docs/models/post/fields.yaml
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
label: acme.blog::lang.posts.title_column
|
||||||
|
type: text
|
||||||
|
span: left
|
||||||
|
required: true
|
||||||
|
slug:
|
||||||
|
label: acme.blog::lang.posts.slug
|
||||||
|
type: text
|
||||||
|
span: right
|
||||||
|
context: update
|
||||||
|
comment: acme.blog::lang.posts.slug_comment
|
||||||
|
status:
|
||||||
|
label: acme.blog::lang.posts.status
|
||||||
|
type: dropdown
|
||||||
|
span: left
|
||||||
|
options:
|
||||||
|
draft: acme.blog::lang.posts.draft
|
||||||
|
published: acme.blog::lang.posts.published
|
||||||
|
published:
|
||||||
|
label: acme.blog::lang.posts.published
|
||||||
|
type: switch
|
||||||
|
span: right
|
||||||
|
content:
|
||||||
|
label: acme.blog::lang.posts.content
|
||||||
|
type: textarea
|
||||||
|
size: large
|
||||||
|
tab: acme.blog::lang.posts.tab_content
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiled schema keeps the fields in file order, with their labels as translation keys until a request asks for them in its locale:
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_test.go#ExampleCompileForm
|
||||||
|
fsys := os.DirFS("testdata/docs")
|
||||||
|
form, err := cabana.CompileForm("acme.blog", PostsController{}, fsys)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range form.Fields {
|
||||||
|
fmt.Printf("%s %s span=%q tab=%q required=%v options=%d\n", f.Name, f.Type, f.Span, f.Tab, f.Required, len(f.Options))
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// title text span="left" tab="" required=true options=0
|
||||||
|
// slug text span="right" tab="" required=false options=0
|
||||||
|
// status dropdown span="left" tab="" required=false options=2
|
||||||
|
// published switch span="right" tab="" required=false options=0
|
||||||
|
// content textarea span="" tab="acme.blog::lang.posts.tab_content" required=false options=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field types
|
||||||
|
|
||||||
|
| Type | Renders |
|
||||||
|
|------|---------|
|
||||||
|
| `text`, `textarea`, `number` | Text inputs. |
|
||||||
|
| `checkbox`, `switch` | Booleans. |
|
||||||
|
| `dropdown` | A select. Options are a map in the YAML, or the name of a method the controller answers through `pact.DropdownOptionsProvider`. |
|
||||||
|
| `relation` | A belongsTo or belongsToMany picker; see [Relation manager](relation-manager.md). |
|
||||||
|
| `relation-manager` | An embedded list of related records; see [Relation manager](relation-manager.md). |
|
||||||
|
| `widget` | A plugin custom element with a server action; see [Partials and widgets](partials-and-widgets.md). |
|
||||||
|
| `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). |
|
||||||
|
|
||||||
|
The WinterCMS widgets that are not in this list (the rich editor, the media finder, the repeater, the file upload and the others) are not provided. A field with one of those types stops the start-up.
|
||||||
|
|
||||||
|
### Field options
|
||||||
|
|
||||||
|
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused.
|
||||||
|
|
||||||
|
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted. The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
|
||||||
|
|
||||||
|
## What a save may write
|
||||||
|
|
||||||
|
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field.
|
||||||
114
docs/backend/lists-and-filters.md
Normal file
114
docs/backend/lists-and-filters.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
title: Lists and filters
|
||||||
|
description: Describe admin lists in config_list.yaml and columns.yaml, with search, sorting, pagination options and switch, date range and scope filters.
|
||||||
|
section: backend
|
||||||
|
order: 30
|
||||||
|
---
|
||||||
|
# Lists and filters
|
||||||
|
|
||||||
|
The List behaviour's `config_list.yaml`, the model's `columns.yaml` and the Filter widget's `config_filter.yaml` keep their WinterCMS shape. [cabana](../../modules/cabana/README.md) compiles them at boot and runs every list query itself, so search, sort and filter parameters from the request apply only to what the YAML declares.
|
||||||
|
|
||||||
|
## config_list.yaml
|
||||||
|
|
||||||
|
```yaml src=modules/cabana/testdata/docs/controllers/posts/config_list.yaml
|
||||||
|
list: ~/plugins/acme/blog/models/post/columns.yaml
|
||||||
|
modelClass: Post
|
||||||
|
title: acme.blog::lang.posts.title
|
||||||
|
recordUrl: acme/blog/posts/update/:id
|
||||||
|
recordsPerPage: 20
|
||||||
|
perPageOptions: [20, 50, 100]
|
||||||
|
showCheckboxes: true
|
||||||
|
defaultSort:
|
||||||
|
column: published_at
|
||||||
|
direction: desc
|
||||||
|
filter: config_filter.yaml
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete]
|
||||||
|
search:
|
||||||
|
prompt: backend::lang.list.search_prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Sets |
|
||||||
|
|-----|------|
|
||||||
|
| `list` | The columns file, with a WinterCMS `~/plugins/...` path into the plugin. |
|
||||||
|
| `modelClass` | Must equal the controller's model name. |
|
||||||
|
| `title`, `noRecordsMessage` | Translation keys shown by the SPA. |
|
||||||
|
| `recordUrl` | Where a click on a row goes; `:id` is replaced. |
|
||||||
|
| `recordsPerPage`, `perPageOptions` | The page size and the sizes a user may pick. |
|
||||||
|
| `showCheckboxes`, `showSetup`, `showSorting`, `showSearch` | Which list controls appear. |
|
||||||
|
| `defaultSort` | `column` and `direction` of the initial order. |
|
||||||
|
| `toolbar` | `buttons` (the built-in `create` and `delete` and registered actions) and `search.prompt`. |
|
||||||
|
| `filter` | The filter file, relative to the controller's directory. |
|
||||||
|
| `headerPartial` | A server-rendered strip above the list; see [Partials and widgets](partials-and-widgets.md). |
|
||||||
|
|
||||||
|
## columns.yaml
|
||||||
|
|
||||||
|
```yaml src=modules/cabana/testdata/docs/models/post/columns.yaml
|
||||||
|
columns:
|
||||||
|
title:
|
||||||
|
label: acme.blog::lang.posts.title_column
|
||||||
|
searchable: true
|
||||||
|
published:
|
||||||
|
label: acme.blog::lang.posts.published
|
||||||
|
type: switch
|
||||||
|
published_at:
|
||||||
|
label: acme.blog::lang.posts.published_at
|
||||||
|
type: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
A column takes `label`, `type` (`text`, `datetime` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot. The compiled list keeps the columns in file order:
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_test.go#ExampleCompileList
|
||||||
|
// The plugin embeds its controllers/ and models/ trees; the example reads
|
||||||
|
// the same files from testdata.
|
||||||
|
fsys := os.DirFS("testdata/docs")
|
||||||
|
list, err := cabana.CompileList("acme.blog", PostsController{}, fsys)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(list.Title, list.RecordsPerPage, list.PerPageOptions, list.ToolbarButtons)
|
||||||
|
for _, c := range list.Columns {
|
||||||
|
fmt.Printf("column %s type=%q searchable=%v sortable=%v\n", c.Key, c.Type, c.Searchable, c.Sortable)
|
||||||
|
}
|
||||||
|
for _, f := range list.Filters {
|
||||||
|
fmt.Printf("filter %s type=%s column=%s\n", f.Name, f.Type, f.Column)
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// acme.blog::lang.posts.title 20 [20 50 100] [create delete]
|
||||||
|
// column title type="" searchable=true sortable=true
|
||||||
|
// column published type="switch" searchable=false sortable=true
|
||||||
|
// column published_at type="datetime" searchable=false sortable=true
|
||||||
|
// filter published type=switch column=published
|
||||||
|
// filter published_at type=daterange column=published_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Search runs over the searchable columns only; sort accepts only sortable columns. A request that names any other column is refused, never passed to SQL.
|
||||||
|
|
||||||
|
## Filters
|
||||||
|
|
||||||
|
`config_filter.yaml` lists scopes. Three filter types are supported:
|
||||||
|
|
||||||
|
```yaml src=modules/cabana/testdata/docs/controllers/posts/config_filter.yaml
|
||||||
|
scopes:
|
||||||
|
published:
|
||||||
|
label: acme.blog::lang.posts.published
|
||||||
|
type: switch
|
||||||
|
column: published
|
||||||
|
published_at:
|
||||||
|
label: acme.blog::lang.posts.published_at
|
||||||
|
type: daterange
|
||||||
|
column: published_at
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | Filters |
|
||||||
|
|------|---------|
|
||||||
|
| `switch` | A boolean `column`. With `options`, the two values are the options' keys, kept as typed scalars. |
|
||||||
|
| `daterange` | A date or timestamp `column` between two dates. |
|
||||||
|
| scope (a `scope` key with `modelClass` and `nameFrom`) | Records by a model-backed choice. The model implements `pact.FilterScope`, whose `FilterScopes` lists the scope names it answers, and `pact.FilterOptions` for the choices the SPA loads. |
|
||||||
|
|
||||||
|
WinterCMS `conditions` SQL fragments are not supported; use a `column` or a scope the model implements. A scope filter whose name the model does not list in `FilterScopes` stops the start-up, so request text can never select another method.
|
||||||
|
|
||||||
|
## Scoping every list
|
||||||
|
|
||||||
|
To restrict which records an administrator sees at all, implement `pact.ListExtendQuery` on the controller. It receives the list query before search, filters and pagination are applied, so the restriction holds for every request. See [Admin controllers](admin-controllers.md) for the other hooks.
|
||||||
63
docs/backend/partials-and-widgets.md
Normal file
63
docs/backend/partials-and-widgets.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
title: Partials and widgets
|
||||||
|
description: Extend admin screens with server-rendered partials, plugin JavaScript and CSS, form widgets backed by server actions, and custom toolbar buttons.
|
||||||
|
section: backend
|
||||||
|
order: 70
|
||||||
|
---
|
||||||
|
# Partials and widgets
|
||||||
|
|
||||||
|
WinterCMS controllers extend their screens with partials, `addJs` and `addCss`, custom form widgets and toolbar buttons that call AJAX handlers. The SummerCMS admin is a single-page app, so [cabana](../../modules/cabana/README.md) keeps these extension points in a form the SPA can render safely: partials arrive as an allowlisted node tree, plugin scripts are declared files, and every button or widget runs a server action through a cabana-owned route.
|
||||||
|
|
||||||
|
## Server-rendered partials
|
||||||
|
|
||||||
|
Two places accept a partial:
|
||||||
|
|
||||||
|
- `headerPartial: <name>` in `config_list.yaml`, a strip above the list;
|
||||||
|
- a `type: partial` field with `path: <name>` in `fields.yaml`.
|
||||||
|
|
||||||
|
Both render `{ConfigDir}/_<name>.htm` with Go's `html/template`. WinterCMS `$/` and `~/` partial paths are not supported. The template's data is `.Data`, the value the controller's `pact.AdminPartialData` returns for that partial name; for a form partial on an existing record, cabana passes the record it loaded through the controller's `pact.FormExtendQuery` scope. `trans "<key>"` translates a phrase key in the request locale.
|
||||||
|
|
||||||
|
A statistics strip above a list, using the SPA's partial style classes:
|
||||||
|
|
||||||
|
```html src=modules/cabana/testdata/extension/controllers/gadgets/_stats.htm
|
||||||
|
<dl class="summer-stats">
|
||||||
|
{{- range .Data.Items -}}
|
||||||
|
<div class="summer-stat"><dt class="summer-stat__label">{{ trans .Label }}</dt><dd class="summer-stat__value">{{ .Count }}</dd></div>
|
||||||
|
{{- end -}}
|
||||||
|
</dl>
|
||||||
|
```
|
||||||
|
|
||||||
|
The view model must be a struct built for the template. cabana refuses a view model that holds the controller's model or any other GORM model, anywhere inside it, and refuses pre-escaped `html/template` content types, so every record value stays escaped.
|
||||||
|
|
||||||
|
The rendered HTML is parsed and walked through an allowlist before it reaches the SPA: script, style, iframe, form and similar elements are removed with their content, unknown elements are unwrapped, `id`, `style` and event handler attributes are dropped, and links and images must be same-origin paths. Output is capped at 64 KiB, 2000 nodes and a depth of 32. The cabana README lists the allowed elements, attributes and style classes.
|
||||||
|
|
||||||
|
## Plugin JavaScript and CSS
|
||||||
|
|
||||||
|
A controller that implements `pact.AdminClientAssets` names `.js`, `.mjs` and `.css` files under its plugin's `assets/` directory, the Go form of `addJs` and `addCss`. They are read from the embedded tree at start-up (a missing file stops it) and served from `<prefix>/assets/{vendor}/{plugin}/...` with a content hash in the URL, the admin Content-Security-Policy (`script-src 'self'`) and `nosniff`. Only declared files are reachable; the YAML and templates never are.
|
||||||
|
|
||||||
|
Plugin CSS may use only the SPA's public CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-primary` and the others the cabana README lists), which switch with dark mode. Do not hardcode colours and do not rely on the SPA's utility classes.
|
||||||
|
|
||||||
|
## Form widgets
|
||||||
|
|
||||||
|
A `type: widget` field puts a plugin custom element in the form and connects it to a server action:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lookup:
|
||||||
|
label: acme.blog::lang.posts.lookup
|
||||||
|
type: widget
|
||||||
|
widget: acme-blog-lookup
|
||||||
|
action: lookup
|
||||||
|
fill: [title, slug]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `widget` is the custom element's tag, which must start with the plugin's `{vendor}-{plugin}-` prefix. A plugin script declared through `pact.AdminClientAssets` defines the element.
|
||||||
|
- `action` names an action the controller registers through `pact.HasAdminActions`.
|
||||||
|
- `fill` lists the writable scalar fields of the same form the action may write back.
|
||||||
|
|
||||||
|
The SPA posts the widget's values to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. An action may return a `cabana.ValidationError` to answer 422 on a field.
|
||||||
|
|
||||||
|
## Toolbar actions
|
||||||
|
|
||||||
|
Names in `toolbar.buttons` of `config_list.yaml`, other than the built-in `create` and `delete`, are actions the controller registers through `pact.HasAdminActions`. Each needs a label. A toolbar action runs with an empty body and no record IDs, so it can never become an unscoped lookup of IDs the client chose. The list schema lists only the actions the administrator may run.
|
||||||
|
|
||||||
|
An unknown action name, a widget tag outside the plugin's prefix or a fill key that is not a writable scalar field stops the start-up.
|
||||||
56
docs/backend/relation-manager.md
Normal file
56
docs/backend/relation-manager.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Relation manager
|
||||||
|
description: Edit belongsTo and belongsToMany relations in admin forms and manage linked records with config_relation.yaml, bound to models the controller names.
|
||||||
|
section: backend
|
||||||
|
order: 40
|
||||||
|
---
|
||||||
|
# Relation manager
|
||||||
|
|
||||||
|
WinterCMS edits relations in two ways: a `relation` form field that picks the related record, and the Relation behaviour, which embeds a list of linked records with link and unlink buttons. [cabana](../../modules/cabana/README.md) has both. The one rule that differs from WinterCMS: the framework never guesses a table, pivot or foreign key name. The controller supplies every name, and a missing or wrong one stops the start-up.
|
||||||
|
|
||||||
|
## Relation fields
|
||||||
|
|
||||||
|
A `type: relation` field in `fields.yaml` picks a belongsTo record or a set of belongsToMany records. `nameFrom` names the related model's label column:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
category:
|
||||||
|
label: acme.blog::lang.posts.category
|
||||||
|
type: relation
|
||||||
|
nameFrom: name
|
||||||
|
```
|
||||||
|
|
||||||
|
The controller implements `cabana.FieldRelationProvider` and returns a `cabana.FieldRelationContract` per field: its `Kind` (`belongsTo` or `belongsToMany`), a factory for the related model, and the `ForeignKey` of a belongsTo or the pivot model and its two key columns for a belongsToMany. An optional `OrderColumn` on the pivot stores the order in which the administrator picked the records.
|
||||||
|
|
||||||
|
The SPA loads the choices, paginated, from `.../fields/{field}/options`, and every record response carries the display labels of the linked records. A controller that implements `pact.RelationExtendOptionsQuery` narrows the choices, and the same scoped query rechecks the submitted IDs on save, so a record it does not offer cannot be attached.
|
||||||
|
|
||||||
|
## Relation managers
|
||||||
|
|
||||||
|
A `type: relation-manager` field embeds a relation manager in the form. Its `relation` key names an entry in the controller's `config_relation.yaml`, which describes the two panels: the linked records (`view`) and the candidates shown when linking (`manage`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
editors:
|
||||||
|
label: acme.blog::lang.posts.editors
|
||||||
|
view:
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
name:
|
||||||
|
label: acme.blog::lang.editors.name
|
||||||
|
email:
|
||||||
|
label: acme.blog::lang.editors.email
|
||||||
|
toolbarButtons: link|unlink
|
||||||
|
showSearch: true
|
||||||
|
manage:
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
name:
|
||||||
|
label: acme.blog::lang.editors.name
|
||||||
|
showSearch: true
|
||||||
|
```
|
||||||
|
|
||||||
|
The controller implements `cabana.AdminRelationContractProvider` and returns a `cabana.RelationContract` per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a `relation-manager` field stops the start-up.
|
||||||
|
|
||||||
|
`cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written.
|
||||||
|
|
||||||
|
## Relations in lists
|
||||||
|
|
||||||
|
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.
|
||||||
85
docs/backend/settings.md
Normal file
85
docs/backend/settings.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
title: Settings
|
||||||
|
description: Declare singleton settings pages with pact.SettingsItem, backed by a model, a fields.yaml form and validation rules, and read them from plugin code.
|
||||||
|
section: backend
|
||||||
|
order: 60
|
||||||
|
---
|
||||||
|
# Settings
|
||||||
|
|
||||||
|
A WinterCMS settings model extends `SettingModel`, stores its values in `system_settings` and registers its page with `registerSettings`. In SummerCMS a settings page is a singleton row of the plugin's own table, edited through a form described in `fields.yaml`, and declared with `pact.HasSettings`.
|
||||||
|
|
||||||
|
## Declaring a settings page
|
||||||
|
|
||||||
|
`pact.HasSettings` returns `pact.SettingsItem` entries. Each names the page's code, label, category and icon for the settings index, the permissions it needs, the form file inside the plugin's embedded tree, and a factory for its model:
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Settings
|
||||||
|
// Settings replaces registerSettings().
|
||||||
|
func (p *BlogPlugin) Settings() []pact.SettingsItem {
|
||||||
|
return []pact.SettingsItem{{
|
||||||
|
Code: "blog",
|
||||||
|
Label: "acme.blog::lang.settings.label",
|
||||||
|
Description: "acme.blog::lang.settings.description",
|
||||||
|
Category: "acme.blog::lang.plugin.name",
|
||||||
|
Icon: "icon-pencil",
|
||||||
|
Model: "BlogSettings",
|
||||||
|
Permissions: []string{"acme.blog.access_settings"},
|
||||||
|
Form: "models/settings/fields.yaml",
|
||||||
|
NewModel: func() any { return &BlogSettings{} },
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The model is a GORM struct with a `Fillable` list and a `Rules` method; [cabana](../../modules/cabana/README.md) refuses at start-up a settings model without either:
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_controller_test.go#BlogSettings
|
||||||
|
// BlogSettings is the singleton row behind the plugin's settings page.
|
||||||
|
type BlogSettings struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
PostsPerPage int `gorm:"column:posts_per_page"`
|
||||||
|
CommentsEnabled bool `gorm:"column:comments_enabled"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_controller_test.go#BlogSettings.Rules
|
||||||
|
// Rules validates a settings save, as a WinterCMS settings model's $rules.
|
||||||
|
func (BlogSettings) Rules() map[string]string {
|
||||||
|
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The form uses the same field types and options as a controller form; see [Forms](forms.md):
|
||||||
|
|
||||||
|
```yaml src=modules/cabana/testdata/docs/models/settings/fields.yaml
|
||||||
|
fields:
|
||||||
|
posts_per_page:
|
||||||
|
label: acme.blog::lang.settings.posts_per_page
|
||||||
|
type: number
|
||||||
|
span: left
|
||||||
|
default: 10
|
||||||
|
comments_enabled:
|
||||||
|
label: acme.blog::lang.settings.comments_enabled
|
||||||
|
type: switch
|
||||||
|
span: right
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the table in a migration like any other; see [Migrations](../database/migrations.md).
|
||||||
|
|
||||||
|
## How values are stored
|
||||||
|
|
||||||
|
The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does.
|
||||||
|
|
||||||
|
The admin API serves the page at `<prefix>/api/v1/settings/{code}` (values) and `.../settings/{code}/schema` (the form), and `GET .../settings` lists the pages the administrator may open.
|
||||||
|
|
||||||
|
## Reading settings in plugin code
|
||||||
|
|
||||||
|
Settings are an ordinary table, so plugin code reads them with GORM:
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/example_test.go#gate
|
||||||
|
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
|
||||||
|
var enabled bool
|
||||||
|
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
|
||||||
|
return err == nil && enabled
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
That example reads a flag from a settings row inside a search gate, treating a failed read as off. A setting that changes rarely and that operators, not administrators, own belongs in configuration instead; see [Configuration](../services/configuration.md).
|
||||||
83
docs/backend/users-and-permissions.md
Normal file
83
docs/backend/users-and-permissions.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
title: Users and permissions
|
||||||
|
description: Sign administrators in with JWT and cookie auth, declare permissions and navigation, and manage administrators from the console.
|
||||||
|
section: backend
|
||||||
|
order: 50
|
||||||
|
---
|
||||||
|
# Users and permissions
|
||||||
|
|
||||||
|
The admin keeps WinterCMS's backend user model: the `backend_users` and `backend_user_roles` tables, roles with permission grants, and superusers who pass every check. [cabana](../../modules/cabana/README.md) signs administrators in and checks their permissions; the tables are created by the framework migrations that `migrate` runs.
|
||||||
|
|
||||||
|
## Signing in
|
||||||
|
|
||||||
|
`POST <prefix>/api/v1/auth/login` checks the login and password against `backend_users` and issues a JWT for the admin audience, signed with `admin.jwt.secret`. Login attempts are throttled per `admin.login.max_attempts` and `admin.login.decay_minutes`. `POST .../auth/refresh` reissues a token inside the refresh window, and `POST .../auth/logout` revokes the current token by blacklisting its ID in `backend_jwt_blacklist`.
|
||||||
|
|
||||||
|
The admin API accepts the token two ways:
|
||||||
|
|
||||||
|
- API clients send `Authorization: Bearer <token>`.
|
||||||
|
- The admin SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in an HttpOnly, SameSite=Strict cookie. A cookie-authenticated request that changes state must carry that header, which blocks cross-site request forgery.
|
||||||
|
|
||||||
|
The guard is registered in [bouncer](../../modules/bouncer/README.md) under the name `backend` and is the middleware of every admin route except login, refresh and the language bundle. See [Authentication](../services/authentication.md) for tokens and guards in general.
|
||||||
|
|
||||||
|
The admin keys go in `config/admin.yaml`, with the secret in the environment (`SUMMER_ADMIN__JWT__SECRET`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jwt:
|
||||||
|
ttl: 60
|
||||||
|
refresh_ttl: 20160
|
||||||
|
password:
|
||||||
|
bcrypt_cost: 12
|
||||||
|
login:
|
||||||
|
max_attempts: 5
|
||||||
|
decay_minutes: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The cookie carries the Secure attribute. `backend.cookie_secure: false` drops it for plain-HTTP development and is refused in the `production` environment.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
A plugin declares its permissions with `pact.HasPermissions`, the Go form of `registerPermissions`, and its menu entries with `pact.HasNavigation`:
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Permissions
|
||||||
|
// Permissions replaces registerPermissions().
|
||||||
|
func (p *BlogPlugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{
|
||||||
|
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
|
||||||
|
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Navigation
|
||||||
|
// Navigation replaces registerNavigation().
|
||||||
|
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
|
||||||
|
return []pact.NavigationItem{{
|
||||||
|
Code: "blog",
|
||||||
|
Label: "acme.blog::lang.plugin.name",
|
||||||
|
Icon: "icon-pencil",
|
||||||
|
Permissions: []string{"acme.blog.access_posts"},
|
||||||
|
Controller: "acme.blog.posts",
|
||||||
|
SideMenu: []pact.NavigationItem{
|
||||||
|
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open. `cabana.Allows` is the check: superusers pass, a grant ending in `.*` matches every code with that prefix, and an empty requirement list allows any signed-in administrator. The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
|
||||||
|
|
||||||
|
Actions registered through `pact.HasAdminActions` may name extra permissions, checked on top of the controller's.
|
||||||
|
|
||||||
|
## Managing administrators
|
||||||
|
|
||||||
|
The application binary has two commands for operators:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./bin/acme admin:create --email admin@example.com --password '<secret>' --superuser
|
||||||
|
./bin/acme admin:reset-password admin@example.com --password '<secret>'
|
||||||
|
```
|
||||||
|
|
||||||
|
`admin:create` creates an activated administrator; `--login` defaults to the lower-cased email and `--role <code>` assigns a role. `admin:reset-password` takes a login or an email, sets the password and revokes every token issued before the reset. Passwords are hashed with bcrypt at `admin.password.bcrypt_cost`, so hashes copied from a WinterCMS database keep working.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Pass the password through an environment variable or a prompt of your shell rather than typing it on the command line, where it stays in the shell history.
|
||||||
@@ -6,7 +6,7 @@ order: 60
|
|||||||
---
|
---
|
||||||
# Attachments
|
# Attachments
|
||||||
|
|
||||||
WinterCMS attaches files to models with `$attachOne` and `$attachMany`, storing a row per file in `system_files` and the bytes on a disk. The `attach` package of [lagoon](../../modules/lagoon/README.md) ports the same table and storage layout, so files uploaded to a WinterCMS site keep working after a data copy. The bytes live in a [gocloud.dev](https://gocloud.dev/howto/blob/) bucket, configured with the `storage.uploads.*` keys.
|
WinterCMS attaches files to models with `$attachOne` and `$attachMany`, storing a row per file in `system_files` and the bytes on a disk. The `attach` package of [lagoon](../../modules/lagoon/README.md) ports the same table and storage layout, so files uploaded to a WinterCMS site keep working after a data copy. The bytes live in a [gocloud.dev](https://gocloud.dev/howto/blob/) bucket; see [Storage](../services/storage.md) for configuring it.
|
||||||
|
|
||||||
## The system_files row
|
## The system_files row
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,7 @@ Start with [Installation](setup/installation.md) to set up the toolchain and the
|
|||||||
- [Setup](setup/introduction.md): what SummerCMS is, installation, configuration and the move from WinterCMS.
|
- [Setup](setup/introduction.md): what SummerCMS is, installation, configuration and the move from WinterCMS.
|
||||||
- [Architecture](architecture/introduction.md): the single binary, Go modules, the application lifecycle and the request lifecycle.
|
- [Architecture](architecture/introduction.md): the single binary, Go modules, the application lifecycle and the request lifecycle.
|
||||||
- [Plugins](plugins/registration.md): registering a plugin, scheduling, extending other plugins and testing.
|
- [Plugins](plugins/registration.md): registering a plugin, scheduling, extending other plugins and testing.
|
||||||
|
- [Backend](backend/admin-controllers.md): admin controllers, forms, lists, relations, users, settings and the admin SPA.
|
||||||
|
- [Database](database/models.md): models, migrations, queries, relations, casts, attachments and transactions.
|
||||||
|
- [Services](services/configuration.md): configuration, events, routing, authentication, mail, jobs, realtime, search and the other services, plus what SummerCMS does not provide for the frontend.
|
||||||
- [Console](console/introduction.md): the `summer` tool, the application binary's commands and writing your own.
|
- [Console](console/introduction.md): the `summer` tool, the application binary's commands and writing your own.
|
||||||
|
|||||||
@@ -125,4 +125,4 @@ fmt.Println(bouncer.NeedsRehash(hash, 12))
|
|||||||
|
|
||||||
Rehash a password on the next successful login when `bouncer.NeedsRehash` reports true.
|
Rehash a password on the next successful login when `bouncer.NeedsRehash` reports true.
|
||||||
|
|
||||||
Admin sign-in, admin permissions and the admin user commands are covered in the Backend section.
|
Admin sign-in, admin permissions and the admin user commands are covered in [Users and permissions](../backend/users-and-permissions.md).
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ The application opens its configuration once with `compass.Load("config")` in th
|
|||||||
|
|
||||||
`compass.Config.Set` changes a value in memory. `compass.Config.Persist` saves every value set at runtime to `config/env/<environment>/overrides.yaml`, keeping the keys already saved there. It replaces the file atomically, creates it readable only by its owner and refuses any path outside the config directory. `compass.Config.Reload` rereads every source and discards values that were set but not persisted.
|
`compass.Config.Set` changes a value in memory. `compass.Config.Persist` saves every value set at runtime to `config/env/<environment>/overrides.yaml`, keeping the keys already saved there. It replaces the file atomically, creates it readable only by its owner and refuses any path outside the config directory. `compass.Config.Reload` rereads every source and discards values that were set but not persisted.
|
||||||
|
|
||||||
Values that admins edit in the backend are not configuration: settings pages store them in a database row.
|
Values that admins edit in the backend are not configuration: settings pages store them in a database row; see [Settings](../backend/settings.md).
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> `overrides.yaml` is written by the running application. Keep it out of version control, and keep secrets in environment variables rather than in values the application persists.
|
> `overrides.yaml` is written by the running application. Keep it out of version control, and keep secrets in environment variables rather than in values the application persists.
|
||||||
|
|||||||
37
docs/services/frontend-and-ajax.md
Normal file
37
docs/services/frontend-and-ajax.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Frontend and AJAX (not provided)
|
||||||
|
description: SummerCMS is headless, so CMS pages, themes, components, the AJAX framework and Snowboard are not provided; build the frontend as a separate application.
|
||||||
|
section: services
|
||||||
|
order: 160
|
||||||
|
---
|
||||||
|
# Frontend and AJAX (not provided)
|
||||||
|
|
||||||
|
WinterCMS renders its frontend on the server: CMS pages and layouts in a theme, partials, components that plugins attach to pages, and the AJAX framework with Snowboard for handlers such as `onSave` that update parts of a page without a reload. SummerCMS provides none of these. It is headless: it serves a JSON API, realtime channels and the admin SPA, and the frontend is a separate application that talks to it.
|
||||||
|
|
||||||
|
## What is not provided
|
||||||
|
|
||||||
|
| WinterCMS | In SummerCMS |
|
||||||
|
|-----------|--------------|
|
||||||
|
| CMS pages, layouts and partials in `themes/` | Not provided. The frontend application renders every page. |
|
||||||
|
| Themes and the theme customisation form | Not provided. |
|
||||||
|
| Components and `componentDetails`, `defineProperties`, `onRun` | Not provided. Expose the data a component loaded as a JSON route. |
|
||||||
|
| The AJAX framework (`data-request`, `$this->page`, AJAX handlers) | Not provided. Call JSON routes with the frontend's own HTTP client. |
|
||||||
|
| Snowboard and its plugins | Not provided. |
|
||||||
|
| Twig and the Twig filters and functions | Not provided. |
|
||||||
|
| Sessions and flash messages | Not provided. The API is stateless and authenticates each request with a token. |
|
||||||
|
|
||||||
|
A WinterCMS plugin that shipped components and AJAX handlers is ported as routes: each component's data loading and each handler becomes a JSON endpoint declared through `pact.HasRoutes`.
|
||||||
|
|
||||||
|
## Building the frontend
|
||||||
|
|
||||||
|
Build the frontend with any framework that can call a JSON API, as its own project with its own build and deployment:
|
||||||
|
|
||||||
|
- **Data:** call the plugins' JSON routes. [Routing](routing.md) shows how routes, auth groups and JSON responses are declared, and [Queries and pagination](../database/queries-and-pagination.md) the list envelope.
|
||||||
|
- **Signing in:** the user plugin issues JWTs; send them as a bearer token or in the cookie the guard reads. See [Authentication](authentication.md).
|
||||||
|
- **Live updates:** instead of polling an AJAX handler, subscribe to realtime channels. The frontend connects to Centrifugo with a token from the token route and receives model broadcasts and explicit events. See [Realtime](realtime.md).
|
||||||
|
- **Cross-origin calls:** when the frontend runs on another origin, allow it in `http.cors`, as [Routing](routing.md) describes.
|
||||||
|
- **Push notifications:** see [Web Push](push.md).
|
||||||
|
|
||||||
|
The admin is the one frontend SummerCMS ships. It is a single-page app built the same way, against the admin API; see [Admin SPA](../backend/admin-spa.md).
|
||||||
|
|
||||||
|
The full map of what carries over from WinterCMS, and what does not, is on [Coming from WinterCMS](../setup/coming-from-wintercms.md).
|
||||||
72
docs/services/outbound-http.md
Normal file
72
docs/services/outbound-http.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
title: Outbound HTTP
|
||||||
|
description: Fetch URLs that users or third parties supply through fetchguard, which allows HTTPS only, blocks private addresses at dial time and limits size and time.
|
||||||
|
section: services
|
||||||
|
order: 100
|
||||||
|
---
|
||||||
|
# Outbound HTTP
|
||||||
|
|
||||||
|
A WinterCMS plugin fetches a remote URL with the Laravel HTTP client or Guzzle, and checks the URL by hand when it came from a user. When a URL comes from outside the application, such as a remote image address, fetch it with [fetchguard](../../modules/fetchguard/README.md). It is the framework's guard against server-side request forgery: a request that a user can aim at the application's own network, a cloud metadata service or an internal admin panel.
|
||||||
|
|
||||||
|
For calls to services the application itself chose, such as a payment provider's API, the standard `net/http` client is fine.
|
||||||
|
|
||||||
|
## Policies
|
||||||
|
|
||||||
|
Every call takes a `fetchguard.Policy`:
|
||||||
|
|
||||||
|
- `fetchguard.AllowHostsMode` allows only the hosts in `AllowHosts`, matched exactly or as a dotted suffix.
|
||||||
|
- `fetchguard.PublicOnlyMode` allows any public host.
|
||||||
|
|
||||||
|
In both modes only `https` is allowed, and the resolved IP address is checked when the connection is dialled, so a DNS name that resolves into the network is refused too. The check covers private, loopback, link-local, carrier-grade NAT, documentation, multicast and other reserved IPv4 and IPv6 ranges, including IPv4 addresses inside NAT64 and 6to4 addresses. Environment proxy settings are ignored, so the check always sees the real target.
|
||||||
|
|
||||||
|
```go src=modules/fetchguard/example_test.go#ExampleFetch
|
||||||
|
ctx := context.Background()
|
||||||
|
// Only the application's image host, at most 5 MiB within 5 seconds.
|
||||||
|
images := fetchguard.Policy{
|
||||||
|
Mode: fetchguard.AllowHostsMode,
|
||||||
|
AllowHosts: []string{"images.example.com"},
|
||||||
|
MaxBytes: 5 << 20,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
// Any public host, for a URL a user pasted.
|
||||||
|
public := fetchguard.Policy{Mode: fetchguard.PublicOnlyMode}
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
url string
|
||||||
|
policy fetchguard.Policy
|
||||||
|
}{
|
||||||
|
{"http://images.example.com/cover.jpg", images},
|
||||||
|
{"https://cdn.attacker.example/cover.jpg", images},
|
||||||
|
{"https://127.0.0.1/admin", public},
|
||||||
|
{"https://169.254.169.254/latest/meta-data/", public},
|
||||||
|
{"https://[::ffff:10.0.0.1]/", public},
|
||||||
|
{"https://%zz", public},
|
||||||
|
} {
|
||||||
|
// The last argument is the application's config (app.Config), for
|
||||||
|
// limits the policy leaves at zero; nil uses the framework defaults.
|
||||||
|
_, err := fetchguard.Fetch(ctx, c.url, c.policy, nil)
|
||||||
|
var fe *fetchguard.Error
|
||||||
|
if errors.As(err, &fe) {
|
||||||
|
fmt.Println(fe.Reason, c.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(fetchguard.Defaults())
|
||||||
|
// Output:
|
||||||
|
// scheme http://images.example.com/cover.jpg
|
||||||
|
// invalid_url https://cdn.attacker.example/cover.jpg
|
||||||
|
// private_ip https://127.0.0.1/admin
|
||||||
|
// private_ip https://169.254.169.254/latest/meta-data/
|
||||||
|
// private_ip https://[::ffff:10.0.0.1]/
|
||||||
|
// invalid_url https://%zz
|
||||||
|
// 10485760 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
A failure is always a `fetchguard.Error` with one `fetchguard.Reason` from a closed set, so a handler can map it to a stable API error code. A host outside the allow list is reported as `invalid_url`, as the example shows.
|
||||||
|
|
||||||
|
## Responses and limits
|
||||||
|
|
||||||
|
`fetchguard.Fetch` returns a `fetchguard.Result` with the body, the content type and the status code for any response the server completed, including 4xx and 5xx. Check the status yourself.
|
||||||
|
|
||||||
|
Redirects are never followed: a 3xx response is returned as a result. To follow it, call `fetchguard.Fetch` again with the `Location` URL, which runs every check again.
|
||||||
|
|
||||||
|
The body is capped at the policy's `MaxBytes` (a larger body is `fetchguard.ReasonTooLarge`) and the call at its `Timeout`. A limit left at zero falls back to `http.fetch.max_bytes` and `http.fetch.timeout_seconds` from the configuration you pass, then to the framework defaults of 10 MiB and 10 seconds (`fetchguard.Defaults`). A configured value of zero or less is an error, not a way to turn a limit off.
|
||||||
105
docs/services/parity-testing.md
Normal file
105
docs/services/parity-testing.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
title: Parity testing
|
||||||
|
description: Record the reference backend's responses and broadcasts with tide, replay them against the Go port and diff them after masking IDs and timestamps.
|
||||||
|
section: services
|
||||||
|
order: 150
|
||||||
|
---
|
||||||
|
# Parity testing
|
||||||
|
|
||||||
|
When a plugin is ported from WinterCMS, its existing clients define the contract: the Go port must answer every request the way the PHP backend did. [tide](../../modules/tide/README.md) turns that rule into tests. It records the reference backend's real responses as YAML fixtures, replays the same requests against the port, and diffs the responses after masking the values that legitimately differ. WinterCMS has no counterpart.
|
||||||
|
|
||||||
|
## Flows
|
||||||
|
|
||||||
|
A fixture is a `tide.Flow`: a versioned, ordered list of steps, each a request with its recorded response. You write a spec, a flow with requests only:
|
||||||
|
|
||||||
|
```yaml src=modules/tide/testdata/docs/posts-spec.yaml
|
||||||
|
version: 1
|
||||||
|
name: blog-posts
|
||||||
|
description: List the posts of one blog
|
||||||
|
steps:
|
||||||
|
- id: list-posts
|
||||||
|
route_id: GET /api/blog/posts
|
||||||
|
request:
|
||||||
|
method: GET
|
||||||
|
path: /api/blog/posts?page=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Recording sends each request to the reference backend and fills in the responses. Replaying sends them to the port and compares status, a fixed set of contract headers and the body. JSON bodies are compared structurally after masking `id`, `*_id` and `*_ids` values and `*_at` timestamps; other bodies byte for byte:
|
||||||
|
|
||||||
|
```go src=modules/tide/example_test.go#ExampleReplayFlow
|
||||||
|
ctx := context.Background()
|
||||||
|
// The reference (PHP) backend and two Go ports. IDs and *_at timestamps
|
||||||
|
// legitimately differ; the second port changed a title.
|
||||||
|
reference := backend(`{"data":[{"id":12,"title":"Hello","created_at":"2026-09-30T10:00:00+00:00"}]}`)
|
||||||
|
defer reference.Close()
|
||||||
|
port := backend(`{"data":[{"id":3,"title":"Hello","created_at":"2026-10-01T08:30:00+00:00"}]}`)
|
||||||
|
defer port.Close()
|
||||||
|
broken := backend(`{"data":[{"id":3,"title":"hello","created_at":"2026-10-01T08:30:00+00:00"}]}`)
|
||||||
|
defer broken.Close()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile("testdata/docs/posts-spec.yaml")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spec, err := tide.ParseFlow(raw)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Record the reference once; the flow is what testdata/parity keeps.
|
||||||
|
flow, err := tide.RecordFlow(ctx, spec, tide.RecordConfig{Target: reference.URL})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, target := range []string{port.URL, broken.URL} {
|
||||||
|
res, err := tide.ReplayFlow(ctx, flow, tide.ReplayConfig{Target: target})
|
||||||
|
var mismatch *tide.MismatchError
|
||||||
|
if errors.As(err, &mismatch) {
|
||||||
|
res = mismatch.Result // a difference is an error carrying the result
|
||||||
|
} else if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("ok:", res.OK)
|
||||||
|
for _, step := range res.Steps {
|
||||||
|
for _, d := range step.Diffs {
|
||||||
|
fmt.Printf(" %s %s: want %s, got %s\n", step.ID, d.Path, d.Expected, d.Actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// ok: true
|
||||||
|
// ok: false
|
||||||
|
// list-posts $.data[0].title: want "Hello", got "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
The first port returns other IDs and timestamps and passes; the second changed a title and fails with the JSON path of the difference. A difference makes `tide.ReplayFlow` return a `tide.MismatchError` that carries the full `tide.Result`.
|
||||||
|
|
||||||
|
## The parity commands
|
||||||
|
|
||||||
|
The `summer` CLI wraps tide. Run the reference backend and the port on loopback addresses:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
summer parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml
|
||||||
|
summer parity:replay --fixtures testdata/parity --target http://127.0.0.1:8080 --vars /tmp/parity/vars.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`parity:proxy` records a real client instead: it runs a reverse proxy in front of the reference backend, and each named session of traffic becomes one fixture. Point the existing frontend at the proxy and click through a feature. The proxy binds to and forwards to loopback addresses only.
|
||||||
|
|
||||||
|
Values captured during a flow, such as tokens and created IDs, live in a variables file (`--vars`) readable only by its owner, and fixtures refer to them as `{{name}}` placeholders. Recording refuses to write a fixture that still holds a token- or password-shaped value, so credentials do not end up in committed fixtures. Keep the variables file outside the repository.
|
||||||
|
|
||||||
|
A route manifest (`tide.Manifest`) lists a plugin's routes with their auth groups, status and cases; `parity:record --manifest` records the missing cases in batches, and `parity:replay --manifest` reports coverage. The [Console utilities](../console/utilities.md) page lists every flag.
|
||||||
|
|
||||||
|
## Broadcast goldens
|
||||||
|
|
||||||
|
Realtime side effects are part of the contract too. `summer parity:broadcasts` runs a flow against the reference backend while a fake Centrifugo server (`tide.NewCentrifugoRecorder`) records the publications the backend sends, and writes them to a golden file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
summer parity:broadcasts --flow testdata/broadcasts/flows/post-lifecycle.yaml --step delete --name deleted --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --out testdata/broadcasts/deleted.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the reference backend's Centrifugo API URL at the recorder (`127.0.0.1:8424` by default). `--step` keeps only the publications of one step, running the earlier steps as setup. Timestamps, the actor and captured IDs are masked (`tide.NormalizePublications`), so the Go port's publications, recorded the same way, compare with `tide.DiffPublications`. A golden with `--pending` set is recorded but not yet asserted.
|
||||||
|
|
||||||
|
On the Go side, the memory realtime driver records publications the same way in tests; see [Realtime](realtime.md).
|
||||||
137
docs/services/push.md
Normal file
137
docs/services/push.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
---
|
||||||
|
title: Web Push
|
||||||
|
description: Send browser push notifications with flare over VAPID, only to https push service hosts on push.allowed_hosts and without redirects, and manage VAPID keys.
|
||||||
|
section: services
|
||||||
|
order: 130
|
||||||
|
---
|
||||||
|
# Web Push
|
||||||
|
|
||||||
|
[flare](../../modules/flare/README.md) sends browser push notifications. Push is a different channel from realtime: realtime reaches pages that hold an open connection, while a push goes to the browser vendor's push service, which wakes the browser even when no page is open. flare is written on the standard library: the RFC 8291 payload encryption and the RFC 8292 VAPID authorization are implemented in the package, with no Web Push library.
|
||||||
|
|
||||||
|
## Subscriptions belong to the application
|
||||||
|
|
||||||
|
When a browser subscribes, the frontend posts its `PushSubscription` (the endpoint URL and the `p256dh` and `auth` keys) to an application route, and the application stores it in its own table. flare never reads the database. Code that sends a push passes a `flare.Subscription` to the `flare.Pusher` that `flare.From` returns through `flare.Service.Pusher`.
|
||||||
|
|
||||||
|
For the operator commands below, the application also publishes a `flare.SubscriptionSource` on the app, which reads a user's stored subscriptions.
|
||||||
|
|
||||||
|
## Sending
|
||||||
|
|
||||||
|
`flare.Pusher.Send` encrypts the payload for the subscriber and posts it to the endpoint with the VAPID `Authorization` header. `flare.SendOptions` sets the `TTL` (default `push.ttl`), `Urgency` and `Topic` headers:
|
||||||
|
|
||||||
|
```go src=modules/flare/example_test.go#ExampleVAPIDPusher_Send
|
||||||
|
// A stand-in push service: 201 for a live subscription, 410 for one the
|
||||||
|
// browser dropped.
|
||||||
|
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/gone" {
|
||||||
|
w.WriteHeader(http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency"))
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
defer push.Close()
|
||||||
|
|
||||||
|
keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := flare.Config{
|
||||||
|
Enabled: true,
|
||||||
|
PublicKey: keys.PublicKey,
|
||||||
|
PrivateKey: keys.PrivateKey,
|
||||||
|
Subject: "mailto:admin@example.com",
|
||||||
|
TTL: time.Hour,
|
||||||
|
AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services
|
||||||
|
}
|
||||||
|
// The test server's client trusts its certificate.
|
||||||
|
pusher := flare.NewVAPIDPusher(cfg, push.Client())
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
|
||||||
|
for _, endpoint := range []string{
|
||||||
|
push.URL + "/live",
|
||||||
|
push.URL + "/gone",
|
||||||
|
"https://push.attacker.example/steal",
|
||||||
|
"http://127.0.0.1/plain",
|
||||||
|
} {
|
||||||
|
sub, err := browserSubscription(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"})
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
fmt.Println("sent")
|
||||||
|
case errors.Is(err, flare.ErrSubscriptionGone):
|
||||||
|
fmt.Println("gone: delete the subscription")
|
||||||
|
case errors.Is(err, flare.ErrEndpointNotAllowed):
|
||||||
|
fmt.Println("refused before connecting")
|
||||||
|
default:
|
||||||
|
fmt.Println("error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Formatting the keys never prints the private key.
|
||||||
|
fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey))
|
||||||
|
// Output:
|
||||||
|
// push service got aes128gcm 3600 normal
|
||||||
|
// sent
|
||||||
|
// gone: delete the subscription
|
||||||
|
// refused before connecting
|
||||||
|
// refused before connecting
|
||||||
|
// false
|
||||||
|
```
|
||||||
|
|
||||||
|
- A 2xx answer is success.
|
||||||
|
- 404 and 410 return `flare.ErrSubscriptionGone`: the browser unsubscribed, so delete the stored subscription.
|
||||||
|
- Any other status returns a `flare.StatusError` with the code, never the response body.
|
||||||
|
- A payload over `flare.MaxPayloadSize` (3993 bytes) returns `flare.ErrPayloadTooLarge`.
|
||||||
|
- While `push.enabled` is false, nothing is sent and `flare.ErrPushDisabled` is returned.
|
||||||
|
|
||||||
|
A send is one HTTP request with a 10-second timeout. Send from a queued job when a request would otherwise wait for it; see [Queued jobs](jobs.md).
|
||||||
|
|
||||||
|
## Endpoint safety
|
||||||
|
|
||||||
|
Endpoints come from browsers, so they are untrusted URLs. flare sends only to `https` endpoints whose host is on `push.allowed_hosts`, checks this before it opens a connection, and never follows a redirect, so a push service cannot bounce the request to another host. A refused endpoint returns `flare.ErrEndpointNotAllowed`, which names the host but never the endpoint path.
|
||||||
|
|
||||||
|
The default allowlist, `flare.DefaultAllowedHosts`, covers Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. `*.example.com` matches any subdomain but not `example.com` itself:
|
||||||
|
|
||||||
|
```go src=modules/flare/example_test.go#ExampleHostAllowed
|
||||||
|
allowed := []string{"fcm.googleapis.com", "*.push.apple.com"}
|
||||||
|
for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} {
|
||||||
|
fmt.Println(host, flare.HostAllowed(host, allowed))
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// fcm.googleapis.com true
|
||||||
|
// api.push.apple.com true
|
||||||
|
// push.apple.com false
|
||||||
|
// evil.example false
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the default unless you know a browser your users run pushes through another service.
|
||||||
|
|
||||||
|
## VAPID keys
|
||||||
|
|
||||||
|
A push service accepts a push only when it carries a token signed with the application's VAPID key pair. Generate the pair once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./bin/acme websockets:generate-vapid-keys
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints `SUMMER_PUSH__PUBLIC_KEY=...` and `SUMMER_PUSH__PRIVATE_KEY=...` lines to set in the environment. With `--update` it saves the keys to the environment's `overrides.yaml` instead. Keep the private key out of committed files. flare never writes the private key to a log or an error, and `flare.VAPIDKeys` and `flare.Config` redact it when printed.
|
||||||
|
|
||||||
|
The frontend needs the public key to subscribe; serve it from a route of your own. Set `push.subject` to a `mailto:` or `https:` contact address for the push services, and `push.enabled` to `true`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
enabled: true
|
||||||
|
subject: mailto:admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
in `config/push.yaml`.
|
||||||
|
|
||||||
|
`websockets:test-push <user_id>` prints the push configuration without the key values, lists the user's subscriptions from the published `flare.SubscriptionSource`, and sends each one a test notification:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./bin/acme websockets:test-push 42
|
||||||
|
```
|
||||||
246
docs/services/realtime.md
Normal file
246
docs/services/realtime.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
title: Realtime
|
||||||
|
description: Publish model changes and events to realtime channels with lighthouse, authorize subscriptions per channel namespace, and run the Centrifugo driver.
|
||||||
|
section: services
|
||||||
|
order: 120
|
||||||
|
---
|
||||||
|
# Realtime
|
||||||
|
|
||||||
|
[lighthouse](../../modules/lighthouse/README.md) is the SummerCMS counterpart of the WinterCMS websockets plugin. The application publishes events to named channels; the frontend holds a connection to a realtime server, subscribes to channels and receives the events. SummerCMS does not run the connection server itself. The Centrifugo driver publishes to a Centrifugo server through its HTTP API, issues the connection tokens the frontend needs, and answers Centrifugo's subscribe checks.
|
||||||
|
|
||||||
|
## Drivers
|
||||||
|
|
||||||
|
`realtime.driver` selects the driver:
|
||||||
|
|
||||||
|
| Driver | Publishes |
|
||||||
|
|--------|-----------|
|
||||||
|
| `null` (default) | Nothing. |
|
||||||
|
| `log` | To the log: channel names and the event, never the payload. |
|
||||||
|
| `memory` | Into memory, readable with `lighthouse.MemoryDriver.Publications`, for tests. |
|
||||||
|
| `centrifugo` | To Centrifugo, from the `lighthouse/centrifugo` package. |
|
||||||
|
|
||||||
|
A driver registers itself from its package's `init` function, as `database/sql` drivers do, so the application imports the driver package for its side effect: `_ ".../modules/lighthouse/centrifugo"`. An unknown driver name stops the start-up with the list of registered drivers. `lighthouse.From` returns the application's `lighthouse.Service`, which holds the driver, the authorizer registry and the broadcast settings.
|
||||||
|
|
||||||
|
## Channels and authorization
|
||||||
|
|
||||||
|
A channel name is `namespace:entity:id`, optionally prefixed once with `presence:`. A plugin registers a `lighthouse.Authorizer` per namespace on the service's `lighthouse.Registry`. The driver asks the namespace's authorizer on every subscribe, so a user who loses access is refused the next time the client subscribes; nothing is cached:
|
||||||
|
|
||||||
|
```go src=modules/lighthouse/example_test.go#ExampleRegistry_Register
|
||||||
|
app, err := newApp(map[string]any{"realtime.driver": "memory"})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := lighthouse.From(app)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// blog:{entity}:{id} channels are open to members of the blog only. The
|
||||||
|
// authorizer runs on every subscribe; nothing is cached.
|
||||||
|
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
|
||||||
|
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
|
||||||
|
if isMember(ctx, userID, lighthouse.ChannelID(channel)) {
|
||||||
|
return lighthouse.Allowed(nil)
|
||||||
|
}
|
||||||
|
return lighthouse.Denied("not a member of the blog")
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sub := range []struct {
|
||||||
|
user uint
|
||||||
|
channel string
|
||||||
|
}{{42, "blog:7"}, {42, "blog:8"}, {42, "presence:blog:7"}, {42, "shop:7"}} {
|
||||||
|
ns, presence := lighthouse.ParseChannel(sub.channel)
|
||||||
|
auth, ok := svc.Registry().Get(ns)
|
||||||
|
if !ok {
|
||||||
|
fmt.Println(sub.channel, "no authorizer")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res := auth.Authorize(context.Background(), sub.user, sub.channel)
|
||||||
|
fmt.Printf("%d %s namespace=%s presence=%v allowed=%v reason=%q\n", sub.user, sub.channel, ns, presence, res.Allowed, res.Reason())
|
||||||
|
}
|
||||||
|
fmt.Println(lighthouse.ChannelID("blog:12abc"), lighthouse.FormatChannels("acme", []string{"Blog:7"}))
|
||||||
|
// Output:
|
||||||
|
// 42 blog:7 namespace=blog presence=false allowed=true reason=""
|
||||||
|
// 42 blog:8 namespace=blog presence=false allowed=false reason="not a member of the blog"
|
||||||
|
// 42 presence:blog:7 namespace=blog presence=true allowed=false reason="not a member of the blog"
|
||||||
|
// shop:7 no authorizer
|
||||||
|
// 12 [acme:blog:7]
|
||||||
|
```
|
||||||
|
|
||||||
|
The channel rules follow the WinterCMS plugin byte for byte:
|
||||||
|
|
||||||
|
- `lighthouse.ParseChannel` returns the namespace and whether the channel is a presence channel. A doubled `presence:` prefix or more than three segments give an empty namespace, which no authorizer matches.
|
||||||
|
- `lighthouse.ChannelID` reads segment 1 with PHP's `(int)` cast: `12abc` is 12. For a `presence:` channel, segment 1 is the namespace, so the ID is 0, as the presence line of the example shows. An authorizer for presence channels must parse the ID itself.
|
||||||
|
- `lighthouse.FormatChannels` lowercases channel names and applies the `realtime.broadcast_namespace` prefix.
|
||||||
|
|
||||||
|
A denial's reason goes to the log only; the client always sees the same refusal.
|
||||||
|
|
||||||
|
## Mounting the driver's routes
|
||||||
|
|
||||||
|
A driver may need HTTP routes. The Centrifugo driver has two: the token route, which signed-in users call, and the subscribe proxy, which Centrifugo calls. The application mounts them once, from a plugin's `Routes`, with `lighthouse.Mount`, choosing the middleware per surface:
|
||||||
|
|
||||||
|
```go src=modules/lighthouse/centrifugo/example_test.go#ExampleDriver_Routes
|
||||||
|
app, err := newApp(map[string]any{"realtime.driver": "centrifugo"})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := lighthouse.From(app)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// In a plugin's Routes method, r is the router the plugin receives.
|
||||||
|
r := surf.New(nil)
|
||||||
|
err = lighthouse.Mount(r, svc.Driver(), lighthouse.Surfaces{
|
||||||
|
UserAuth: surf.Use("acme.auth"),
|
||||||
|
Middleware: surf.Use("throttle:60,1"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, rt := range r.Routes() {
|
||||||
|
fmt.Println(rt.Method, rt.Pattern, rt.Middleware, "raw:", rt.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user route without a guard is refused.
|
||||||
|
err = lighthouse.Mount(surf.New(nil), svc.Driver(), lighthouse.Surfaces{})
|
||||||
|
fmt.Println(err != nil)
|
||||||
|
// Output:
|
||||||
|
// GET /api/realtime/token [acme.auth throttle:60,1] raw: false
|
||||||
|
// POST /api/realtime/subscribe [throttle:60,1] raw: true
|
||||||
|
// true
|
||||||
|
```
|
||||||
|
|
||||||
|
`lighthouse.UserAuth` routes get the `UserAuth` middleware, `lighthouse.ServerToServer` routes are mounted in a raw group, and `Middleware` is added to every route after the surface's own. A user route without a guard is refused, so the token route can never be exposed to anonymous callers. Switching drivers never changes the application's route declarations.
|
||||||
|
|
||||||
|
## Model broadcasts
|
||||||
|
|
||||||
|
A model broadcasts its creates, updates and deletes when a `lighthouse.Binding` is registered for it, or when its pointer type implements `lighthouse.Broadcastable`. A binding keeps realtime code out of the models package:
|
||||||
|
|
||||||
|
```go src=modules/lighthouse/example_test.go#bind
|
||||||
|
return lighthouse.Bind[Post](svc, lighthouse.Binding[Post]{
|
||||||
|
Alias: "blog.post",
|
||||||
|
Channels: func(ctx context.Context, tx *gorm.DB, p *Post) ([]string, error) {
|
||||||
|
return []string{"blog:" + strconv.FormatUint(uint64(p.BlogID), 10)}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The event name is `{action}.{alias}`, here `created.blog.post`, and the default payload is `{"model":...,"actor":...,"timestamp":"...+00:00","ttl":60}`. A binding's `Payload`, `ShouldBroadcast` and `TTL` fields, or the matching model methods, replace the defaults.
|
||||||
|
|
||||||
|
Delivery is transactional. The write enqueues a broadcast job, through [conga](../../modules/conga/README.md), inside its own transaction, so nothing is published for a write that rolls back, and the job publishes after the commit:
|
||||||
|
|
||||||
|
```go src=modules/lighthouse/example_test.go#write
|
||||||
|
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&Post{BlogID: 7, Title: "Hello"}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The broadcast job is now queued in this transaction. It is
|
||||||
|
// published only if the transaction commits.
|
||||||
|
if fail {
|
||||||
|
return fmt.Errorf("rolled back")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The job runs once, best effort: a failed publish is logged as `realtime: broadcast failed` and never affects the write. Delivery order across separate jobs is not guaranteed. A job worker must be running, in `serve` or in `queue:work`; see [Queued jobs](jobs.md). A write without a primary key value, such as `Model(&Post{}).Where(...).Updates(...)`, is not broadcast.
|
||||||
|
|
||||||
|
## Bulk writes
|
||||||
|
|
||||||
|
`lighthouse.WithoutBroadcasting` silences one model type for writes made with the context it hands to its function; other types still broadcast. `lighthouse.Service.Emit` enqueues one explicit event on the caller's transaction. Together they turn a thousand row events into one summary:
|
||||||
|
|
||||||
|
```go src=modules/lighthouse/example_test.go#bulk
|
||||||
|
return lighthouse.WithoutBroadcasting[Post](ctx, func(ctx context.Context) error {
|
||||||
|
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
for _, title := range titles {
|
||||||
|
if err := tx.Create(&Post{BlogID: 7, Title: title}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return svc.Emit(ctx, tx, lighthouse.Broadcast{
|
||||||
|
Channels: []string{"blog:7"},
|
||||||
|
Event: "blog.posts_imported",
|
||||||
|
Payload: struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}{len(titles)},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Only writes that use the context passed to the function are silenced, so write through it, as `lagoon.Transaction` does above.
|
||||||
|
|
||||||
|
## The Centrifugo driver
|
||||||
|
|
||||||
|
The driver reads `realtime.centrifugo.*` from `config/realtime.yaml`. Secrets go in the environment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
driver: centrifugo
|
||||||
|
centrifugo:
|
||||||
|
api_url: http://127.0.0.1:8001/api
|
||||||
|
ws_url: /ws
|
||||||
|
```
|
||||||
|
|
||||||
|
with `SUMMER_REALTIME__CENTRIFUGO__API_KEY`, `SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET` and `SUMMER_REALTIME__CENTRIFUGO__PROXY_SECRET` set.
|
||||||
|
|
||||||
|
- The token route (`realtime.centrifugo.token_path`, `/api/realtime/token` by default) answers a signed-in user with `{"token":"..."}`, an HS256 connection token signed with the token secret. It answers 401 without a user and 503 when the token secret is empty.
|
||||||
|
- The subscribe proxy (`realtime.centrifugo.subscribe_path`) accepts a call only when its `X-Centrifugo-Secret` header equals the proxy secret, compared in constant time; an empty proxy secret refuses every subscribe. It then asks the channel's authorizer. Every answer is HTTP 200, as Centrifugo requires, with the decision in the body.
|
||||||
|
- Publishing uses the HTTP API with the API key. With an empty API key nothing is sent and no broadcast jobs are queued.
|
||||||
|
|
||||||
|
The subscribe proxy runs the same authorizers as above:
|
||||||
|
|
||||||
|
```go src=modules/lighthouse/centrifugo/example_test.go#ExampleProxyHandler
|
||||||
|
svc, err := lighthouse.From(backpack.New(nil))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Members of blog 7 may subscribe to its channels.
|
||||||
|
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
|
||||||
|
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
|
||||||
|
if userID == 42 && lighthouse.ChannelID(channel) == 7 {
|
||||||
|
return lighthouse.Allowed(nil)
|
||||||
|
}
|
||||||
|
return lighthouse.Denied("not a member of the blog")
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// realtime.centrifugo.proxy_secret; set it through the environment.
|
||||||
|
proxy := centrifugo.ProxyHandler(svc, centrifugo.Config{ProxySecret: "test-only-proxy-secret"})
|
||||||
|
|
||||||
|
// What Centrifugo posts to the subscribe proxy.
|
||||||
|
subscribe := func(secret, user, channel string) {
|
||||||
|
body := fmt.Sprintf(`{"client":"c1","user":%q,"channel":%q}`, user, channel)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/realtime/subscribe", strings.NewReader(body))
|
||||||
|
req.Header.Set("X-Centrifugo-Secret", secret)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
proxy.ServeHTTP(rec, req)
|
||||||
|
fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||||
|
}
|
||||||
|
subscribe("test-only-proxy-secret", "42", "blog:7")
|
||||||
|
subscribe("test-only-proxy-secret", "5", "blog:7")
|
||||||
|
subscribe("wrong-secret", "42", "blog:7")
|
||||||
|
// Output:
|
||||||
|
// 200 {"result":{"info":[]}}
|
||||||
|
// 200 {"error":{"code":403,"message":"Access denied"}}
|
||||||
|
// 200 {"error":{"code":403,"message":"Access denied"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure Centrifugo to call the subscribe proxy with the same secret, and keep its HTTP API on a private address.
|
||||||
|
|
||||||
|
`websockets:health` checks the connection to Centrifugo and prints the settings, never the API key:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./bin/acme websockets:health
|
||||||
|
```
|
||||||
183
docs/services/search.md
Normal file
183
docs/services/search.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
---
|
||||||
|
title: Search
|
||||||
|
description: Keep models in a search index with beachcomber, synced after commit behind a kill-switch, and re-check the candidate IDs a search returns in SQL.
|
||||||
|
section: services
|
||||||
|
order: 140
|
||||||
|
---
|
||||||
|
# Search
|
||||||
|
|
||||||
|
[beachcomber](../../modules/beachcomber/README.md) is the SummerCMS counterpart of Laravel Scout, as WinterCMS applications use it without a queue. A model opts in by implementing `beachcomber.Searchable`; after a write of such a model commits, its row is reloaded and its document written to, or removed from, the search index. A search asks the index for matching IDs, and the application loads the rows from the database.
|
||||||
|
|
||||||
|
## Engines
|
||||||
|
|
||||||
|
`search.driver` selects the engine. The default, `null`, indexes nothing and finds nothing. The `typesense` engine, from the `beachcomber/typesense` package, talks to a Typesense server over its HTTP API. An engine registers itself from its package's `init` function, so the application imports the engine package for its side effect; an unknown name stops the start-up. `search.prefix` is prepended to every index name, for example to keep staging and production apart on one server:
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/example_test.go#ExampleFrom
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = cfg.Set("search.prefix", "staging_")
|
||||||
|
svc, err := beachcomber.From(backpack.New(cfg)) // search.driver defaults to null
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(svc.Engine().Name(), svc.Engine().Configured(), svc.IndexName(&Post{}))
|
||||||
|
ids, err := svc.Engine().SearchIDs(context.Background(), svc.IndexName(&Post{}), beachcomber.Query{Q: "go"})
|
||||||
|
fmt.Println(ids, err)
|
||||||
|
|
||||||
|
_ = cfg.Set("search.driver", "elastic")
|
||||||
|
_, err = beachcomber.From(backpack.New(cfg))
|
||||||
|
fmt.Println(err != nil)
|
||||||
|
// Output:
|
||||||
|
// null false staging_acme_blog_posts
|
||||||
|
// [] <nil>
|
||||||
|
// true
|
||||||
|
```
|
||||||
|
|
||||||
|
An engine implements `beachcomber.Engine` and registers with `beachcomber.RegisterEngine`. The examples on this page use a small in-memory engine that matches titles:
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/example_test.go#memoryEngine.SearchIDs
|
||||||
|
func (e *memoryEngine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
ids := []string{}
|
||||||
|
for id, d := range e.docs[index] {
|
||||||
|
if strings.Contains(strings.ToLower(fmt.Sprint(d["title"])), strings.ToLower(q.Q)) {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(ids)
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Searchable models
|
||||||
|
|
||||||
|
A model implements `beachcomber.Searchable` with three methods, and needs no import of beachcomber to do so:
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/example_test.go#Post.SearchableAs
|
||||||
|
// SearchableAs is the index name, before search.prefix.
|
||||||
|
func (Post) SearchableAs() string { return "acme_blog_posts" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/example_test.go#Post.ShouldBeSearchable
|
||||||
|
// ShouldBeSearchable keeps drafts out of the index.
|
||||||
|
func (p *Post) ShouldBeSearchable() bool { return p.Published }
|
||||||
|
```
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/example_test.go#Post.ToSearchableArray
|
||||||
|
// ToSearchableArray builds the document from the committed row.
|
||||||
|
func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) {
|
||||||
|
return map[string]any{
|
||||||
|
"id": strconv.FormatUint(uint64(p.ID), 10),
|
||||||
|
"blog_id": int64(p.BlogID),
|
||||||
|
"title": p.Title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ToSearchableArray` runs after the commit on a fresh copy of the row, so it may query related rows. A row whose `ShouldBeSearchable` is false, a soft-deleted row and a deleted row have their documents removed. A model can also implement `beachcomber.IndexSchemaProvider`, the schema the engine creates a missing index with, and `beachcomber.SearchKeyer`, a document key other than the primary key.
|
||||||
|
|
||||||
|
## Sync after commit
|
||||||
|
|
||||||
|
`beachcomber.From` installs GORM callbacks that register the sync with `lagoon.AfterCommit`:
|
||||||
|
|
||||||
|
- Inside `lagoon.Transaction`, the sync runs after the commit, and never after a rollback.
|
||||||
|
- A single-statement write syncs after GORM commits it.
|
||||||
|
- Inside a plain GORM transaction the sync is skipped with a warning, because the commit cannot be observed. Wrap such writes in `lagoon.Transaction`, or call `beachcomber.Service.Sync` after the commit.
|
||||||
|
|
||||||
|
The sync runs inline in the writing goroutine, so a search right after a save finds the document, and it is bounded by the engine's timeout. It is never fatal: a failure is logged as `search: sync failed` with the index, key and operation, never the document or the API key, and the write stays committed. A write without a primary key value, such as `Model(&Post{}).Where(...).Updates(...)`, cannot be synced row by row; bulk paths call `beachcomber.Service.Sync` and `beachcomber.Service.Remove` per row, or reindex.
|
||||||
|
|
||||||
|
## The kill-switch
|
||||||
|
|
||||||
|
Nothing is sent when the engine is not configured (the `null` engine, or Typesense without an API key), when no database is published, or when the application's `beachcomber.Gate` reports off. Install the gate from a plugin's `Boot`. A gate must treat a read error as off:
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/example_test.go#gate
|
||||||
|
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
|
||||||
|
var enabled bool
|
||||||
|
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
|
||||||
|
return err == nil && enabled
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Searching
|
||||||
|
|
||||||
|
`beachcomber.Engine.SearchIDs` returns the IDs of matching documents, in the engine's order. They are candidates, not answers: the index can be stale (a write it missed, a document from before a permission change) and its filters are only as good as the document. Re-check every ID in SQL, with the same ownership, visibility and soft-delete conditions the rest of the API applies, before a row reaches a response:
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/example_test.go#search
|
||||||
|
ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&Post{}), beachcomber.Query{
|
||||||
|
Q: term,
|
||||||
|
QueryBy: []string{"title"},
|
||||||
|
FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The ids are candidates from an index that may be stale or loosely
|
||||||
|
// filtered: re-check every one in SQL before exposing a row.
|
||||||
|
var posts []Post
|
||||||
|
err = db.WithContext(ctx).
|
||||||
|
Where("id IN ? AND blog_id = ? AND published", ids, blogID).
|
||||||
|
Order("id").
|
||||||
|
Find(&posts).Error
|
||||||
|
return posts, err
|
||||||
|
```
|
||||||
|
|
||||||
|
An empty result is an empty list, never an error.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Never return rows, or even counts, straight from search IDs. A stale index would otherwise show a draft, a deleted record or another user's data.
|
||||||
|
|
||||||
|
## Typesense
|
||||||
|
|
||||||
|
The Typesense engine follows the Scout Typesense wire contract, so indexes built by a WinterCMS application can be searched by the port:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
driver: typesense
|
||||||
|
typesense:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8108
|
||||||
|
protocol: http
|
||||||
|
```
|
||||||
|
|
||||||
|
in `config/search.yaml`, with the key in `SUMMER_SEARCH__TYPESENSE__API_KEY`. Without an API key nothing is ever sent. A search is one request to the collection's search endpoint, and the engine returns the hit IDs in Typesense's order:
|
||||||
|
|
||||||
|
```go src=modules/beachcomber/typesense/example_test.go#ExampleEngine_SearchIDs
|
||||||
|
// A stand-in Typesense node.
|
||||||
|
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
fmt.Println(r.Method, r.URL.Path, "key sent:", r.Header.Get("X-TYPESENSE-API-KEY") != "")
|
||||||
|
fmt.Println("q", q.Get("q"), "query_by", q.Get("query_by"), "filter_by", q.Get("filter_by"))
|
||||||
|
fmt.Fprint(w, `{"hits":[{"document":{"id":"3"}},{"document":{"id":"1"}}]}`)
|
||||||
|
}))
|
||||||
|
defer node.Close()
|
||||||
|
u, _ := url.Parse(node.URL)
|
||||||
|
port, _ := strconv.Atoi(u.Port())
|
||||||
|
|
||||||
|
engine := typesense.New(typesense.Config{
|
||||||
|
APIKey: "test-only-key", // SUMMER_SEARCH__TYPESENSE__API_KEY
|
||||||
|
Host: u.Hostname(),
|
||||||
|
Port: port,
|
||||||
|
Protocol: "http",
|
||||||
|
ConnectionTimeout: 2 * time.Second,
|
||||||
|
})
|
||||||
|
ids, err := engine.SearchIDs(context.Background(), "acme_blog_posts", beachcomber.Query{
|
||||||
|
Q: "go",
|
||||||
|
QueryBy: []string{"title"},
|
||||||
|
FilterBy: "blog_id:=7",
|
||||||
|
})
|
||||||
|
fmt.Println(ids, err)
|
||||||
|
|
||||||
|
// Without an API key the engine is not configured and nothing is sent.
|
||||||
|
fmt.Println(typesense.New(typesense.Config{}).Configured())
|
||||||
|
// Output:
|
||||||
|
// GET /collections/acme_blog_posts/documents/search key sent: true
|
||||||
|
// q go query_by title filter_by blog_id:=7
|
||||||
|
// [3 1] <nil>
|
||||||
|
// false
|
||||||
|
```
|
||||||
|
|
||||||
|
Each request times out after `search.typesense.connection_timeout_seconds` (2 seconds by default). A failed answer is a `typesense.StatusError` with the method, path and status, never the answer body.
|
||||||
36
docs/services/storage.md
Normal file
36
docs/services/storage.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: Storage
|
||||||
|
description: Configure the uploads bucket that serve opens, choose file or memory bucket URLs, serve stored files, and size upload routes.
|
||||||
|
section: services
|
||||||
|
order: 90
|
||||||
|
---
|
||||||
|
# Storage
|
||||||
|
|
||||||
|
WinterCMS stores uploads on a Laravel filesystem disk. SummerCMS stores them in one [gocloud.dev](https://gocloud.dev/howto/blob/) bucket, opened from `storage.uploads.bucket_url` by the `attach` package of [lagoon](../../modules/lagoon/README.md). The `serve` command opens the bucket at start-up, before it accepts requests, and publishes it on the application; an empty `bucket_url` stops the start-up.
|
||||||
|
|
||||||
|
## Bucket URLs
|
||||||
|
|
||||||
|
| URL | Stores |
|
||||||
|
|-----|--------|
|
||||||
|
| `file:///var/lib/acme/uploads` | In a directory on the server. |
|
||||||
|
| `mem://` | In memory, for tests. Everything is lost when the process exits. |
|
||||||
|
|
||||||
|
The keys go in `config/storage.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
uploads:
|
||||||
|
bucket_url: file:///var/lib/acme/uploads
|
||||||
|
public_path_prefix: /storage/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
Files are laid out as WinterCMS lays out its uploads disk, so a copy of a WinterCMS `storage/app/uploads/public` directory can serve as the bucket after a port. `public_path_prefix` is the URL prefix that file and thumbnail URLs start with.
|
||||||
|
|
||||||
|
Application code gets the bucket with `app.Lookup[*blob.Bucket]()` and reads and writes it through the `gocloud.dev/blob` API. Model attachments, thumbnails and deleting files after commit are covered in [Attachments](../database/attachments.md).
|
||||||
|
|
||||||
|
## Serving files
|
||||||
|
|
||||||
|
The framework does not mount a file route by itself. The application decides where files are served: mount `attach.StaticHandlerPublic` under `public_path_prefix` to serve originals and thumbnails and answer 404 for files whose row is not public, or put a web server or CDN in front of the bucket directory. Serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why.
|
||||||
|
|
||||||
|
## Upload size
|
||||||
|
|
||||||
|
Every non-raw route has a request body limit of `http.body_limits.default_bytes`. A route that accepts uploads raises its own limit with the `body.limit:<bytes>` middleware; see [Routing](routing.md). `http.body_limits.upload_bytes` is required and validated at start-up, but the framework applies it to no route; a plugin that wants its upload routes to follow it reads it in `Register` and puts the value in the route's `body.limit`.
|
||||||
@@ -12,31 +12,31 @@ What changes is everything that depends on PHP at runtime. Plugins are Go packag
|
|||||||
|
|
||||||
## Concept map
|
## Concept map
|
||||||
|
|
||||||
Each row names the WinterCMS concept, the SummerCMS identifiers that replace it and the module that documents them.
|
Each row names the WinterCMS concept, the SummerCMS identifiers that replace it, and where to read more: the guide page first, then the module reference.
|
||||||
|
|
||||||
| WinterCMS | SummerCMS | Where |
|
| WinterCMS | SummerCMS | Where |
|
||||||
|-----------|-----------|-------|
|
|-----------|-----------|-------|
|
||||||
| `Plugin.php` with `pluginDetails`, `register` and `boot` | A type implementing `party.Plugin` (`party.Plugin.ID`, `party.Plugin.Requires`, `party.Plugin.Register`, `party.Plugin.Boot`), registered from `init` with `party.Register` | [party](../../modules/party/README.md) |
|
| `Plugin.php` with `pluginDetails`, `register` and `boot` | A type implementing `party.Plugin` (`party.Plugin.ID`, `party.Plugin.Requires`, `party.Plugin.Register`, `party.Plugin.Boot`), registered from `init` with `party.Register` | [Plugin registration](../plugins/registration.md), [party](../../modules/party/README.md) |
|
||||||
| `$require` plugin dependencies | `party.Plugin.Requires`; `party.Activate` orders plugins so each comes after the ones it requires | [party](../../modules/party/README.md) |
|
| `$require` plugin dependencies | `party.Plugin.Requires`; `party.Activate` orders plugins so each comes after the ones it requires | [Plugin registration](../plugins/registration.md), [party](../../modules/party/README.md) |
|
||||||
| `registerPermissions`, `registerNavigation` | `pact.HasPermissions` returning `pact.Permission` values, `pact.HasNavigation` returning `pact.NavigationItem` values | [pact](../../modules/pact/README.md) |
|
| `registerPermissions`, `registerNavigation` | `pact.HasPermissions` returning `pact.Permission` values, `pact.HasNavigation` returning `pact.NavigationItem` values | [Users and permissions](../backend/users-and-permissions.md), [pact](../../modules/pact/README.md) |
|
||||||
| `version.yaml` and the `updates/` directory | `pact.HasMigrations` returning an ordered gormigrate set; `lagoon.Migrate` runs every plugin's set and `lagoon.RollbackLast` undoes the last one | [lagoon](../../modules/lagoon/README.md) |
|
| `version.yaml` and the `updates/` directory | `pact.HasMigrations` returning an ordered gormigrate set; `lagoon.Migrate` runs every plugin's set and `lagoon.RollbackLast` undoes the last one | [Migrations](../database/migrations.md), [lagoon](../../modules/lagoon/README.md) |
|
||||||
| Eloquent models | GORM structs with lagoon helpers: `lagoon.Fill` for mass assignment, `lagoon.Validate` for rules, `lagoon.Jsonable` for JSON columns, `lagoon.Page` for pagination | [lagoon](../../modules/lagoon/README.md) |
|
| Eloquent models | GORM structs with lagoon helpers: `lagoon.Fill` for mass assignment, `lagoon.Validate` for rules, `lagoon.Jsonable` for JSON columns, `lagoon.Page` for pagination | [Models](../database/models.md), [Casts and validation](../database/casts-and-validation.md), [lagoon](../../modules/lagoon/README.md) |
|
||||||
| `fields.yaml` and `columns.yaml` | The same YAML, embedded in the plugin through `pact.AdminAssets` and compiled at boot into a `cabana.CompiledController` | [cabana](../../modules/cabana/README.md) |
|
| `fields.yaml` and `columns.yaml` | The same YAML, embedded in the plugin through `pact.AdminAssets` and compiled at boot into a `cabana.CompiledController` | [Forms](../backend/forms.md), [Lists and filters](../backend/lists-and-filters.md), [cabana](../../modules/cabana/README.md) |
|
||||||
| Backend controllers with the Form, List and Relation behaviours | A `pact.AdminController` returned from `pact.HasAdminControllers`; the generic admin API replaces the behaviours, and hooks such as `pact.FormBeforeCreate` and `pact.ListExtendQuery` replace behaviour overrides | [cabana](../../modules/cabana/README.md) |
|
| Backend controllers with the Form, List and Relation behaviours | A `pact.AdminController` returned from `pact.HasAdminControllers`; the generic admin API replaces the behaviours, and hooks such as `pact.FormBeforeCreate` and `pact.ListExtendQuery` replace behaviour overrides | [Admin controllers](../backend/admin-controllers.md), [cabana](../../modules/cabana/README.md) |
|
||||||
| `routes.php` | `pact.HasRoutes`, declaring routes on a `pact.Router` with groups, middleware names and `pact.Router.Where` constraints | [surf](../../modules/surf/README.md) |
|
| `routes.php` | `pact.HasRoutes`, declaring routes on a `pact.Router` with groups, middleware names and `pact.Router.Where` constraints | [Routing](../services/routing.md), [surf](../../modules/surf/README.md) |
|
||||||
| Route middleware | Named middleware of type `pact.Middleware`, registered through `pact.HasMiddleware` | [surf](../../modules/surf/README.md) |
|
| Route middleware | Named middleware of type `pact.Middleware`, registered through `pact.HasMiddleware` | [Routing](../services/routing.md), [surf](../../modules/surf/README.md) |
|
||||||
| `config/*.php`, `.env` and `Config::get` | `compass.Config` with per-environment directories and `SUMMER_` overrides; plugin defaults through `pact.HasConfig` | [compass](../../modules/compass/README.md) |
|
| `config/*.php`, `.env` and `Config::get` | `compass.Config` with per-environment directories and `SUMMER_` overrides; plugin defaults through `pact.HasConfig` | [Configuration](../services/configuration.md), [compass](../../modules/compass/README.md) |
|
||||||
| `lang/` files and `Lang::get` | `pact.HasLang` for plugin catalogs, read through `phrasebook.Translator.Get` and `phrasebook.Translator.Choice` | [phrasebook](../../modules/phrasebook/README.md) |
|
| `lang/` files and `Lang::get` | `pact.HasLang` for plugin catalogs, read through `phrasebook.Translator.Get` and `phrasebook.Translator.Choice` | [Localization](../services/localization.md), [phrasebook](../../modules/phrasebook/README.md) |
|
||||||
| `Event::listen` and `Event::fire` | `festival.Bus.Listen` and `festival.Bus.Fire` on `backpack.App.Events`, keyed by the event's Go type | [festival](../../modules/festival/README.md) |
|
| `Event::listen` and `Event::fire` | `festival.Bus.Listen` and `festival.Bus.Fire` on `backpack.App.Events`, keyed by the event's Go type | [Events](../services/events.md), [festival](../../modules/festival/README.md) |
|
||||||
| `App::make` and singleton bindings | `backpack.App.Publish` and `backpack.App.Lookup`, keyed by type | [backpack](../../modules/backpack/README.md) |
|
| `App::make` and singleton bindings | `backpack.App.Publish` and `backpack.App.Lookup`, keyed by type | [Extending plugins](../plugins/extending.md), [backpack](../../modules/backpack/README.md) |
|
||||||
| Artisan commands and `registerConsoleCommand` | `bonfire.Command` values returned from `pact.HasCommands` | [bonfire](../../modules/bonfire/README.md) |
|
| Artisan commands and `registerConsoleCommand` | `bonfire.Command` values returned from `pact.HasCommands` | [Writing commands](../console/writing-commands.md), [bonfire](../../modules/bonfire/README.md) |
|
||||||
| Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](../services/jobs.md), [conga](../../modules/conga/README.md) |
|
| Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](../services/jobs.md), [conga](../../modules/conga/README.md) |
|
||||||
| `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [pact](../../modules/pact/README.md) |
|
| `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](../plugins/scheduling.md), [pact](../../modules/pact/README.md) |
|
||||||
| Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [postcard](../../modules/postcard/README.md) |
|
| Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](../services/mail.md), [postcard](../../modules/postcard/README.md) |
|
||||||
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries | [cabana](../../modules/cabana/README.md) |
|
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
|
||||||
| Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [lighthouse](../../modules/lighthouse/README.md) |
|
| Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) |
|
||||||
| Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [beachcomber](../../modules/beachcomber/README.md) |
|
| Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) |
|
||||||
| The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [fetchguard](../../modules/fetchguard/README.md) |
|
| The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) |
|
||||||
|
|
||||||
## What is not provided
|
## What is not provided
|
||||||
|
|
||||||
@@ -44,16 +44,16 @@ SummerCMS does not port the WinterCMS frontend or the PHP helpers that Go alread
|
|||||||
|
|
||||||
| WinterCMS | SummerCMS |
|
| WinterCMS | SummerCMS |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| CMS pages, themes, layouts and partials | Not provided. The frontend is a separate application that calls the JSON API. |
|
| CMS pages, themes, layouts and partials | Not provided. The frontend is a separate application that calls the JSON API; see [Frontend and AJAX](../services/frontend-and-ajax.md). |
|
||||||
| Components | Not provided. Write an HTTP handler and declare its route through `pact.HasRoutes`. |
|
| Components | Not provided. Write an HTTP handler and declare its route through `pact.HasRoutes`; see [Frontend and AJAX](../services/frontend-and-ajax.md). |
|
||||||
| The AJAX framework and Snowboard | Not provided. The frontend calls the JSON API and subscribes to realtime channels. |
|
| The AJAX framework and Snowboard | Not provided. The frontend calls the JSON API and subscribes to realtime channels; see [Frontend and AJAX](../services/frontend-and-ajax.md). |
|
||||||
| The media manager | Not provided. Store uploads as model attachments. |
|
| The media manager | Not provided. Store uploads as model attachments; see [Attachments](../database/attachments.md). |
|
||||||
| Import and export in backend lists | Not provided. |
|
| Import and export in backend lists | Not provided. |
|
||||||
| Record sorting (the Reorder behaviour) | Not provided. |
|
| Record sorting (the Reorder behaviour) | Not provided. |
|
||||||
| Collections | Not provided. Use Go slices and the `slices` and `maps` packages. |
|
| Collections | Not provided. Use Go slices and the `slices` and `maps` packages. |
|
||||||
| Behaviours and dynamic class extension | Not provided. Use Go interfaces and composition. |
|
| Behaviours and dynamic class extension | Not provided. Use Go interfaces and composition. |
|
||||||
| Cache | Not provided. Use the Go standard library or a service another plugin publishes. |
|
| Cache | Not provided. Use the Go standard library or a service another plugin publishes. |
|
||||||
| Session | Not provided. The API is stateless and authenticates with tokens. |
|
| Session | Not provided. The API is stateless and authenticates with tokens; see [Frontend and AJAX](../services/frontend-and-ajax.md). |
|
||||||
|
|
||||||
## Plugin.php in Go
|
## Plugin.php in Go
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ sections:
|
|||||||
title: Architecture
|
title: Architecture
|
||||||
- name: plugins
|
- name: plugins
|
||||||
title: Plugins
|
title: Plugins
|
||||||
|
- name: backend
|
||||||
|
title: Backend
|
||||||
- name: database
|
- name: database
|
||||||
title: Database
|
title: Database
|
||||||
- name: services
|
- name: services
|
||||||
|
|||||||
258
modules/beachcomber/example_test.go
Normal file
258
modules/beachcomber/example_test.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package beachcomber_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Post is the acme.blog post model. It implements beachcomber.Searchable
|
||||||
|
// without importing beachcomber.
|
||||||
|
type Post struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
BlogID uint `gorm:"column:blog_id"`
|
||||||
|
Title string `gorm:"column:title"`
|
||||||
|
Published bool `gorm:"column:published"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Post) TableName() string { return "acme_blog_posts" }
|
||||||
|
|
||||||
|
// SearchableAs is the index name, before search.prefix.
|
||||||
|
func (Post) SearchableAs() string { return "acme_blog_posts" }
|
||||||
|
|
||||||
|
// ShouldBeSearchable keeps drafts out of the index.
|
||||||
|
func (p *Post) ShouldBeSearchable() bool { return p.Published }
|
||||||
|
|
||||||
|
// ToSearchableArray builds the document from the committed row.
|
||||||
|
func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) {
|
||||||
|
return map[string]any{
|
||||||
|
"id": strconv.FormatUint(uint64(p.ID), 10),
|
||||||
|
"blog_id": int64(p.BlogID),
|
||||||
|
"title": p.Title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryEngine is a toy engine: it matches Q against the title and ignores
|
||||||
|
// FilterBy, so its answers are loose candidates, as a stale index's are.
|
||||||
|
type memoryEngine struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
docs map[string]map[string]map[string]any // index -> id -> document
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *memoryEngine) Name() string { return "acme-memory" }
|
||||||
|
func (e *memoryEngine) Configured() bool { return true }
|
||||||
|
|
||||||
|
func (e *memoryEngine) Upsert(ctx context.Context, index string, schema map[string]any, docs []map[string]any) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.docs[index] == nil {
|
||||||
|
e.docs[index] = map[string]map[string]any{}
|
||||||
|
}
|
||||||
|
for _, d := range docs {
|
||||||
|
e.docs[index][fmt.Sprint(d["id"])] = d
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *memoryEngine) Delete(ctx context.Context, index string, ids []string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
for _, id := range ids {
|
||||||
|
delete(e.docs[index], id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *memoryEngine) Flush(ctx context.Context, index string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
delete(e.docs, index)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *memoryEngine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
ids := []string{}
|
||||||
|
for id, d := range e.docs[index] {
|
||||||
|
if strings.Contains(strings.ToLower(fmt.Sprint(d["title"])), strings.ToLower(q.Q)) {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(ids)
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var memory = &memoryEngine{docs: map[string]map[string]map[string]any{}}
|
||||||
|
|
||||||
|
func ExampleFrom() {
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = cfg.Set("search.prefix", "staging_")
|
||||||
|
svc, err := beachcomber.From(backpack.New(cfg)) // search.driver defaults to null
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(svc.Engine().Name(), svc.Engine().Configured(), svc.IndexName(&Post{}))
|
||||||
|
ids, err := svc.Engine().SearchIDs(context.Background(), svc.IndexName(&Post{}), beachcomber.Query{Q: "go"})
|
||||||
|
fmt.Println(ids, err)
|
||||||
|
|
||||||
|
_ = cfg.Set("search.driver", "elastic")
|
||||||
|
_, err = beachcomber.From(backpack.New(cfg))
|
||||||
|
fmt.Println(err != nil)
|
||||||
|
// Output:
|
||||||
|
// null false staging_acme_blog_posts
|
||||||
|
// [] <nil>
|
||||||
|
// true
|
||||||
|
}
|
||||||
|
|
||||||
|
// installGate turns search sync on only while the blog's search setting is
|
||||||
|
// on; a failed read counts as off.
|
||||||
|
func installGate(svc *beachcomber.Service) {
|
||||||
|
// docs:start gate
|
||||||
|
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
|
||||||
|
var enabled bool
|
||||||
|
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
|
||||||
|
return err == nil && enabled
|
||||||
|
}))
|
||||||
|
// docs:end gate
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchPosts answers a search in one blog.
|
||||||
|
func searchPosts(ctx context.Context, svc *beachcomber.Service, db *gorm.DB, blogID uint, term string) ([]Post, error) {
|
||||||
|
// docs:start search
|
||||||
|
ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&Post{}), beachcomber.Query{
|
||||||
|
Q: term,
|
||||||
|
QueryBy: []string{"title"},
|
||||||
|
FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The ids are candidates from an index that may be stale or loosely
|
||||||
|
// filtered: re-check every one in SQL before exposing a row.
|
||||||
|
var posts []Post
|
||||||
|
err = db.WithContext(ctx).
|
||||||
|
Where("id IN ? AND blog_id = ? AND published", ids, blogID).
|
||||||
|
Order("id").
|
||||||
|
Find(&posts).Error
|
||||||
|
return posts, err
|
||||||
|
// docs:end search
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocsSearch runs the gate and search regions of the Search page on the
|
||||||
|
// package's Postgres harness.
|
||||||
|
func TestDocsSearch(t *testing.T) {
|
||||||
|
app, db := beachcomber.DocsApp(t, nil)
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`CREATE TABLE acme_blog_posts (id SERIAL PRIMARY KEY, blog_id INTEGER NOT NULL, title TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT FALSE)`,
|
||||||
|
`CREATE TABLE acme_blog_settings (id INTEGER PRIMARY KEY, search_enabled BOOLEAN NOT NULL)`,
|
||||||
|
`INSERT INTO acme_blog_settings VALUES (1, TRUE)`,
|
||||||
|
} {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svc, err := beachcomber.From(app)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// An application selects a registered engine with search.driver; the
|
||||||
|
// test installs the toy engine directly, so the global engine list the
|
||||||
|
// package's own tests check stays unchanged.
|
||||||
|
beachcomber.DocsUseEngine(svc, memory)
|
||||||
|
installGate(svc)
|
||||||
|
ctx := t.Context()
|
||||||
|
indexed := func() int {
|
||||||
|
memory.mu.Lock()
|
||||||
|
defer memory.mu.Unlock()
|
||||||
|
return len(memory.docs[svc.IndexName(&Post{})])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A committed write is indexed after commit; a rolled-back one never is.
|
||||||
|
create := func(p *Post, fail bool) error {
|
||||||
|
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(p).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fail {
|
||||||
|
return fmt.Errorf("rolled back")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hello := &Post{BlogID: 7, Title: "Hello Go", Published: true}
|
||||||
|
if err := create(hello, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := create(&Post{BlogID: 7, Title: "Go rolled back", Published: true}, true); err == nil {
|
||||||
|
t.Fatal("rolled-back create succeeded")
|
||||||
|
}
|
||||||
|
if err := create(&Post{BlogID: 8, Title: "Go elsewhere", Published: true}, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := indexed(); n != 2 {
|
||||||
|
t.Fatalf("indexed %d documents, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine answers both blogs; SQL keeps blog 7's post only.
|
||||||
|
posts, err := searchPosts(ctx, svc, db, 7, "go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(posts) != 1 || posts[0].ID != hello.ID {
|
||||||
|
t.Fatalf("search = %+v", posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write in a plain GORM transaction is not synced, so the index keeps
|
||||||
|
// the stale document; the SQL re-check still hides the draft.
|
||||||
|
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
return tx.Model(hello).Update("published", false).Error
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := indexed(); n != 2 {
|
||||||
|
t.Fatalf("indexed %d documents after an unsynced write, want 2", n)
|
||||||
|
}
|
||||||
|
if posts, err = searchPosts(ctx, svc, db, 7, "go"); err != nil || len(posts) != 0 {
|
||||||
|
t.Fatalf("search after unpublish = %+v, %v", posts, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the gate off nothing is sent.
|
||||||
|
if err := db.Exec(`UPDATE acme_blog_settings SET search_enabled = FALSE`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := create(&Post{BlogID: 7, Title: "Go quietly", Published: true}, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := indexed(); n != 2 {
|
||||||
|
t.Fatalf("indexed %d documents with the gate off, want 2", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocsDeclarations checks the Searchable methods the Search page shows.
|
||||||
|
func TestDocsDeclarations(t *testing.T) {
|
||||||
|
var _ beachcomber.Searchable = &Post{}
|
||||||
|
p := &Post{ID: 5, BlogID: 7, Title: "Hello", Published: true}
|
||||||
|
if p.SearchableAs() != "acme_blog_posts" || !p.ShouldBeSearchable() {
|
||||||
|
t.Fatalf("SearchableAs %q, ShouldBeSearchable %v", p.SearchableAs(), p.ShouldBeSearchable())
|
||||||
|
}
|
||||||
|
doc, err := p.ToSearchableArray(t.Context(), nil)
|
||||||
|
if err != nil || doc["id"] != "5" || doc["blog_id"] != int64(7) {
|
||||||
|
t.Fatalf("ToSearchableArray = %v, %v", doc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
modules/beachcomber/export_docs_test.go
Normal file
47
modules/beachcomber/export_docs_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package beachcomber
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DocsApp returns an app with the given config settings on a fresh migrated
|
||||||
|
// database of this package's Postgres harness, with the database published,
|
||||||
|
// for the database-backed docs examples in example_test.go. It skips under
|
||||||
|
// -short and fails when the harness has no database, like the package's
|
||||||
|
// other database tests.
|
||||||
|
func DocsApp(t *testing.T, settings map[string]any) (*backpack.App, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
db, dsn := migratedDB(t)
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cfg.Set("database.dsn", dsn); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for k, v := range settings {
|
||||||
|
if err := cfg.Set(k, v); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
gdb, err := lagoon.Use(t.Context(), db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := lagoon.Publish(app, db, gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app, gdb
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocsUseEngine replaces the engine of svc, for docs examples whose toy
|
||||||
|
// engine must not join the global engine registry.
|
||||||
|
func DocsUseEngine(svc *Service, e Engine) {
|
||||||
|
svc.engine = e
|
||||||
|
}
|
||||||
49
modules/beachcomber/typesense/example_test.go
Normal file
49
modules/beachcomber/typesense/example_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package typesense_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/beachcomber/typesense"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleEngine_SearchIDs() {
|
||||||
|
// A stand-in Typesense node.
|
||||||
|
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
fmt.Println(r.Method, r.URL.Path, "key sent:", r.Header.Get("X-TYPESENSE-API-KEY") != "")
|
||||||
|
fmt.Println("q", q.Get("q"), "query_by", q.Get("query_by"), "filter_by", q.Get("filter_by"))
|
||||||
|
fmt.Fprint(w, `{"hits":[{"document":{"id":"3"}},{"document":{"id":"1"}}]}`)
|
||||||
|
}))
|
||||||
|
defer node.Close()
|
||||||
|
u, _ := url.Parse(node.URL)
|
||||||
|
port, _ := strconv.Atoi(u.Port())
|
||||||
|
|
||||||
|
engine := typesense.New(typesense.Config{
|
||||||
|
APIKey: "test-only-key", // SUMMER_SEARCH__TYPESENSE__API_KEY
|
||||||
|
Host: u.Hostname(),
|
||||||
|
Port: port,
|
||||||
|
Protocol: "http",
|
||||||
|
ConnectionTimeout: 2 * time.Second,
|
||||||
|
})
|
||||||
|
ids, err := engine.SearchIDs(context.Background(), "acme_blog_posts", beachcomber.Query{
|
||||||
|
Q: "go",
|
||||||
|
QueryBy: []string{"title"},
|
||||||
|
FilterBy: "blog_id:=7",
|
||||||
|
})
|
||||||
|
fmt.Println(ids, err)
|
||||||
|
|
||||||
|
// Without an API key the engine is not configured and nothing is sent.
|
||||||
|
fmt.Println(typesense.New(typesense.Config{}).Configured())
|
||||||
|
// Output:
|
||||||
|
// GET /collections/acme_blog_posts/documents/search key sent: true
|
||||||
|
// q go query_by title filter_by blog_id:=7
|
||||||
|
// [3 1] <nil>
|
||||||
|
// false
|
||||||
|
}
|
||||||
133
modules/cabana/example_controller_test.go
Normal file
133
modules/cabana/example_controller_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Post is the model behind the acme.blog posts controller.
|
||||||
|
type Post struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Title string `gorm:"column:title"`
|
||||||
|
Slug string `gorm:"column:slug"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
Published bool `gorm:"column:published"`
|
||||||
|
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||||
|
Content string `gorm:"column:content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Post) TableName() string { return "acme_blog_posts" }
|
||||||
|
|
||||||
|
// Fillable lists the columns the admin form may write.
|
||||||
|
func (Post) Fillable() []string {
|
||||||
|
return []string{"title", "slug", "status", "published", "content"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsController is the admin controller for posts: the Go form of a
|
||||||
|
// WinterCMS controller with the List and Form behaviours.
|
||||||
|
type PostsController struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminController = PostsController{}
|
||||||
|
_ pact.AdminRecordSource = PostsController{}
|
||||||
|
_ pact.AdminPermissioned = PostsController{}
|
||||||
|
_ pact.HasAdminControllers = (*BlogPlugin)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the controller ID; its admin API path is /acme/blog/posts.
|
||||||
|
func (PostsController) ID() string { return "acme.blog.posts" }
|
||||||
|
|
||||||
|
// ModelName must equal modelClass in the YAML.
|
||||||
|
func (PostsController) ModelName() string { return "Post" }
|
||||||
|
|
||||||
|
// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
|
||||||
|
func (PostsController) ConfigDir() string { return "controllers/posts" }
|
||||||
|
|
||||||
|
// NewRecord returns the model the generic admin handlers query.
|
||||||
|
func (PostsController) NewRecord() any { return &Post{} }
|
||||||
|
|
||||||
|
// RequiredPermissions are checked before any schema or query.
|
||||||
|
func (PostsController) RequiredPermissions() []string {
|
||||||
|
return []string{"acme.blog.access_posts"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlogSettings is the singleton row behind the plugin's settings page.
|
||||||
|
type BlogSettings struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
PostsPerPage int `gorm:"column:posts_per_page"`
|
||||||
|
CommentsEnabled bool `gorm:"column:comments_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BlogSettings) TableName() string { return "acme_blog_settings" }
|
||||||
|
|
||||||
|
func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }
|
||||||
|
|
||||||
|
// Rules validates a settings save, as a WinterCMS settings model's $rules.
|
||||||
|
func (BlogSettings) Rules() map[string]string {
|
||||||
|
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
|
||||||
|
type BlogPlugin struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminAssets = (*BlogPlugin)(nil)
|
||||||
|
_ pact.HasPermissions = (*BlogPlugin)(nil)
|
||||||
|
_ pact.HasNavigation = (*BlogPlugin)(nil)
|
||||||
|
_ pact.HasSettings = (*BlogPlugin)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *BlogPlugin) ID() string { return "acme.blog" }
|
||||||
|
func (p *BlogPlugin) Requires() []string { return nil }
|
||||||
|
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
|
||||||
|
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
|
||||||
|
|
||||||
|
// AdminControllers registers the plugin's admin controllers.
|
||||||
|
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
|
||||||
|
return []pact.AdminController{PostsController{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
|
||||||
|
// plugin returns an embed.FS; the example reads the same files from testdata.
|
||||||
|
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }
|
||||||
|
|
||||||
|
// Permissions replaces registerPermissions().
|
||||||
|
func (p *BlogPlugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{
|
||||||
|
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
|
||||||
|
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation replaces registerNavigation().
|
||||||
|
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
|
||||||
|
return []pact.NavigationItem{{
|
||||||
|
Code: "blog",
|
||||||
|
Label: "acme.blog::lang.plugin.name",
|
||||||
|
Icon: "icon-pencil",
|
||||||
|
Permissions: []string{"acme.blog.access_posts"},
|
||||||
|
Controller: "acme.blog.posts",
|
||||||
|
SideMenu: []pact.NavigationItem{
|
||||||
|
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings replaces registerSettings().
|
||||||
|
func (p *BlogPlugin) Settings() []pact.SettingsItem {
|
||||||
|
return []pact.SettingsItem{{
|
||||||
|
Code: "blog",
|
||||||
|
Label: "acme.blog::lang.settings.label",
|
||||||
|
Description: "acme.blog::lang.settings.description",
|
||||||
|
Category: "acme.blog::lang.plugin.name",
|
||||||
|
Icon: "icon-pencil",
|
||||||
|
Model: "BlogSettings",
|
||||||
|
Permissions: []string{"acme.blog.access_settings"},
|
||||||
|
Form: "models/settings/fields.yaml",
|
||||||
|
NewModel: func() any { return &BlogSettings{} },
|
||||||
|
}}
|
||||||
|
}
|
||||||
103
modules/cabana/example_test.go
Normal file
103
modules/cabana/example_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleCompileList() {
|
||||||
|
// The plugin embeds its controllers/ and models/ trees; the example reads
|
||||||
|
// the same files from testdata.
|
||||||
|
fsys := os.DirFS("testdata/docs")
|
||||||
|
list, err := cabana.CompileList("acme.blog", PostsController{}, fsys)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(list.Title, list.RecordsPerPage, list.PerPageOptions, list.ToolbarButtons)
|
||||||
|
for _, c := range list.Columns {
|
||||||
|
fmt.Printf("column %s type=%q searchable=%v sortable=%v\n", c.Key, c.Type, c.Searchable, c.Sortable)
|
||||||
|
}
|
||||||
|
for _, f := range list.Filters {
|
||||||
|
fmt.Printf("filter %s type=%s column=%s\n", f.Name, f.Type, f.Column)
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// acme.blog::lang.posts.title 20 [20 50 100] [create delete]
|
||||||
|
// column title type="" searchable=true sortable=true
|
||||||
|
// column published type="switch" searchable=false sortable=true
|
||||||
|
// column published_at type="datetime" searchable=false sortable=true
|
||||||
|
// filter published type=switch column=published
|
||||||
|
// filter published_at type=daterange column=published_at
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleCompileForm() {
|
||||||
|
fsys := os.DirFS("testdata/docs")
|
||||||
|
form, err := cabana.CompileForm("acme.blog", PostsController{}, fsys)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range form.Fields {
|
||||||
|
fmt.Printf("%s %s span=%q tab=%q required=%v options=%d\n", f.Name, f.Type, f.Span, f.Tab, f.Required, len(f.Options))
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// title text span="left" tab="" required=true options=0
|
||||||
|
// slug text span="right" tab="" required=false options=0
|
||||||
|
// status dropdown span="left" tab="" required=false options=2
|
||||||
|
// published switch span="right" tab="" required=false options=0
|
||||||
|
// content textarea span="" tab="acme.blog::lang.posts.tab_content" required=false options=0
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleActivate() {
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
|
||||||
|
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
|
||||||
|
_ = cfg.Set("backend.uri", "/admin")
|
||||||
|
|
||||||
|
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(routes.Prefix)
|
||||||
|
|
||||||
|
editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
|
||||||
|
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
|
||||||
|
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
|
||||||
|
// Output:
|
||||||
|
// /admin
|
||||||
|
// true
|
||||||
|
// false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocsDeclarations checks the plugin declarations the Backend pages show.
|
||||||
|
func TestDocsDeclarations(t *testing.T) {
|
||||||
|
p := &BlogPlugin{}
|
||||||
|
if n := len(p.Permissions()); n != 2 {
|
||||||
|
t.Fatalf("Permissions() = %d entries", n)
|
||||||
|
}
|
||||||
|
if nav := p.Navigation(); len(nav) != 1 || nav[0].Controller != "acme.blog.posts" {
|
||||||
|
t.Fatalf("Navigation() = %+v", nav)
|
||||||
|
}
|
||||||
|
settings := p.Settings()
|
||||||
|
if len(settings) != 1 {
|
||||||
|
t.Fatalf("Settings() = %+v", settings)
|
||||||
|
}
|
||||||
|
if _, ok := settings[0].NewModel().(*BlogSettings); !ok {
|
||||||
|
t.Fatal("settings model is not *BlogSettings")
|
||||||
|
}
|
||||||
|
if rule := (BlogSettings{}).Rules()["posts_per_page"]; rule == "" {
|
||||||
|
t.Fatal("BlogSettings has no posts_per_page rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
9
modules/cabana/testdata/docs/controllers/posts/config_filter.yaml
vendored
Normal file
9
modules/cabana/testdata/docs/controllers/posts/config_filter.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
scopes:
|
||||||
|
published:
|
||||||
|
label: acme.blog::lang.posts.published
|
||||||
|
type: switch
|
||||||
|
column: published
|
||||||
|
published_at:
|
||||||
|
label: acme.blog::lang.posts.published_at
|
||||||
|
type: daterange
|
||||||
|
column: published_at
|
||||||
10
modules/cabana/testdata/docs/controllers/posts/config_form.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/controllers/posts/config_form.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: acme.blog::lang.posts.form
|
||||||
|
form: ~/plugins/acme/blog/models/post/fields.yaml
|
||||||
|
modelClass: Post
|
||||||
|
defaultRedirect: acme/blog/posts
|
||||||
|
create:
|
||||||
|
redirect: acme/blog/posts/update/:id
|
||||||
|
redirectClose: acme/blog/posts
|
||||||
|
update:
|
||||||
|
redirect: acme/blog/posts
|
||||||
|
redirectClose: acme/blog/posts
|
||||||
15
modules/cabana/testdata/docs/controllers/posts/config_list.yaml
vendored
Normal file
15
modules/cabana/testdata/docs/controllers/posts/config_list.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
list: ~/plugins/acme/blog/models/post/columns.yaml
|
||||||
|
modelClass: Post
|
||||||
|
title: acme.blog::lang.posts.title
|
||||||
|
recordUrl: acme/blog/posts/update/:id
|
||||||
|
recordsPerPage: 20
|
||||||
|
perPageOptions: [20, 50, 100]
|
||||||
|
showCheckboxes: true
|
||||||
|
defaultSort:
|
||||||
|
column: published_at
|
||||||
|
direction: desc
|
||||||
|
filter: config_filter.yaml
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete]
|
||||||
|
search:
|
||||||
|
prompt: backend::lang.list.search_prompt
|
||||||
10
modules/cabana/testdata/docs/models/post/columns.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/models/post/columns.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
columns:
|
||||||
|
title:
|
||||||
|
label: acme.blog::lang.posts.title_column
|
||||||
|
searchable: true
|
||||||
|
published:
|
||||||
|
label: acme.blog::lang.posts.published
|
||||||
|
type: switch
|
||||||
|
published_at:
|
||||||
|
label: acme.blog::lang.posts.published_at
|
||||||
|
type: datetime
|
||||||
28
modules/cabana/testdata/docs/models/post/fields.yaml
vendored
Normal file
28
modules/cabana/testdata/docs/models/post/fields.yaml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
fields:
|
||||||
|
title:
|
||||||
|
label: acme.blog::lang.posts.title_column
|
||||||
|
type: text
|
||||||
|
span: left
|
||||||
|
required: true
|
||||||
|
slug:
|
||||||
|
label: acme.blog::lang.posts.slug
|
||||||
|
type: text
|
||||||
|
span: right
|
||||||
|
context: update
|
||||||
|
comment: acme.blog::lang.posts.slug_comment
|
||||||
|
status:
|
||||||
|
label: acme.blog::lang.posts.status
|
||||||
|
type: dropdown
|
||||||
|
span: left
|
||||||
|
options:
|
||||||
|
draft: acme.blog::lang.posts.draft
|
||||||
|
published: acme.blog::lang.posts.published
|
||||||
|
published:
|
||||||
|
label: acme.blog::lang.posts.published
|
||||||
|
type: switch
|
||||||
|
span: right
|
||||||
|
content:
|
||||||
|
label: acme.blog::lang.posts.content
|
||||||
|
type: textarea
|
||||||
|
size: large
|
||||||
|
tab: acme.blog::lang.posts.tab_content
|
||||||
10
modules/cabana/testdata/docs/models/settings/fields.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/models/settings/fields.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
fields:
|
||||||
|
posts_per_page:
|
||||||
|
label: acme.blog::lang.settings.posts_per_page
|
||||||
|
type: number
|
||||||
|
span: left
|
||||||
|
default: 10
|
||||||
|
comments_enabled:
|
||||||
|
label: acme.blog::lang.settings.comments_enabled
|
||||||
|
type: switch
|
||||||
|
span: right
|
||||||
52
modules/fetchguard/example_test.go
Normal file
52
modules/fetchguard/example_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package fetchguard_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleFetch() {
|
||||||
|
ctx := context.Background()
|
||||||
|
// Only the application's image host, at most 5 MiB within 5 seconds.
|
||||||
|
images := fetchguard.Policy{
|
||||||
|
Mode: fetchguard.AllowHostsMode,
|
||||||
|
AllowHosts: []string{"images.example.com"},
|
||||||
|
MaxBytes: 5 << 20,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
// Any public host, for a URL a user pasted.
|
||||||
|
public := fetchguard.Policy{Mode: fetchguard.PublicOnlyMode}
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
url string
|
||||||
|
policy fetchguard.Policy
|
||||||
|
}{
|
||||||
|
{"http://images.example.com/cover.jpg", images},
|
||||||
|
{"https://cdn.attacker.example/cover.jpg", images},
|
||||||
|
{"https://127.0.0.1/admin", public},
|
||||||
|
{"https://169.254.169.254/latest/meta-data/", public},
|
||||||
|
{"https://[::ffff:10.0.0.1]/", public},
|
||||||
|
{"https://%zz", public},
|
||||||
|
} {
|
||||||
|
// The last argument is the application's config (app.Config), for
|
||||||
|
// limits the policy leaves at zero; nil uses the framework defaults.
|
||||||
|
_, err := fetchguard.Fetch(ctx, c.url, c.policy, nil)
|
||||||
|
var fe *fetchguard.Error
|
||||||
|
if errors.As(err, &fe) {
|
||||||
|
fmt.Println(fe.Reason, c.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(fetchguard.Defaults())
|
||||||
|
// Output:
|
||||||
|
// scheme http://images.example.com/cover.jpg
|
||||||
|
// invalid_url https://cdn.attacker.example/cover.jpg
|
||||||
|
// private_ip https://127.0.0.1/admin
|
||||||
|
// private_ip https://169.254.169.254/latest/meta-data/
|
||||||
|
// private_ip https://[::ffff:10.0.0.1]/
|
||||||
|
// invalid_url https://%zz
|
||||||
|
// 10485760 10s
|
||||||
|
}
|
||||||
112
modules/flare/example_test.go
Normal file
112
modules/flare/example_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package flare_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/flare"
|
||||||
|
)
|
||||||
|
|
||||||
|
// browserSubscription stands in for the PushSubscription a browser posts to
|
||||||
|
// the application: an endpoint and the subscriber's p256dh and auth keys.
|
||||||
|
func browserSubscription(endpoint string) (flare.Subscription, error) {
|
||||||
|
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return flare.Subscription{}, err
|
||||||
|
}
|
||||||
|
auth := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(auth); err != nil {
|
||||||
|
return flare.Subscription{}, err
|
||||||
|
}
|
||||||
|
enc := base64.RawURLEncoding
|
||||||
|
return flare.Subscription{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
P256dh: enc.EncodeToString(key.PublicKey().Bytes()),
|
||||||
|
Auth: enc.EncodeToString(auth),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleVAPIDPusher_Send() {
|
||||||
|
// A stand-in push service: 201 for a live subscription, 410 for one the
|
||||||
|
// browser dropped.
|
||||||
|
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/gone" {
|
||||||
|
w.WriteHeader(http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency"))
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
defer push.Close()
|
||||||
|
|
||||||
|
keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := flare.Config{
|
||||||
|
Enabled: true,
|
||||||
|
PublicKey: keys.PublicKey,
|
||||||
|
PrivateKey: keys.PrivateKey,
|
||||||
|
Subject: "mailto:admin@example.com",
|
||||||
|
TTL: time.Hour,
|
||||||
|
AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services
|
||||||
|
}
|
||||||
|
// The test server's client trusts its certificate.
|
||||||
|
pusher := flare.NewVAPIDPusher(cfg, push.Client())
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
|
||||||
|
for _, endpoint := range []string{
|
||||||
|
push.URL + "/live",
|
||||||
|
push.URL + "/gone",
|
||||||
|
"https://push.attacker.example/steal",
|
||||||
|
"http://127.0.0.1/plain",
|
||||||
|
} {
|
||||||
|
sub, err := browserSubscription(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"})
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
fmt.Println("sent")
|
||||||
|
case errors.Is(err, flare.ErrSubscriptionGone):
|
||||||
|
fmt.Println("gone: delete the subscription")
|
||||||
|
case errors.Is(err, flare.ErrEndpointNotAllowed):
|
||||||
|
fmt.Println("refused before connecting")
|
||||||
|
default:
|
||||||
|
fmt.Println("error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Formatting the keys never prints the private key.
|
||||||
|
fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey))
|
||||||
|
// Output:
|
||||||
|
// push service got aes128gcm 3600 normal
|
||||||
|
// sent
|
||||||
|
// gone: delete the subscription
|
||||||
|
// refused before connecting
|
||||||
|
// refused before connecting
|
||||||
|
// false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleHostAllowed() {
|
||||||
|
allowed := []string{"fcm.googleapis.com", "*.push.apple.com"}
|
||||||
|
for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} {
|
||||||
|
fmt.Println(host, flare.HostAllowed(host, allowed))
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// fcm.googleapis.com true
|
||||||
|
// api.push.apple.com true
|
||||||
|
// push.apple.com false
|
||||||
|
// evil.example false
|
||||||
|
}
|
||||||
102
modules/lighthouse/centrifugo/example_test.go
Normal file
102
modules/lighthouse/centrifugo/example_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package centrifugo_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newApp returns an app whose config selects a realtime driver.
|
||||||
|
func newApp(settings map[string]any) (*backpack.App, error) {
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, v := range settings {
|
||||||
|
if err := cfg.Set(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backpack.New(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleProxyHandler() {
|
||||||
|
svc, err := lighthouse.From(backpack.New(nil))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Members of blog 7 may subscribe to its channels.
|
||||||
|
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
|
||||||
|
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
|
||||||
|
if userID == 42 && lighthouse.ChannelID(channel) == 7 {
|
||||||
|
return lighthouse.Allowed(nil)
|
||||||
|
}
|
||||||
|
return lighthouse.Denied("not a member of the blog")
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// realtime.centrifugo.proxy_secret; set it through the environment.
|
||||||
|
proxy := centrifugo.ProxyHandler(svc, centrifugo.Config{ProxySecret: "test-only-proxy-secret"})
|
||||||
|
|
||||||
|
// What Centrifugo posts to the subscribe proxy.
|
||||||
|
subscribe := func(secret, user, channel string) {
|
||||||
|
body := fmt.Sprintf(`{"client":"c1","user":%q,"channel":%q}`, user, channel)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/realtime/subscribe", strings.NewReader(body))
|
||||||
|
req.Header.Set("X-Centrifugo-Secret", secret)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
proxy.ServeHTTP(rec, req)
|
||||||
|
fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||||
|
}
|
||||||
|
subscribe("test-only-proxy-secret", "42", "blog:7")
|
||||||
|
subscribe("test-only-proxy-secret", "5", "blog:7")
|
||||||
|
subscribe("wrong-secret", "42", "blog:7")
|
||||||
|
// Output:
|
||||||
|
// 200 {"result":{"info":[]}}
|
||||||
|
// 200 {"error":{"code":403,"message":"Access denied"}}
|
||||||
|
// 200 {"error":{"code":403,"message":"Access denied"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleDriver_Routes() {
|
||||||
|
app, err := newApp(map[string]any{"realtime.driver": "centrifugo"})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := lighthouse.From(app)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// In a plugin's Routes method, r is the router the plugin receives.
|
||||||
|
r := surf.New(nil)
|
||||||
|
err = lighthouse.Mount(r, svc.Driver(), lighthouse.Surfaces{
|
||||||
|
UserAuth: surf.Use("acme.auth"),
|
||||||
|
Middleware: surf.Use("throttle:60,1"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, rt := range r.Routes() {
|
||||||
|
fmt.Println(rt.Method, rt.Pattern, rt.Middleware, "raw:", rt.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user route without a guard is refused.
|
||||||
|
err = lighthouse.Mount(surf.New(nil), svc.Driver(), lighthouse.Surfaces{})
|
||||||
|
fmt.Println(err != nil)
|
||||||
|
// Output:
|
||||||
|
// GET /api/realtime/token [acme.auth throttle:60,1] raw: false
|
||||||
|
// POST /api/realtime/subscribe [throttle:60,1] raw: true
|
||||||
|
// true
|
||||||
|
}
|
||||||
196
modules/lighthouse/example_test.go
Normal file
196
modules/lighthouse/example_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package lighthouse_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newApp returns an app whose config selects a realtime driver.
|
||||||
|
func newApp(settings map[string]any) (*backpack.App, error) {
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, v := range settings {
|
||||||
|
if err := cfg.Set(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backpack.New(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMember stands in for the application's own membership query.
|
||||||
|
func isMember(ctx context.Context, userID uint, blogID int64) bool {
|
||||||
|
return userID == 42 && blogID == 7
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleRegistry_Register() {
|
||||||
|
app, err := newApp(map[string]any{"realtime.driver": "memory"})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := lighthouse.From(app)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// blog:{entity}:{id} channels are open to members of the blog only. The
|
||||||
|
// authorizer runs on every subscribe; nothing is cached.
|
||||||
|
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
|
||||||
|
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
|
||||||
|
if isMember(ctx, userID, lighthouse.ChannelID(channel)) {
|
||||||
|
return lighthouse.Allowed(nil)
|
||||||
|
}
|
||||||
|
return lighthouse.Denied("not a member of the blog")
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sub := range []struct {
|
||||||
|
user uint
|
||||||
|
channel string
|
||||||
|
}{{42, "blog:7"}, {42, "blog:8"}, {42, "presence:blog:7"}, {42, "shop:7"}} {
|
||||||
|
ns, presence := lighthouse.ParseChannel(sub.channel)
|
||||||
|
auth, ok := svc.Registry().Get(ns)
|
||||||
|
if !ok {
|
||||||
|
fmt.Println(sub.channel, "no authorizer")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res := auth.Authorize(context.Background(), sub.user, sub.channel)
|
||||||
|
fmt.Printf("%d %s namespace=%s presence=%v allowed=%v reason=%q\n", sub.user, sub.channel, ns, presence, res.Allowed, res.Reason())
|
||||||
|
}
|
||||||
|
fmt.Println(lighthouse.ChannelID("blog:12abc"), lighthouse.FormatChannels("acme", []string{"Blog:7"}))
|
||||||
|
// Output:
|
||||||
|
// 42 blog:7 namespace=blog presence=false allowed=true reason=""
|
||||||
|
// 42 blog:8 namespace=blog presence=false allowed=false reason="not a member of the blog"
|
||||||
|
// 42 presence:blog:7 namespace=blog presence=true allowed=false reason="not a member of the blog"
|
||||||
|
// shop:7 no authorizer
|
||||||
|
// 12 [acme:blog:7]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post is the acme.blog post model. It knows nothing about realtime.
|
||||||
|
type Post struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
BlogID uint `gorm:"column:blog_id"`
|
||||||
|
Title string `gorm:"column:title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Post) TableName() string { return "acme_blog_posts" }
|
||||||
|
|
||||||
|
// bindPosts registers the broadcast contract of Post from the plugin's Boot.
|
||||||
|
func bindPosts(svc *lighthouse.Service) error {
|
||||||
|
// docs:start bind
|
||||||
|
return lighthouse.Bind[Post](svc, lighthouse.Binding[Post]{
|
||||||
|
Alias: "blog.post",
|
||||||
|
Channels: func(ctx context.Context, tx *gorm.DB, p *Post) ([]string, error) {
|
||||||
|
return []string{"blog:" + strconv.FormatUint(uint64(p.BlogID), 10)}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// docs:end bind
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishPost creates a post; its broadcast is published after commit.
|
||||||
|
func publishPost(ctx context.Context, db *gorm.DB, fail bool) error {
|
||||||
|
// docs:start write
|
||||||
|
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&Post{BlogID: 7, Title: "Hello"}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The broadcast job is now queued in this transaction. It is
|
||||||
|
// published only if the transaction commits.
|
||||||
|
if fail {
|
||||||
|
return fmt.Errorf("rolled back")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
// docs:end write
|
||||||
|
}
|
||||||
|
|
||||||
|
// importPosts writes many posts and publishes one summary event.
|
||||||
|
func importPosts(ctx context.Context, svc *lighthouse.Service, db *gorm.DB, titles []string) error {
|
||||||
|
// docs:start bulk
|
||||||
|
return lighthouse.WithoutBroadcasting[Post](ctx, func(ctx context.Context) error {
|
||||||
|
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
for _, title := range titles {
|
||||||
|
if err := tx.Create(&Post{BlogID: 7, Title: title}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return svc.Emit(ctx, tx, lighthouse.Broadcast{
|
||||||
|
Channels: []string{"blog:7"},
|
||||||
|
Event: "blog.posts_imported",
|
||||||
|
Payload: struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}{len(titles)},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// docs:end bulk
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor waits until the memory driver has recorded n publications.
|
||||||
|
func waitFor(t *testing.T, mem *lighthouse.MemoryDriver, n int) []lighthouse.Publication {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for len(mem.Publications()) < n {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("got %d publications, want %d", len(mem.Publications()), n)
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond) // catch extra publications
|
||||||
|
pubs := mem.Publications()
|
||||||
|
if len(pubs) != n {
|
||||||
|
t.Fatalf("got %d publications, want exactly %d: %+v", len(pubs), n, pubs)
|
||||||
|
}
|
||||||
|
return pubs
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocsBroadcast runs the bind, write and bulk regions of the Realtime
|
||||||
|
// page on the package's Postgres harness with a job worker.
|
||||||
|
func TestDocsBroadcast(t *testing.T) {
|
||||||
|
_, svc, db := lighthouse.DocsEnv(t)
|
||||||
|
if err := db.Exec(`CREATE TABLE acme_blog_posts (id SERIAL PRIMARY KEY, blog_id INTEGER NOT NULL, title TEXT NOT NULL)`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := bindPosts(svc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mem, ok := svc.Driver().(*lighthouse.MemoryDriver)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("driver is %T", svc.Driver())
|
||||||
|
}
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
if err := publishPost(ctx, db, true); err == nil {
|
||||||
|
t.Fatal("rolled-back publish succeeded")
|
||||||
|
}
|
||||||
|
if err := publishPost(ctx, db, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pubs := waitFor(t, mem, 1)
|
||||||
|
if pubs[0].Event != "created.blog.post" || len(pubs[0].Channels) != 1 || pubs[0].Channels[0] != "blog:7" {
|
||||||
|
t.Fatalf("publication = %+v", pubs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := importPosts(ctx, svc, db, []string{"A", "B", "C"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pubs = waitFor(t, mem, 2)
|
||||||
|
if pubs[1].Event != "blog.posts_imported" || string(pubs[1].Payload) != `{"count":3}` {
|
||||||
|
t.Fatalf("summary = %+v %s", pubs[1], pubs[1].Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
modules/lighthouse/export_docs_test.go
Normal file
19
modules/lighthouse/export_docs_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package lighthouse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DocsEnv returns an app with the memory driver on a fresh migrated
|
||||||
|
// database of this package's Postgres harness, with a job worker running,
|
||||||
|
// for the database-backed docs examples in example_test.go. It skips under
|
||||||
|
// -short and fails when the harness has no database, like the package's
|
||||||
|
// other database tests.
|
||||||
|
func DocsEnv(t *testing.T) (*backpack.App, *Service, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
env := newLHEnv(t, nil, nil)
|
||||||
|
return env.app, env.svc, env.gdb
|
||||||
|
}
|
||||||
69
modules/tide/example_test.go
Normal file
69
modules/tide/example_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package tide_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/tide"
|
||||||
|
)
|
||||||
|
|
||||||
|
// backend stands in for one implementation of GET /api/blog/posts.
|
||||||
|
func backend(body string) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, body)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleReplayFlow() {
|
||||||
|
ctx := context.Background()
|
||||||
|
// The reference (PHP) backend and two Go ports. IDs and *_at timestamps
|
||||||
|
// legitimately differ; the second port changed a title.
|
||||||
|
reference := backend(`{"data":[{"id":12,"title":"Hello","created_at":"2026-09-30T10:00:00+00:00"}]}`)
|
||||||
|
defer reference.Close()
|
||||||
|
port := backend(`{"data":[{"id":3,"title":"Hello","created_at":"2026-10-01T08:30:00+00:00"}]}`)
|
||||||
|
defer port.Close()
|
||||||
|
broken := backend(`{"data":[{"id":3,"title":"hello","created_at":"2026-10-01T08:30:00+00:00"}]}`)
|
||||||
|
defer broken.Close()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile("testdata/docs/posts-spec.yaml")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spec, err := tide.ParseFlow(raw)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Record the reference once; the flow is what testdata/parity keeps.
|
||||||
|
flow, err := tide.RecordFlow(ctx, spec, tide.RecordConfig{Target: reference.URL})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, target := range []string{port.URL, broken.URL} {
|
||||||
|
res, err := tide.ReplayFlow(ctx, flow, tide.ReplayConfig{Target: target})
|
||||||
|
var mismatch *tide.MismatchError
|
||||||
|
if errors.As(err, &mismatch) {
|
||||||
|
res = mismatch.Result // a difference is an error carrying the result
|
||||||
|
} else if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("ok:", res.OK)
|
||||||
|
for _, step := range res.Steps {
|
||||||
|
for _, d := range step.Diffs {
|
||||||
|
fmt.Printf(" %s %s: want %s, got %s\n", step.ID, d.Path, d.Expected, d.Actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// ok: true
|
||||||
|
// ok: false
|
||||||
|
// list-posts $.data[0].title: want "Hello", got "hello"
|
||||||
|
}
|
||||||
9
modules/tide/testdata/docs/posts-spec.yaml
vendored
Normal file
9
modules/tide/testdata/docs/posts-spec.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: 1
|
||||||
|
name: blog-posts
|
||||||
|
description: List the posts of one blog
|
||||||
|
steps:
|
||||||
|
- id: list-posts
|
||||||
|
route_id: GET /api/blog/posts
|
||||||
|
request:
|
||||||
|
method: GET
|
||||||
|
path: /api/blog/posts?page=1
|
||||||
Reference in New Issue
Block a user