From efb35a2d354671f8c2d7214e74b5239646c7bab7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 30 Sep 2026 22:59:25 +0200 Subject: [PATCH] feat(11.1-04): add the Database section and the core Services pages - docs/database: models, migrations, queries and pagination, relations, casts and validation, attachments and transactions (lagoon.Transaction, lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase) - docs/services: configuration, events, routing with auth groups, rate limiting, authentication, the OAuth server, mail and localization - runnable Examples for lagoon, attach, compass, surf, wire, bouncer, wristband, postcard, phrasebook and festival; lagoon TestDocs* regions run on the package's Postgres harness through DocsDB - 15 new required pages --- cmd/summer/docs_test.go | 15 + docs/database/attachments.md | 97 ++++ docs/database/casts-and-validation.md | 104 ++++ docs/database/migrations.md | 115 +++++ docs/database/models.md | 147 ++++++ docs/database/queries-and-pagination.md | 81 +++ docs/database/relations.md | 73 +++ docs/database/transactions.md | 91 ++++ docs/services/authentication.md | 128 +++++ docs/services/configuration.md | 97 ++++ docs/services/events.md | 98 ++++ docs/services/localization.md | 84 ++++ docs/services/mail.md | 102 ++++ docs/services/oauth-server.md | 148 ++++++ docs/services/rate-limiting.md | 73 +++ docs/services/routing.md | 210 ++++++++ docs/site.yaml | 2 + modules/bouncer/example_test.go | 105 ++++ modules/compass/example_test.go | 95 ++++ modules/festival/example_test.go | 57 +++ modules/lagoon/attach/example_test.go | 81 +++ modules/lagoon/example_test.go | 626 ++++++++++++++++++++++++ modules/lagoon/export_docs_test.go | 21 + modules/phrasebook/example_test.go | 78 +++ modules/postcard/example_test.go | 73 +++ modules/surf/example_test.go | 208 ++++++++ modules/wire/example_test.go | 45 ++ modules/wristband/example_test.go | 84 ++++ 28 files changed, 3138 insertions(+) create mode 100644 docs/database/attachments.md create mode 100644 docs/database/casts-and-validation.md create mode 100644 docs/database/migrations.md create mode 100644 docs/database/models.md create mode 100644 docs/database/queries-and-pagination.md create mode 100644 docs/database/relations.md create mode 100644 docs/database/transactions.md create mode 100644 docs/services/authentication.md create mode 100644 docs/services/configuration.md create mode 100644 docs/services/events.md create mode 100644 docs/services/localization.md create mode 100644 docs/services/mail.md create mode 100644 docs/services/oauth-server.md create mode 100644 docs/services/rate-limiting.md create mode 100644 docs/services/routing.md create mode 100644 modules/bouncer/example_test.go create mode 100644 modules/compass/example_test.go create mode 100644 modules/lagoon/attach/example_test.go create mode 100644 modules/lagoon/example_test.go create mode 100644 modules/lagoon/export_docs_test.go create mode 100644 modules/phrasebook/example_test.go create mode 100644 modules/postcard/example_test.go create mode 100644 modules/surf/example_test.go create mode 100644 modules/wire/example_test.go create mode 100644 modules/wristband/example_test.go diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go index a861d8f..2cfed2c 100644 --- a/cmd/summer/docs_test.go +++ b/cmd/summer/docs_test.go @@ -94,6 +94,21 @@ var requiredPages = []string{ "console/writing-commands", "console/utilities", "services/jobs", + "database/models", + "database/migrations", + "database/queries-and-pagination", + "database/relations", + "database/casts-and-validation", + "database/attachments", + "database/transactions", + "services/configuration", + "services/events", + "services/routing", + "services/rate-limiting", + "services/authentication", + "services/oauth-server", + "services/mail", + "services/localization", } // TestDocsRequiredPages asserts that every required page is in the loaded diff --git a/docs/database/attachments.md b/docs/database/attachments.md new file mode 100644 index 0000000..13ede9a --- /dev/null +++ b/docs/database/attachments.md @@ -0,0 +1,97 @@ +--- +title: Attachments +description: Attach files to models through WinterCMS-compatible system_files rows, serve originals and thumbnails, and delete blobs only after the transaction commits. +section: database +order: 60 +--- +# 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. + +## The system_files row + +`attach.File` is the `system_files` row. It links a file to its owner with the WinterCMS polymorphic columns: `AttachmentType` holds the owner's morph name and `AttachmentID` its ID, as a string, and `Field` names the relation, such as `cover` or `gallery`. The `system_files` table is created by the framework migrations, so a plugin does not migrate it. + +An owner model implements `attach.Owner`. Its `MorphName` returns the PHP class name, so the `attachment_type` values copied from WinterCMS still match: + +```go src=modules/lagoon/attach/example_test.go#Post.MorphName +func (Post) MorphName() string { return `Acme\Blog\Models\Post` } +``` + +## Storing and serving files + +The original is stored under WinterCMS's partitioned key: `attach.PartitionDirectory` splits the first nine characters of the random `disk_name` into three directories, and `attach.BlobKey` appends the name. `attach.File.Thumb` returns the public URL of a thumbnail, generating it in the same partition on first use and reusing it afterwards: + +```go src=modules/lagoon/attach/example_test.go#ExampleFile_Thumb +ctx := context.Background() +dir, err := os.MkdirTemp("", "acme-config") +if err != nil { + fmt.Println(err) + return +} +defer os.RemoveAll(dir) + +// storage.uploads.bucket_url is a file:// URL in production; mem:// keeps +// the example in memory. +cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}}) +if err != nil { + fmt.Println(err) + return +} +_ = cfg.Set("storage.uploads.bucket_url", "mem://") +bucket, err := attach.OpenBucket(ctx, cfg) +if err != nil { + fmt.Println(err) + return +} +defer bucket.Close() + +// The system_files row of a post's cover image. +var owner attach.Owner = Post{ID: 1} +f := attach.File{ + ID: 7, + DiskName: "5f1d0c2e9a7b4c3d8e6f.jpg", + FileName: "cover.jpg", + ContentType: "image/jpeg", + Field: "cover", + AttachmentType: owner.MorphName(), + AttachmentID: "1", +} + +// The original is stored under its partitioned WinterCMS key. +var img bytes.Buffer +if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 640, 480)), nil); err != nil { + fmt.Println(err) + return +} +if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), img.Bytes(), nil); err != nil { + fmt.Println(err) + return +} +fmt.Println(attach.BlobKey(f.DiskName)) + +// A thumbnail is generated on first use and reused afterwards. +url, err := f.Thumb(ctx, bucket, 200, 200, "crop") +if err != nil { + fmt.Println(err) + return +} +fmt.Println(url) +// Output: +// 5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg +// /storage/uploads/5f1/d0c/2e9/thumb_7_200_200_0_0_crop.jpg +``` + +URLs start with `storage.uploads.public_path_prefix` (`/storage/uploads` by default). `attach.StaticHandler` serves originals and thumbnails under that prefix; `attach.StaticHandlerPublic` does the same and answers 404 for a row whose `is_public` flag is false. Mount the gated handler when a bucket holds any private file. + +> [!WARNING] +> Serve uploads from a separate origin, or at least never mount the ungated handler on the application's own origin. An uploaded file served with its own content type from the API's origin can run script in that origin. + +## Deleting files after commit + +A rolled-back transaction can restore a row but not the bytes of a deleted blob. Deleting an owner's files is therefore split in two: + +1. Inside the transaction that deletes the owner, `attach.DeleteForOwner` deletes the owner's `system_files` rows and passes their blob keys to a callback. The callback only records the keys; it must not delete anything. +2. After the transaction commits, `attach.DeleteKeys` deletes the originals and their thumbnails from the bucket. + +A soft-deleted owner keeps its rows and files, so only a force delete (`Unscoped().Delete`) runs this. `lagoon.AfterCommit` is a natural place for the second step; see [Transactions](transactions.md). diff --git a/docs/database/casts-and-validation.md b/docs/database/casts-and-validation.md new file mode 100644 index 0000000..f1bd132 --- /dev/null +++ b/docs/database/casts-and-validation.md @@ -0,0 +1,104 @@ +--- +title: Casts and validation +description: Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with Laravel-style rule strings through lagoon.Validate. +section: database +order: 50 +--- +# Casts and validation + +Eloquent casts a column through `$jsonable`, `$casts` and the `encrypted` cast, and WinterCMS models validate with a `$rules` array. [lagoon](../../modules/lagoon/README.md) keeps both: column types that implement `sql.Scanner` and `driver.Valuer`, and a validator that reads the same rule strings. + +## JSON columns + +`lagoon.Jsonable` is the Go form of `$jsonable`. It stores any Go value as JSON text in a `TEXT` column (not `jsonb`, so data copied from a WinterCMS database fits as it is). The value lives in `Data`; `Valid` tells SQL `NULL` apart from an empty value, because a nil slice and an empty one are different rows: + +```go src=modules/lagoon/example_test.go#ExampleJsonable +tags := lagoon.Jsonable[[]string]{Data: []string{"go", "cms"}, Valid: true} +v, _ := tags.Value() +fmt.Println(v) + +var none lagoon.Jsonable[[]string] // Valid false stores SQL NULL +v, _ = none.Value() +fmt.Println(v) + +var read lagoon.Jsonable[[]string] +_ = read.Scan(`["winter"]`) +fmt.Println(read.Get(), read.Valid) +// Output: +// ["go","cms"] +// +// [winter] true +``` + +Set `NullOnEmpty` on a slice or map column that should store `NULL` rather than `[]` or `{}` when it is empty. Pick the behaviour the PHP table already has. + +## Encrypted columns + +`lagoon.Encrypted` is the `encrypted` cast. It stores AES-256-GCM ciphertext under a key derived from `app.key`, and decrypts values written under any key in `app.previous_keys`, so you can rotate the key without rewriting every row at once. The plaintext has one accessor, `lagoon.Encrypted.Reveal`; printing the value or marshalling it to JSON always gives `[redacted]`: + +```go src=modules/lagoon/example_test.go#ExampleEncrypted +// The application publishes the keys from app.key at boot; a test can +// install a key directly. +key := []byte("0123456789abcdef0123456789abcdef") +if err := lagoon.PublishEncryptionKeys(nil, key, nil); err != nil { + fmt.Println(err) + return +} +token := lagoon.NewEncrypted("s3cret") +stored, _ := token.Value() // what the column holds +fmt.Println(strings.Contains(fmt.Sprint(stored), "s3cret")) + +var read lagoon.Encrypted +if err := read.Scan(stored); err != nil { + fmt.Println(err) + return +} +out, _ := json.Marshal(map[string]any{"api_token": read}) +fmt.Println(read, string(out)) +fmt.Println(read.Reveal()) +// Output: +// false +// [redacted] {"api_token":"[redacted]"} +// s3cret +``` + +The application loads the keys once at boot (`lagoon.OpenFromApp` calls `lagoon.LoadAppKey` and `lagoon.PublishEncryptionKeys`). A missing or short `app.key` stops the application with an error; there is no default key. Generate one with `key:generate`, which prints a key and writes nothing: + +```sh +./bin/acme key:generate +``` + +Keep the key in the environment (`SUMMER_APP__KEY`), not in a committed file. + +The ciphertext format is not Laravel's. To import rows that the PHP application encrypted, decrypt them once with `lagoon.DecryptLaravelPayload` and the old `APP_KEY`, then save them through `lagoon.Encrypted`. It is meant for a one-off import, never for reading live data. + +## Validation + +`lagoon.Validate` checks a map of input values against Laravel-style rule strings and returns the errors in Laravel's shape, a map from field to messages. It returns `nil` when the input is valid. The second return value is for failures that are not the user's fault, such as an unknown rule or a database error: + +```go src=modules/lagoon/example_test.go#ExampleValidate +rules := map[string]string{ + "title": "required|max:10", + "views": "nullable|integer|max:1000", +} +input := map[string]any{"title": "", "views": 5000} +// A nil translator gives the built-in English messages; unique: rules +// need a database handle instead of nil. +errs, err := lagoon.Validate(context.Background(), nil, &Post{}, rules, input, nil) +if err != nil { + fmt.Println(err) +} +out, _ := json.Marshal(errs) +fmt.Println(string(out)) +// Output: +// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]} +``` + +The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. On a field that is `integer` or `numeric`, `min`, `max` and `between` compare the number; on other fields they compare the length. + +> [!NOTE] +> A failed numeric range check is currently always reported with the `max` message, even when the value is below `min`, and a `min`-only rule then shows an empty limit. Check range errors by field, not by message text. + +`unique:` runs a query to check that no other row of the table has the value in the field's column, so it needs a database handle; pass the transaction you are writing in. Soft-deleted rows do not count, and when the model you pass has an ID, its own row does not count either, so the same rules work for create and update. Pass a `phrasebook.Translator` as the last argument to get the messages in the request locale from the `lagoon::validate` catalog; with `nil` they are in English. See [Localization](../services/localization.md). + +A handler validates before it fills and saves the model, as the create example on [Models](models.md) shows. Answer a non-nil error map with status 422 and the body shape the endpoint's existing clients expect. diff --git a/docs/database/migrations.md b/docs/database/migrations.md new file mode 100644 index 0000000..595c53e --- /dev/null +++ b/docs/database/migrations.md @@ -0,0 +1,115 @@ +--- +title: Migrations +description: Ship a plugin's schema as an ordered gormigrate set through pact.HasMigrations, and run, inspect and roll back migrations per plugin. +section: database +order: 20 +--- +# Migrations + +WinterCMS plugins keep their schema in the `updates/` directory and list the steps in `version.yaml`. A SummerCMS plugin keeps the same `updates/` directory, but each step is a Go migration in a [gormigrate](https://github.com/go-gormigrate/gormigrate) set that the plugin returns from `pact.HasMigrations`. [lagoon](../../modules/lagoon/README.md) runs the sets and records each plugin's history in its own table. + +## Writing migrations + +`summer make:model` writes a create-table migration with the model, and `summer make:migration` writes an empty one to fill in: + +```sh +summer make:migration acme.blog AddPublishedAt +``` + +The file is `updates/_add_published_at.go`, with a migration ID that starts with the same timestamp. `summer build` generates the plugin's list of migrations in file name order, so the timestamp is also the order in which they run. The scaffolded plugin returns that generated list from its `Migrations` method. + +A migration has an ID, a `Migrate` function and a `Rollback` function, both given the transaction to run in. Write DDL as SQL with `tx.Exec`: the migration then says exactly what the database gets, and it keeps working when the model struct changes later. This is the whole set of an example plugin, written out by hand: + +```go src=modules/lagoon/example_test.go#BlogPlugin.Migrations +// Migrations returns the plugin's schema as an ordered gormigrate set. In a +// scaffolded plugin each migration is a file in updates/ and this list is +// generated in file name order. +func (p *BlogPlugin) Migrations() []*gormigrate.Migration { + return []*gormigrate.Migration{ + { + ID: "20260101000100_create_posts", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE acme_blog_posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT NOT NULL, + views INTEGER NOT NULL DEFAULT 0, + tags TEXT, + api_token TEXT, + deleted_at TIMESTAMPTZ +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS acme_blog_posts`).Error + }, + }, + { + ID: "20260101000200_create_comments_and_categories", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE acme_blog_comments (id SERIAL PRIMARY KEY, post_id INTEGER NOT NULL, body TEXT NOT NULL, deleted_at TIMESTAMPTZ)`, + `CREATE TABLE acme_blog_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`, + `CREATE TABLE acme_blog_post_categories (post_id INTEGER NOT NULL, category_id INTEGER NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, category_id))`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS acme_blog_post_categories, acme_blog_categories, acme_blog_comments`).Error + }, + }, + } +} +``` + +Treat an ID as permanent once the migration has run anywhere. gormigrate records the IDs it has applied, so renaming one makes it run again. + +## Running migrations + +The application binary has the migration commands: + +```sh +./bin/acme migrate +./bin/acme migrate:status +./bin/acme migrate:rollback --plugin acme.blog +``` + +`migrate` runs the framework's own sets first (file attachments, the admin users and roles, and the job queue), then each plugin's set in plugin activation order, so a plugin's migrations run after those of the plugins it requires. Each plugin has its own history table, `summer_migrations_` with dots replaced by underscores, as `lagoon.HistoryTableName` returns. + +`migrate:rollback` rolls back the last applied migration of one plugin. Without `--plugin` it picks the last activated plugin that has migrations. To fix a migration you just wrote, roll it back, edit it and run `migrate` again. + +`migrate:status` prints each plugin's history table and applied IDs. + +The same operations are Go functions, which is how tests migrate a fresh database: + +```go src=modules/lagoon/example_test.go#migrate +plugins := []party.Plugin{&BlogPlugin{}} +if err := lagoon.Migrate(db, plugins); err != nil { + return nil, err +} +rows, err := lagoon.Status(db, plugins) +if err != nil { + return nil, err +} +for _, row := range rows { + lines = append(lines, fmt.Sprintf("%s %s %v", row.Plugin, row.Table, row.IDs)) +} +if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil { + return nil, err +} +``` + +`lagoon.Migrate`, `lagoon.Status` and `lagoon.RollbackLast` take the activated plugins; a plugin that does not implement `pact.HasMigrations` is skipped. + +## Porting version.yaml + +WinterCMS runs a plugin's update scripts by version number and seeds data from the same files. When you port a plugin: + +- Fold the existing tables into one create migration per table, matching the final PHP schema column for column, so data copied from the PHP database fits. +- Keep data changes (backfills, seeds) as their own migrations, written in SQL. +- Give every `Rollback` a real inverse. It is what makes `migrate:rollback` safe while you develop. + +The migration commands open the database through `database.dsn` and load `app.key`; see [Configuration](../setup/configuration.md). diff --git a/docs/database/models.md b/docs/database/models.md new file mode 100644 index 0000000..787d36a --- /dev/null +++ b/docs/database/models.md @@ -0,0 +1,147 @@ +--- +title: Models +description: Define models as GORM structs with lagoon helpers for mass assignment, hidden columns and lifecycle hooks, and keep the models package a leaf. +section: database +order: 10 +--- +# Models + +A WinterCMS model extends Eloquent and describes its behaviour with properties such as `$fillable`, `$hidden` and `$jsonable`. A SummerCMS model is a plain GORM struct in the plugin's `models` package, and [lagoon](../../modules/lagoon/README.md) supplies the Eloquent conventions that GORM does not have: allow-listed mass assignment, JSON and encrypted columns, Laravel-style validation and pagination. + +The data layer supports PostgreSQL only. `lagoon.Open` refuses a database whose default collation is not the ICU `pl-PL` locale (`lagoon.CheckLocale`), so text ordering is the same in every query without a `COLLATE` clause. Create the database with that locale, as shown in [Installation](../setup/installation.md). + +## Defining a model + +`summer make:model acme.blog Post` writes `models/post.go` and a migration that creates the table. The model is an ordinary struct with `gorm` column tags. Keep the WinterCMS table name with a `TableName` method, and use the lagoon column types where Eloquent used casts: + +```go src=modules/lagoon/example_test.go#Post +// Post is the acme.blog post model: a plain GORM struct with lagoon column +// types. +type Post struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + Title string `gorm:"column:title" json:"title"` + Slug string `gorm:"column:slug" json:"slug"` + Views int `gorm:"column:views" json:"views"` + Tags lagoon.Jsonable[[]string] `gorm:"column:tags" json:"-"` + APIToken lagoon.Encrypted `gorm:"column:api_token" json:"-"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"-"` + + Categories []Category `gorm:"many2many:acme_blog_post_categories" json:"-"` +} +``` + +```go src=modules/lagoon/example_test.go#Post.TableName +// TableName keeps the WinterCMS table name. +func (Post) TableName() string { return "acme_blog_posts" } +``` + +The column types are described in [Casts and validation](casts-and-validation.md). Relations are ordinary GORM fields; see [Relations](relations.md). + +## Mass assignment + +`$fillable` becomes a `Fillable` method that returns the column names mass assignment may set. The model implements `lagoon.HasFillable`: + +```go src=modules/lagoon/example_test.go#Post.Fillable +// Fillable is the Go form of $fillable: the keys mass assignment may set. +func (Post) Fillable() []string { return []string{"title", "views"} } +``` + +`lagoon.Fill` copies the keys of a request map onto the model, but only keys that are also in the allow-list you pass. Other keys are dropped without an error, as Eloquent does. Outside production, pass `false` as the last argument and each dropped key is logged once, which catches typos in development. + +A value that does not fit its column, such as text for an integer field, is a `lagoon.FillTypeError` whose `Key` names the column, so a handler can answer it as a validation error on that field: + +```go src=modules/lagoon/example_test.go#ExampleFill +input := map[string]any{"title": "Hello", "views": 3, "slug": "forged"} +var post Post +// production=false logs each dropped key once, to catch typos in development. +if err := lagoon.Fill(&post, post.Fillable(), input, true); err != nil { + fmt.Println(err) +} +fmt.Printf("%q %q %d\n", post.Title, post.Slug, post.Views) + +err := lagoon.Fill(&post, post.Fillable(), map[string]any{"views": "many"}, true) +var typeErr *lagoon.FillTypeError +if errors.As(err, &typeErr) { + fmt.Println("invalid value for", typeErr.Key) +} +// Output: +// "Hello" "" 3 +// invalid value for views +``` + +`lagoon.Fill` matches keys by the GORM column name, not the Go field name. A number decoded with `json.Decoder.UseNumber` fills integer and float fields; a fraction or an overflow for an integer field is a `lagoon.FillTypeError`. + +## Creating a record + +A handler usually validates the input, fills the model and creates it. `lagoon.Validate` is described in [Casts and validation](casts-and-validation.md): + +```go src=modules/lagoon/example_test.go#create-post +rules := map[string]string{ + "title": "required|max:255|unique:acme_blog_posts", + "views": "nullable|integer|min:0", +} +var post Post +errs, err := lagoon.Validate(ctx, db, &post, rules, input, nil) +if err != nil || errs != nil { + return nil, errs, err +} +if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil { + return nil, nil, err +} +if err := db.WithContext(ctx).Create(&post).Error; err != nil { + return nil, nil, err +} +return &post, nil, nil +``` + +## Hidden columns + +`$hidden` becomes a `Hidden` method, the `lagoon.HasHidden` interface. It lists the columns that must never appear in a JSON response. The list documents the rule; the `json:"-"` tag on each field is what enforces it, because the Go JSON encoder reads struct tags, not methods: + +```go src=modules/lagoon/example_test.go#Post.Hidden +// Hidden is the Go form of $hidden. The json:"-" tags are what keep these +// columns out of JSON; the list documents them for tooling. +func (Post) Hidden() []string { return []string{"tags", "api_token", "deleted_at"} } +``` + +```go src=modules/lagoon/example_test.go#ExampleHasHidden +post := Post{ID: 1, Title: "Hello", APIToken: lagoon.NewEncrypted("s3cret")} +out, _ := json.Marshal(post) +fmt.Println(string(out)) +var _ lagoon.HasHidden = post +// Output: +// {"id":1,"title":"Hello","slug":"","views":0} +``` + +A `lagoon.Encrypted` column is also redacted when it is marshalled by mistake, so a secret never reaches a response even without the tag. + +> [!TIP] +> Ported endpoints rarely marshal the model itself. Build a response struct with exactly the fields the API returns, and use the [wire](../../modules/wire/README.md) helpers for Carbon-style timestamps and `[]` for empty lists. See [Routing](../services/routing.md). + +## Lifecycle hooks + +GORM calls hook methods by name, so a model that needs `beforeCreate` or `beforeDelete` defines `BeforeCreate` or `BeforeDelete` with GORM's signature. lagoon names them as interfaces (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) so a compile-time assertion can check the signature. `lagoon.HasBeforeValidate` is the WinterCMS `beforeValidate` hook. GORM does not call it: the admin form and settings saves call it before they validate, and your own handlers call it when they need it. + +A hook that only touches the model itself stays on the model: + +```go src=modules/lagoon/example_test.go#Post.BeforeCreate +// BeforeCreate fills the slug from the title. A hook that only touches the +// model stays on the model. +func (p *Post) BeforeCreate(tx *gorm.DB) error { + if p.Slug == "" { + p.Slug = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(p.Title)), " ", "-") + } + return nil +} +``` + +GORM runs the hook inside the transaction of the write, and an error from it aborts the write. + +## The models package is a leaf + +A plugin's `models` package never imports another package of the same plugin. `summer build` fails when it does. This keeps the dependency graph one way: `classes`, `controllers`, `console` and `jobs` import `models`, never the reverse. + +The rule decides where two kinds of WinterCMS model code go when you port a plugin: + +- Casts and other types the model owns, such as JSON value objects, move into `models`. +- A hook that calls a service, such as a model event that dispatches a job, broadcasts or clears a cache, becomes a GORM callback registered from the plugin's `Boot` step or from `classes`. See [Transactions](transactions.md) for registering callbacks with `lagoon.OnDatabase` and deferring their side effects until the write commits. diff --git a/docs/database/queries-and-pagination.md b/docs/database/queries-and-pagination.md new file mode 100644 index 0000000..005c344 --- /dev/null +++ b/docs/database/queries-and-pagination.md @@ -0,0 +1,81 @@ +--- +title: Queries and pagination +description: Query models with GORM, sort by a client-chosen column safely with lagoon.OrderBy, and return Laravel-shaped pages with lagoon.Paginate. +section: database +order: 30 +--- +# Queries and pagination + +Queries are plain GORM: `Where`, `Joins`, `Preload`, `Count`, `Find` and the rest work as the [GORM documentation](https://gorm.io/docs/) describes. [lagoon](../../modules/lagoon/README.md) adds two helpers for list endpoints, which almost always let the client choose the sort order and ask for one page. + +Pass the request context to every query with `WithContext`, so a cancelled request stops its query. + +## Sorting by a column the client names + +A list endpoint usually takes `?sort=title&order=desc`. Never pass those values to `Order` directly: a column name is SQL, not a bound parameter. `lagoon.OrderBy` appends the ORDER BY only when the column is in an allow-list you give it and the direction is `asc` or `desc`, and returns an error for anything else: + +```go src=modules/lagoon/example_test.go#ExampleOrderBy +// A dry-run handle shows the SQL without a database. +db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) +allowed := []string{"title", "views"} + +q, err := lagoon.OrderBy(db.Model(&Post{}), "views", "desc", allowed) +if err != nil { + fmt.Println(err) + return +} +var posts []Post +fmt.Println(q.Find(&posts).Statement.SQL.String()) + +_, err = lagoon.OrderBy(db, "api_token", "asc", allowed) +fmt.Println(err) +_, err = lagoon.OrderBy(db, "title", "asc; DROP TABLE acme_blog_posts", allowed) +fmt.Println(err) +// Output: +// SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY views DESC +// lagoon: order column "api_token" is not allow-listed +// lagoon: order direction "asc; DROP TABLE acme_blog_posts" is not allow-listed +``` + +Answer the error as a validation failure (422). The column must match an allow-list entry exactly, so list the qualified name (`acme_blog_posts.title`) when the query joins another table. + +`lagoon.OrderBy` never adds a `COLLATE` clause. Text sorts by the database's ICU `pl-PL` default collation, which lagoon checks when it connects. + +## Pagination + +`lagoon.Paginate` wraps the rows of one page in the envelope Laravel's paginator produces for the API: `data` and a `meta` object with `current_page`, `last_page`, `per_page` and `total`. You run the count and the page query yourself, so the query stays under your control: + +```go src=modules/lagoon/example_test.go#list-posts +q, err := lagoon.OrderBy(db.WithContext(ctx).Model(&Post{}), sort, dir, []string{"title", "views"}) +if err != nil { + return lagoon.Page[Post]{}, err // answer 422: the client asked for a column it may not sort by +} +var total int64 +if err := q.Count(&total).Error; err != nil { + return lagoon.Page[Post]{}, err +} +var posts []Post +if err := q.Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { + return lagoon.Page[Post]{}, err +} +return lagoon.Paginate(posts, page, perPage, total), nil +``` + +The result is a `lagoon.Page` whose `lagoon.PageMeta` marshals to the Laravel field names: + +```go src=modules/lagoon/example_test.go#ExamplePaginate +rows := []map[string]any{{"id": 3, "title": "Third"}} +page := lagoon.Paginate(rows, 2, 2, 3) +out, _ := json.Marshal(page) +fmt.Println(string(out)) + +empty, _ := json.Marshal(lagoon.Paginate[map[string]any](nil, 1, 15, 0)) +fmt.Println(string(empty)) +// Output: +// {"data":[{"id":3,"title":"Third"}],"meta":{"current_page":2,"last_page":2,"per_page":2,"total":3}} +// {"data":[],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":0}} +``` + +A nil slice becomes `[]`, and a zero or negative page size gives one page instead of dividing by zero. There is no `links` block. When a ported endpoint's response has a different shape, build that shape yourself: the existing clients define the contract. + +Clamp `page` and `per_page` from the request before you use them, for example to at least 1 and at most 100, so a client cannot ask for the whole table in one page. diff --git a/docs/database/relations.md b/docs/database/relations.md new file mode 100644 index 0000000..2800ed5 --- /dev/null +++ b/docs/database/relations.md @@ -0,0 +1,73 @@ +--- +title: Relations +description: Declare relations as GORM associations, write pivot tables with business columns explicitly, and cascade soft deletes inside the parent delete. +section: database +order: 40 +--- +# Relations + +Eloquent relations (`$belongsTo`, `$hasMany`, `$belongsToMany`) become GORM associations: struct fields whose type is another model, configured with `gorm` tags. Load them with `Preload` and filter through them with `Joins`, as the [GORM association documentation](https://gorm.io/docs/associations.html) describes. [lagoon](../../modules/lagoon/README.md) adds two helpers for the cases GORM leaves open. + +## Many-to-many with a pivot model + +A `many2many` field joins two models through a join table. When the join table has columns of its own, such as a sort order or a role, describe it as a model: + +```go src=modules/lagoon/example_test.go#PostCategory +// PostCategory is the pivot model: the join table has a business column. +type PostCategory struct { + PostID uint `gorm:"column:post_id;primaryKey"` + CategoryID uint `gorm:"column:category_id;primaryKey"` + SortOrder int `gorm:"column:sort_order"` +} +``` + +and register it for the field with `lagoon.RegisterJoinTable`, which calls GORM's `SetupJoinTable` and returns an error instead of panicking on a nil handle. GORM's association mode cannot set the extra columns, so write the pivot rows yourself: delete the post's rows and insert the new ones in one transaction, in the same transaction as the parent save when there is one. To read the rows in pivot order, join the pivot table: + +```go src=modules/lagoon/example_test.go#pivot +if err := lagoon.RegisterJoinTable(db, &Post{}, "Categories", &PostCategory{}); err != nil { + return nil, err +} +err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.Where("post_id = ?", post.ID).Delete(&PostCategory{}).Error; err != nil { + return err + } + rows := make([]PostCategory, len(categoryIDs)) + for i, id := range categoryIDs { + rows[i] = PostCategory{PostID: post.ID, CategoryID: id, SortOrder: i} + } + return tx.Create(&rows).Error +}) +if err != nil { + return nil, err +} +var categories []Category +err = db.WithContext(ctx). + Joins("JOIN acme_blog_post_categories pc ON pc.category_id = acme_blog_categories.id"). + Where("pc.post_id = ?", post.ID). + Order("pc.sort_order"). + Find(&categories).Error +return categories, err +``` + +After `lagoon.RegisterJoinTable`, `Preload("Categories")` also loads the categories through the pivot model, without an order. + +> [!WARNING] +> Do not order a `Preload` of a many-to-many field by a pivot column. GORM preloads the related rows in a query that does not join the pivot table, so the query fails. Use a join, as above. + +## Soft deletes and cascades + +A model with a `gorm.DeletedAt` field is soft-deleted: `Delete` sets `deleted_at`, and queries skip deleted rows unless you call `Unscoped`. This is the SummerCMS form of the `SoftDelete` trait. + +WinterCMS cascades a delete to dependent records through `$hasMany` options. In SummerCMS, the parent model does it in its `BeforeDelete` hook with `lagoon.WithSoftDeleteCascade`. GORM already runs the hook inside the transaction of the parent's delete, so the cascade commits or rolls back with it, and an error from the cascade aborts the parent delete: + +```go src=modules/lagoon/example_test.go#Post.BeforeDelete +// BeforeDelete soft-deletes the post's comments in the transaction of the +// post's own delete; an error aborts that delete. +func (p *Post) BeforeDelete(tx *gorm.DB) error { + return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error { + return tx.Where("post_id = ?", p.ID).Delete(&Comment{}).Error + }) +} +``` + +Deleting a post then soft-deletes its comments in the same transaction. Files attached to a record are deleted differently, after the transaction commits; see [Attachments](attachments.md). diff --git a/docs/database/transactions.md b/docs/database/transactions.md new file mode 100644 index 0000000..6041356 --- /dev/null +++ b/docs/database/transactions.md @@ -0,0 +1,91 @@ +--- +title: Transactions +description: Run writes in lagoon.Transaction, defer side effects with lagoon.AfterCommit until the commit, and install GORM callbacks from Boot with lagoon.OnDatabase. +section: database +order: 70 +--- +# Transactions + +Laravel's `DB::transaction` runs a closure in a transaction, and `DB::afterCommit` defers work until it commits. [lagoon](../../modules/lagoon/README.md) has the same pair, `lagoon.Transaction` and `lagoon.AfterCommit`, and the rest of the framework relies on them: realtime broadcasts, search index updates and blob deletions wait for the commit, so no client hears about a row that was rolled back. + +## Running a transaction + +`lagoon.Transaction` runs a function in a transaction and commits when it returns `nil`. Inside the function, use the `ctx` and `tx` it receives for every write. Work registered with `lagoon.AfterCommit` runs, in registration order, only after the commit succeeds; when the function returns an error, the transaction rolls back and the work is dropped: + +```go src=modules/lagoon/example_test.go#publish +return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil { + return err + } + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) { + *log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail... + }) + if fail { + return errors.New("rolled back") // the AfterCommit work never runs + } + return nil +}) +``` + +The callback receives a database handle with an empty statement, so a query it runs never continues from the written model's statement. A panicking callback is logged and does not turn a committed write into an error. + +`lagoon.AfterCommit` behaves differently depending on where it is called: + +| Called | The work runs | +|--------|---------------| +| Inside `lagoon.Transaction` | After the outermost transaction commits; never after a rollback. | +| In a GORM callback of a single-statement write (GORM's own implicit transaction) | After GORM commits that write; never when the write fails. | +| Inside a plain `gorm.DB.Transaction` or another transaction lagoon did not open | Never. lagoon cannot see whether that transaction commits, so it logs a warning and skips the work. | +| Outside any transaction | Immediately. | + +The third row is deliberate: running the work early could announce a write that later rolls back. When code in a transaction needs after-commit work, open the transaction with `lagoon.Transaction`. + +## Nested transactions + +A `lagoon.Transaction` inside another becomes a savepoint. Its after-commit work joins the outer transaction's only when its own function succeeds, so work dropped with a failed savepoint never runs: + +```go src=modules/lagoon/example_test.go#nested +return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") }) + + // A nested Transaction is a savepoint. Pass it the outer tx: given + // the root db handle it returns an error instead. + _ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error { + lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") }) + return errors.New("savepoint rolled back") + }) + return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error { + lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") }) + return nil + }) +}) +``` + +Pass the nested call the outer transaction's `tx`. Given the root database handle instead, the nested `lagoon.Transaction` returns an error without running its function. Otherwise it would open a second, independent transaction whose after-commit work would wait for the outer one. + +## Callbacks registered at boot + +A hook that calls a service, such as a broadcast or a job dispatch, is a GORM callback rather than a model method (see [Models](models.md)). A plugin registers it from `Boot`, but `Boot` runs before the `serve` command opens the database. `lagoon.OnDatabase` bridges the gap: it runs your function as soon as the database is published, immediately when it already is. + +Register the callback before GORM's `gorm:commit_or_rollback_transaction` step and defer its side effect with `lagoon.AfterCommit`: + +```go src=modules/lagoon/example_test.go#on-database +return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error { + return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) { + post, ok := db.Statement.Dest.(*Post) + if db.Error != nil || !ok { + return + } + lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) { + *log = append(*log, "created "+post.Slug) // runs only once the insert is committed + }) + }) +}) +``` + +> [!WARNING] +> Register such a callback with a `Before("gorm:commit_or_rollback_transaction")` constraint, as above. lagoon runs a single-statement write's after-commit work from its own callback right after that commit step; a callback that GORM sorts after it buffers work that is never run, without an error. + +## Boot-time work that needs the database + +`lagoon.OnDatabase` is also the place for anything else a plugin must do with the database handle at start-up, such as registering a join table with `lagoon.RegisterJoinTable`. The error of a queued function is returned by `lagoon.Publish`, so a failing callback stops the start-up. diff --git a/docs/services/authentication.md b/docs/services/authentication.md new file mode 100644 index 0000000..470fdc7 --- /dev/null +++ b/docs/services/authentication.md @@ -0,0 +1,128 @@ +--- +title: Authentication +description: Mint and verify JWTs with bouncer, turn guards into route middleware, revoke tokens through a jti blacklist and hash passwords with bcrypt. +section: services +order: 50 +--- +# Authentication + +WinterCMS reads the current user through the `Auth` and `BackendAuth` facades, and API plugins add a JWT layer on top. SummerCMS has no facades: [bouncer](../../modules/bouncer/README.md) turns a request into a `bouncer.Principal` through a guard, stores it on the request context, and issues and checks the tokens. The frontend user model and its login endpoints belong to the application's user plugin; bouncer supplies the building blocks. + +## Tokens + +bouncer issues HS256 JSON Web Tokens for two audiences: frontend users (`bouncer.AudienceUser`) and admins (`bouncer.AudienceBackend`). `bouncer.Mint` signs a frontend token for a subject, the user ID as a string, and returns the token and its random `jti`. `bouncer.MintAudience` signs one for any audience. + +`bouncer.VerifyClaims` checks the signature with HS256 pinned, requires `exp` and `sub`, and returns the claims; `bouncer.Verify` returns only the subject. Both accept frontend tokens, including older tokens without an audience claim. `bouncer.VerifyClaimsAudience` requires the audience you name, so a frontend token never passes an admin check and the other way round: + +```go src=modules/bouncer/example_test.go#ExampleMint +const issuer = "http://127.0.0.1:8080/api/login" +token, _, err := bouncer.Mint(testSecret, "42", issuer, time.Hour) +if err != nil { + fmt.Println(err) + return +} +sub, iat, exp, _, err := bouncer.VerifyClaims(token, testSecret) +fmt.Println(sub, exp.Sub(iat), err) + +// A frontend token never passes a backend check, and a wrong secret fails. +_, _, _, _, err = bouncer.VerifyClaimsAudience(token, testSecret, bouncer.AudienceBackend) +fmt.Println(err != nil) +_, err = bouncer.Verify(token, "another-secret-with-at-least-32-bytes") +fmt.Println(err != nil) + +// Refresh reissues the token and blacklists the old jti after the grace. +bl := bouncer.NewMemoryBlacklist() +fresh, err := bouncer.Refresh(testSecret, token, 14*24*time.Hour, bl, 0, issuer) +fmt.Println(fresh != token, err) +_, _, _, jti, _ := bouncer.VerifyClaims(token, testSecret) +revoked, _ := bl.IsBlacklisted(context.Background(), jti) +fmt.Println(revoked) +// Output: +// 42 1h0m0s +// true +// true +// true +// true +``` + +The secret in these examples is a test value. In an application, read the signing secret from configuration set through an environment variable, use at least 32 random bytes and never commit it. + +## Refreshing and revoking + +`bouncer.Refresh` reissues a token while its `iat` is inside the refresh window, even when it has expired, and blacklists the old `jti` after a grace period, so requests already in flight with the old token still succeed. `bouncer.RefreshAudienceFor` also reloads the user and refuses one who was deleted, or whose tokens were issued before `bouncer.Principal.TokensValidAfter`, with `bouncer.ErrSubjectRejected`. The rules match the PHP jwt-auth library, so tokens issued by a WinterCMS application keep working after a port. + +Revoked token IDs are kept in a `bouncer.BlacklistStore`: + +- `bouncer.NewMemoryBlacklist` keeps them in the process, for tests. +- `bouncer.NewPostgresBlacklist` keeps them in a table you name, with `jti`, `expires_at` and `valid_until` columns. It rejects table names that are not plain identifiers. + +Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfter` to now revokes all their tokens at once, for example after a password change. + +## Guards and middleware + +A guard implements `bouncer.Guard`: it turns a request into a principal or an error. `bouncer.NewJWTGuard` is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your `bouncer.UserProvider`. On failure it answers 401 with `{"error":true,"message":...}`. `bouncer.NewBackendJWTGuard` is the same for the admin audience. + +Register guards in a `bouncer.Registry` under a name, and turn one into middleware with `bouncer.Registry.Middleware`. The middleware stores the principal on the context, where handlers read it with `bouncer.User`: + +```go src=modules/bouncer/example_test.go#ExampleNewJWTGuard +guards := bouncer.NewRegistry() +guard := bouncer.NewJWTGuard(testSecret, users{}, bouncer.NewMemoryBlacklist(), "token") +if err := guards.Register("acme.blog", "acme.auth", guard); err != nil { + fmt.Println(err) + return +} +auth, err := guards.Middleware("acme.auth") +if err != nil { + fmt.Println(err) + return +} +me := auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, _ := bouncer.User(r.Context()) + fmt.Fprintf(w, "user %d, locale %s", user.ID, user.PreferredLocale) +})) + +token, _, _ := bouncer.Mint(testSecret, "42", "http://127.0.0.1:8080/api/login", time.Hour) +for _, set := range []func(*http.Request){ + func(r *http.Request) {}, + func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }, + func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "token", Value: token}) }, +} { + req := httptest.NewRequest("GET", "/api/me", nil) + set(req) + rec := httptest.NewRecorder() + me.ServeHTTP(rec, req) + fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String())) +} +// Output: +// 401 {"error":true,"message":"Token not provided"} +// 200 user 42, locale pl +// 200 user 42, locale pl +``` + +To protect routes, return that middleware from `pact.HasMiddleware` under a name and put the name on a route group. [Routing](routing.md) shows the complete auth group. A guard that does not implement `bouncer.UnauthorizedWriter` lets an unauthenticated request through without a principal, for routes that behave differently for guests. + +A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`. + +## Passwords + +`bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working: + +```go src=modules/bouncer/example_test.go#ExampleHashPassword +hash, err := bouncer.HashPassword(10, "correct horse battery staple") +if err != nil { + fmt.Println(err) + return +} +fmt.Println(bouncer.CheckPassword(hash, "correct horse battery staple")) +fmt.Println(bouncer.CheckPassword(hash, "wrong")) +// After raising the configured cost, rehash on the next successful login. +fmt.Println(bouncer.NeedsRehash(hash, 12)) +// Output: +// true +// false +// 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. diff --git a/docs/services/configuration.md b/docs/services/configuration.md new file mode 100644 index 0000000..728e8a8 --- /dev/null +++ b/docs/services/configuration.md @@ -0,0 +1,97 @@ +--- +title: Configuration +description: Read layered configuration with compass, from plugin defaults through per-environment files and SUMMER_ variables to runtime overrides saved to disk. +section: services +order: 10 +--- +# Configuration + +WinterCMS reads configuration with `Config::get('app.name')` from `config/*.php`, per-environment directories and `.env`. SummerCMS reads it from a `compass.Config` built by [compass](../../modules/compass/README.md), with the same dot paths. [Setup, Configuration](../setup/configuration.md) lists the keys an application sets; this page covers how the layers merge and how code reads and changes them. + +## Layers + +`compass.Config` merges its sources in a fixed order. Each layer overrides the ones before it: + +1. Plugin defaults, merged with `compass.Config.MergePlugin` when the plugin is activated. A plugin's `config/config.yaml` becomes `.`, so `acme.blog.posts_per_page` is the WinterCMS `acme.blog::posts_per_page`. Any other `config/.yaml` becomes `..`. +2. `config/*.yaml`. Each file is a section named after the file, so `config/app.yaml` provides `app.*`. +3. `config/env//*.yaml`, the per-environment sections. +4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable. +5. `config/env//overrides.yaml`, written by `compass.Config.Persist`. +6. Values set in memory with `compass.Config.Set`. + +The environment comes from `SUMMER_ENV` and defaults to `production`. Its name may contain only letters, digits, `-` and `_`. + +## Reading values + +The typed getters `compass.Config.String`, `compass.Config.Int` and `compass.Config.Bool` return the zero value for a missing key. Use `compass.Config.Lookup` or `compass.Config.Has` when a missing key must be told apart from a zero value, and `compass.Config.LoadSection` to read a whole section into a struct with `koanf` tags: + +```go src=modules/compass/example_test.go#ExampleOpen +dir, err := os.MkdirTemp("", "acme-config") +if err != nil { + fmt.Println(err) + return +} +defer os.RemoveAll(dir) +if err := writeConfig(dir); err != nil { + fmt.Println(err) + return +} + +// Environ stands in for the process environment (nil reads os.Environ). +cfg, err := compass.Open(compass.Options{ + Dir: dir, + Env: "development", + Environ: []string{"SUMMER_MAIL__DRIVER=smtp"}, +}) +if err != nil { + fmt.Println(err) + return +} + +// A plugin's embedded config/config.yaml becomes its defaults. +plugin := fstest.MapFS{"config/config.yaml": {Data: []byte("posts_per_page: 10\n")}} +if err := cfg.MergePlugin("acme.blog", plugin); err != nil { + fmt.Println(err) + return +} + +fmt.Println(cfg.String("app.name"), cfg.Bool("app.debug"), cfg.Int("acme.blog.posts_per_page")) +var mail mailSettings +if err := cfg.LoadSection("mail", &mail); err != nil { + fmt.Println(err) + return +} +fmt.Println(mail.Driver, mail.From) +_, found := cfg.Lookup("app.timezone") +fmt.Println(cfg.Environment(), found) + +// A runtime override, saved to env/development/overrides.yaml. +if err := cfg.Set("acme.blog.posts_per_page", 25); err != nil { + fmt.Println(err) + return +} +if err := cfg.Persist(); err != nil { + fmt.Println(err) + return +} +saved, _ := os.ReadFile(filepath.Join(dir, "env", "development", "overrides.yaml")) +fmt.Print(string(saved)) +// Output: +// Acme true 10 +// smtp blog@example.com +// development false +// acme: +// blog: +// posts_per_page: 25 +``` + +The application opens its configuration once with `compass.Load("config")` in the generated `main` and hands it to the `backpack.App`. Plugins read it through `app.Config` and never open their own. + +## Changing values at runtime + +`compass.Config.Set` changes a value in memory. `compass.Config.Persist` saves every value set at runtime to `config/env//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. + +> [!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. diff --git a/docs/services/events.md b/docs/services/events.md new file mode 100644 index 0000000..2d92a1a --- /dev/null +++ b/docs/services/events.md @@ -0,0 +1,98 @@ +--- +title: Events +description: Listen for and fire typed events on the application bus with festival, with priorities, collected results and a halting fire that stops when handled. +section: services +order: 20 +--- +# Events + +`Event::listen` and `Event::fire` are how WinterCMS plugins extend each other. SummerCMS keeps the pattern with [festival](../../modules/festival/README.md): each application has one bus, `backpack.App.Events`, and plugins listen from their `Boot` step for events that other plugins fire. [Extending plugins](../plugins/extending.md) shows where events fit among the other extension points; this page covers the bus itself. + +## Typed events + +An event is a Go type, not a string. A listener is a function that takes a context and the event, and the bus routes by type, so a listener never receives a payload of the wrong shape and a mismatch does not compile. Name events after what happened, and keep them in the package of the plugin that fires them so listeners can import the type. + +`festival.Bus.Listen` registers a listener at priority 0 and `festival.Bus.ListenPriority` at a given priority. Higher priorities run first, and listeners with the same priority run in registration order. The first argument is the ID of the plugin that owns the listener: + +```go src=modules/festival/example_test.go#ExampleBus_Fire +bus := festival.New() // in a plugin, use app.Events + +// acme.search and acme.notify extend acme.blog from their Boot steps. +bus.Listen("acme.search", func(ctx context.Context, e PostPublished) error { + fmt.Println("index", e.Title) + return nil +}) +bus.ListenPriority("acme.notify", 10, func(ctx context.Context, e PostPublished) error { + fmt.Println("notify subscribers of", e.Title) + return nil +}) + +// acme.blog fires the event; higher priorities run first. +if err := bus.Fire(context.Background(), PostPublished{Title: "Hello"}); err != nil { + fmt.Println(err) +} +// Output: +// notify subscribers of Hello +// index Hello +``` + +`festival.Bus.Fire` runs every listener, even after one fails, and returns the failures joined with `errors.Join`. A listener that panics is recovered and reported as an error that names its plugin, so one faulty plugin cannot stop the others. + +All three dispatch methods run the listeners on the caller's goroutine, before they return. For work that should not delay the request, a listener dispatches a job; see [Queued jobs](jobs.md). + +## Collecting contributions + +WinterCMS events often gather something from their listeners, such as extra form fields or menu items. `festival.Bus.Collect` runs every listener and, after each one, merges the map the event returns from `festival.Collectable.Collected`. A later listener wins when two set the same key. Use a pointer event so listeners can write to it: + +```go src=modules/festival/example_test.go#ExampleBus_Collect +bus := festival.New() +bus.Listen("acme.seo", func(ctx context.Context, e *PostFormExtended) error { + e.fields = map[string]any{"meta_title": "text"} + return nil +}) +bus.Listen("acme.gallery", func(ctx context.Context, e *PostFormExtended) error { + e.fields = map[string]any{"cover": "fileupload"} + return nil +}) + +fields, err := bus.Collect(context.Background(), &PostFormExtended{}) +fmt.Println(fields, err) +// Output: map[cover:fileupload meta_title:text] +``` + +`festival.Bus.Collect` returns the payload gathered so far together with the joined errors, so one failing listener does not lose the others' contributions. + +## Stopping at the first handler + +The WinterCMS halting fire stops at the first listener that returns a result. `festival.Bus.UntilHandled` stops as soon as the event's `festival.Handleable.IsHandled` reports true, or at the first error, and returns whether the event was handled: + +```go src=modules/festival/example_test.go#ExampleBus_UntilHandled +bus := festival.New() +bus.ListenPriority("acme.pages", 10, func(ctx context.Context, e *SlugResolving) error { + if e.Slug == "about" { + e.Found = "page" + } + return nil +}) +bus.Listen("acme.blog", func(ctx context.Context, e *SlugResolving) error { + fmt.Println("acme.blog asked for", e.Slug) + e.Found = "post" + return nil +}) + +for _, slug := range []string{"about", "hello-world"} { + e := &SlugResolving{Slug: slug} + handled, err := bus.UntilHandled(context.Background(), e) + fmt.Println(slug, handled, e.Found, err) +} +// Output: +// about true page +// acme.blog asked for hello-world +// hello-world true post +``` + +Here `acme.pages` listens at a higher priority, so it gets the first chance to claim a slug, and `acme.blog` is asked only when no page matched. + +## Events and transactions + +Listeners run where the event is fired, inside any transaction the caller has open. A listener that writes to the database joins that transaction when it uses the transaction handle the event carries. A listener with a side effect outside the database, such as a mail or a broadcast, should defer it with `lagoon.AfterCommit`, so it does not announce a write that rolls back. See [Transactions](../database/transactions.md). diff --git a/docs/services/localization.md b/docs/services/localization.md new file mode 100644 index 0000000..68d459b --- /dev/null +++ b/docs/services/localization.md @@ -0,0 +1,84 @@ +--- +title: Localization +description: Ship plugin translations in lang YAML catalogs, translate with :name placeholders and CLDR plurals through phrasebook, and read the request locale. +section: services +order: 80 +--- +# Localization + +WinterCMS plugins keep their strings in `lang//*.php` and read them with `Lang::get('acme.blog::lang.posts.title')` and `trans_choice`. SummerCMS keeps the key form and Laravel's message syntax; [phrasebook](../../modules/phrasebook/README.md) loads the catalogs and translates. + +## Catalogs + +A plugin ships `lang//.yaml` files and implements `pact.HasLang` to return them. Nested maps flatten into dotted keys under the plugin ID, so `title` in `lang/en/posts.yaml` of `acme.blog` is the key `acme.blog::posts.title`. At boot, `phrasebook.Activate` loads the framework strings and every plugin's catalogs and publishes one `phrasebook.Translator` on the application. Duplicate keys, malformed paths and values that are not strings fail the start-up. + +A plugin that implements `pact.HasLangOverrides` can replace keys of any loaded namespace, the framework's admin strings included, with files laid out as `lang///.yaml`. Overrides may also add a locale. + +## Translating + +`phrasebook.Translator.Get` translates a key in the request locale, and `phrasebook.Translator.GetIn` in a locale you name. A lookup tries the locale, then its parent (`pt-BR`, then `pt`), then `app.fallback_locale`. A key that no locale has comes back unchanged, and outside production it is logged once. Placeholders follow Laravel: `:name` inserts the value, `:Name` capitalizes its first letter and `:NAME` upper-cases it: + +```go src=modules/phrasebook/example_test.go#ExampleTranslator_Get +cat := phrasebook.NewCatalog() +if err := cat.Load("acme.blog", langFS); err != nil { + fmt.Println(err) + return +} +tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) + +// surf stores the request locale on the context; here the example does. +ctx := towel.WithLocale(context.Background(), "pl") +fmt.Println(tr.Get(ctx, "acme.blog::posts.title", nil)) +// pl has no greeting, so the fallback locale answers. +fmt.Println(tr.Get(ctx, "acme.blog::posts.greeting", map[string]string{"name": "Ada"})) +fmt.Println(tr.GetIn("en", "acme.blog::posts.shout", map[string]string{"name": "Ada"})) +// A missing key comes back as the key. +fmt.Println(tr.Get(ctx, "acme.blog::posts.missing", nil)) +// Output: +// Posty +// Hello, Ada +// Welcome, ADA +// acme.blog::posts.missing +``` + +Application code gets the published translator with `app.Lookup[*phrasebook.Translator]()`. + +## Plurals + +`phrasebook.Translator.Choice` and `phrasebook.Translator.ChoiceIn` pick a plural form and fill in `:count`. A key can hold a map of CLDR plural categories (`one`, `few`, `many`, `other`, ...), checked against the categories the locale actually has, so a Polish string gets the forms Polish needs. Laravel's pipe syntax works too, with exact (`{0}`) and range (`[2,*]`) conditions: + +```go src=modules/phrasebook/example_test.go#ExampleTranslator_Choice +cat := phrasebook.NewCatalog() +if err := cat.Load("acme.blog", langFS); err != nil { + fmt.Println(err) + return +} +tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) + +for _, n := range []int{1, 3, 5, 22} { + fmt.Println(tr.ChoiceIn("pl", "acme.blog::posts.count", n, nil)) +} +fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.count", 5, nil)) +for _, n := range []int{0, 1, 7} { + fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.drafts", n, nil)) +} +// Output: +// 1 post +// 3 posty +// 5 postów +// 22 posty +// 5 posts +// No drafts +// One draft +// 7 drafts +``` + +## The request locale + +The locale lives on the request context, not in a global. For every route, [surf](../../modules/surf/README.md) sets it from the `Accept-Language` header; the `locale.from-principal` middleware switches it to the signed-in user's preferred locale. Code reads it with `towel.Locale`, and code outside a request sets it with `towel.WithLocale`, as the example above does. A context without a locale uses `app.locale`. + +## Strings for the admin + +The admin SPA receives its strings from the server. `phrasebook.Translator.Bundle` returns every key under a prefix as CLDR plural forms, merged over the fallback chain, and `phrasebook.Translator.Forms` returns one key. Start-up fails if an admin (`backend::`) string cannot be expressed as CLDR forms, so a pipe string with a condition the SPA cannot evaluate is caught before any admin sees it. + +The framework ships its validation messages (`lagoon::validate`) and admin strings (`backend::lang`) in English and Polish. diff --git a/docs/services/mail.md b/docs/services/mail.md new file mode 100644 index 0000000..1b6cb12 --- /dev/null +++ b/docs/services/mail.md @@ -0,0 +1,102 @@ +--- +title: Mail +description: Ship WinterCMS-style mail templates in a plugin, send them through postcard, and deliver them with the memory, log or SMTP driver. +section: services +order: 70 +--- +# Mail + +WinterCMS plugins ship mail templates in `views/mail` and send them with `Mail::send`. SummerCMS keeps the file format and the dotted template names; [postcard](../../modules/postcard/README.md) loads them at boot and sends them through a configured driver. + +## Templates and layouts + +A plugin implements `pact.HasMailTemplates`: it returns its embedded `views/mail` files, the template names it ships and short aliases for its layouts. A template named `acme.blog::mail.welcome` lives in `views/mail/welcome.htm` (dots in the name become directories), and a plugin may only register names in its own namespace. A missing file, a duplicate name or an unknown layout alias fails the start-up. + +The file format is WinterCMS's: an INI header with the `subject`, the `layout` alias and a `description`, a `==` line, then a Markdown body with Go template variables such as `{{ .name }}`. A layout has a header, a text wrapper and an HTML wrapper, separated by `==` lines, each with `{{ .Content }}` where the message goes. A neutral `default` layout is built in. + +Each message gets an HTML part, rendered from the Markdown, and a plain-text part. `mail.css` and `mail.brandCss` are inlined into the layout's style block. + +## Sending + +At boot, `postcard.Activate` publishes one `postcard.Mailer` on the application, and `postcard.BootPlugin` registers each plugin's templates as it boots. Look the mailer up with `app.Lookup[postcard.Mailer]()` and send a `postcard.Message` with the template name, the recipients and the variables. Tests build the catalog and a memory driver directly, and read back what was sent: + +```go src=modules/postcard/example_test.go#ExampleMailer_Send +// At boot, postcard.BootPlugin registers what pact.HasMailTemplates +// declares; a test registers the same thing directly. +cat := postcard.NewCatalog() +err := cat.Register("acme.blog", mailFS, + []string{"acme.blog::mail.welcome"}, + map[string]string{"blog": "acme.blog::mail.layouts.blog"}) +if err != nil { + fmt.Println(err) + return +} +driver := postcard.NewMemoryDriver() // mail.driver: memory +mailer := postcard.NewMailer(cat, driver, postcard.Options{From: "blog@example.com"}) + +err = mailer.Send(context.Background(), postcard.Message{ + Template: "acme.blog::mail.welcome", + To: []string{"ada@example.com"}, + Vars: map[string]any{"name": "Ada"}, +}) +if err != nil { + fmt.Println(err) + return +} +sent := driver.Messages()[0] +fmt.Println(sent.From, sent.To, sent.Subject) +fmt.Println(sent.Text) +fmt.Println(strings.Contains(sent.HTML, `

