feat(17-03): sitemap admin, permissions, lang and README
- definitions singleton controller (theme default, first or create) with a repeater on data: type dropdown (url plus registry types), reference dependsOn type through signpost.ReferenceOptions - Settings page entries: Sitemap (link to the controller) and Sitemap settings (form), both behind golem15.sitemap.access_definitions - en and pl phrasebooks with the PHP keys - README with the PHP parity rules, tiers, hooks, commands and the Winter YAML adaptations - TestSitemapAdminSmoke: singleton, PHP-keyed rows, 422 on a foreign reference, settings save, 403 without the permission
This commit is contained in:
235
README.md
Normal file
235
README.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# golem15.sitemap
|
||||
|
||||
The compiled sitemap plugin for SummerCMS applications: it serves `/sitemap.xml` and its stylesheet from one sitemap definition editors manage in the admin, byte for byte the way the WinterCMS Golem15.Sitemap plugin did.
|
||||
|
||||
```go
|
||||
import _ "git.golem15.com/golem15/sm-sitemap-plugin"
|
||||
```
|
||||
|
||||
Module `git.golem15.com/golem15/sm-sitemap-plugin`, package `sitemap`, plugin ID `golem15.sitemap`.
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`.
|
||||
|
||||
It is a port of the WinterCMS plugin for headless sites. An application has one sitemap definition (`models.Definition`, theme key `default`), a list of items: explicit URLs and items of the menu item types other plugins register with the framework's registry (`signpost`). `Requires()` is empty: content plugins such as the Journal register their types and the sitemap reads them through the registry.
|
||||
|
||||
The output keeps the PHP plugin's bytes: element order, escaping, whitespace, date format, headers and the `frontend_url` rewrite are not changed. The goldens in `classes/testdata` are generated from the PHP code and pin them.
|
||||
|
||||
## Features
|
||||
|
||||
- **Routes.** Anonymous `GET /sitemap.xml` and `GET /sitemap.xsl` at the application root, outside any throttle group and without the backend middleware.
|
||||
- **One definition.** Table `golem15_sitemap_definitions` keeps the PHP columns (`id`, `theme` nullable and indexed, `data` text, timestamps). The `theme` column holds the fixed key `default` (`models.DefinitionTheme`); `/sitemap.xml` serves that row. `models.Definition.Fillable` is exactly `theme` and `data`, the PHP `$fillable` with `$guarded = ['*']`.
|
||||
- **Items.** An item is a URL (`type: url`) or a registry menu item type. Winter.Pages static pages, all static pages and the CMS page field are not ported. Stored item JSON in the PHP shape decodes tolerantly: numbers, strings and booleans are coerced, and unknown keys (`cmsPage`, `code`, `exists`, `fillable` and any other) are ignored. A save writes the PHP `toArray` key set (`changefreq`, `priority`, `nesting`, `type`, `url`, `reference`, `cmsPage` as null, `urlPattern`).
|
||||
- **Settings.** A singleton row (`golem15_sitemap_settings`, id 1) holds the frontend URL, the frontend default locale, the updates manifest path and the priority tiers, with the PHP defaults.
|
||||
- **Priority tiers.** Rules in config (`golem15.sitemap.priority_tiers`) map URL paths to five priority settings; the shipped defaults are the PHP plugin's rules.
|
||||
- **Hooks.** Typed festival events `classes.BeforeAddItem` and `classes.AddItem` replace the PHP events `golem15.sitemap.beforeAddItem` and `golem15.sitemap.addItem`.
|
||||
- **Admin.** The definition opens from the Settings page as a singleton form with a repeater of item rows; a second settings entry holds the settings form. Permission `golem15.sitemap.access_definitions`.
|
||||
- **Commands.** `sitemap:import` (the PHP JSON import) and `sitemap:import-winter` (a copy of a WinterCMS site's definition and settings).
|
||||
- **Phrasebooks.** `lang/en/lang.yaml` and `lang/pl/lang.yaml` keep the PHP key names.
|
||||
|
||||
## Usage
|
||||
|
||||
Mount the plugin in an application as a git submodule at `plugins/golem15/sitemap`, add it to the application's `go.work` and `go.mod`, and list it in `summer.yaml`:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
- id: golem15.sitemap
|
||||
module: git.golem15.com/golem15/sm-sitemap-plugin
|
||||
```
|
||||
|
||||
The plugin's own `go.mod` and `go.work` replace the framework with `../summercms.go` for a sibling checkout.
|
||||
|
||||
### /sitemap.xml and /sitemap.xsl
|
||||
|
||||
`GET /sitemap.xml` answers:
|
||||
|
||||
- `404` when there is no definition with the theme `default`;
|
||||
- `200` with an empty body when the definition has no items;
|
||||
- otherwise the sitemap, `Content-Type: application/xml`, `Cache-Control: no-cache, private`.
|
||||
|
||||
The document is the PHP `DOMDocument::saveXML()` format: the XML declaration and the `xml-stylesheet` processing instruction each followed by a newline, everything else on one line ending with `</urlset>` and a newline. Items that all resolve to nothing produce a self-closing `<urlset .../>`. Text is escaped like libxml (`&`, `<`, `>` and carriage return; attributes also `"`, tab and newline); single quotes and UTF-8 stay literal.
|
||||
|
||||
Each URL is written in the PHP order (`classes.Generator`):
|
||||
|
||||
1. The 50000th URL is written, the 50001st is not (`classes.MaxURLs`).
|
||||
2. A URL item's address goes through the port of Winter's `Url::to` (`classes.URLTo`): root plus the path trimmed of slashes, `/` keeps its slash, every path segment re-encoded like `rawurlencode`, valid absolute, `mailto:`, `tel:` and fragment URLs kept.
|
||||
3. `lastmod` is the record's modification time, else the updates manifest's date for the URL path, else omitted. It is written as `2006-01-02T15:04:05+00:00` in UTC.
|
||||
4. `classes.BeforeAddItem` may drop the URL.
|
||||
5. The priority is the first matching tier's setting when tiers are on, else the item's priority, written as stored text (`1.0` stays `1.0`).
|
||||
6. `loc`, `lastmod`, `changefreq` and `priority` are written, then the alternates as `xhtml:link` elements, only when there are more than one.
|
||||
7. `classes.AddItem` may append XML before `</url>`.
|
||||
|
||||
A registry item is resolved with its URL pattern, its nesting flag and the settings' frontend default locale as the unprefixed locale. A resolved item with a URL is written, then its sub-items once each with their own URL. A list type (a resolved item without a URL, such as all posts) writes each sub-item once per alternate, with that alternate's URL as `loc` and every alternate as `hreflang`, which is what the PHP list types returned.
|
||||
|
||||
When the settings hold a frontend URL, the backend URL (`app.url` when set, else the request's scheme and `Host`) is replaced literally inside every `<loc>`, every `xhtml:link href` and the stylesheet href, as the PHP `str_replace` did. The scheme is `https` for a TLS connection, or when the immediate peer is in `http.trusted_proxies` and its `X-Forwarded-Proto` says `https`.
|
||||
|
||||
`GET /sitemap.xsl` serves the PHP plugin's stylesheet byte for byte, `Content-Type: text/xsl; charset=UTF-8`.
|
||||
|
||||
A frontend served from another host routes both paths to the application, before its catch-all:
|
||||
|
||||
```nginx
|
||||
location = /sitemap.xml {
|
||||
proxy_pass http://127.0.0.1:8096;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location = /sitemap.xsl {
|
||||
proxy_pass http://127.0.0.1:8096;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
### Item types
|
||||
|
||||
| Type | Item fields used |
|
||||
|------|------------------|
|
||||
| `url` | `url`, `changefreq`, `priority` |
|
||||
| A registry type (for example `journal-post`) | `reference` (when the type takes one), `urlPattern` (overrides the host and default patterns), `nesting`, `changefreq`, `priority` |
|
||||
|
||||
The admin's type dropdown offers `url` and every registry type in registry order with its label. The reference dropdown depends on the row's type and lists the type's references (`signpost.ReferenceOptions`); `url` and types without references take none, and a reference the type does not offer is refused on save.
|
||||
|
||||
### Settings
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
|---------|---------|---------|
|
||||
| `frontend_url` | empty | The public frontend URL that replaces the backend URL in the XML. |
|
||||
| `frontend_default_locale` | empty | The locale whose URLs carry no prefix, passed to registry types; empty leaves it to the type. |
|
||||
| `updates_manifest_path` | empty | A JSON file mapping a URL path to an ISO 8601 date, used as `lastmod` for items without their own date. |
|
||||
| `enable_priority_tiers` | on | Tier priorities replace item priorities. |
|
||||
| `priority_homepage`, `priority_services`, `priority_blog`, `priority_categories`, `priority_legal` | `1.0`, `0.8`, `0.6`, `0.4`, `0.2` | The tier values, stored as text. |
|
||||
|
||||
The updates manifest is read only when it is a regular file of at most 5 MiB (`classes.ManifestMaxBytes`) holding a JSON object; paths match with the leading slash added and the trailing slash removed (`/` stays). Any error omits `lastmod` and is logged without the file's content. The manifest is a frontend build output, for example a script that writes the git commit date of each static page's source:
|
||||
|
||||
```json
|
||||
{
|
||||
"/": "2026-06-10T09:12:44+00:00",
|
||||
"/about": "2026-05-02T14:31:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Priority tiers
|
||||
|
||||
With `enable_priority_tiers` on, the URL's path without trailing slashes (`""` for the homepage) is matched against the rules in `golem15.sitemap.priority_tiers` in order; the first match's setting value is the priority, and a URL no rule matches keeps its item's priority. A rule names a `setting` and either `exact` (a list of paths) or `pattern` (a regular expression in RE2 syntax). The rules are compiled at boot: an unknown setting, a rule with neither or both of `exact` and `pattern`, or a pattern that does not compile fails the boot (`classes.CompileTiers`).
|
||||
|
||||
The plugin's `config/config.yaml` ships the rules the PHP plugin had in code, so a site configured the same way gets the same priorities. A host replaces the whole list in its own config, for example `config/golem15.yaml`:
|
||||
|
||||
```yaml
|
||||
sitemap:
|
||||
priority_tiers:
|
||||
- setting: priority_homepage
|
||||
exact: ["", "/en"]
|
||||
- setting: priority_blog
|
||||
pattern: '^/(en/)?articles/[^/]+$'
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
`classes.BeforeAddItem` fires through `festival.Bus.UntilHandled` before a URL is counted; a listener that sets `Skip` drops it (the PHP listener returned `false`). `classes.AddItem` fires through `festival.Bus.Fire` after a URL's elements are built; a listener may append escaped child XML to `Extra`. Both carry the definition item and a `classes.ItemInfo` (URL, modification time, written `lastmod`, alternates).
|
||||
|
||||
```go
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
app.Events.Listen("acme.seo", func(ctx context.Context, e *classes.BeforeAddItem) error {
|
||||
if strings.HasSuffix(e.Info.URL, "/draft") {
|
||||
e.Skip = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
app.Events.Listen("acme.seo", func(ctx context.Context, e *classes.AddItem) error {
|
||||
e.Extra = append(e.Extra, "<mobile>"+classes.EscapeText("yes")+"</mobile>")
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Admin
|
||||
|
||||
Two entries on the Settings page, category CMS, both requiring `golem15.sitemap.access_definitions` (developer role):
|
||||
|
||||
- **Sitemap** links to the definitions controller (`golem15.sitemap.definitions`), a singleton form: the server opens the definition with the theme `default`, creating it when absent, and the form has no list, create or delete. Its one field is a repeater on `data` whose rows hold type, reference, URL, URL pattern, priority, change frequency and the nesting checkbox.
|
||||
- **Sitemap settings** is the settings form.
|
||||
|
||||
The WinterCMS forms are adapted to the admin's YAML:
|
||||
|
||||
- The definition form's tab is flattened, and the `SitemapItems` form widget is replaced by a `type: repeater` field on `data`.
|
||||
- `span: none` and `cssClass` become `left` and `right`.
|
||||
- The settings form's nginx hint and Priority Tiers section are field comments and this README; the `trigger` conditions on the tier fields are dropped, so those fields always show and their comments say when they apply.
|
||||
- The tier comments no longer name one site's paths.
|
||||
- The CMS page field and its messages are not ported; the editor's empty-definition action (`empty_confirm`) is not ported.
|
||||
- The form's "View sitemap.xml" subtitle link (`definition.view`) waits for a form schema key in the framework; the phrase exists.
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `sitemap.Plugin` | Compiled plugin; `ID` is `golem15.sitemap`. |
|
||||
| `sitemap.Plugin.Routes` | `GET /sitemap.xml` and `GET /sitemap.xsl`. |
|
||||
| `sitemap.Plugin.Boot` | Compiles the tier rules; a bad rule fails the boot. |
|
||||
| `sitemap.Plugin.ConfigFS` | The default `config/config.yaml` (tier rules). |
|
||||
| `sitemap.Plugin.Commands` | Registers `sitemap:import` and `sitemap:import-winter`. |
|
||||
| `sitemap.Plugin.AdminControllers` | The definitions controller. |
|
||||
| `sitemap.Plugin.Settings` | The Sitemap link and the Sitemap settings form. |
|
||||
| `sitemap.Plugin.Permissions` | `golem15.sitemap.access_definitions`. |
|
||||
| `sitemap.Plugin.LangFS`, `sitemap.Plugin.AdminFS` | Embedded phrasebooks and admin YAML. |
|
||||
| `sitemap.Plugin.Migrations`, `sitemap.Plugin.Models` | The two migrations and the two models. |
|
||||
| `models.Definition` | The definition row; `Items` and `SetItems` read and write its item list. |
|
||||
| `models.DefinitionTheme` | The fixed theme key `default`. |
|
||||
| `models.DefinitionItem`, `models.DefinitionItems` | One item and the stored list, with the tolerant PHP-shaped decoding. |
|
||||
| `models.ItemTypeURL` | The built-in item type `url`. |
|
||||
| `models.Text`, `models.DecodeText` | A text field's value; the tolerant decoding of one stored text value. |
|
||||
| `models.Settings`, `models.DefaultSettings` | The settings row and its PHP defaults; `Settings.Priority` reads a tier value. |
|
||||
| `models.PriorityHomepage`, `models.PriorityServices`, `models.PriorityBlog`, `models.PriorityCategories`, `models.PriorityLegal`, `models.PriorityDefaults` | The tier setting keys and their defaults. |
|
||||
| `controllers.DefinitionsControllerID`, `controllers.PermissionAccessDefinitions` | `golem15.sitemap.definitions` and `golem15.sitemap.access_definitions`. |
|
||||
| `classes.Generator` | Writes a definition's sitemap; `Generate`, `Stylesheet`. |
|
||||
| `classes.Resolver` | What the generator resolves registry items through (`*signpost.Registry`). |
|
||||
| `classes.Serialize`, `classes.URLEntry`, `classes.Alternate` | The libxml-format writer and its entries. |
|
||||
| `classes.EscapeText`, `classes.EscapeAttr` | libxml's text and attribute escaping. |
|
||||
| `classes.URLTo`, `classes.BuildURL`, `classes.URLPath` | Ports of Winter `Url::to`, `Url::buildUrl` and `parse_url(..., PHP_URL_PATH)`. |
|
||||
| `classes.Rewrite`, `classes.BackendURL` | The `frontend_url` rewrite and the backend URL it replaces. |
|
||||
| `classes.TiersConfigKey`, `classes.TierConfig`, `classes.TierRule`, `classes.Tiers`, `classes.CompileTiers`, `classes.LoadTiers` | The tier rules. |
|
||||
| `classes.Manifest`, `classes.LoadManifest`, `classes.ManifestMaxBytes` | The updates manifest. |
|
||||
| `classes.BeforeAddItem`, `classes.AddItem`, `classes.ItemInfo` | The hooks. |
|
||||
| `classes.MaxURLs` | 50000. |
|
||||
| `console.Import`, `console.ImportFile` | `sitemap:import`. |
|
||||
| `console.ImportWinter`, `console.ImportWinterFiles`, `console.WinterOptions`, `console.WinterReport` | `sitemap:import-winter`. |
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Arguments and flags | Effect |
|
||||
|---------|---------------------|--------|
|
||||
| `sitemap:import` | `FILE`, `--theme`, `--append` | The PHP `ImportCommand`: reads a JSON array of `{url, changefreq, priority}` entries (`monthly` and `0.5` by default). Without `--append` the definition keeps its non-URL items first and the imported URLs replace its URL items; with it the URLs follow every existing item. The definition is always `default`; a `--theme` other than `default` is reported. |
|
||||
| `sitemap:import-winter` | `--definitions FILE`, `--settings FILE`, `--theme`, `--map old=new` (repeatable), `--force`, `--dry-run` | Copies a WinterCMS export into the definition and the settings row. URL items are kept; static and CMS page items are dropped; `--map` renames types first (for example `--map blog-post=journal-post`); an item of an unknown type, or whose reference is not among its type's current references, is dropped. Every dropped or remapped item and every dropped `cmsPage` is printed. A definition that already has items is refused without `--force`; `--dry-run` writes nothing. Settings missing from the export take the defaults. `--theme` picks a definition when the export holds several. |
|
||||
|
||||
Both commands open the database themselves when the application has not published one.
|
||||
|
||||
The export runs on the WinterCMS database with the read-only scripts in `scripts/`:
|
||||
|
||||
```sh
|
||||
mysql -N -B --raw <database> < scripts/winter-export-definitions.sql > sitemap-definitions.jsonl
|
||||
mysql -N -B --raw <database> < scripts/winter-export-settings.sql > sitemap-settings.json
|
||||
./bin/acme sitemap:import-winter --definitions sitemap-definitions.jsonl --settings sitemap-settings.json --dry-run
|
||||
./bin/acme sitemap:import-winter --definitions sitemap-definitions.jsonl --settings sitemap-settings.json
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: backpack, bonfire, compass, festival, lagoon, pact, party, signpost, surf; bouncer and cabana in tests.
|
||||
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`. Test-only: `github.com/testcontainers/testcontainers-go`, `github.com/jackc/pgx/v5`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
go test -short ./... # goldens and config checks, no Docker
|
||||
go test ./... # also the Postgres smoke tests
|
||||
```
|
||||
|
||||
`TestSitemapGolden` (`classes`) feeds `testdata/tracer.json`, `urlto.json` and `full.json` through the Go port and compares bytes with the goldens; it also checks the 50000 URL cap. `TestSitemapTiersConfig` checks the tier rule refusals, a host's replacement of the rule list and the manifest's fail-safe reads. The Postgres smokes (`TestSitemapTracer`, `TestSitemapImportSmoke`, `TestSitemapAdminSmoke`) boot the plugin with a test content plugin and drive the routes, the import commands and the admin API.
|
||||
|
||||
The goldens are generated from the PHP plugin's generation code and the vendored Winter URL generator of any WinterCMS application, and committed, so CI needs no PHP:
|
||||
|
||||
```sh
|
||||
WINTER_VENDOR=/path/to/winter-app/vendor php tests/php/gen-goldens.php classes/testdata/full.json classes/testdata/full.golden
|
||||
```
|
||||
72
admin.go
Normal file
72
admin.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package sitemap
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// adminFS holds the admin YAML file by file: the controllers directory also
|
||||
// holds Go sources.
|
||||
//
|
||||
//go:embed controllers/definitions/config_form.yaml models/definition/fields.yaml models/settings/fields.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
//go:embed lang
|
||||
var langFS embed.FS
|
||||
|
||||
// AdminFS serves the admin YAML.
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
// LangFS is the embedded English and Polish phrasebooks.
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
|
||||
// AdminControllers hands the controllers a lazy accessor of the booted app,
|
||||
// so they read the database and the menu item type registry at request
|
||||
// time.
|
||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return controllers.AdminControllers(func() *backpack.App {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.app
|
||||
})
|
||||
}
|
||||
|
||||
// Settings registers the two PHP settings entries in the CMS category: the
|
||||
// sitemap definition (a link to the singleton definitions controller) and
|
||||
// the sitemap settings form. Both require golem15.sitemap.access_definitions.
|
||||
func (p *Plugin) Settings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{
|
||||
{
|
||||
Code: "sitemap",
|
||||
Label: "golem15.sitemap::lang.plugin.name",
|
||||
Description: "golem15.sitemap::lang.plugin.description",
|
||||
Category: "golem15.sitemap::lang.settings.category",
|
||||
Icon: "network",
|
||||
Permissions: []string{controllers.PermissionAccessDefinitions},
|
||||
Controller: controllers.DefinitionsControllerID,
|
||||
},
|
||||
{
|
||||
Code: "sitemap_settings",
|
||||
Label: "golem15.sitemap::lang.settings.label",
|
||||
Description: "golem15.sitemap::lang.settings.description",
|
||||
Category: "golem15.sitemap::lang.settings.category",
|
||||
Icon: "settings",
|
||||
Permissions: []string{controllers.PermissionAccessDefinitions},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminAssets = (*Plugin)(nil)
|
||||
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||
_ pact.HasSettings = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
)
|
||||
19
admin_permissions.go
Normal file
19
admin_permissions.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package sitemap
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/controllers"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Permissions is the plugin's Winter registerPermissions() catalog: the PHP
|
||||
// code golem15.sitemap.access_definitions on the developer role.
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{
|
||||
Code: controllers.PermissionAccessDefinitions,
|
||||
Tab: "golem15.sitemap::lang.plugin.name",
|
||||
Label: "golem15.sitemap::lang.plugin.permissions.access_definitions",
|
||||
Roles: []string{"developer"},
|
||||
}}
|
||||
}
|
||||
|
||||
var _ pact.HasPermissions = (*Plugin)(nil)
|
||||
15
controllers/admin_registry.go
Normal file
15
controllers/admin_registry.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// AdminControllers is the plugin's admin controller list. app returns the
|
||||
// booted application; it is asked on every call, so a controller never keeps
|
||||
// a database handle or registry of its own.
|
||||
func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
return []pact.AdminController{
|
||||
definitionsAdminController{app: app},
|
||||
}
|
||||
}
|
||||
106
controllers/definitions.go
Normal file
106
controllers/definitions.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package controllers holds the sitemap plugin's admin controllers.
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefinitionsControllerID is the cabana controller id of the sitemap
|
||||
// definition form.
|
||||
DefinitionsControllerID = "golem15.sitemap.definitions"
|
||||
// PermissionAccessDefinitions is Definitions::$requiredPermissions.
|
||||
PermissionAccessDefinitions = "golem15.sitemap.access_definitions"
|
||||
)
|
||||
|
||||
// typeURLLabel is the label of the built-in url item type.
|
||||
const typeURLLabel = "golem15.sitemap::lang.item.url"
|
||||
|
||||
// definitionsAdminController edits the one sitemap definition of the
|
||||
// application (theme default): a singleton form whose items are a repeater
|
||||
// with a type dropdown (url plus the registry types) and a reference
|
||||
// dropdown that depends on it.
|
||||
type definitionsAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (definitionsAdminController) ID() string { return DefinitionsControllerID }
|
||||
|
||||
func (definitionsAdminController) ModelName() string { return `Golem15\Sitemap\Models\Definition` }
|
||||
|
||||
func (definitionsAdminController) ConfigDir() string { return "controllers/definitions" }
|
||||
|
||||
func (definitionsAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionAccessDefinitions}
|
||||
}
|
||||
|
||||
func (definitionsAdminController) NewRecord() any { return &models.Definition{} }
|
||||
|
||||
// The singleton record is the definition with the fixed theme key, created
|
||||
// with no items when there is none (PHP index(): firstOrCreate, then the
|
||||
// update form).
|
||||
func (definitionsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("golem15.sitemap: database is not configured")
|
||||
}
|
||||
var defs []models.Definition
|
||||
if err := db.Where("theme = ?", models.DefinitionTheme).Order("id ASC").Limit(1).Find(&defs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(defs) > 0 {
|
||||
return &defs[0], nil
|
||||
}
|
||||
theme := models.DefinitionTheme
|
||||
def := &models.Definition{Theme: &theme}
|
||||
def.SetItems(nil)
|
||||
if err := db.Create(def).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
func (c definitionsAdminController) registry() *signpost.Registry {
|
||||
if c.app == nil {
|
||||
return nil
|
||||
}
|
||||
app := c.app()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
return reg
|
||||
}
|
||||
|
||||
// DropdownOptions serves the item type dropdown: url, then every registry
|
||||
// type in registry order with its label (PHP getTypeOptions).
|
||||
func (c definitionsAdminController) DropdownOptions(string) []pact.Option {
|
||||
out := []pact.Option{{Value: models.ItemTypeURL, Label: typeURLLabel}}
|
||||
for _, t := range c.registry().Types() {
|
||||
out = append(out, pact.Option{Value: t.Key, Label: t.Label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DependentOptions serves the reference dropdown of an item row: the
|
||||
// references of the row's type through signpost.ReferenceOptions; url and a
|
||||
// type without references answer Applies false.
|
||||
func (c definitionsAdminController) DependentOptions(ctx context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) {
|
||||
typ, _ := values["type"].(string)
|
||||
return signpost.ReferenceOptions(ctx, c.registry(), typ)
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = definitionsAdminController{}
|
||||
_ pact.AdminPermissioned = definitionsAdminController{}
|
||||
_ pact.AdminRecordSource = definitionsAdminController{}
|
||||
_ pact.AdminSingleton = definitionsAdminController{}
|
||||
_ pact.DropdownOptionsProvider = definitionsAdminController{}
|
||||
_ pact.DependentOptionsProvider = definitionsAdminController{}
|
||||
)
|
||||
24
controllers/definitions/config_form.yaml
Normal file
24
controllers/definitions/config_form.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
# ===================================
|
||||
# Form Behavior Config
|
||||
# ===================================
|
||||
|
||||
# Record name
|
||||
name: golem15.sitemap::lang.definition.title
|
||||
|
||||
# Model Form Field configuration
|
||||
form: ~/plugins/golem15/sitemap/models/definition/fields.yaml
|
||||
|
||||
# Model Class name
|
||||
modelClass: Golem15\Sitemap\Models\Definition
|
||||
|
||||
# Default redirect location
|
||||
defaultRedirect: system/settings
|
||||
|
||||
# Update page
|
||||
update:
|
||||
redirect: golem15/sitemap/definitions
|
||||
redirectClose: system/settings
|
||||
|
||||
messages:
|
||||
update: golem15.sitemap::lang.definition.title
|
||||
saved: backend::lang.messages.form.saved
|
||||
65
lang/en/lang.yaml
Normal file
65
lang/en/lang.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
plugin:
|
||||
name: Sitemap
|
||||
description: Generate a sitemap.xml file for your website.
|
||||
permissions:
|
||||
access_settings: Access sitemap configuration settings
|
||||
access_definitions: Access sitemap definitions page
|
||||
item:
|
||||
location: 'Location:'
|
||||
priority: Priority
|
||||
changefreq: Change frequency
|
||||
always: always
|
||||
hourly: hourly
|
||||
daily: daily
|
||||
weekly: weekly
|
||||
monthly: monthly
|
||||
yearly: yearly
|
||||
never: never
|
||||
editor_title: Edit Sitemap Item
|
||||
type: Type
|
||||
allow_nested_items: Allow nested items
|
||||
allow_nested_items_comment: Nested items could be generated dynamically by static page and some other item types
|
||||
url: URL
|
||||
reference: Reference
|
||||
title_required: The title is required
|
||||
unknown_type: Unknown item type
|
||||
unnamed: Unnamed item
|
||||
add_item: Add item
|
||||
new_item: New item
|
||||
reference_required: The item reference is required.
|
||||
url_required: The URL is required
|
||||
url_pattern: URL Pattern
|
||||
url_pattern_comment: Use :placeholder syntax, e.g. /blog/:slug
|
||||
url_pattern_required: Please enter a URL pattern
|
||||
page: Page
|
||||
check: Check
|
||||
definition: Definition
|
||||
save_definition: Saving Definition...
|
||||
load_indicator: Emptying Definition...
|
||||
empty_confirm: Empty this definition?
|
||||
definition:
|
||||
not_found: No sitemap definition was found. Try creating one first.
|
||||
title: Sitemap definition
|
||||
view: View sitemap.xml
|
||||
settings:
|
||||
label: Sitemap settings
|
||||
description: Frontend URL, locale and priority options.
|
||||
category: CMS
|
||||
frontend_url: Frontend URL
|
||||
frontend_url_comment: The public URL of the frontend app (e.g. https://www.example.com). When set, sitemap URLs use this domain instead of the backend URL.
|
||||
frontend_default_locale: Frontend Default Locale
|
||||
frontend_default_locale_comment: The unprefixed locale on the frontend (e.g. pl for a prefix_except_default strategy). Other locales get a /{code}/ prefix in sitemap URLs.
|
||||
updates_manifest_path: Updates Manifest Path
|
||||
updates_manifest_path_comment: Absolute path to a JSON file mapping URL path => ISO 8601 last-modified date, produced by the frontend build. Used as the <lastmod> for URL items that have no date of their own (e.g. static pages). When empty or unreadable, those items omit <lastmod>. Dynamic sets keep their own dates.
|
||||
enable_priority_tiers: Enable Priority Tiers
|
||||
enable_priority_tiers_comment: When enabled, priorities are auto-assigned based on the URL path rules in the config, overriding per-item priorities. When disabled, per-item priorities are used.
|
||||
priority_homepage: Homepage Priority
|
||||
priority_homepage_comment: Used for URLs that match the homepage tier rules in the config. Default 1.0. Applies only when priority tiers are enabled.
|
||||
priority_services: Services & Work Priority
|
||||
priority_services_comment: Used for URLs that match the services tier rules in the config. Default 0.8. Applies only when priority tiers are enabled.
|
||||
priority_blog: Blog Posts Priority
|
||||
priority_blog_comment: Used for URLs that match the blog tier rules in the config. Default 0.6. Applies only when priority tiers are enabled.
|
||||
priority_categories: Categories & Hub Pages Priority
|
||||
priority_categories_comment: Used for URLs that match the categories tier rules in the config. Default 0.4. Applies only when priority tiers are enabled.
|
||||
priority_legal: Legal Pages Priority
|
||||
priority_legal_comment: Used for URLs that match the legal tier rules in the config. Default 0.2. Applies only when priority tiers are enabled.
|
||||
65
lang/pl/lang.yaml
Normal file
65
lang/pl/lang.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
plugin:
|
||||
name: Mapa strony
|
||||
description: Generuje plik sitemap.xml dla Twojej strony.
|
||||
permissions:
|
||||
access_settings: Zarządzaj ustawieniami wtyczki
|
||||
access_definitions: Zarządzaj definicją mapy strony
|
||||
item:
|
||||
location: 'Lokacja:'
|
||||
priority: Priorytet
|
||||
changefreq: Częstotliwość zmian
|
||||
always: cały czas
|
||||
hourly: co godzinę
|
||||
daily: codziennie
|
||||
weekly: co tydzień
|
||||
monthly: co miesiąc
|
||||
yearly: co rok
|
||||
never: nigdy
|
||||
editor_title: Edytuj pozycję mapy
|
||||
type: Typ
|
||||
allow_nested_items: Pozwól na zagnieżdżanie pozycji
|
||||
allow_nested_items_comment: Zagnieżdżone pozycje mogą zostać wygenerowane dynamicznie przez statyczne strony i niektóre rodzaje typów pozycji.
|
||||
url: URL
|
||||
reference: Odnośnik
|
||||
title_required: Tytuł jest wymagany
|
||||
unknown_type: Nieznany typ pozycji
|
||||
unnamed: Pozycja bez nazwy
|
||||
add_item: Dodaj pozycję
|
||||
new_item: Nowa pozycja
|
||||
reference_required: Odnośnik dla pozycji jest wymagany.
|
||||
url_required: Adres URL jest wymagany
|
||||
url_pattern: Wzorzec URL
|
||||
url_pattern_comment: Użyj składni :placeholder, np. /blog/:slug
|
||||
url_pattern_required: Podaj wzorzec URL
|
||||
page: Strona
|
||||
check: Sprawdź
|
||||
definition: Definicja
|
||||
save_definition: Zapisywanie definicji...
|
||||
load_indicator: Czyszczenie definicji...
|
||||
empty_confirm: Wyczyścić tę definicję?
|
||||
definition:
|
||||
not_found: Nie znaleziono definicji mapy strony. Musisz ją utworzyć.
|
||||
title: Definicja mapy strony
|
||||
view: Zobacz sitemap.xml
|
||||
settings:
|
||||
label: Ustawienia mapy strony
|
||||
description: Adres frontendu, język i priorytety.
|
||||
category: CMS
|
||||
frontend_url: Adres URL frontendu
|
||||
frontend_url_comment: Publiczny adres aplikacji frontendowej (np. https://www.example.com). Gdy jest ustawiony, adresy w mapie strony używają tej domeny zamiast adresu backendu.
|
||||
frontend_default_locale: Domyślny język frontendu
|
||||
frontend_default_locale_comment: Język bez prefiksu na frontendzie (np. pl przy strategii prefix_except_default). Pozostałe języki dostają w mapie strony prefiks /{kod}/.
|
||||
updates_manifest_path: Ścieżka manifestu aktualizacji
|
||||
updates_manifest_path_comment: Bezwzględna ścieżka do pliku JSON, który mapuje ścieżkę URL na datę ostatniej zmiany ISO 8601, tworzonego przez build frontendu. Służy jako <lastmod> dla pozycji URL bez własnej daty (np. stron statycznych). Gdy jest pusta lub plik jest nieczytelny, te pozycje nie mają <lastmod>. Zestawy dynamiczne zachowują własne daty.
|
||||
enable_priority_tiers: Włącz poziomy priorytetów
|
||||
enable_priority_tiers_comment: Po włączeniu priorytety są nadawane według reguł ścieżek URL z konfiguracji i zastępują priorytety pozycji. Po wyłączeniu używane są priorytety pozycji.
|
||||
priority_homepage: Priorytet strony głównej
|
||||
priority_homepage_comment: Dla adresów pasujących do reguł poziomu strony głównej z konfiguracji. Domyślnie 1.0. Działa tylko przy włączonych poziomach priorytetów.
|
||||
priority_services: Priorytet usług i realizacji
|
||||
priority_services_comment: Dla adresów pasujących do reguł poziomu usług z konfiguracji. Domyślnie 0.8. Działa tylko przy włączonych poziomach priorytetów.
|
||||
priority_blog: Priorytet wpisów bloga
|
||||
priority_blog_comment: Dla adresów pasujących do reguł poziomu bloga z konfiguracji. Domyślnie 0.6. Działa tylko przy włączonych poziomach priorytetów.
|
||||
priority_categories: Priorytet kategorii i stron zbiorczych
|
||||
priority_categories_comment: Dla adresów pasujących do reguł poziomu kategorii z konfiguracji. Domyślnie 0.4. Działa tylko przy włączonych poziomach priorytetów.
|
||||
priority_legal: Priorytet stron prawnych
|
||||
priority_legal_comment: Dla adresów pasujących do reguł poziomu stron prawnych z konfiguracji. Domyślnie 0.2. Działa tylko przy włączonych poziomach priorytetów.
|
||||
@@ -31,6 +31,10 @@ func (Definition) MorphName() string { return `Golem15\Sitemap\Models\Definition
|
||||
// (MassAssignmentTest).
|
||||
func (Definition) Fillable() []string { return []string{"theme", "data"} }
|
||||
|
||||
// Rules is empty: the PHP model validated nothing. The admin form checks
|
||||
// each item row's reference against its type.
|
||||
func (Definition) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// Items returns the stored items.
|
||||
func (d Definition) Items() []DefinitionItem { return d.Data.Get() }
|
||||
|
||||
|
||||
69
models/definition/fields.yaml
Normal file
69
models/definition/fields.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
#
|
||||
# The WinterCMS form was one tab holding the SitemapItems widget. Here the
|
||||
# items are a repeater on data; each row is one DefinitionItem. The CMS page
|
||||
# field is not ported: items are URLs or registry types.
|
||||
|
||||
fields:
|
||||
data:
|
||||
label: golem15.sitemap::lang.item.definition
|
||||
type: repeater
|
||||
prompt: golem15.sitemap::lang.item.add_item
|
||||
titleFrom: url
|
||||
form:
|
||||
fields:
|
||||
type:
|
||||
label: golem15.sitemap::lang.item.type
|
||||
type: dropdown
|
||||
options: getTypeOptions
|
||||
span: left
|
||||
reference:
|
||||
label: golem15.sitemap::lang.item.reference
|
||||
type: dropdown
|
||||
dependsOn: type
|
||||
emptyOption: backend::lang.form.none
|
||||
span: right
|
||||
url:
|
||||
label: golem15.sitemap::lang.item.url
|
||||
type: text
|
||||
span: left
|
||||
urlPattern:
|
||||
label: golem15.sitemap::lang.item.url_pattern
|
||||
comment: golem15.sitemap::lang.item.url_pattern_comment
|
||||
type: text
|
||||
span: right
|
||||
priority:
|
||||
label: golem15.sitemap::lang.item.priority
|
||||
type: dropdown
|
||||
span: left
|
||||
options:
|
||||
'0.1': '0.1'
|
||||
'0.2': '0.2'
|
||||
'0.3': '0.3'
|
||||
'0.4': '0.4'
|
||||
'0.5': '0.5'
|
||||
'0.6': '0.6'
|
||||
'0.7': '0.7'
|
||||
'0.8': '0.8'
|
||||
'0.9': '0.9'
|
||||
'1.0': '1.0'
|
||||
changefreq:
|
||||
label: golem15.sitemap::lang.item.changefreq
|
||||
type: dropdown
|
||||
span: right
|
||||
options:
|
||||
always: golem15.sitemap::lang.item.always
|
||||
hourly: golem15.sitemap::lang.item.hourly
|
||||
daily: golem15.sitemap::lang.item.daily
|
||||
weekly: golem15.sitemap::lang.item.weekly
|
||||
monthly: golem15.sitemap::lang.item.monthly
|
||||
yearly: golem15.sitemap::lang.item.yearly
|
||||
never: golem15.sitemap::lang.item.never
|
||||
nesting:
|
||||
label: golem15.sitemap::lang.item.allow_nested_items
|
||||
comment: golem15.sitemap::lang.item.allow_nested_items_comment
|
||||
type: checkbox
|
||||
default: true
|
||||
span: full
|
||||
68
models/settings/fields.yaml
Normal file
68
models/settings/fields.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
#
|
||||
# The WinterCMS form's nginx hint and Priority Tiers section are field
|
||||
# comments and README text here, and the tier fields always show (the
|
||||
# trigger on enable_priority_tiers is not ported).
|
||||
|
||||
fields:
|
||||
frontend_url:
|
||||
label: golem15.sitemap::lang.settings.frontend_url
|
||||
comment: golem15.sitemap::lang.settings.frontend_url_comment
|
||||
type: text
|
||||
span: left
|
||||
|
||||
frontend_default_locale:
|
||||
label: golem15.sitemap::lang.settings.frontend_default_locale
|
||||
comment: golem15.sitemap::lang.settings.frontend_default_locale_comment
|
||||
type: text
|
||||
span: right
|
||||
|
||||
updates_manifest_path:
|
||||
label: golem15.sitemap::lang.settings.updates_manifest_path
|
||||
comment: golem15.sitemap::lang.settings.updates_manifest_path_comment
|
||||
type: text
|
||||
span: full
|
||||
|
||||
enable_priority_tiers:
|
||||
label: golem15.sitemap::lang.settings.enable_priority_tiers
|
||||
comment: golem15.sitemap::lang.settings.enable_priority_tiers_comment
|
||||
type: checkbox
|
||||
default: true
|
||||
span: full
|
||||
|
||||
priority_homepage:
|
||||
label: golem15.sitemap::lang.settings.priority_homepage
|
||||
comment: golem15.sitemap::lang.settings.priority_homepage_comment
|
||||
type: text
|
||||
default: '1.0'
|
||||
span: auto
|
||||
|
||||
priority_services:
|
||||
label: golem15.sitemap::lang.settings.priority_services
|
||||
comment: golem15.sitemap::lang.settings.priority_services_comment
|
||||
type: text
|
||||
default: '0.8'
|
||||
span: auto
|
||||
|
||||
priority_blog:
|
||||
label: golem15.sitemap::lang.settings.priority_blog
|
||||
comment: golem15.sitemap::lang.settings.priority_blog_comment
|
||||
type: text
|
||||
default: '0.6'
|
||||
span: auto
|
||||
|
||||
priority_categories:
|
||||
label: golem15.sitemap::lang.settings.priority_categories
|
||||
comment: golem15.sitemap::lang.settings.priority_categories_comment
|
||||
type: text
|
||||
default: '0.4'
|
||||
span: auto
|
||||
|
||||
priority_legal:
|
||||
label: golem15.sitemap::lang.settings.priority_legal
|
||||
comment: golem15.sitemap::lang.settings.priority_legal_comment
|
||||
type: text
|
||||
default: '0.2'
|
||||
span: auto
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +21,8 @@ import (
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
@@ -359,3 +364,160 @@ func TestSitemapImportSmoke(t *testing.T) {
|
||||
t.Fatalf("missing url: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var adminSeq atomic.Uint64
|
||||
|
||||
// call sends an admin API request with an optional bearer token and JSON
|
||||
// body.
|
||||
func (e *sitemapEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req := httptest.NewRequest(method, cabana.DefaultAdminPrefix+"/api/v1"+path, reader)
|
||||
req.Host = "api.example.test"
|
||||
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// admin signs in a new administrator whose role grants permissions.
|
||||
func (e *sitemapEnv) admin(t *testing.T, permissions ...string) string {
|
||||
t.Helper()
|
||||
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
|
||||
login, password := "admin-"+stamp, "admin-secret-"+stamp
|
||||
hash, err := bouncer.HashPassword(10, password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants := map[string]int{}
|
||||
for _, code := range permissions {
|
||||
grants[code] = 1
|
||||
}
|
||||
raw, _ := json.Marshal(grants)
|
||||
var roleID uint
|
||||
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
|
||||
"Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||
t.Fatalf("backend role: id=%d err=%v", roleID, err)
|
||||
}
|
||||
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
|
||||
login, login+"@example.test", hash, roleID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := e.call(t, http.MethodPost, "/auth/login", "", map[string]string{"login": login, "password": password})
|
||||
var body struct {
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" {
|
||||
t.Fatalf("admin login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return body.Data.AccessToken
|
||||
}
|
||||
|
||||
// TestSitemapAdminSmoke drives the definition singleton, the item repeater
|
||||
// with its dependent reference, the settings form and the permission.
|
||||
func TestSitemapAdminSmoke(t *testing.T) {
|
||||
e := bootSitemap(t)
|
||||
const perm = "golem15.sitemap.access_definitions"
|
||||
token := e.admin(t, perm)
|
||||
const base = "/golem15/sitemap/definitions"
|
||||
|
||||
// The singleton route creates the definition once.
|
||||
var ids []float64
|
||||
for range 2 {
|
||||
rec := e.call(t, http.MethodGet, base+"/singleton", token, nil)
|
||||
var body struct {
|
||||
Data struct {
|
||||
ID float64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.ID == 0 {
|
||||
t.Fatalf("GET singleton: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
ids = append(ids, body.Data.ID)
|
||||
}
|
||||
var n int64
|
||||
e.gdb.Model(&models.Definition{}).Count(&n)
|
||||
if ids[0] != ids[1] || n != 1 {
|
||||
t.Fatalf("singleton ids %v, %d rows", ids, n)
|
||||
}
|
||||
id := fmt.Sprintf("%d", int(ids[0]))
|
||||
|
||||
// The form schema compiles: a singleton with the type options and the
|
||||
// dependent reference.
|
||||
rec := e.call(t, http.MethodGet, base+"/schema/form", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"singleton":true`) || !strings.Contains(rec.Body.String(), `"acme-post"`) || !strings.Contains(rec.Body.String(), `"dependsOn":"type"`) {
|
||||
t.Fatalf("form schema: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = e.call(t, http.MethodGet, base+"/fields/data.reference/dependent-options?values%5Btype%5D=acme-post", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"value":"7"`) {
|
||||
t.Fatalf("dependent options: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Saving rows stores PHP-keyed item JSON.
|
||||
rows := []map[string]any{
|
||||
{"type": "url", "url": "/", "reference": "", "urlPattern": "", "priority": "1.0", "changefreq": "always", "nesting": false},
|
||||
{"type": "acme-post", "url": "", "reference": "7", "urlPattern": "/n/:slug", "priority": "0.5", "changefreq": "weekly", "nesting": true},
|
||||
}
|
||||
rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": rows})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT rows: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var raw string
|
||||
if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, id).Scan(&raw).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const wantData = `[{"changefreq":"always","priority":"1.0","nesting":false,"type":"url","url":"/","reference":null,"cmsPage":null,"urlPattern":""},{"changefreq":"weekly","priority":"0.5","nesting":true,"type":"acme-post","url":"","reference":"7","cmsPage":null,"urlPattern":"/n/:slug"}]`
|
||||
if raw != wantData {
|
||||
t.Fatalf("stored data = %s", raw)
|
||||
}
|
||||
|
||||
// A reference not offered for its row type is a 422 on the row.
|
||||
rows[1]["reference"] = "99"
|
||||
rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": rows})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "data.1.reference") {
|
||||
t.Fatalf("PUT unknown reference: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The settings form saves the frontend URL.
|
||||
rec = e.call(t, http.MethodPut, "/settings/sitemap_settings", token, map[string]any{"frontend_url": "https://www.example.test", "enable_priority_tiers": true})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var st models.Settings
|
||||
if err := e.gdb.First(&st, 1).Error; err != nil || models.Text(st.FrontendURL) != "https://www.example.test" {
|
||||
t.Fatalf("settings = %+v %v", st, err)
|
||||
}
|
||||
rec = e.call(t, http.MethodGet, "/settings", token, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"controller":"golem15.sitemap.definitions"`) {
|
||||
t.Fatalf("settings index: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Without the permission every route answers 403.
|
||||
other := e.admin(t)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{http.MethodGet, base + "/singleton"},
|
||||
{http.MethodPut, base + "/" + id},
|
||||
{http.MethodGet, "/settings/sitemap_settings"},
|
||||
{http.MethodPut, "/settings/sitemap_settings"},
|
||||
} {
|
||||
if rec := e.call(t, c.method, c.path, other, map[string]any{}); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s without permission: %d %s", c.method, c.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user