Hi Ada`)) + +// Header injection is refused before any driver sees the message. +err = mailer.Send(context.Background(), postcard.Message{ + Template: "acme.blog::mail.welcome", + To: []string{"ada@example.com\r\nBcc: all@example.com"}, + Vars: map[string]any{"name": "Ada"}, +}) +fmt.Println(err != nil, len(driver.Messages())) +// Output: +// blog@example.com [ada@example.com] Welcome, Ada +// Hi **Ada**, thanks for joining the blog. +// +// -- The Acme blog +// true +// true 1 +``` + +postcard does not pick a locale. For a per-language template, register one name per language (`acme.blog::mail.welcome_pl`) and pass the full name. + +Before a driver sees a message, postcard refuses a subject or address with a line break, parses every address with `net/mail`, and rejects rendered HTML that contains script, iframe, object or embed tags, inline event handlers, or `javascript:`, `vbscript:` or `data:` URLs. Variables are escaped by Go's `html/template`. + +`postcard.Mailer.Send` delivers before it returns. To keep a request fast, send from a queued job, and send after the write that triggered the mail has committed; see [Queued jobs](jobs.md) and [Transactions](../database/transactions.md). + +## Drivers + +`mail.driver` selects the driver: + +| Driver | Delivers | +|--------|----------| +| `memory` (default) | Nowhere: messages are kept in the process, for tests. | +| `log` | To the log: headers and the text part, never the HTML part or credentials. For development. | +| `smtp` | Through an SMTP server with the configured TLS policy. | + +The SMTP settings go in `config/mail.yaml`, with the password in the environment (`SUMMER_MAIL__SMTP__PASSWORD`): + +```yaml +driver: smtp +from: blog@example.com +smtp: + host: smtp.example.com + port: 587 + username: blog + password: + tls: mandatory +``` + +`mail.smtp.tls` defaults to `mandatory`: the connection must upgrade with STARTTLS, and sending fails if the server does not offer it. postcard never infers a plain connection. The other two values exist for local mail catchers only: + +- `starttls` (or `opportunistic`) uses TLS when the server offers it and sends in plain text when it does not, so an attacker on the network can strip the upgrade. +- `none` sends in plain text. + +> [!WARNING] +> Use `starttls` or `none` only against a local development mail catcher. In production, keep the default `mandatory`. diff --git a/docs/services/oauth-server.md b/docs/services/oauth-server.md new file mode 100644 index 0000000..6e87dc4 --- /dev/null +++ b/docs/services/oauth-server.md @@ -0,0 +1,148 @@ +--- +title: OAuth server +description: Let MCP clients act for your users with the wristband OAuth server, covering metadata, dynamic client registration, PKCE, consent and refresh token rotation. +section: services +order: 60 +--- +# OAuth server + +[wristband](../../modules/wristband/README.md) is the protocol side of an OAuth 2 authorization server, built for MCP clients such as AI assistants and connectors that act on behalf of the application's users. WinterCMS core has no counterpart. wristband provides the HTTP handlers and the consent operations; the application provides the storage, the access tokens it already uses for its API, and the consent screen. + +## What it implements + +- The RFC 8414 metadata document, advertising the endpoints, the `authorization_code` and `refresh_token` grants, S256 PKCE and the RFC 9207 `iss` response parameter. +- RFC 7591 dynamic client registration: public clients (`none`) and confidential ones (`client_secret_post`, `client_secret_basic`), redirect URI validation, a cap on unrevoked clients and a sweep of old clients that never got consent. +- The authorization endpoint, which validates the client and its exact registered redirect URI before it redirects anywhere, then checks PKCE, the client's scopes and the RFC 8707 `resource` value, stores a pending request and sends the browser to the application's consent page. +- The token endpoint: code exchange with PKCE verification, then an access token from the application and a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the access tokens issued from it. + +Client secrets, codes and refresh tokens are random strings stored only as SHA-256 hashes and compared in constant time. + +## Configuring the server + +wristband reads no configuration keys. The application builds a `wristband.Options` value from `wristband.DefaultOptions` and sets at least `wristband.Options.Issuer`, its own URL without a trailing slash, and `wristband.Options.Resource`, the URL of the protected resource its tokens are for: + +```go src=modules/wristband/example_test.go#newServer +// newServer builds the authorization server of an application served at +// https://blog.example.com. The application sets Issuer and Resource for +// its own deployment; the defaults cover everything else. +func newServer() *wristband.Server { + opts := wristband.DefaultOptions() + opts.Issuer = "https://blog.example.com" + opts.Resource = "https://blog.example.com/mcp" + opts.ScopesSupported = []string{"read", "write", "offline_access"} + return wristband.NewServer(opts) +} +``` + +> [!WARNING] +> Always set `wristband.Options.Resource` for your deployment. Do not rely on the value `wristband.DefaultOptions` returns. + +The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. The metadata document serves the configured values: + +```go src=modules/wristband/example_test.go#ExampleServer_Metadata +srv := newServer() +// srv.SetBackend(backend) attaches the application's stores; the +// metadata document does not need them. +rec := httptest.NewRecorder() +srv.Metadata(rec, httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil)) + +var doc map[string]any +if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + fmt.Println(err) + return +} +for _, key := range []string{ + "issuer", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "scopes_supported", + "grant_types_supported", + "code_challenge_methods_supported", +} { + fmt.Println(key, doc[key]) +} +// Output: +// issuer https://blog.example.com +// authorization_endpoint https://blog.example.com/oauth/mcp/authorize +// token_endpoint https://blog.example.com/oauth/mcp/token +// registration_endpoint https://blog.example.com/oauth/mcp/register +// scopes_supported [read write offline_access] +// grant_types_supported [authorization_code refresh_token] +// code_challenge_methods_supported [S256] +``` + +## Storage + +The server has no database code. The application attaches its storage with `wristband.Server.SetBackend`; until it does, handlers that need storage answer 500. A `wristband.Backend` runs a function inside one database transaction with a `wristband.Tx`, which bundles the stores and the token issuer: + +| Interface | Stores | +|-----------|--------| +| `wristband.ClientStore` | Registered clients (`wristband.ClientRecord`): lookup, capped create, the sweep and the consent stamp. | +| `wristband.AuthCodeStore` | Pending requests and issued codes (`wristband.AuthCodeRecord`). | +| `wristband.RefreshTokenStore` | Refresh token lineages (`wristband.RefreshTokenRecord`), including rotation and revocation. | +| `wristband.AccessTokenIssuer` | Mints and revokes the application's own API tokens. | + +Registration, code exchange and refresh each run in one transaction through this interface, so the writes of each step commit or roll back together: a code is never marked used without its tokens, and a refresh token is never spent without its replacement. + +## Routes + +Mount the handlers in a raw group, because the OAuth endpoints define their own response formats and must not be wrapped in the JSON envelope middleware (see [Routing](routing.md)): + +| Route | Handler | +|-------|---------| +| `GET /.well-known/oauth-authorization-server` | `wristband.Server.Metadata` | +| `GET /oauth/mcp/authorize` | `wristband.Server.Authorize` | +| `POST /oauth/mcp/token` | `wristband.Server.Token` | +| `POST /oauth/mcp/register` | `wristband.Server.Register` | + +The metadata document advertises these paths under the issuer, so mount them at exactly these paths. + +## Consent + +The authorization endpoint sends the browser to `/connect?request=`. That page belongs to the application: it signs the user in, shows what the client asks for and posts the decision to the application's own consent handler, which calls: + +- `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`; +- `wristband.Server.IssueCode` to grant, which returns the redirect URL carrying the code, `iss` and `state`; +- `wristband.Server.DenyPending` to refuse, which returns the `access_denied` redirect URL. + +`wristband.Server.IssueCode` stores exactly the scopes it is given. The consent handler must pass only scopes that the pending request asked for, the user accepted and the application can grant. A missing, foreign, used or expired request is `wristband.ErrPendingNotFound` in every case, so the handler cannot tell another user's request ID from an invalid one. + +`wristband.Server.Revoke` disconnects an app: it revokes an access token and the refresh lineage behind it. + +## Clients created outside registration + +Operator tooling that creates clients directly uses the same rules as registration. `wristband.RejectRedirectURI` accepts `https://` URIs and loopback `http://` URIs only: + +```go src=modules/wristband/example_test.go#ExampleRejectRedirectURI +for _, uri := range []string{ + "https://client.example.org/callback", + "http://127.0.0.1:33418/callback", + "http://client.example.org/callback", +} { + if reason := wristband.RejectRedirectURI(uri); reason != "" { + fmt.Println("rejected:", reason) + continue + } + fmt.Println("accepted:", uri) +} +// Output: +// accepted: https://client.example.org/callback +// accepted: http://127.0.0.1:33418/callback +// rejected: Redirect URI must be https:// or loopback http://127.0.0.1 / http://localhost: http://client.example.org/callback +``` + +`wristband.IssueClientCredentials` generates the client ID and, for a confidential client, a secret that is returned once and its hash, which is what you store: + +```go src=modules/wristband/example_test.go#ExampleIssueClientCredentials +// A confidential client gets a secret, shown once; store only the hash. +id, secret, hash, err := wristband.IssueClientCredentials("client_secret_post") +fmt.Println(id != "", secret != "", hash != nil && *hash != secret, err) + +// A public client (PKCE only) gets no secret. +_, secret, hash, err = wristband.IssueClientCredentials("none") +fmt.Println(secret == "", hash == nil, err) +// Output: +// true true true +// true true +``` diff --git a/docs/services/rate-limiting.md b/docs/services/rate-limiting.md new file mode 100644 index 0000000..676fa4b --- /dev/null +++ b/docs/services/rate-limiting.md @@ -0,0 +1,73 @@ +--- +title: Rate limiting +description: Throttle routes with inline limits or named buckets, key them by user or client IP, and trust X-Forwarded-For only from configured proxies. +section: services +order: 40 +--- +# Rate limiting + +Laravel limits requests with the `throttle` middleware and named limiters from `RateLimiter::for`. SummerCMS has both through [surf](../../modules/surf/README.md): a `throttle` middleware that takes an inline limit or the name of a bucket a plugin declares. + +## Inline limits + +`throttle:,` allows `max` requests per window of `minutes` minutes. The counter is kept per signed-in user, or per client IP for guests. The plugin on [Routing](routing.md) puts `throttle:60,1` on its whole `/api/blog` group, so each client may make 60 requests a minute to it. + +## Named buckets + +A bucket gives a limit its own key, such as the client IP plus the route, or a token ID. A plugin declares buckets by implementing `surf.BucketProvider`, and routes name them as `throttle:`: + +```go src=modules/surf/example_test.go#BlogPlugin.Buckets +// Buckets declares a named rate limit, used as throttle:blog.comments. +func (p *BlogPlugin) Buckets() map[string]surf.Bucket { + return map[string]surf.Bucket{ + "blog.comments": { + Max: 1, + Decay: time.Minute, + Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) }, + }, + } +} +``` + +Each `surf.Bucket` has the maximum number of requests, the window length (`Decay`) and a `Key` function that builds the counter key from the request. Prefix the key with the bucket's purpose, as above, so two buckets never share a counter. A route may name several throttles; each counts separately. A throttle naming a bucket that no plugin declares fails the start-up. + +A request over the limit gets a 429 response with the body `{"message":"Too Many Attempts."}` and a `Retry-After` header. Every throttled response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, and a rejected one also `X-RateLimit-Reset`. + +The limiter is a fixed window: the counter resets when the window ends, as Laravel's cache limiter does. Counters live in the process (`surf.MemoryStore`, behind the `surf.Store` interface), so each application instance counts on its own. Behind a load balancer with several instances, the effective limit is the configured limit times the number of instances. + +## Client IP and trusted proxies + +`surf.ClientIP` is the one place the client IP comes from. It uses the connection's remote address, and reads `X-Forwarded-For` only when that address is inside a range listed in `http.trusted_proxies`. It then takes the rightmost address that is not itself a trusted proxy, so a client cannot choose its own IP by sending the header: + +```go src=modules/surf/example_test.go#ExampleClientIP +cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}}) +if err != nil { + fmt.Println(err) + return +} +_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"}) +trusted := surf.TrustedProxies(cfg) + +// Through the load balancer at 10.0.0.5: the forwarded client is used. +viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil) +viaProxy.RemoteAddr = "10.0.0.5:4711" +viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9") +fmt.Println(surf.ClientIP(viaProxy, trusted)) + +// Straight from the internet: a forged header is ignored. +direct := httptest.NewRequest("GET", "/api/blog/posts", nil) +direct.RemoteAddr = "203.0.113.7:5000" +direct.Header.Set("X-Forwarded-For", "127.0.0.1") +fmt.Println(surf.ClientIP(direct, trusted)) +// Output: +// 198.51.100.23 +// 203.0.113.7 +``` + +List only the proxies you run, in `config/http.yaml`: + +```yaml +trusted_proxies: ["10.0.0.0/8"] +``` + +With the list empty (the default), the header is ignored and every request behind a proxy has the proxy's IP. A malformed entry is skipped. A bucket's `Key` function should use the same trusted list, as the plugin above does by reading `surf.TrustedProxies` in `Register`. diff --git a/docs/services/routing.md b/docs/services/routing.md new file mode 100644 index 0000000..9454eee --- /dev/null +++ b/docs/services/routing.md @@ -0,0 +1,210 @@ +--- +title: Routing +description: Declare a plugin's HTTP routes with groups, auth groups, path constraints and named middleware through pact.HasRoutes, and write JSON responses with wire. +section: services +order: 30 +--- +# Routing + +A WinterCMS plugin declares its routes in `routes.php` with `Route::group`, `->middleware()` and `->where()`. A SummerCMS plugin implements `pact.HasRoutes`: its `Routes` method receives a `pact.Router` with the same builder shape. [surf](../../modules/surf/README.md) collects every plugin's routes into one standard library `http.ServeMux`, and checks all of them when the application starts, so a duplicate route, an unknown middleware name or a malformed throttle stops the start-up instead of failing on the first request. + +Handlers are ordinary `http.HandlerFunc` values. There are no controllers to extend and no request objects to learn. + +## Declaring routes + +This plugin declares public routes, an auth group, path constraints and per-route middleware: + +```go src=modules/surf/example_test.go#BlogPlugin.Routes +// Routes is the Go form of the plugin's routes.php. +func (p *BlogPlugin) Routes(r pact.Router) error { + r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) { + g.Get("/posts/{id}", showPost) + g.Where("id", `[0-9]+`) + g.Get("/posts/{status}/list", listPosts) + g.WhereIn("status", "draft", "published") + + // An auth group: every route inside needs a signed-in user. + g.Group("", surf.Use("acme.auth"), func(auth pact.Router) { + auth.Get("/me", showMe) + auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536") + }) + }) + return nil +} +``` + +- `pact.Router.Group` adds a path prefix and a middleware list to the routes declared inside it; groups nest. `surf.Use` builds the list. +- `pact.Router.Get`, `pact.Router.Post`, `pact.Router.Put`, `pact.Router.Patch` and `pact.Router.Delete` take a path in Go's pattern syntax (`/posts/{id}`) and optional middleware names for that route alone. +- `pact.Router.Where` restricts a path parameter of the route declared just before it to a regular expression matched against the whole segment, and `pact.Router.WhereIn` to a list of values. A request that fails a constraint gets a 404. + +In a handler, `r.PathValue("status")` reads a parameter, and `surf.IntParam` reads one as a positive integer: + +```go src=modules/surf/example_test.go#showPost +func showPost(w http.ResponseWriter, r *http.Request) { + id, ok := surf.IntParam(r, "id") + if !ok { + http.NotFound(w, r) + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id}) +} +``` + +## Auth groups + +An auth group is a group whose middleware list names a guard. The plugin above turns a [bouncer](../../modules/bouncer/README.md) JWT guard into named middleware and returns it from `pact.HasMiddleware`: + +```go src=modules/surf/example_test.go#BlogPlugin.Middlewares +// Middlewares registers the plugin's named middleware: here, a JWT guard +// that answers 401 when the request has no valid token. +func (p *BlogPlugin) Middlewares() map[string]pact.Middleware { + guards := bouncer.NewRegistry() + guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist()) + if err := guards.Register(p.ID(), "acme.auth", guard); err != nil { + panic(err) + } + auth, err := guards.Middleware("acme.auth") + if err != nil { + panic(err) + } + return map[string]pact.Middleware{"acme.auth": auth} +} +``` + +Every route in the group then requires a valid token, and handlers read the signed-in user with `bouncer.User`. The guard answers 401 with a JSON body when the token is missing or invalid. See [Authentication](authentication.md) for guards and tokens. + +The admin API uses the built-in `backend` middleware name, which the framework registers when the admin is enabled. + +## Middleware + +Named middleware is any `func(http.Handler) http.Handler` a plugin returns from `pact.HasMiddleware`. A plugin that needs a parameter, used as `name:param`, returns a factory from `pact.HasMiddlewareFactories`. Middleware names are global, so prefix them with the plugin: `acme.auth`, `blog.no-store`. A duplicate name fails the start-up. + +The framework registers these names: + +| Name | Does | +|------|------| +| `throttle:` or `throttle:,` | Rate limiting; see [Rate limiting](rate-limiting.md). | +| `body.limit:` | Replaces the default request body limit for the route. | +| `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. | +| `backend` | The admin guard, when the admin is enabled. | + +Every route also gets, around its own middleware, JSON panic recovery, the request locale from `Accept-Language`, the body limit from `http.body_limits.default_bytes`, and CORS headers when its path matches `http.cors.paths`. The order is described in [Request lifecycle](../architecture/request-lifecycle.md). + +`pact.Router.GroupRaw` declares a raw group for routes that must not be wrapped in the house JSON middleware, such as webhooks, file streams or the OAuth endpoints: the default body limit is skipped, and a panic returns a bare 500. + +## Responses + +Write JSON with `wire.WriteJSON`. It produces what PHP's `json_encode` produces: HTML characters are not escaped and there is no trailing newline. `wire.Time` marshals a timestamp as Carbon does (`+00:00`, never `Z`), `wire.TriBool` is a nullable boolean, and `wire.Slice` turns a nil slice into `[]`: + +```go src=modules/wire/example_test.go#ExampleWriteJSON +var tags []string // nil: the post has no tags +warsaw := time.FixedZone("CEST", 2*60*60) +body := postJSON{ + ID: 1, + Title: "Tips & ", + Tags: wire.Slice(tags), + Featured: wire.TriBool{}, + Pinned: wire.TriBool{Value: true, Valid: true}, + PublishedAt: wire.Time{Time: time.Date(2026, 9, 30, 14, 5, 0, 0, warsaw)}, +} +rec := httptest.NewRecorder() +wire.WriteJSON(rec, http.StatusOK, map[string]any{"data": body}) +fmt.Println(rec.Code, rec.Header().Get("Content-Type")) +fmt.Printf("%s|\n", rec.Body.String()) + +rec = httptest.NewRecorder() +wire.WriteOpaque500(rec) +fmt.Println(rec.Code, rec.Body.String()) +// Output: +// 200 application/json +// {"data":{"id":1,"title":"Tips & ","tags":[],"featured":null,"pinned":true,"published_at":"2026-09-30T12:05:00+00:00"}}| +// 500 {"error":true,"message":"Internal server error"} +``` + +`wire.WriteOpaque500` writes the fixed 500 body that panic recovery also uses; it reveals nothing about the failure. + +## Testing and listing routes + +`surf.Assemble` builds the complete handler from the application and its plugins, so a test can drive it with `net/http/httptest`: + +```go src=modules/surf/example_test.go#ExampleAssemble +// The application passes its config; http.body_limits is required there. +app := backpack.New(nil) +plugin := &BlogPlugin{} +if err := plugin.Register(app); err != nil { // the runtime calls Register + fmt.Println(err) + return +} +h, err := surf.Assemble(app, []party.Plugin{plugin}) +if err != nil { + fmt.Println(err) + return +} +token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour) + +do := func(method, path string, auth bool) { + req := httptest.NewRequest(method, path, strings.NewReader("{}")) + if auth { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String())) +} +do("GET", "/api/blog/posts/7", false) +do("GET", "/api/blog/posts/seven", false) +do("GET", "/api/blog/posts/draft/list", false) +do("GET", "/api/blog/posts/deleted/list", false) +do("GET", "/api/blog/me", false) +do("GET", "/api/blog/me", true) +do("POST", "/api/blog/posts/7/comments", true) +do("POST", "/api/blog/posts/7/comments", true) +// Output: +// GET /api/blog/posts/7 200 {"id":7} +// GET /api/blog/posts/seven 404 404 page not found +// GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"} +// GET /api/blog/posts/deleted/list 404 404 page not found +// GET /api/blog/me 401 {"error":true,"message":"Token not provided"} +// GET /api/blog/me 200 {"id":42} +// POST /api/blog/posts/7/comments 201 {"created":true} +// POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."} +``` + +`route:list` builds the router the way `serve` does, without opening the database or listening, and prints every route with its plugin and middleware: + +```sh +./bin/acme route:list +``` + +`surf.BuildRouter` returns the same information to Go code through `surf.Router.Routes`: + +```go src=modules/surf/example_test.go#ExampleBuildRouter +r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}}) +if err != nil { + fmt.Println(err) + return +} +for _, rt := range r.Routes() { + fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware) +} +// Output: +// GET /api/blog/posts/{id} acme.blog [throttle:60,1] +// GET /api/blog/posts/{status}/list acme.blog [throttle:60,1] +// GET /api/blog/me acme.blog [throttle:60,1 acme.auth] +// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536] +``` + +## CORS + +CORS is configured with the keys of Laravel's `config/cors.php`, under `http.cors`, and applies only to paths that match `http.cors.paths`. With no `http.cors` section, no CORS headers are sent: + +```yaml +cors: + paths: ["api/*"] + allowed_origins: ["https://blog.example.com"] + allowed_methods: ["*"] + allowed_headers: ["*"] + supports_credentials: true +``` + +This fragment belongs in `config/http.yaml`. List the frontend's exact origin; `*` is for public, credential-free APIs only. diff --git a/docs/site.yaml b/docs/site.yaml index 8ace1cf..13afe0c 100644 --- a/docs/site.yaml +++ b/docs/site.yaml @@ -17,6 +17,8 @@ sections: title: Architecture - name: plugins title: Plugins + - name: database + title: Database - name: services title: Services - name: console diff --git a/modules/bouncer/example_test.go b/modules/bouncer/example_test.go new file mode 100644 index 0000000..d65a02e --- /dev/null +++ b/modules/bouncer/example_test.go @@ -0,0 +1,105 @@ +package bouncer_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "time" + + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// testSecret is a test-only signing secret. A real application reads its +// secret from configuration and never commits it. +const testSecret = "test-only-secret-with-at-least-32-bytes" + +func ExampleMint() { + const issuer = "http://127.0.0.1:8080/api/login" + token, _, err := bouncer.Mint(testSecret, "42", issuer, time.Hour) + if err != nil { + fmt.Println(err) + return + } + sub, iat, exp, _, err := bouncer.VerifyClaims(token, testSecret) + fmt.Println(sub, exp.Sub(iat), err) + + // A frontend token never passes a backend check, and a wrong secret fails. + _, _, _, _, err = bouncer.VerifyClaimsAudience(token, testSecret, bouncer.AudienceBackend) + fmt.Println(err != nil) + _, err = bouncer.Verify(token, "another-secret-with-at-least-32-bytes") + fmt.Println(err != nil) + + // Refresh reissues the token and blacklists the old jti after the grace. + bl := bouncer.NewMemoryBlacklist() + fresh, err := bouncer.Refresh(testSecret, token, 14*24*time.Hour, bl, 0, issuer) + fmt.Println(fresh != token, err) + _, _, _, jti, _ := bouncer.VerifyClaims(token, testSecret) + revoked, _ := bl.IsBlacklisted(context.Background(), jti) + fmt.Println(revoked) + // Output: + // 42 1h0m0s + // true + // true + // true + // true +} + +// users loads the principal behind a token subject. +type users struct{} + +func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { + return &bouncer.Principal{ID: id, PreferredLocale: "pl"}, nil +} + +func ExampleNewJWTGuard() { + guards := bouncer.NewRegistry() + guard := bouncer.NewJWTGuard(testSecret, users{}, bouncer.NewMemoryBlacklist(), "token") + if err := guards.Register("acme.blog", "acme.auth", guard); err != nil { + fmt.Println(err) + return + } + auth, err := guards.Middleware("acme.auth") + if err != nil { + fmt.Println(err) + return + } + me := auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, _ := bouncer.User(r.Context()) + fmt.Fprintf(w, "user %d, locale %s", user.ID, user.PreferredLocale) + })) + + token, _, _ := bouncer.Mint(testSecret, "42", "http://127.0.0.1:8080/api/login", time.Hour) + for _, set := range []func(*http.Request){ + func(r *http.Request) {}, + func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }, + func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "token", Value: token}) }, + } { + req := httptest.NewRequest("GET", "/api/me", nil) + set(req) + rec := httptest.NewRecorder() + me.ServeHTTP(rec, req) + fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String())) + } + // Output: + // 401 {"error":true,"message":"Token not provided"} + // 200 user 42, locale pl + // 200 user 42, locale pl +} + +func ExampleHashPassword() { + hash, err := bouncer.HashPassword(10, "correct horse battery staple") + if err != nil { + fmt.Println(err) + return + } + fmt.Println(bouncer.CheckPassword(hash, "correct horse battery staple")) + fmt.Println(bouncer.CheckPassword(hash, "wrong")) + // After raising the configured cost, rehash on the next successful login. + fmt.Println(bouncer.NeedsRehash(hash, 12)) + // Output: + // true + // false + // true +} diff --git a/modules/compass/example_test.go b/modules/compass/example_test.go new file mode 100644 index 0000000..cd05fa0 --- /dev/null +++ b/modules/compass/example_test.go @@ -0,0 +1,95 @@ +package compass_test + +import ( + "fmt" + "os" + "path/filepath" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/compass" +) + +// writeConfig lays out a config directory: base sections and a development +// override of one of them. +func writeConfig(dir string) error { + files := map[string]string{ + "app.yaml": "name: Acme\ndebug: false\n", + "mail.yaml": "driver: log\nfrom: blog@example.com\n", + "env/development/app.yaml": "debug: true\n", + } + for name, body := range files { + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return err + } + } + return nil +} + +type mailSettings struct { + Driver string `koanf:"driver"` + From string `koanf:"from"` +} + +func ExampleOpen() { + dir, err := os.MkdirTemp("", "acme-config") + if err != nil { + fmt.Println(err) + return + } + defer os.RemoveAll(dir) + if err := writeConfig(dir); err != nil { + fmt.Println(err) + return + } + + // Environ stands in for the process environment (nil reads os.Environ). + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Env: "development", + Environ: []string{"SUMMER_MAIL__DRIVER=smtp"}, + }) + if err != nil { + fmt.Println(err) + return + } + + // A plugin's embedded config/config.yaml becomes its defaults. + plugin := fstest.MapFS{"config/config.yaml": {Data: []byte("posts_per_page: 10\n")}} + if err := cfg.MergePlugin("acme.blog", plugin); err != nil { + fmt.Println(err) + return + } + + fmt.Println(cfg.String("app.name"), cfg.Bool("app.debug"), cfg.Int("acme.blog.posts_per_page")) + var mail mailSettings + if err := cfg.LoadSection("mail", &mail); err != nil { + fmt.Println(err) + return + } + fmt.Println(mail.Driver, mail.From) + _, found := cfg.Lookup("app.timezone") + fmt.Println(cfg.Environment(), found) + + // A runtime override, saved to env/development/overrides.yaml. + if err := cfg.Set("acme.blog.posts_per_page", 25); err != nil { + fmt.Println(err) + return + } + if err := cfg.Persist(); err != nil { + fmt.Println(err) + return + } + saved, _ := os.ReadFile(filepath.Join(dir, "env", "development", "overrides.yaml")) + fmt.Print(string(saved)) + // Output: + // Acme true 10 + // smtp blog@example.com + // development false + // acme: + // blog: + // posts_per_page: 25 +} diff --git a/modules/festival/example_test.go b/modules/festival/example_test.go index 70cef2a..b4a0f99 100644 --- a/modules/festival/example_test.go +++ b/modules/festival/example_test.go @@ -33,3 +33,60 @@ func ExampleBus_Fire() { // notify subscribers of Hello // index Hello } + +// PostFormExtended gathers extra fields other plugins add to the post form. +type PostFormExtended struct { + fields map[string]any +} + +func (e *PostFormExtended) Collected() map[string]any { return e.fields } + +func ExampleBus_Collect() { + bus := festival.New() + bus.Listen("acme.seo", func(ctx context.Context, e *PostFormExtended) error { + e.fields = map[string]any{"meta_title": "text"} + return nil + }) + bus.Listen("acme.gallery", func(ctx context.Context, e *PostFormExtended) error { + e.fields = map[string]any{"cover": "fileupload"} + return nil + }) + + fields, err := bus.Collect(context.Background(), &PostFormExtended{}) + fmt.Println(fields, err) + // Output: map[cover:fileupload meta_title:text] +} + +// SlugResolving asks plugins to resolve a URL slug; the first one that +// knows it handles the event. +type SlugResolving struct { + Slug string + Found string +} + +func (e *SlugResolving) IsHandled() bool { return e.Found != "" } + +func ExampleBus_UntilHandled() { + bus := festival.New() + bus.ListenPriority("acme.pages", 10, func(ctx context.Context, e *SlugResolving) error { + if e.Slug == "about" { + e.Found = "page" + } + return nil + }) + bus.Listen("acme.blog", func(ctx context.Context, e *SlugResolving) error { + fmt.Println("acme.blog asked for", e.Slug) + e.Found = "post" + return nil + }) + + for _, slug := range []string{"about", "hello-world"} { + e := &SlugResolving{Slug: slug} + handled, err := bus.UntilHandled(context.Background(), e) + fmt.Println(slug, handled, e.Found, err) + } + // Output: + // about true page + // acme.blog asked for hello-world + // hello-world true post +} diff --git a/modules/lagoon/attach/example_test.go b/modules/lagoon/attach/example_test.go new file mode 100644 index 0000000..6ea65a8 --- /dev/null +++ b/modules/lagoon/attach/example_test.go @@ -0,0 +1,81 @@ +package attach_test + +import ( + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + "os" + + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" +) + +// Post owns attachments. MorphName is the attachment_type value its rows +// carry: the PHP class name, so rows copied from WinterCMS keep matching. +type Post struct { + ID uint +} + +func (Post) MorphName() string { return `Acme\Blog\Models\Post` } + +func ExampleFile_Thumb() { + ctx := context.Background() + dir, err := os.MkdirTemp("", "acme-config") + if err != nil { + fmt.Println(err) + return + } + defer os.RemoveAll(dir) + + // storage.uploads.bucket_url is a file:// URL in production; mem:// keeps + // the example in memory. + cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}}) + if err != nil { + fmt.Println(err) + return + } + _ = cfg.Set("storage.uploads.bucket_url", "mem://") + bucket, err := attach.OpenBucket(ctx, cfg) + if err != nil { + fmt.Println(err) + return + } + defer bucket.Close() + + // The system_files row of a post's cover image. + var owner attach.Owner = Post{ID: 1} + f := attach.File{ + ID: 7, + DiskName: "5f1d0c2e9a7b4c3d8e6f.jpg", + FileName: "cover.jpg", + ContentType: "image/jpeg", + Field: "cover", + AttachmentType: owner.MorphName(), + AttachmentID: "1", + } + + // The original is stored under its partitioned WinterCMS key. + var img bytes.Buffer + if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 640, 480)), nil); err != nil { + fmt.Println(err) + return + } + if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), img.Bytes(), nil); err != nil { + fmt.Println(err) + return + } + fmt.Println(attach.BlobKey(f.DiskName)) + + // A thumbnail is generated on first use and reused afterwards. + url, err := f.Thumb(ctx, bucket, 200, 200, "crop") + if err != nil { + fmt.Println(err) + return + } + fmt.Println(url) + // Output: + // 5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg + // /storage/uploads/5f1/d0c/2e9/thumb_7_200_200_0_0_crop.jpg +} diff --git a/modules/lagoon/example_test.go b/modules/lagoon/example_test.go new file mode 100644 index 0000000..4d69de0 --- /dev/null +++ b/modules/lagoon/example_test.go @@ -0,0 +1,626 @@ +package lagoon_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// Post is the acme.blog post model: a plain GORM struct with lagoon column +// types. +type Post struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + Title string `gorm:"column:title" json:"title"` + Slug string `gorm:"column:slug" json:"slug"` + Views int `gorm:"column:views" json:"views"` + Tags lagoon.Jsonable[[]string] `gorm:"column:tags" json:"-"` + APIToken lagoon.Encrypted `gorm:"column:api_token" json:"-"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"-"` + + Categories []Category `gorm:"many2many:acme_blog_post_categories" json:"-"` +} + +// TableName keeps the WinterCMS table name. +func (Post) TableName() string { return "acme_blog_posts" } + +// Fillable is the Go form of $fillable: the keys mass assignment may set. +func (Post) Fillable() []string { return []string{"title", "views"} } + +// Hidden is the Go form of $hidden. The json:"-" tags are what keep these +// columns out of JSON; the list documents them for tooling. +func (Post) Hidden() []string { return []string{"tags", "api_token", "deleted_at"} } + +// BeforeCreate fills the slug from the title. A hook that only touches the +// model stays on the model. +func (p *Post) BeforeCreate(tx *gorm.DB) error { + if p.Slug == "" { + p.Slug = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(p.Title)), " ", "-") + } + return nil +} + +// BeforeDelete soft-deletes the post's comments in the transaction of the +// post's own delete; an error aborts that delete. +func (p *Post) BeforeDelete(tx *gorm.DB) error { + return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error { + return tx.Where("post_id = ?", p.ID).Delete(&Comment{}).Error + }) +} + +// Comment belongs to a post and is soft-deleted with it. +type Comment struct { + ID uint `gorm:"column:id;primaryKey"` + PostID uint `gorm:"column:post_id"` + Body string `gorm:"column:body"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` +} + +func (Comment) TableName() string { return "acme_blog_comments" } + +// Category is linked to posts through a pivot with its own sort order. +type Category struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` +} + +func (Category) TableName() string { return "acme_blog_categories" } + +// PostCategory is the pivot model: the join table has a business column. +type PostCategory struct { + PostID uint `gorm:"column:post_id;primaryKey"` + CategoryID uint `gorm:"column:category_id;primaryKey"` + SortOrder int `gorm:"column:sort_order"` +} + +func (PostCategory) TableName() string { return "acme_blog_post_categories" } + +// BlogPlugin is the acme.blog plugin; only its migrations are shown here. +type BlogPlugin struct{} + +var _ pact.HasMigrations = (*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 } + +// Migrations returns the plugin's schema as an ordered gormigrate set. In a +// scaffolded plugin each migration is a file in updates/ and this list is +// generated in file name order. +func (p *BlogPlugin) Migrations() []*gormigrate.Migration { + return []*gormigrate.Migration{ + { + ID: "20260101000100_create_posts", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE acme_blog_posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT NOT NULL, + views INTEGER NOT NULL DEFAULT 0, + tags TEXT, + api_token TEXT, + deleted_at TIMESTAMPTZ +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS acme_blog_posts`).Error + }, + }, + { + ID: "20260101000200_create_comments_and_categories", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE acme_blog_comments (id SERIAL PRIMARY KEY, post_id INTEGER NOT NULL, body TEXT NOT NULL, deleted_at TIMESTAMPTZ)`, + `CREATE TABLE acme_blog_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`, + `CREATE TABLE acme_blog_post_categories (post_id INTEGER NOT NULL, category_id INTEGER NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, category_id))`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS acme_blog_post_categories, acme_blog_categories, acme_blog_comments`).Error + }, + }, + } +} + +func ExampleFill() { + input := map[string]any{"title": "Hello", "views": 3, "slug": "forged"} + var post Post + // production=false logs each dropped key once, to catch typos in development. + if err := lagoon.Fill(&post, post.Fillable(), input, true); err != nil { + fmt.Println(err) + } + fmt.Printf("%q %q %d\n", post.Title, post.Slug, post.Views) + + err := lagoon.Fill(&post, post.Fillable(), map[string]any{"views": "many"}, true) + var typeErr *lagoon.FillTypeError + if errors.As(err, &typeErr) { + fmt.Println("invalid value for", typeErr.Key) + } + // Output: + // "Hello" "" 3 + // invalid value for views +} + +func ExampleValidate() { + rules := map[string]string{ + "title": "required|max:10", + "views": "nullable|integer|max:1000", + } + input := map[string]any{"title": "", "views": 5000} + // A nil translator gives the built-in English messages; unique: rules + // need a database handle instead of nil. + errs, err := lagoon.Validate(context.Background(), nil, &Post{}, rules, input, nil) + if err != nil { + fmt.Println(err) + } + out, _ := json.Marshal(errs) + fmt.Println(string(out)) + // Output: + // {"title":["The title field is required."],"views":["The views may not be greater than 1000."]} +} + +func ExampleHasHidden() { + post := Post{ID: 1, Title: "Hello", APIToken: lagoon.NewEncrypted("s3cret")} + out, _ := json.Marshal(post) + fmt.Println(string(out)) + var _ lagoon.HasHidden = post + // Output: + // {"id":1,"title":"Hello","slug":"","views":0} +} + +func ExampleOrderBy() { + // A dry-run handle shows the SQL without a database. + db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) + allowed := []string{"title", "views"} + + q, err := lagoon.OrderBy(db.Model(&Post{}), "views", "desc", allowed) + if err != nil { + fmt.Println(err) + return + } + var posts []Post + fmt.Println(q.Find(&posts).Statement.SQL.String()) + + _, err = lagoon.OrderBy(db, "api_token", "asc", allowed) + fmt.Println(err) + _, err = lagoon.OrderBy(db, "title", "asc; DROP TABLE acme_blog_posts", allowed) + fmt.Println(err) + // Output: + // SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY views DESC + // lagoon: order column "api_token" is not allow-listed + // lagoon: order direction "asc; DROP TABLE acme_blog_posts" is not allow-listed +} + +func ExamplePaginate() { + rows := []map[string]any{{"id": 3, "title": "Third"}} + page := lagoon.Paginate(rows, 2, 2, 3) + out, _ := json.Marshal(page) + fmt.Println(string(out)) + + empty, _ := json.Marshal(lagoon.Paginate[map[string]any](nil, 1, 15, 0)) + fmt.Println(string(empty)) + // Output: + // {"data":[{"id":3,"title":"Third"}],"meta":{"current_page":2,"last_page":2,"per_page":2,"total":3}} + // {"data":[],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":0}} +} + +func ExampleJsonable() { + tags := lagoon.Jsonable[[]string]{Data: []string{"go", "cms"}, Valid: true} + v, _ := tags.Value() + fmt.Println(v) + + var none lagoon.Jsonable[[]string] // Valid false stores SQL NULL + v, _ = none.Value() + fmt.Println(v) + + var read lagoon.Jsonable[[]string] + _ = read.Scan(`["winter"]`) + fmt.Println(read.Get(), read.Valid) + // Output: + // ["go","cms"] + // + // [winter] true +} + +func ExampleEncrypted() { + // The application publishes the keys from app.key at boot; a test can + // install a key directly. + key := []byte("0123456789abcdef0123456789abcdef") + if err := lagoon.PublishEncryptionKeys(nil, key, nil); err != nil { + fmt.Println(err) + return + } + token := lagoon.NewEncrypted("s3cret") + stored, _ := token.Value() // what the column holds + fmt.Println(strings.Contains(fmt.Sprint(stored), "s3cret")) + + var read lagoon.Encrypted + if err := read.Scan(stored); err != nil { + fmt.Println(err) + return + } + out, _ := json.Marshal(map[string]any{"api_token": read}) + fmt.Println(read, string(out)) + fmt.Println(read.Reveal()) + // Output: + // false + // [redacted] {"api_token":"[redacted]"} + // s3cret +} + +// createPost validates input, mass-assigns the fillable keys and inserts +// the post. It returns the validation errors, if any. +func createPost(ctx context.Context, db *gorm.DB, input map[string]any) (*Post, map[string][]string, error) { + // docs:start create-post + rules := map[string]string{ + "title": "required|max:255|unique:acme_blog_posts", + "views": "nullable|integer|min:0", + } + var post Post + errs, err := lagoon.Validate(ctx, db, &post, rules, input, nil) + if err != nil || errs != nil { + return nil, errs, err + } + if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil { + return nil, nil, err + } + if err := db.WithContext(ctx).Create(&post).Error; err != nil { + return nil, nil, err + } + return &post, nil, nil + // docs:end create-post +} + +// TestDocsModels runs the create-post region of the Models page. +func TestDocsModels(t *testing.T) { + db := lagoon.DocsDB(t, "docs_models") + plugins := []party.Plugin{&BlogPlugin{}} + if err := lagoon.Migrate(db, plugins); err != nil { + t.Fatal(err) + } + post, errs, err := createPost(t.Context(), db, map[string]any{"title": "Hello World", "views": 2, "slug": "forged"}) + if err != nil || errs != nil { + t.Fatalf("createPost: %v %v", errs, err) + } + if post.ID == 0 || post.Slug != "hello-world" || post.Views != 2 { + t.Fatalf("post = %+v", post) + } + _, errs, err = createPost(t.Context(), db, map[string]any{"title": "Hello World"}) + if err != nil || len(errs["title"]) != 1 { + t.Fatalf("duplicate title: %v %v", errs, err) + } +} + +// migrateBlog runs the framework and plugin migrations, prints the status +// and rolls back the plugin's last migration. +func migrateBlog(db *gorm.DB) ([]string, error) { + var lines []string + // docs:start migrate + plugins := []party.Plugin{&BlogPlugin{}} + if err := lagoon.Migrate(db, plugins); err != nil { + return nil, err + } + rows, err := lagoon.Status(db, plugins) + if err != nil { + return nil, err + } + for _, row := range rows { + lines = append(lines, fmt.Sprintf("%s %s %v", row.Plugin, row.Table, row.IDs)) + } + if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil { + return nil, err + } + // docs:end migrate + return lines, nil +} + +// TestDocsMigrate runs the migrate region of the Migrations page. +func TestDocsMigrate(t *testing.T) { + db := lagoon.DocsDB(t, "docs_migrate") + lines, err := migrateBlog(db) + if err != nil { + t.Fatal(err) + } + want := "acme.blog summer_migrations_acme_blog [20260101000100_create_posts 20260101000200_create_comments_and_categories]" + if len(lines) != 1 || lines[0] != want { + t.Fatalf("status = %q, want %q", lines, want) + } + if db.Migrator().HasTable("acme_blog_comments") { + t.Fatal("rollback left acme_blog_comments") + } + if !db.Migrator().HasTable("acme_blog_posts") { + t.Fatal("rollback removed acme_blog_posts") + } +} + +// listPosts returns one page of posts sorted by a column the client names. +func listPosts(ctx context.Context, db *gorm.DB, sort, dir string, page, perPage int) (lagoon.Page[Post], error) { + // docs:start list-posts + q, err := lagoon.OrderBy(db.WithContext(ctx).Model(&Post{}), sort, dir, []string{"title", "views"}) + if err != nil { + return lagoon.Page[Post]{}, err // answer 422: the client asked for a column it may not sort by + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return lagoon.Page[Post]{}, err + } + var posts []Post + if err := q.Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { + return lagoon.Page[Post]{}, err + } + return lagoon.Paginate(posts, page, perPage, total), nil + // docs:end list-posts +} + +// TestDocsQueries runs the list-posts region of the Queries page. +func TestDocsQueries(t *testing.T) { + db := lagoon.DocsDB(t, "docs_queries") + if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil { + t.Fatal(err) + } + for i, title := range []string{"Alpha", "Beta", "Gamma"} { + if err := db.Create(&Post{Title: title, Views: i * 10}).Error; err != nil { + t.Fatal(err) + } + } + page, err := listPosts(t.Context(), db, "views", "desc", 2, 2) + if err != nil { + t.Fatal(err) + } + if len(page.Data) != 1 || page.Data[0].Title != "Alpha" || page.Meta.LastPage != 2 || page.Meta.Total != 3 { + t.Fatalf("page = %+v", page) + } + if _, err := listPosts(t.Context(), db, "api_token", "asc", 1, 2); err == nil { + t.Fatal("sorting by api_token succeeded") + } +} + +// setCategories replaces a post's categories in the given order and reads +// them back through the pivot. +func setCategories(ctx context.Context, db *gorm.DB, post *Post, categoryIDs []uint) ([]Category, error) { + // docs:start pivot + if err := lagoon.RegisterJoinTable(db, &Post{}, "Categories", &PostCategory{}); err != nil { + return nil, err + } + err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.Where("post_id = ?", post.ID).Delete(&PostCategory{}).Error; err != nil { + return err + } + rows := make([]PostCategory, len(categoryIDs)) + for i, id := range categoryIDs { + rows[i] = PostCategory{PostID: post.ID, CategoryID: id, SortOrder: i} + } + return tx.Create(&rows).Error + }) + if err != nil { + return nil, err + } + var categories []Category + err = db.WithContext(ctx). + Joins("JOIN acme_blog_post_categories pc ON pc.category_id = acme_blog_categories.id"). + Where("pc.post_id = ?", post.ID). + Order("pc.sort_order"). + Find(&categories).Error + return categories, err + // docs:end pivot +} + +// TestDocsRelations runs the pivot region of the Relations page and the +// soft-delete cascade of Post.BeforeDelete. +func TestDocsRelations(t *testing.T) { + db := lagoon.DocsDB(t, "docs_relations") + if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil { + t.Fatal(err) + } + post := Post{Title: "Hello"} + news, events := Category{Name: "News"}, Category{Name: "Events"} + for _, v := range []any{&post, &news, &events} { + if err := db.Create(v).Error; err != nil { + t.Fatal(err) + } + } + got, err := setCategories(t.Context(), db, &post, []uint{events.ID, news.ID}) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Name != "Events" || got[1].Name != "News" { + t.Fatalf("categories = %+v", got) + } + var loaded Post + if err := db.Preload("Categories").First(&loaded, post.ID).Error; err != nil || len(loaded.Categories) != 2 { + t.Fatalf("Preload(Categories) = %+v, %v", loaded.Categories, err) + } + + if err := db.Create(&Comment{PostID: post.ID, Body: "First"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Delete(&post).Error; err != nil { + t.Fatal(err) + } + var left int64 + if err := db.Model(&Comment{}).Where("post_id = ?", post.ID).Count(&left).Error; err != nil { + t.Fatal(err) + } + if left != 0 { + t.Fatalf("%d comments left after the post was deleted", left) + } +} + +// publishPost renames a post in a transaction and notifies after commit. +// fail makes the transaction roll back. +func publishPost(ctx context.Context, db *gorm.DB, id uint, fail bool, log *[]string) error { + // docs:start publish + return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil { + return err + } + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) { + *log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail... + }) + if fail { + return errors.New("rolled back") // the AfterCommit work never runs + } + return nil + }) + // docs:end publish +} + +// nestedPublish runs a savepoint inside a transaction; a failing savepoint +// drops its own AfterCommit work only. +func nestedPublish(ctx context.Context, db *gorm.DB, log *[]string) error { + // docs:start nested + return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") }) + + // A nested Transaction is a savepoint. Pass it the outer tx: given + // the root db handle it returns an error instead. + _ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error { + lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") }) + return errors.New("savepoint rolled back") + }) + return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error { + lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") }) + return nil + }) + }) + // docs:end nested +} + +// TestDocsTransactions runs the publish and nested regions of the +// Transactions page, and checks the rules the page states. +func TestDocsTransactions(t *testing.T) { + db := lagoon.DocsDB(t, "docs_transactions") + if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil { + t.Fatal(err) + } + post := Post{Title: "Draft"} + if err := db.Create(&post).Error; err != nil { + t.Fatal(err) + } + ctx := t.Context() + + var log []string + if err := publishPost(ctx, db, post.ID, true, &log); err == nil || len(log) != 0 { + t.Fatalf("rolled-back publish: err %v, log %q", err, log) + } + if err := publishPost(ctx, db, post.ID, false, &log); err != nil { + t.Fatal(err) + } + if want := fmt.Sprintf("post %d published", post.ID); len(log) != 1 || log[0] != want { + t.Fatalf("log = %q, want %q", log, want) + } + + log = nil + if err := nestedPublish(ctx, db, &log); err != nil { + t.Fatal(err) + } + if strings.Join(log, ",") != "outer,inner" { + t.Fatalf("nested log = %q, want outer,inner", log) + } + + // A nested Transaction given the root handle fails without running. + ran := false + err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + return lagoon.Transaction(ctx, db, func(context.Context, *gorm.DB) error { + ran = true + return nil + }) + }) + if err == nil || ran { + t.Fatalf("nested Transaction on the root handle: err %v, ran %v", err, ran) + } + + // Inside a plain GORM transaction AfterCommit skips the work. + log = nil + if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { log = append(log, "foreign") }) + return nil + }); err != nil { + t.Fatal(err) + } + if len(log) != 0 { + t.Fatalf("AfterCommit ran inside a plain GORM transaction: %q", log) + } +} + +// installCallbacks registers a GORM callback from a plugin's Boot through +// OnDatabase; its after-commit work runs once the insert commits. +func installCallbacks(app *backpack.App, log *[]string) error { + // docs:start on-database + return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error { + return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) { + post, ok := db.Statement.Dest.(*Post) + if db.Error != nil || !ok { + return + } + lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) { + *log = append(*log, "created "+post.Slug) // runs only once the insert is committed + }) + }) + }) + // docs:end on-database +} + +// TestDocsOnDatabase runs the on-database region of the Transactions page. +func TestDocsOnDatabase(t *testing.T) { + db := lagoon.DocsDB(t, "docs_on_database") + if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil { + t.Fatal(err) + } + app := backpack.New(nil) + var log []string + if err := installCallbacks(app, &log); err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + if err := lagoon.Publish(app, sqlDB, db); err != nil { + t.Fatal(err) + } + if err := db.Create(&Post{Title: "Hello"}).Error; err != nil { + t.Fatal(err) + } + if len(log) != 1 || log[0] != "created hello" { + t.Fatalf("log = %q", log) + } +} + +// TestDocsModelDeclarations checks the model and plugin declarations the +// Database pages show, without a database. +func TestDocsModelDeclarations(t *testing.T) { + if got := (Post{}).TableName(); got != "acme_blog_posts" { + t.Fatalf("TableName = %q", got) + } + if got := (Post{}).Hidden(); len(got) != 3 { + t.Fatalf("Hidden = %q", got) + } + post := &Post{Title: " Hello World "} + if err := post.BeforeCreate(nil); err != nil || post.Slug != "hello-world" { + t.Fatalf("BeforeCreate: slug %q, err %v", post.Slug, err) + } + if err := post.BeforeDelete(nil); err == nil { + t.Fatal("BeforeDelete accepted a nil transaction") + } + if n := len((&BlogPlugin{}).Migrations()); n != 2 { + t.Fatalf("Migrations() = %d, want 2", n) + } +} diff --git a/modules/lagoon/export_docs_test.go b/modules/lagoon/export_docs_test.go new file mode 100644 index 0000000..0fbc45b --- /dev/null +++ b/modules/lagoon/export_docs_test.go @@ -0,0 +1,21 @@ +package lagoon + +import ( + "testing" + + "gorm.io/gorm" +) + +// DocsDB returns a GORM handle on a fresh ICU pl-PL database named name in +// this package's Postgres harness, 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 DocsDB(t *testing.T, name string) *gorm.DB { + t.Helper() + db, _ := dedicatedDB(t, name) + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + return gdb +} diff --git a/modules/phrasebook/example_test.go b/modules/phrasebook/example_test.go new file mode 100644 index 0000000..c092cbc --- /dev/null +++ b/modules/phrasebook/example_test.go @@ -0,0 +1,78 @@ +package phrasebook_test + +import ( + "context" + "fmt" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/towel" +) + +// langFS stands in for the plugin's embedded lang directory. +var langFS = fstest.MapFS{ + "lang/en/posts.yaml": {Data: []byte(`title: Posts +greeting: "Hello, :name" +shout: "Welcome, :NAME" +count: + one: ":count post" + other: ":count posts" +drafts: "{0} No drafts|{1} One draft|[2,*] :count drafts" +`)}, + "lang/pl/posts.yaml": {Data: []byte(`title: Posty +count: + one: ":count post" + few: ":count posty" + many: ":count postów" + other: ":count posta" +`)}, +} + +func ExampleTranslator_Get() { + cat := phrasebook.NewCatalog() + if err := cat.Load("acme.blog", langFS); err != nil { + fmt.Println(err) + return + } + tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) + + // surf stores the request locale on the context; here the example does. + ctx := towel.WithLocale(context.Background(), "pl") + fmt.Println(tr.Get(ctx, "acme.blog::posts.title", nil)) + // pl has no greeting, so the fallback locale answers. + fmt.Println(tr.Get(ctx, "acme.blog::posts.greeting", map[string]string{"name": "Ada"})) + fmt.Println(tr.GetIn("en", "acme.blog::posts.shout", map[string]string{"name": "Ada"})) + // A missing key comes back as the key. + fmt.Println(tr.Get(ctx, "acme.blog::posts.missing", nil)) + // Output: + // Posty + // Hello, Ada + // Welcome, ADA + // acme.blog::posts.missing +} + +func ExampleTranslator_Choice() { + cat := phrasebook.NewCatalog() + if err := cat.Load("acme.blog", langFS); err != nil { + fmt.Println(err) + return + } + tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) + + for _, n := range []int{1, 3, 5, 22} { + fmt.Println(tr.ChoiceIn("pl", "acme.blog::posts.count", n, nil)) + } + fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.count", 5, nil)) + for _, n := range []int{0, 1, 7} { + fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.drafts", n, nil)) + } + // Output: + // 1 post + // 3 posty + // 5 postów + // 22 posty + // 5 posts + // No drafts + // One draft + // 7 drafts +} diff --git a/modules/postcard/example_test.go b/modules/postcard/example_test.go new file mode 100644 index 0000000..d1638a3 --- /dev/null +++ b/modules/postcard/example_test.go @@ -0,0 +1,73 @@ +package postcard_test + +import ( + "context" + "fmt" + "strings" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/postcard" +) + +// mailFS stands in for the plugin's embedded views/mail directory. +var mailFS = fstest.MapFS{ + "views/mail/welcome.htm": {Data: []byte(`subject = "Welcome, {{ .name }}" +layout = "blog" +description = "Sent after registration" +== +Hi **{{ .name }}**, thanks for joining the blog. +`)}, + "views/mail/layouts/blog.htm": {Data: []byte(`name = "Blog" +== +{{ .Content }} + +-- The Acme blog +== +

{{ .Content }}
+ +`)}, +} + +func ExampleMailer_Send() { + // At boot, postcard.BootPlugin registers what pact.HasMailTemplates + // declares; a test registers the same thing directly. + cat := postcard.NewCatalog() + err := cat.Register("acme.blog", mailFS, + []string{"acme.blog::mail.welcome"}, + map[string]string{"blog": "acme.blog::mail.layouts.blog"}) + if err != nil { + fmt.Println(err) + return + } + driver := postcard.NewMemoryDriver() // mail.driver: memory + mailer := postcard.NewMailer(cat, driver, postcard.Options{From: "blog@example.com"}) + + err = mailer.Send(context.Background(), postcard.Message{ + Template: "acme.blog::mail.welcome", + To: []string{"ada@example.com"}, + Vars: map[string]any{"name": "Ada"}, + }) + if err != nil { + fmt.Println(err) + return + } + sent := driver.Messages()[0] + fmt.Println(sent.From, sent.To, sent.Subject) + fmt.Println(sent.Text) + fmt.Println(strings.Contains(sent.HTML, `

Hi Ada`)) + + // Header injection is refused before any driver sees the message. + err = mailer.Send(context.Background(), postcard.Message{ + Template: "acme.blog::mail.welcome", + To: []string{"ada@example.com\r\nBcc: all@example.com"}, + Vars: map[string]any{"name": "Ada"}, + }) + fmt.Println(err != nil, len(driver.Messages())) + // Output: + // blog@example.com [ada@example.com] Welcome, Ada + // Hi **Ada**, thanks for joining the blog. + // + // -- The Acme blog + // true + // true 1 +} diff --git a/modules/surf/example_test.go b/modules/surf/example_test.go new file mode 100644 index 0000000..a7bf8b2 --- /dev/null +++ b/modules/surf/example_test.go @@ -0,0 +1,208 @@ +package surf_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/golem15/summercms/modules/wire" +) + +// secret signs the example's tokens. A real application reads its JWT +// secret from configuration. +const secret = "example-secret-that-is-long-enough" + +// users loads the principal behind a token subject. +type users struct{} + +func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { + return &bouncer.Principal{ID: id}, nil +} + +// BlogPlugin is the acme.blog plugin; only its HTTP surface is shown here. +type BlogPlugin struct { + trusted []netip.Prefix +} + +func (p *BlogPlugin) ID() string { return "acme.blog" } +func (p *BlogPlugin) Requires() []string { return nil } + +// Register reads http.trusted_proxies once, for the plugin's bucket keys. +func (p *BlogPlugin) Register(app *backpack.App) error { + p.trusted = surf.TrustedProxies(app.Config) + return nil +} + +func (p *BlogPlugin) Boot(app *backpack.App) error { return nil } + +// Middlewares registers the plugin's named middleware: here, a JWT guard +// that answers 401 when the request has no valid token. +func (p *BlogPlugin) Middlewares() map[string]pact.Middleware { + guards := bouncer.NewRegistry() + guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist()) + if err := guards.Register(p.ID(), "acme.auth", guard); err != nil { + panic(err) + } + auth, err := guards.Middleware("acme.auth") + if err != nil { + panic(err) + } + return map[string]pact.Middleware{"acme.auth": auth} +} + +// Buckets declares a named rate limit, used as throttle:blog.comments. +func (p *BlogPlugin) Buckets() map[string]surf.Bucket { + return map[string]surf.Bucket{ + "blog.comments": { + Max: 1, + Decay: time.Minute, + Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) }, + }, + } +} + +// Routes is the Go form of the plugin's routes.php. +func (p *BlogPlugin) Routes(r pact.Router) error { + r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) { + g.Get("/posts/{id}", showPost) + g.Where("id", `[0-9]+`) + g.Get("/posts/{status}/list", listPosts) + g.WhereIn("status", "draft", "published") + + // An auth group: every route inside needs a signed-in user. + g.Group("", surf.Use("acme.auth"), func(auth pact.Router) { + auth.Get("/me", showMe) + auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536") + }) + }) + return nil +} + +func showPost(w http.ResponseWriter, r *http.Request) { + id, ok := surf.IntParam(r, "id") + if !ok { + http.NotFound(w, r) + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id}) +} + +func listPosts(w http.ResponseWriter, r *http.Request) { + wire.WriteJSON(w, http.StatusOK, map[string]any{"status": r.PathValue("status"), "data": wire.Slice[string](nil)}) +} + +func showMe(w http.ResponseWriter, r *http.Request) { + user, _ := bouncer.User(r.Context()) + wire.WriteJSON(w, http.StatusOK, map[string]any{"id": user.ID}) +} + +func addComment(w http.ResponseWriter, r *http.Request) { + wire.WriteJSON(w, http.StatusCreated, map[string]any{"created": true}) +} + +func ExampleAssemble() { + // The application passes its config; http.body_limits is required there. + app := backpack.New(nil) + plugin := &BlogPlugin{} + if err := plugin.Register(app); err != nil { // the runtime calls Register + fmt.Println(err) + return + } + h, err := surf.Assemble(app, []party.Plugin{plugin}) + if err != nil { + fmt.Println(err) + return + } + token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour) + + do := func(method, path string, auth bool) { + req := httptest.NewRequest(method, path, strings.NewReader("{}")) + if auth { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String())) + } + do("GET", "/api/blog/posts/7", false) + do("GET", "/api/blog/posts/seven", false) + do("GET", "/api/blog/posts/draft/list", false) + do("GET", "/api/blog/posts/deleted/list", false) + do("GET", "/api/blog/me", false) + do("GET", "/api/blog/me", true) + do("POST", "/api/blog/posts/7/comments", true) + do("POST", "/api/blog/posts/7/comments", true) + // Output: + // GET /api/blog/posts/7 200 {"id":7} + // GET /api/blog/posts/seven 404 404 page not found + // GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"} + // GET /api/blog/posts/deleted/list 404 404 page not found + // GET /api/blog/me 401 {"error":true,"message":"Token not provided"} + // GET /api/blog/me 200 {"id":42} + // POST /api/blog/posts/7/comments 201 {"created":true} + // POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."} +} + +func ExampleBuildRouter() { + r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}}) + if err != nil { + fmt.Println(err) + return + } + for _, rt := range r.Routes() { + fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware) + } + // Output: + // GET /api/blog/posts/{id} acme.blog [throttle:60,1] + // GET /api/blog/posts/{status}/list acme.blog [throttle:60,1] + // GET /api/blog/me acme.blog [throttle:60,1 acme.auth] + // POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536] +} + +func ExampleClientIP() { + cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}}) + if err != nil { + fmt.Println(err) + return + } + _ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"}) + trusted := surf.TrustedProxies(cfg) + + // Through the load balancer at 10.0.0.5: the forwarded client is used. + viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil) + viaProxy.RemoteAddr = "10.0.0.5:4711" + viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9") + fmt.Println(surf.ClientIP(viaProxy, trusted)) + + // Straight from the internet: a forged header is ignored. + direct := httptest.NewRequest("GET", "/api/blog/posts", nil) + direct.RemoteAddr = "203.0.113.7:5000" + direct.Header.Set("X-Forwarded-For", "127.0.0.1") + fmt.Println(surf.ClientIP(direct, trusted)) + // Output: + // 198.51.100.23 + // 203.0.113.7 +} + +// TestDocsDeclarations checks the plugin declarations the Routing and Rate +// limiting pages show. +func TestDocsDeclarations(t *testing.T) { + p := &BlogPlugin{} + if _, ok := p.Middlewares()["acme.auth"]; !ok { + t.Fatal("Middlewares() has no acme.auth") + } + if b := p.Buckets()["blog.comments"]; b.Max != 1 || b.Decay != time.Minute { + t.Fatalf("Buckets() = %+v", b) + } +} diff --git a/modules/wire/example_test.go b/modules/wire/example_test.go new file mode 100644 index 0000000..8fb41d5 --- /dev/null +++ b/modules/wire/example_test.go @@ -0,0 +1,45 @@ +package wire_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "time" + + "git.golem15.com/golem15/summercms/modules/wire" +) + +// postJSON is the response shape of one acme.blog post. +type postJSON struct { + ID uint `json:"id"` + Title string `json:"title"` + Tags []string `json:"tags"` + Featured wire.TriBool `json:"featured"` + Pinned wire.TriBool `json:"pinned"` + PublishedAt wire.Time `json:"published_at"` +} + +func ExampleWriteJSON() { + var tags []string // nil: the post has no tags + warsaw := time.FixedZone("CEST", 2*60*60) + body := postJSON{ + ID: 1, + Title: "Tips & ", + Tags: wire.Slice(tags), + Featured: wire.TriBool{}, + Pinned: wire.TriBool{Value: true, Valid: true}, + PublishedAt: wire.Time{Time: time.Date(2026, 9, 30, 14, 5, 0, 0, warsaw)}, + } + rec := httptest.NewRecorder() + wire.WriteJSON(rec, http.StatusOK, map[string]any{"data": body}) + fmt.Println(rec.Code, rec.Header().Get("Content-Type")) + fmt.Printf("%s|\n", rec.Body.String()) + + rec = httptest.NewRecorder() + wire.WriteOpaque500(rec) + fmt.Println(rec.Code, rec.Body.String()) + // Output: + // 200 application/json + // {"data":{"id":1,"title":"Tips & ","tags":[],"featured":null,"pinned":true,"published_at":"2026-09-30T12:05:00+00:00"}}| + // 500 {"error":true,"message":"Internal server error"} +} diff --git a/modules/wristband/example_test.go b/modules/wristband/example_test.go new file mode 100644 index 0000000..7cf5f14 --- /dev/null +++ b/modules/wristband/example_test.go @@ -0,0 +1,84 @@ +package wristband_test + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + + "git.golem15.com/golem15/summercms/modules/wristband" +) + +// newServer builds the authorization server of an application served at +// https://blog.example.com. The application sets Issuer and Resource for +// its own deployment; the defaults cover everything else. +func newServer() *wristband.Server { + opts := wristband.DefaultOptions() + opts.Issuer = "https://blog.example.com" + opts.Resource = "https://blog.example.com/mcp" + opts.ScopesSupported = []string{"read", "write", "offline_access"} + return wristband.NewServer(opts) +} + +func ExampleServer_Metadata() { + srv := newServer() + // srv.SetBackend(backend) attaches the application's stores; the + // metadata document does not need them. + rec := httptest.NewRecorder() + srv.Metadata(rec, httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil)) + + var doc map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + fmt.Println(err) + return + } + for _, key := range []string{ + "issuer", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "scopes_supported", + "grant_types_supported", + "code_challenge_methods_supported", + } { + fmt.Println(key, doc[key]) + } + // Output: + // issuer https://blog.example.com + // authorization_endpoint https://blog.example.com/oauth/mcp/authorize + // token_endpoint https://blog.example.com/oauth/mcp/token + // registration_endpoint https://blog.example.com/oauth/mcp/register + // scopes_supported [read write offline_access] + // grant_types_supported [authorization_code refresh_token] + // code_challenge_methods_supported [S256] +} + +func ExampleRejectRedirectURI() { + for _, uri := range []string{ + "https://client.example.org/callback", + "http://127.0.0.1:33418/callback", + "http://client.example.org/callback", + } { + if reason := wristband.RejectRedirectURI(uri); reason != "" { + fmt.Println("rejected:", reason) + continue + } + fmt.Println("accepted:", uri) + } + // Output: + // accepted: https://client.example.org/callback + // accepted: http://127.0.0.1:33418/callback + // rejected: Redirect URI must be https:// or loopback http://127.0.0.1 / http://localhost: http://client.example.org/callback +} + +func ExampleIssueClientCredentials() { + // A confidential client gets a secret, shown once; store only the hash. + id, secret, hash, err := wristband.IssueClientCredentials("client_secret_post") + fmt.Println(id != "", secret != "", hash != nil && *hash != secret, err) + + // A public client (PKCE only) gets no secret. + _, secret, hash, err = wristband.IssueClientCredentials("none") + fmt.Println(secret == "", hash == nil, err) + // Output: + // true true true + // true true +}