Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f5e5dbb0 | ||
| ad419e9e6e | |||
| 5c2ad6aee1 | |||
| 923d0f9c3a | |||
| 4ca26e9a89 | |||
| 7677d8d8af | |||
| ed2d919aee | |||
| 882d4ee05f | |||
| d8aa989dfc | |||
| cff2d29fdd | |||
| 24e6f384ca | |||
| c8e0961740 | |||
| cab1b9aef4 | |||
| 59c37a8fea | |||
| c2ba33ee94 | |||
| 98fbeb6791 | |||
| d20f14fe67 | |||
| c2f136ad7d | |||
| cb614cb1fb | |||
| 58b2504fe3 | |||
| 3ecbd1f711 | |||
| 2d81c261b5 |
18
README.md
18
README.md
@@ -22,9 +22,9 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t
|
|||||||
- **URL rules** (`golem15_seo_rules`): SEO for an exact address or a pattern (`/blog?tag=*`), with or without a record behind it. A rule wins over the record's tab.
|
- **URL rules** (`golem15_seo_rules`): SEO for an exact address or a pattern (`/blog?tag=*`), with or without a record behind it. A rule wins over the record's tab.
|
||||||
- **Settings** (`golem15_seo_settings`, one row): site name, title and home templates with `%title%`, `%description%` and `%site%`, default description and social image, X account, the query-parameter list (content, filter and tracking parameters, optionally per path), never-indexed and search paths, address options and verification codes and files. One tab per content type (`golem15_seo_type_settings`) holds the inject switch, the title and description templates and the default robots (tag types default to `noindex,follow`).
|
- **Settings** (`golem15_seo_settings`, one row): site name, title and home templates with `%title%`, `%description%` and `%site%`, default description and social image, X account, the query-parameter list (content, filter and tracking parameters, optionally per path), never-indexed and search paths, address options and verification codes and files. One tab per content type (`golem15_seo_type_settings`) holds the inject switch, the title and description templates and the default robots (tag types default to `noindex,follow`).
|
||||||
- **Resolver.** Canonical URLs are self-referencing and absolute from the configured site URL, keep content parameters and drop filter and tracking parameters; overrides may be relative or absolute. Robots resolve per directive and are also answered as the `X-Robots-Tag` value. Open Graph and Twitter values inherit from the effective title, description and image. Verification meta tags come with the home page.
|
- **Resolver.** Canonical URLs are self-referencing and absolute from the configured site URL, keep content parameters and drop filter and tracking parameters; overrides may be relative or absolute. Robots resolve per directive and are also answered as the `X-Robots-Tag` value. Open Graph and Twitter values inherit from the effective title, description and image. Verification meta tags come with the home page.
|
||||||
- **Redirects** (`golem15_seo_redirects`): 301, 302 and 410 rules with exact, pattern (`/old/*` → `/new/*`) and regex (`^/p/(\d+)$` → `/post/$1`) sources, evaluated exact, then patterns (longest literal prefix first), then regex. Options: ignore letter case, match with and without a trailing slash, ignore or pass the query string; a source with `?` matches its query exactly. Regex sources use the standard library (RE2, linear time) and are capped at 512 characters. A capture never turns a path target into another host, and an absolute target keeps its own host.
|
- **Redirects** (`golem15_seo_redirects`): 301, 302 and 410 rules with exact, pattern (`/old/*` → `/new/*`) and regex (`^/p/(\d+)$` → `/post/$1`) sources, evaluated exact, then patterns (longest literal prefix first), then regex. Options: ignore letter case, match with and without a trailing slash, ignore or pass the query string; a source with `?` matches its query exactly and wins over the same path without a query. Regex sources use the standard library (RE2, linear time) and are capped at 512 characters. A capture never turns a path target into another host, and an absolute target keeps its own host.
|
||||||
- **Loops and chains.** Every redirect write runs a simulation with the rule in place (a probe URL for patterns and regex, at most 10 hops): a loop is refused, from the admin with `redirects.loop` on the target and nothing saved; a chain is saved with `redirects.chain_notice` naming the final target and is marked in the list.
|
- **Loops and chains.** Every redirect write runs a simulation with the rule in place (a probe URL for patterns and regex, at most 10 hops): a loop is refused, from the admin with `redirects.loop` on the target and nothing saved; a chain is saved with `redirects.chain_notice` naming the final target and is marked in the list. Redirect writes take a transaction-level advisory lock before the simulation, so two concurrent writes cannot together store a loop.
|
||||||
- **Address changes.** When a published record's address changes, a 301 from the old to the new address is created, earlier redirects to the old address are pointed straight at the new one, and a redirect whose source is the new address is removed (a slug changed back and forth ends with one redirect per old address). In the admin this happens inside the save transaction and the editor gets `redirects.auto_created` on the slug field; writes outside the admin (Journal's API, MCP, imports) are handled after commit through `pact.RecordSaved`. Deleting or unpublishing a record creates nothing.
|
- **Address changes.** When a published record's address changes, a 301 from the old to the new address is created, earlier redirects to the old address are pointed straight at the new one, and an exact redirect that matches the new address (with its case and trailing-slash options) is removed (a slug changed back and forth ends with one redirect per old address). The change is simulated first: when it would loop through a pattern or regex rule, or an address is not a path on the site, no redirect is written and a warning is logged. In the admin this happens inside the save transaction and the editor gets `redirects.auto_created` on the slug field; writes outside the admin (Journal's API, MCP, imports) are handled after commit through `pact.RecordSaved`. Deleting or unpublishing a record creates nothing.
|
||||||
- **404 log** (`golem15_seo_notfound`): the frontend reports each 404 to `POST /_seo/api/v1/notfound`; the log keeps the address, hits, first seen, last hit and last referer. An entry is resolved with one click: **Create redirect…** (a prompted 301), **Mark as gone** (410), or a bulk 410.
|
- **404 log** (`golem15_seo_notfound`): the frontend reports each 404 to `POST /_seo/api/v1/notfound`; the log keeps the address, hits, first seen, last hit and last referer. An entry is resolved with one click: **Create redirect…** (a prompted 301), **Mark as gone** (410), or a bulk 410.
|
||||||
- **URL normalisation** (settings tab Addresses): https, `www` or bare host, trailing slash added or removed, optional lowercasing of ASCII letters, double slashes and `/index.html` collapsed. The defaults change nothing but scheme, slashes and index files.
|
- **URL normalisation** (settings tab Addresses): https, `www` or bare host, trailing slash added or removed, optional lowercasing of ASCII letters, double slashes and `/index.html` collapsed. The defaults change nothing but scheme, slashes and index files.
|
||||||
- **Strict URLs.** A path that names a record without being its address (a wrong category, a locale prefix with another locale's slug) 301s to the record's address; a wrong URL never answers `ok`.
|
- **Strict URLs.** A path that names a record without being its address (a wrong category, a locale prefix with another locale's slug) 301s to the record's address; a wrong URL never answers `ok`.
|
||||||
@@ -35,9 +35,9 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t
|
|||||||
- **History** (`golem15_seo_revisions`): every SEO write (the SEO tab, rules, redirects, robots.txt, settings, the 404 log) is recorded with author, time, via (admin, MCP, import, auto for address changes, undo) and the changed fields before and after. Deleted records keep their values in history.
|
- **History** (`golem15_seo_revisions`): every SEO write (the SEO tab, rules, redirects, robots.txt, settings, the 404 log) is recorded with author, time, via (admin, MCP, import, auto for address changes, undo) and the changed fields before and after. Deleted records keep their values in history.
|
||||||
- **Undo** of one change or of a whole batch, from the History preview (record actions **Undo this change** and **Undo the whole batch**) and through MCP. An undo is refused when a later change touched the same values (`history.undo_stale`, naming who and when), when it would create a redirect loop (`history.undo_loop`) or when the record is gone (`history.undo_gone`); an undo is itself recorded.
|
- **Undo** of one change or of a whole batch, from the History preview (record actions **Undo this change** and **Undo the whole batch**) and through MCP. An undo is refused when a later change touched the same values (`history.undo_stale`, naming who and when), when it would create a redirect loop (`history.undo_loop`) or when the record is gone (`history.undo_gone`); an undo is itself recorded.
|
||||||
- **Batches** (D-12): a list of changes (SEO by URL, redirects, URL rules) applied all or nothing under one batch id. A dry run answers the diff and the conflicts (loops, chains, noindex on a published record's address, a canonical pointing to a redirected or noindex address) and writes nothing.
|
- **Batches** (D-12): a list of changes (SEO by URL, redirects, URL rules) applied all or nothing under one batch id. A dry run answers the diff and the conflicts (loops, chains, noindex on a published record's address, a canonical pointing to a redirected or noindex address) and writes nothing.
|
||||||
- **CSV import and export** of redirects, URL rules and the SEO of records (by URL), in the **Import and export** screen (element `golem15-seo-transfer`) and through MCP. Files are UTF-8 with a BOM, comma or semicolon; exported cells that start a formula (`=`, `+`, `-`, `@`, tab, CR) are prefixed with an apostrophe, which the import drops again. An import is previewed row by row (new, changed, unchanged, refused, with notes) and applied as one batch with via `import`, or not at all while a row is refused. The screen's apply token is an HMAC-SHA256 under the app key over the file and a fingerprint of the data the preview read; it is refused with `transfer.stale` when the data changed. The screen previews files up to 60 KB and downloads exports up to 250 KB; MCP imports take up to 900 KB per call and exports come in pages of 1000 rows.
|
- **CSV import and export** of redirects, URL rules and the SEO of records (by URL), in the **Import and export** screen (element `golem15-seo-transfer`) and through MCP. Files are UTF-8 with a BOM, comma or semicolon; exported cells that start a formula (`=`, `+`, `-`, `@`, tab, CR), or apostrophes followed by one, are prefixed with an apostrophe, and the import drops exactly that one again, so a re-imported export is unchanged. An import is previewed row by row (new, changed, unchanged, refused, with notes) and applied as one batch with via `import`, or not at all while a row is refused. The screen's apply token is an HMAC-SHA256 under the app key over the file and a fingerprint of the data the preview read; it is refused with `transfer.stale` when the data changed. The screen previews files up to 60 KB and downloads exports up to 250 KB; MCP imports take up to 900 KB per call and exports come in pages of 1000 rows.
|
||||||
- **Record events.** A deleted record loses its entries after commit; a created record gets its address snapshot; an updated record whose address changed gets its 301.
|
- **Record events.** A deleted record loses its entries after commit; a created record gets its address snapshot; an updated record whose address changed gets its 301.
|
||||||
- **Entry generation.** The `golem15.seo.generate_entries` job (conga) creates missing entries with the current address of every published record and refreshes the effective-value cache; editor values are never changed.
|
- **Entry generation.** The `golem15.seo.generate_entries` job (conga) creates missing entries with the current address of every published record and refreshes the effective-value cache; a record whose address changed without a record event gets its address change (with its 301) like an admin save. Editor values are never changed.
|
||||||
- **Field types** `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage` and `robotstxt`, with the custom elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp` (the result preview widget), `golem15-seo-image`, `golem15-seo-robotstxt` (the robots.txt editor), `golem15-seo-robotstester` (the tester widget) and `golem15-seo-transfer` (the import and export widget).
|
- **Field types** `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage` and `robotstxt`, with the custom elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp` (the result preview widget), `golem15-seo-image`, `golem15-seo-robotstxt` (the robots.txt editor), `golem15-seo-robotstester` (the tester widget) and `golem15-seo-transfer` (the import and export widget).
|
||||||
- **One permission**, `golem15.seo.manage`, for the SEO tab, rules, redirects, the 404 log, robots.txt, settings and history, in the admin and through MCP.
|
- **One permission**, `golem15.seo.manage`, for the SEO tab, rules, redirects, the 404 log, robots.txt, settings and history, in the admin and through MCP.
|
||||||
- **Management API and MCP.** Every SEO admin operation has a JSON route under `/_seo/api/v1` and an MCP tool bound to it (pier, prefix `seo_`). Tools address pages by URL: a change goes to the record behind the URL, else to an exact URL rule, and the answer says where. A read grant sees only the read tools. `AdminOperations` maps each admin operation to its tool, and a test fails when one has none.
|
- **Management API and MCP.** Every SEO admin operation has a JSON route under `/_seo/api/v1` and an MCP tool bound to it (pier, prefix `seo_`). Tools address pages by URL: a change goes to the record behind the URL, else to an exact URL rule, and the answer says where. A read grant sees only the read tools. `AdminOperations` maps each admin operation to its tool, and a test fails when one has none.
|
||||||
@@ -109,7 +109,9 @@ The pipeline runs normalisation, then redirects on the normalised path (followed
|
|||||||
{"action": "redirect", "status": 301, "location": "https://example.com/pages/b", "url": "https://example.com/pages/a", "locale": "", "staging": false, "seo": null}
|
{"action": "redirect", "status": 301, "location": "https://example.com/pages/b", "url": "https://example.com/pages/a", "locale": "", "staging": false, "seo": null}
|
||||||
```
|
```
|
||||||
|
|
||||||
`status` is 302 when a temporary redirect takes part. A 410 rule answers `"action": "gone"`, `"status": 410`. A redirect cycle met at runtime (stored past the save check) stops after 10 hops with `not_found` and is logged once per URL. A path that no published record owns and no URL rule covers answers `"action": "not_found"`, `"status": 404` and `"seo": null` (the home page `/` is always known). A `url` that is neither a path nor an http(s) URL is a `422`. Absolute URLs are built from `golem15.seo.site_url`, or from the request URL when it names the site's host or its `www` twin (for the scheme and host rules), never from the request's Host header.
|
**Behind the frontend server.** The resolve calls and the 404 reports come from the frontend (SSR) server, not from the visitor, so the throttle buckets see that server's address unless it is listed in `http.trusted_proxies` and forwards the visitor's address in `X-Forwarded-For` (the rightmost hop outside the trusted list counts). Without that, `seo-resolve` and `seo-notfound` are caps for the whole site, 600 requests a minute each (about 10 rendered pages a second), and resolve answers `429` beyond them. Put the frontend server in `http.trusted_proxies` and have it forward `X-Forwarded-For`.
|
||||||
|
|
||||||
|
`status` is 302 when a temporary redirect takes part. A 410 rule answers `"action": "gone"`, `"status": 410`. A redirect cycle met at runtime (stored past the save check) stops after 10 hops with `not_found` and is logged at most once an hour per cycle (keyed by its rules, not by the requested URL). A path that no published record owns and no URL rule covers answers `"action": "not_found"`, `"status": 404` and `"seo": null` (the home page `/` is always known). A `url` that is neither a path nor an http(s) URL is a `422`. Absolute URLs are built from `golem15.seo.site_url`, or from the request URL when it names the site's host or its `www` twin (for the scheme and host rules), never from the request's Host header.
|
||||||
|
|
||||||
### The 404 report endpoint
|
### The 404 report endpoint
|
||||||
|
|
||||||
@@ -230,7 +232,9 @@ The non-production lock reads the application environment (`SUMMER_ENV`); it has
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
go test -short ./... # without Docker
|
go test -short ./... # without Docker
|
||||||
go test ./... # with Docker: the Postgres smoke tests
|
go test ./... # with Docker: the Postgres smoke tests
|
||||||
npm ci && npm run typecheck && bash scripts/check-dist.sh
|
npm ci && npm run typecheck && npm test && bash scripts/check-dist.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The element tests (`tests/elements`, vitest on happy-dom) drive the widget elements through a fake of the admin's widget host (`tests/elements/host.ts`), including a host that drops an action.
|
||||||
|
|
||||||
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs, and the management API as an MCP client (`TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues`), batches, undo and CSV (`TestBatch`, `TestUndo`, `TestCSVTransfer`, and the `mcp` and `import` cases of `TestLoopRefused`). `TestEveryAdminOperationHasTool` and `TestMCPReadToolsAreGET` run without Docker.
|
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs, and the management API as an MCP client (`TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues`), batches, undo and CSV (`TestBatch`, `TestUndo`, `TestCSVTransfer`, and the `mcp` and `import` cases of `TestLoopRefused`). `TestEveryAdminOperationHasTool` and `TestMCPReadToolsAreGET` run without Docker.
|
||||||
|
|||||||
2
assets/dist/seo.css
vendored
2
assets/dist/seo.css
vendored
File diff suppressed because one or more lines are too long
2
assets/dist/seo.js
vendored
2
assets/dist/seo.js
vendored
File diff suppressed because one or more lines are too long
101
assets/src/elements/actions.ts
Normal file
101
assets/src/elements/actions.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { emit } from './labels'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a widget element's summer-action payloads one at a time.
|
||||||
|
*
|
||||||
|
* The admin's widget host takes an action by setting the element's `busy`
|
||||||
|
* attribute synchronously, inside the summer-action listener, and removes
|
||||||
|
* it when the request settles (after it dispatched summer-result, or set
|
||||||
|
* `state=error`). A payload sent while a request runs is queued (one at
|
||||||
|
* most, the newest wins) and sent when `busy` goes.
|
||||||
|
*
|
||||||
|
* The queue does not trust that an emitted action was taken: when `busy`
|
||||||
|
* is still absent after the event returns, the host dropped it (for
|
||||||
|
* example an action sent from connectedCallback before the host wired the
|
||||||
|
* element). The payload is then retried once on the next task; a second
|
||||||
|
* drop is reported through onDropped, so the element shows an error rather
|
||||||
|
* than waiting forever.
|
||||||
|
*/
|
||||||
|
export class ActionQueue<P> {
|
||||||
|
private inflight: P | null = null
|
||||||
|
private queued: P | null = null
|
||||||
|
private retry: P | null = null
|
||||||
|
private retried = false
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly host: HTMLElement,
|
||||||
|
private readonly onDropped: (payload: P) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The payload the host is working on, null when none. */
|
||||||
|
get current(): P | null {
|
||||||
|
return this.inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends payload, or queues it while a request runs. */
|
||||||
|
request(payload: P): void {
|
||||||
|
if (this.host.hasAttribute('busy') || this.inflight !== null) {
|
||||||
|
this.queued = payload
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.send(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the request that just settled (summer-result or state=error) and sends what waits. */
|
||||||
|
settle(): P | null {
|
||||||
|
const done = this.inflight
|
||||||
|
this.inflight = null
|
||||||
|
this.flush()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends the waiting payload when the host is free. */
|
||||||
|
flush(): void {
|
||||||
|
if (this.host.hasAttribute('busy') || this.inflight !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = this.retry ?? this.queued
|
||||||
|
if (next === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.retry !== null) {
|
||||||
|
this.retry = null
|
||||||
|
} else {
|
||||||
|
this.queued = null
|
||||||
|
}
|
||||||
|
this.send(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets the request in flight and any pending retry (the element left the page). */
|
||||||
|
reset(): void {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = undefined
|
||||||
|
this.inflight = null
|
||||||
|
this.retry = null
|
||||||
|
this.retried = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(payload: P): void {
|
||||||
|
this.inflight = payload
|
||||||
|
emit(this.host, 'summer-action', { payload })
|
||||||
|
if (this.host.hasAttribute('busy') || this.inflight !== payload) {
|
||||||
|
// Taken (or already answered synchronously).
|
||||||
|
this.retried = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.inflight = null
|
||||||
|
if (this.retried) {
|
||||||
|
this.retried = false
|
||||||
|
this.onDropped(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.retried = true
|
||||||
|
this.retry = payload
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = undefined
|
||||||
|
this.flush()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@ import { el, emit, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
|
|||||||
|
|
||||||
export const IMAGE_TAG = 'golem15-seo-image'
|
export const IMAGE_TAG = 'golem15-seo-image'
|
||||||
|
|
||||||
/** Whether a stored image URL may be shown: a path or an http(s) URL. */
|
/**
|
||||||
|
* Whether a stored image URL may be shown: a path or an http(s) URL. A
|
||||||
|
* path starting with // or /\ is refused: browsers read both as another
|
||||||
|
* host (like classes.ValidURLRef).
|
||||||
|
*/
|
||||||
export function safeImageURL(url: string): boolean {
|
export function safeImageURL(url: string): boolean {
|
||||||
return /^\/(?!\/)/.test(url) || /^https?:\/\//i.test(url)
|
return /^\/(?![\/\\])/.test(url) || /^https?:\/\//i.test(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface Labels {
|
|||||||
noimageindex: string
|
noimageindex: string
|
||||||
unavailableAfter: string
|
unavailableAfter: string
|
||||||
invalidNumber: string
|
invalidNumber: string
|
||||||
|
robotsUnparsed: string
|
||||||
|
robotsReset: string
|
||||||
// E3
|
// E3
|
||||||
desktop: string
|
desktop: string
|
||||||
mobile: string
|
mobile: string
|
||||||
@@ -100,6 +102,7 @@ export interface Labels {
|
|||||||
unsaved: string
|
unsaved: string
|
||||||
stagingNote: string
|
stagingNote: string
|
||||||
urlRequired: string
|
urlRequired: string
|
||||||
|
testFailed: string
|
||||||
// E6
|
// E6
|
||||||
exportHeading: string
|
exportHeading: string
|
||||||
importHeading: string
|
importHeading: string
|
||||||
@@ -165,6 +168,8 @@ const en: Labels = {
|
|||||||
noimageindex: 'Do not index images (noimageindex)',
|
noimageindex: 'Do not index images (noimageindex)',
|
||||||
unavailableAfter: 'Remove from results after',
|
unavailableAfter: 'Remove from results after',
|
||||||
invalidNumber: 'Enter a whole number, -1 or more.',
|
invalidNumber: 'Enter a whole number, -1 or more.',
|
||||||
|
robotsUnparsed: 'The stored value “:value” is not one this editor can show. It stays as it is until you reset it.',
|
||||||
|
robotsReset: 'Reset',
|
||||||
desktop: 'Desktop',
|
desktop: 'Desktop',
|
||||||
mobile: 'Mobile',
|
mobile: 'Mobile',
|
||||||
device: 'Preview device',
|
device: 'Preview device',
|
||||||
@@ -217,6 +222,7 @@ const en: Labels = {
|
|||||||
unsaved: 'Tests the text in the editor and the AI bot switches, including unsaved changes.',
|
unsaved: 'Tests the text in the editor and the AI bot switches, including unsaved changes.',
|
||||||
stagingNote: 'On this environment every address is blocked. The result shows the production behaviour.',
|
stagingNote: 'On this environment every address is blocked. The result shows the production behaviour.',
|
||||||
urlRequired: 'Enter an address starting with /.',
|
urlRequired: 'Enter an address starting with /.',
|
||||||
|
testFailed: 'The test could not be run. Try again.',
|
||||||
exportHeading: 'Export',
|
exportHeading: 'Export',
|
||||||
importHeading: 'Import',
|
importHeading: 'Import',
|
||||||
dataset: 'Data',
|
dataset: 'Data',
|
||||||
@@ -281,6 +287,8 @@ const pl: Labels = {
|
|||||||
noimageindex: 'Nie indeksuj obrazów (noimageindex)',
|
noimageindex: 'Nie indeksuj obrazów (noimageindex)',
|
||||||
unavailableAfter: 'Usuń z wyników po dniu',
|
unavailableAfter: 'Usuń z wyników po dniu',
|
||||||
invalidNumber: 'Wpisz liczbę całkowitą, -1 lub większą.',
|
invalidNumber: 'Wpisz liczbę całkowitą, -1 lub większą.',
|
||||||
|
robotsUnparsed: 'Zapisanej wartości „:value” ten edytor nie potrafi pokazać. Zostaje bez zmian, dopóki jej nie wyzerujesz.',
|
||||||
|
robotsReset: 'Wyzeruj',
|
||||||
desktop: 'Komputer',
|
desktop: 'Komputer',
|
||||||
mobile: 'Telefon',
|
mobile: 'Telefon',
|
||||||
device: 'Urządzenie podglądu',
|
device: 'Urządzenie podglądu',
|
||||||
@@ -343,6 +351,7 @@ const pl: Labels = {
|
|||||||
unsaved: 'Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.',
|
unsaved: 'Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.',
|
||||||
stagingNote: 'W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.',
|
stagingNote: 'W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.',
|
||||||
urlRequired: 'Wpisz adres zaczynający się od /.',
|
urlRequired: 'Wpisz adres zaczynający się od /.',
|
||||||
|
testFailed: 'Nie udało się uruchomić testu. Spróbuj ponownie.',
|
||||||
exportHeading: 'Eksport',
|
exportHeading: 'Eksport',
|
||||||
importHeading: 'Import',
|
importHeading: 'Import',
|
||||||
dataset: 'Dane',
|
dataset: 'Dane',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { el, emit, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels'
|
import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels'
|
||||||
|
|
||||||
export const ROBOTS_TAG = 'golem15-seo-robots'
|
export const ROBOTS_TAG = 'golem15-seo-robots'
|
||||||
|
|
||||||
@@ -35,6 +35,26 @@ export function emptyRobots(): Robots {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether s is a real calendar date YYYY-MM-DD, as Go's time.Parse checks
|
||||||
|
* it (2027-02-30 is not; Date.parse would roll it over into March).
|
||||||
|
*/
|
||||||
|
export function validDate(s: string): boolean {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
|
||||||
|
if (!m) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const year = Number(m[1])
|
||||||
|
const month = Number(m[2])
|
||||||
|
const day = Number(m[3])
|
||||||
|
if (month < 1 || month > 12 || day < 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
|
||||||
|
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
|
||||||
|
return day <= days
|
||||||
|
}
|
||||||
|
|
||||||
/** Parses a robots value; null when it is malformed. */
|
/** Parses a robots value; null when it is malformed. */
|
||||||
export function parseRobots(value: string): Robots | null {
|
export function parseRobots(value: string): Robots | null {
|
||||||
const r = emptyRobots()
|
const r = emptyRobots()
|
||||||
@@ -75,7 +95,8 @@ export function parseRobots(value: string): Robots | null {
|
|||||||
break
|
break
|
||||||
case 'max-snippet':
|
case 'max-snippet':
|
||||||
case 'max-video-preview': {
|
case 'max-video-preview': {
|
||||||
if (arg === null || !/^-?\d+$/.test(arg)) return null
|
// An optional sign, like Go's strconv.Atoi.
|
||||||
|
if (arg === null || !/^[+-]?\d+$/.test(arg)) return null
|
||||||
const n = Number.parseInt(arg, 10)
|
const n = Number.parseInt(arg, 10)
|
||||||
if (n < -1) return null
|
if (n < -1) return null
|
||||||
if (name === 'max-snippet') r.maxSnippet = n
|
if (name === 'max-snippet') r.maxSnippet = n
|
||||||
@@ -87,7 +108,7 @@ export function parseRobots(value: string): Robots | null {
|
|||||||
r.maxImagePreview = arg
|
r.maxImagePreview = arg
|
||||||
break
|
break
|
||||||
case 'unavailable_after':
|
case 'unavailable_after':
|
||||||
if (arg === null || !/^\d{4}-\d{2}-\d{2}$/.test(arg) || Number.isNaN(Date.parse(arg + 'T00:00:00Z'))) return null
|
if (arg === null || !validDate(arg)) return null
|
||||||
r.unavailableAfter = arg
|
r.unavailableAfter = arg
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -128,6 +149,9 @@ export class SeoRobotsElement extends HTMLElement {
|
|||||||
private current = ''
|
private current = ''
|
||||||
private model: Robots = emptyRobots()
|
private model: Robots = emptyRobots()
|
||||||
private open = false
|
private open = false
|
||||||
|
// The stored value does not parse: it is kept as it is, the controls are
|
||||||
|
// off and only Reset replaces it (a click must never drop it silently).
|
||||||
|
private unparsed = false
|
||||||
|
|
||||||
get value(): string {
|
get value(): string {
|
||||||
return this.current
|
return this.current
|
||||||
@@ -139,7 +163,9 @@ export class SeoRobotsElement extends HTMLElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.current = next
|
this.current = next
|
||||||
this.model = parseRobots(next) ?? emptyRobots()
|
const parsed = parseRobots(next)
|
||||||
|
this.unparsed = parsed === null
|
||||||
|
this.model = parsed ?? emptyRobots()
|
||||||
this.open = this.open || advancedCount(this.model) > 0
|
this.open = this.open || advancedCount(this.model) > 0
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}
|
||||||
@@ -160,6 +186,9 @@ export class SeoRobotsElement extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private commit(): void {
|
private commit(): void {
|
||||||
|
if (this.unparsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.current = serializeRobots(this.model)
|
this.current = serializeRobots(this.model)
|
||||||
emit(this, 'summer-change', { value: this.current })
|
emit(this, 'summer-change', { value: this.current })
|
||||||
this.render()
|
this.render()
|
||||||
@@ -167,7 +196,7 @@ export class SeoRobotsElement extends HTMLElement {
|
|||||||
|
|
||||||
private render(): void {
|
private render(): void {
|
||||||
const labels = this.labels()
|
const labels = this.labels()
|
||||||
const disabled = this.hasAttribute('disabled')
|
const disabled = this.hasAttribute('disabled') || this.unparsed
|
||||||
const options = jsonAttr<Record<string, unknown>>(this, 'options', {})
|
const options = jsonAttr<Record<string, unknown>>(this, 'options', {})
|
||||||
const inheritLabel = options.scope === 'type' ? labels.siteDefault : labels.inherit
|
const inheritLabel = options.scope === 'type' ? labels.siteDefault : labels.inherit
|
||||||
this.classList.toggle('seo-robots--invalid', this.hasAttribute('invalid'))
|
this.classList.toggle('seo-robots--invalid', this.hasAttribute('invalid'))
|
||||||
@@ -231,7 +260,25 @@ export class SeoRobotsElement extends HTMLElement {
|
|||||||
}
|
}
|
||||||
panel.append(checks)
|
panel.append(checks)
|
||||||
|
|
||||||
this.replaceChildren(groups, row, panel)
|
const parts: HTMLElement[] = [groups, row, panel]
|
||||||
|
if (this.unparsed) {
|
||||||
|
const note = el('div', 'seo-robots__unparsed')
|
||||||
|
note.setAttribute('role', 'status')
|
||||||
|
note.append(icon('triangle-alert'), el('span', '', fmt(labels.robotsUnparsed, { value: this.current })))
|
||||||
|
if (!this.hasAttribute('disabled')) {
|
||||||
|
const reset = el('button', 'seo-robots__reset', labels.robotsReset)
|
||||||
|
reset.type = 'button'
|
||||||
|
reset.dataset.focusKey = 'reset'
|
||||||
|
reset.addEventListener('click', () => {
|
||||||
|
this.unparsed = false
|
||||||
|
this.model = emptyRobots()
|
||||||
|
this.commit()
|
||||||
|
})
|
||||||
|
note.append(reset)
|
||||||
|
}
|
||||||
|
parts.unshift(note)
|
||||||
|
}
|
||||||
|
this.replaceChildren(...parts)
|
||||||
if (focused) {
|
if (focused) {
|
||||||
this.querySelector<HTMLElement>(`[data-focus-key="${focused}"]`)?.focus()
|
this.querySelector<HTMLElement>(`[data-focus-key="${focused}"]`)?.focus()
|
||||||
}
|
}
|
||||||
@@ -348,7 +395,7 @@ export class SeoRobotsElement extends HTMLElement {
|
|||||||
input.dataset.focusKey = 'unavailableAfter'
|
input.dataset.focusKey = 'unavailableAfter'
|
||||||
input.value = this.model.unavailableAfter
|
input.value = this.model.unavailableAfter
|
||||||
input.addEventListener('change', () => {
|
input.addEventListener('change', () => {
|
||||||
this.model.unavailableAfter = /^\d{4}-\d{2}-\d{2}$/.test(input.value) ? input.value : ''
|
this.model.unavailableAfter = validDate(input.value) ? input.value : ''
|
||||||
this.commit()
|
this.commit()
|
||||||
})
|
})
|
||||||
wrap.append(input)
|
wrap.append(input)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { el, emit, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
|
import { ActionQueue } from './actions'
|
||||||
|
import { el, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
|
||||||
|
|
||||||
export const ROBOTSTESTER_TAG = 'golem15-seo-robotstester'
|
export const ROBOTSTESTER_TAG = 'golem15-seo-robotstester'
|
||||||
|
|
||||||
@@ -49,8 +50,7 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
|||||||
private context: ContextData | null = null
|
private context: ContextData | null = null
|
||||||
private result: TestData | null = null
|
private result: TestData | null = null
|
||||||
private error = ''
|
private error = ''
|
||||||
private inflight: Payload | null = null
|
private readonly actions = new ActionQueue<Payload>(this, (payload) => this.failed(payload))
|
||||||
private queued: Payload | null = null
|
|
||||||
private connected = false
|
private connected = false
|
||||||
private input: HTMLInputElement | null = null
|
private input: HTMLInputElement | null = null
|
||||||
private select: HTMLSelectElement | null = null
|
private select: HTMLSelectElement | null = null
|
||||||
@@ -62,7 +62,7 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
|||||||
private readonly onResult = (ev: Event): void => {
|
private readonly onResult = (ev: Event): void => {
|
||||||
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
||||||
const data = detail?.data as ContextData | TestData | undefined
|
const data = detail?.data as ContextData | TestData | undefined
|
||||||
this.inflight = null
|
this.actions.settle()
|
||||||
if (data && data.op === 'context') {
|
if (data && data.op === 'context') {
|
||||||
this.context = data
|
this.context = data
|
||||||
this.build(true)
|
this.build(true)
|
||||||
@@ -71,19 +71,25 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
|||||||
this.error = ''
|
this.error = ''
|
||||||
this.renderResult()
|
this.renderResult()
|
||||||
}
|
}
|
||||||
this.flush()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
this.connected = true
|
this.connected = true
|
||||||
this.addEventListener('summer-result', this.onResult)
|
this.addEventListener('summer-result', this.onResult)
|
||||||
this.build(false)
|
this.build(false)
|
||||||
this.request({ op: 'context' })
|
// Deferred: the host may wire the element only after connecting it.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (this.connected && !this.context) {
|
||||||
|
this.request({ op: 'context' })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this.connected = false
|
this.connected = false
|
||||||
this.removeEventListener('summer-result', this.onResult)
|
this.removeEventListener('summer-result', this.onResult)
|
||||||
|
// A result that arrives while disconnected is lost; start over on reconnect.
|
||||||
|
this.actions.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
||||||
@@ -91,10 +97,10 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name === 'busy' && value === null) {
|
if (name === 'busy' && value === null) {
|
||||||
this.flush()
|
this.actions.flush()
|
||||||
}
|
}
|
||||||
if (name === 'state' && value === 'error') {
|
if (name === 'state' && value === 'error') {
|
||||||
this.inflight = null
|
this.actions.settle()
|
||||||
}
|
}
|
||||||
if (name === 'ui-locale') {
|
if (name === 'ui-locale') {
|
||||||
this.build(true)
|
this.build(true)
|
||||||
@@ -108,21 +114,16 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private request(payload: Payload): void {
|
private request(payload: Payload): void {
|
||||||
if (this.hasAttribute('busy') || this.inflight) {
|
this.actions.request(payload)
|
||||||
this.queued = payload
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.inflight = payload
|
|
||||||
emit(this, 'summer-action', { payload })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private flush(): void {
|
/** A request the host never took: the tester keeps the default crawlers, a test says it failed. */
|
||||||
if (this.hasAttribute('busy') || this.inflight || !this.queued) {
|
private failed(payload: Payload): void {
|
||||||
return
|
if (payload.op === 'test') {
|
||||||
|
this.error = this.labels().testFailed
|
||||||
|
this.result = null
|
||||||
|
this.renderResult()
|
||||||
}
|
}
|
||||||
const next = this.queued
|
|
||||||
this.queued = null
|
|
||||||
this.request(next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private build(force: boolean): void {
|
private build(force: boolean): void {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels'
|
import { ActionQueue } from './actions'
|
||||||
|
import { el, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels'
|
||||||
import { DESCRIPTION_FONT, DESCRIPTION_MAX_PX, DESCRIPTION_MOBILE_MAX_PX, TITLE_FONT, TITLE_MAX_PX, truncate } from './measure'
|
import { DESCRIPTION_FONT, DESCRIPTION_MAX_PX, DESCRIPTION_MOBILE_MAX_PX, TITLE_FONT, TITLE_MAX_PX, truncate } from './measure'
|
||||||
|
|
||||||
export const SERP_TAG = 'golem15-seo-serp'
|
export const SERP_TAG = 'golem15-seo-serp'
|
||||||
@@ -135,8 +136,7 @@ export class SeoSerpElement extends HTMLElement {
|
|||||||
private contextFailed = false
|
private contextFailed = false
|
||||||
private device: 'desktop' | 'mobile' = 'desktop'
|
private device: 'desktop' | 'mobile' = 'desktop'
|
||||||
private shownLocale = ''
|
private shownLocale = ''
|
||||||
private inflight: Payload | null = null
|
private readonly actions = new ActionQueue<Payload>(this, (payload) => this.failed(payload))
|
||||||
private queued: Payload | null = null
|
|
||||||
private duplicates = new Map<string, DuplicatesData>()
|
private duplicates = new Map<string, DuplicatesData>()
|
||||||
private checking = false
|
private checking = false
|
||||||
private timer: ReturnType<typeof setTimeout> | undefined
|
private timer: ReturnType<typeof setTimeout> | undefined
|
||||||
@@ -146,7 +146,7 @@ export class SeoSerpElement extends HTMLElement {
|
|||||||
private readonly onResult = (ev: Event): void => {
|
private readonly onResult = (ev: Event): void => {
|
||||||
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
||||||
const data = detail?.data as ContextData | DuplicatesData | undefined
|
const data = detail?.data as ContextData | DuplicatesData | undefined
|
||||||
this.inflight = null
|
this.actions.settle()
|
||||||
if (data && data.op === 'context') {
|
if (data && data.op === 'context') {
|
||||||
this.context = data
|
this.context = data
|
||||||
this.contextFailed = false
|
this.contextFailed = false
|
||||||
@@ -157,7 +157,6 @@ export class SeoSerpElement extends HTMLElement {
|
|||||||
this.checking = false
|
this.checking = false
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}
|
||||||
this.flush()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly onLocale = (ev: Event): void => {
|
private readonly onLocale = (ev: Event): void => {
|
||||||
@@ -174,7 +173,17 @@ export class SeoSerpElement extends HTMLElement {
|
|||||||
this.addEventListener('summer-result', this.onResult)
|
this.addEventListener('summer-result', this.onResult)
|
||||||
window.addEventListener('summer:ml-locale', this.onLocale)
|
window.addEventListener('summer:ml-locale', this.onLocale)
|
||||||
this.render()
|
this.render()
|
||||||
this.request({ op: 'context' })
|
// Deferred: the host may wire the element only after connecting it.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!this.connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.context) {
|
||||||
|
this.scheduleDuplicates(true)
|
||||||
|
} else {
|
||||||
|
this.request({ op: 'context' })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
@@ -182,6 +191,10 @@ export class SeoSerpElement extends HTMLElement {
|
|||||||
this.removeEventListener('summer-result', this.onResult)
|
this.removeEventListener('summer-result', this.onResult)
|
||||||
window.removeEventListener('summer:ml-locale', this.onLocale)
|
window.removeEventListener('summer:ml-locale', this.onLocale)
|
||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer)
|
||||||
|
// A result that arrives while disconnected is lost; start over on reconnect.
|
||||||
|
this.actions.reset()
|
||||||
|
this.checking = false
|
||||||
|
this.lastChecked = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
||||||
@@ -189,16 +202,17 @@ export class SeoSerpElement extends HTMLElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name === 'busy' && value === null) {
|
if (name === 'busy' && value === null) {
|
||||||
this.flush()
|
this.actions.flush()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name === 'state' && value === 'error') {
|
if (name === 'state' && value === 'error') {
|
||||||
if (this.inflight?.op === 'context') {
|
const failed = this.actions.settle()
|
||||||
this.contextFailed = true
|
if (failed) {
|
||||||
|
this.failed(failed)
|
||||||
|
} else {
|
||||||
|
this.checking = false
|
||||||
|
this.render()
|
||||||
}
|
}
|
||||||
this.checking = false
|
|
||||||
this.inflight = null
|
|
||||||
this.render()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name === 'fill-values') {
|
if (name === 'fill-values') {
|
||||||
@@ -215,21 +229,18 @@ export class SeoSerpElement extends HTMLElement {
|
|||||||
|
|
||||||
/** Sends payload, or queues it (one at most) while a request runs. */
|
/** Sends payload, or queues it (one at most) while a request runs. */
|
||||||
private request(payload: Payload): void {
|
private request(payload: Payload): void {
|
||||||
if (this.hasAttribute('busy') || this.inflight) {
|
this.actions.request(payload)
|
||||||
this.queued = payload
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.inflight = payload
|
|
||||||
emit(this, 'summer-action', { payload })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private flush(): void {
|
/** A request the host refused (state=error) or never took. */
|
||||||
if (this.hasAttribute('busy') || this.inflight || !this.queued) {
|
private failed(payload: Payload): void {
|
||||||
return
|
if (payload.op === 'context') {
|
||||||
|
this.contextFailed = true
|
||||||
|
} else {
|
||||||
|
this.lastChecked = ''
|
||||||
}
|
}
|
||||||
const next = this.queued
|
this.checking = false
|
||||||
this.queued = null
|
this.render()
|
||||||
this.request(next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fill(): Record<string, unknown> {
|
private fill(): Record<string, unknown> {
|
||||||
|
|||||||
@@ -242,6 +242,27 @@ golem15-seo-robots .seo-robots__check input {
|
|||||||
accent-color: var(--c-primary);
|
accent-color: var(--c-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
golem15-seo-robots .seo-robots__unparsed {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--c-danger);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-seo-robots .seo-robots__reset {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
golem15-seo-robots.seo-robots--invalid .seo-robots__track {
|
golem15-seo-robots.seo-robots--invalid .seo-robots__track {
|
||||||
border-color: var(--c-danger);
|
border-color: var(--c-danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ func TestBatch(t *testing.T) {
|
|||||||
if after := env.snapshot(t); after != before {
|
if after := env.snapshot(t); after != before {
|
||||||
t.Fatalf("a refused batch wrote:\n%s", after)
|
t.Fatalf("a refused batch wrote:\n%s", after)
|
||||||
}
|
}
|
||||||
|
coerced := env.mcp(t, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{
|
||||||
|
map[string]any{"op": "create_redirect", "redirect": map[string]any{"source": "/s307", "target": "/pages/a", "status": 307}},
|
||||||
|
}})
|
||||||
|
if coerced.Code != http.StatusUnprocessableEntity || !strings.Contains(coerced.Body.String(), `"status"`) {
|
||||||
|
t.Fatalf("batch with status 307 = %d %s, want 422 on status", coerced.Code, coerced.Body.String())
|
||||||
|
}
|
||||||
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{map[string]any{"op": "explode"}}}); rec.Code != http.StatusUnprocessableEntity {
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{map[string]any{"op": "explode"}}}); rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("unknown op = %d, want 422", rec.Code)
|
t.Fatalf("unknown op = %d, want 422", rec.Code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,99 +36,165 @@ type AddressResult struct {
|
|||||||
|
|
||||||
// AddressChanged applies a record's address change inside tx, the
|
// AddressChanged applies a record's address change inside tx, the
|
||||||
// caller's transaction (D-18, P-20):
|
// caller's transaction (D-18, P-20):
|
||||||
// 1. exact redirects whose source is the new address are deleted, so the
|
// 1. exact redirects that match the new address (with their case and
|
||||||
// record is never shadowed (this also undoes an earlier change back);
|
// trailing-slash options) are deleted, so the record is never shadowed
|
||||||
|
// (this also undoes an earlier change back);
|
||||||
// 2. old → new is created as a 301 (origin slug), or the existing exact
|
// 2. old → new is created as a 301 (origin slug), or the existing exact
|
||||||
// rule of the old address is pointed at the new one;
|
// rule of the old address is pointed at the new one;
|
||||||
// 3. redirects whose target is the old address are pointed at the new
|
// 3. redirects whose target is the old address are pointed at the new
|
||||||
// one, so no chain forms (A→B, then B→C makes A→C);
|
// one, so no chain forms (A→B, then B→C makes A→C);
|
||||||
// 4. redirects that now point to themselves are deleted;
|
// 4. redirects that now point to themselves are deleted;
|
||||||
// 5. the entry's URL becomes the new address.
|
// 5. the entry's URL becomes the new address (as the descriptor wrote it).
|
||||||
|
//
|
||||||
|
// Steps 2 to 4 are simulated first with the stored rules (P-19): when the
|
||||||
|
// result would loop (a pattern or regex rule that sends the new address
|
||||||
|
// back), or when either address is not a path on this site, no redirect is
|
||||||
|
// written, a warning is logged and only the entry's URL moves; the result's
|
||||||
|
// Redirect is then nil.
|
||||||
//
|
//
|
||||||
// Every redirect write is recorded in history with via auto. Nothing
|
// Every redirect write is recorded in history with via auto. Nothing
|
||||||
// happens when either address is empty or they are equal.
|
// happens when either address is empty or they are equal.
|
||||||
func (s *Service) AddressChanged(ctx context.Context, tx *gorm.DB, change AddressChange) (AddressResult, error) {
|
func (s *Service) AddressChanged(ctx context.Context, tx *gorm.DB, change AddressChange) (AddressResult, error) {
|
||||||
res := AddressResult{Locale: change.Locale, Old: strings.TrimSpace(change.Old), New: strings.TrimSpace(change.New)}
|
res := AddressResult{Locale: change.Locale, Old: s.sitePath(change.Old), New: s.sitePath(change.New)}
|
||||||
if res.Old == "" || res.New == "" || res.Old == res.New {
|
if res.Old == "" || res.New == "" || res.Old == res.New {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
ctx = WithVia(ctx, models.ViaAuto)
|
ctx = WithVia(ctx, models.ViaAuto)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
var shadowing []models.Redirect
|
if err := s.applyAddressRedirects(ctx, tx, &res, now); err != nil {
|
||||||
if err := tx.Where("match = ? AND source = ?", models.RedirectExact, res.New).Find(&shadowing).Error; err != nil {
|
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
for _, r := range shadowing {
|
|
||||||
if err := s.deleteRedirect(ctx, tx, r); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing []models.Redirect
|
|
||||||
if err := tx.Where("match = ? AND source = ?", models.RedirectExact, res.Old).Limit(1).Find(&existing).Error; err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
var redirect models.Redirect
|
|
||||||
if len(existing) > 0 {
|
|
||||||
redirect = existing[0]
|
|
||||||
before := RedirectValues(redirect)
|
|
||||||
redirect.Target, redirect.Status, redirect.UpdatedAt = res.New, models.Status301, &now
|
|
||||||
if err := tx.Save(&redirect).Error; err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
if err := s.recordRedirect(ctx, tx, redirect, before, RedirectValues(redirect)); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
redirect = models.NewRedirect()
|
|
||||||
redirect.Source, redirect.Target, redirect.Origin = res.Old, res.New, models.OriginSlug
|
|
||||||
redirect.CreatedAt, redirect.UpdatedAt = &now, &now
|
|
||||||
if err := tx.Create(&redirect).Error; err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
if err := s.recordRedirect(ctx, tx, redirect, nil, RedirectValues(redirect)); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.Redirect = &redirect
|
|
||||||
|
|
||||||
var pointing []models.Redirect
|
|
||||||
targets := []string{res.Old}
|
|
||||||
if site := s.SiteURL(); site != "" {
|
|
||||||
targets = append(targets, site+res.Old)
|
|
||||||
}
|
|
||||||
if err := tx.Where("target IN ? AND id <> ?", targets, redirect.ID).Find(&pointing).Error; err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
for _, r := range pointing {
|
|
||||||
before := RedirectValues(r)
|
|
||||||
r.Target, r.UpdatedAt = res.New, &now
|
|
||||||
if r.Match == models.RedirectExact && s.samePath(r.Target, r.Source) {
|
|
||||||
if err := s.deleteRedirect(ctx, tx, r); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := tx.Save(&r).Error; err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
if err := s.recordRedirect(ctx, tx, r, before, RedirectValues(r)); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
res.Repointed++
|
|
||||||
}
|
|
||||||
|
|
||||||
if change.ModelType != "" && change.RecordID != "" {
|
if change.ModelType != "" && change.RecordID != "" {
|
||||||
if err := tx.Model(&models.Entry{}).
|
if err := tx.Model(&models.Entry{}).
|
||||||
Where("model_type = ? AND record_id = ? AND locale = ?", change.ModelType, change.RecordID, change.Locale).
|
Where("model_type = ? AND record_id = ? AND locale = ?", change.ModelType, change.RecordID, change.Locale).
|
||||||
Updates(map[string]any{"url": res.New, "updated_at": now}).Error; err != nil {
|
Updates(map[string]any{"url": strings.TrimSpace(change.New), "updated_at": now}).Error; err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sitePath trims an address and turns an absolute URL on the site URL into
|
||||||
|
// its path, so the redirect source is the form the resolver matches.
|
||||||
|
func (s *Service) sitePath(u string) string {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if site := s.SiteURL(); site != "" && strings.HasPrefix(u, site+"/") {
|
||||||
|
return strings.TrimPrefix(u, site)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAddressRedirects runs steps 1 to 4 of AddressChanged.
|
||||||
|
func (s *Service) applyAddressRedirects(ctx context.Context, tx *gorm.DB, res *AddressResult, now time.Time) error {
|
||||||
|
candidate := models.NewRedirect()
|
||||||
|
candidate.Source, candidate.Target, candidate.Origin = res.Old, res.New, models.OriginSlug
|
||||||
|
newPath, newQuery, _ := strings.Cut(res.New, "?")
|
||||||
|
if problems := candidate.Problems(nil); len(problems) > 0 || !strings.HasPrefix(res.New, "/") {
|
||||||
|
log.Warn("golem15.seo: address change is not a path on the site, no redirect created", "old", res.Old, "new", res.New)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lockRedirectWrites(tx.WithContext(ctx)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules, err := LoadRedirects(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept := rules[:0:0]
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Match == models.RedirectExact && exactMatches(r, newPath, newQuery) {
|
||||||
|
if err := s.deleteRedirect(ctx, tx, r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate steps 2 to 4 on a copy of the remaining rules.
|
||||||
|
redirect := candidate
|
||||||
|
for _, r := range kept {
|
||||||
|
if r.Match == models.RedirectExact && r.Source == res.Old {
|
||||||
|
redirect = r
|
||||||
|
redirect.Target, redirect.Status = res.New, models.Status301
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets := map[string]bool{res.Old: true}
|
||||||
|
if site := s.SiteURL(); site != "" {
|
||||||
|
targets[site+res.Old] = true
|
||||||
|
}
|
||||||
|
simulated := make([]models.Redirect, 0, len(kept))
|
||||||
|
for _, r := range kept {
|
||||||
|
if r.ID != redirect.ID && targets[r.Target] {
|
||||||
|
r.Target = res.New
|
||||||
|
if r.Match == models.RedirectExact && s.samePath(r.Target, r.Source) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
simulated = append(simulated, r)
|
||||||
|
}
|
||||||
|
check, err := checkRedirectRules(s.SiteURL(), simulated, redirect)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if check.Loop {
|
||||||
|
log.Warn("golem15.seo: address change would create a redirect loop, no redirect created", "old", res.Old, "new", res.New, "path", check.PathText())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if redirect.ID != 0 {
|
||||||
|
var stored models.Redirect
|
||||||
|
for _, r := range kept {
|
||||||
|
if r.ID == redirect.ID {
|
||||||
|
stored = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := RedirectValues(stored)
|
||||||
|
redirect.UpdatedAt = &now
|
||||||
|
if err := tx.Save(&redirect).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.recordRedirect(ctx, tx, redirect, before, RedirectValues(redirect)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
redirect.CreatedAt, redirect.UpdatedAt = &now, &now
|
||||||
|
if err := tx.Create(&redirect).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.recordRedirect(ctx, tx, redirect, nil, RedirectValues(redirect)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.Redirect = &redirect
|
||||||
|
|
||||||
|
for _, r := range kept {
|
||||||
|
if r.ID == redirect.ID || !targets[r.Target] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before := RedirectValues(r)
|
||||||
|
r.Target, r.UpdatedAt = res.New, &now
|
||||||
|
if r.Match == models.RedirectExact && s.samePath(r.Target, r.Source) {
|
||||||
|
if err := s.deleteRedirect(ctx, tx, r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Save(&r).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.recordRedirect(ctx, tx, r, before, RedirectValues(r)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res.Repointed++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// deleteRedirect deletes r inside tx and records it in history.
|
// deleteRedirect deletes r inside tx and records it in history.
|
||||||
func (s *Service) deleteRedirect(ctx context.Context, tx *gorm.DB, r models.Redirect) error {
|
func (s *Service) deleteRedirect(ctx context.Context, tx *gorm.DB, r models.Redirect) error {
|
||||||
if err := tx.Delete(&models.Redirect{}, r.ID).Error; err != nil {
|
if err := tx.Delete(&models.Redirect{}, r.ID).Error; err != nil {
|
||||||
|
|||||||
@@ -49,6 +49,36 @@ type RedirectFields struct {
|
|||||||
Note *string `json:"note,omitempty"`
|
Note *string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Problems refuses a status, match or query option outside the allowed
|
||||||
|
// values (WR-17), so a caller learns its request was not taken rather than
|
||||||
|
// getting a default in its place; an empty match or query is the default.
|
||||||
|
func (f RedirectFields) Problems() map[string][]models.Problem {
|
||||||
|
out := map[string][]models.Problem{}
|
||||||
|
invalid := []models.Problem{{Key: "golem15.seo::lang.api.value_invalid"}}
|
||||||
|
if f.Status != nil {
|
||||||
|
switch models.RedirectStatus(*f.Status) {
|
||||||
|
case models.Status301, models.Status302, models.Status410:
|
||||||
|
default:
|
||||||
|
out["status"] = invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Match != nil {
|
||||||
|
switch strings.TrimSpace(*f.Match) {
|
||||||
|
case "", models.RedirectExact, models.RedirectPattern, models.RedirectRegex:
|
||||||
|
default:
|
||||||
|
out["match"] = invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Query != nil {
|
||||||
|
switch strings.TrimSpace(*f.Query) {
|
||||||
|
case "", models.QueryIgnore, models.QueryPass:
|
||||||
|
default:
|
||||||
|
out["query"] = invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Apply writes the fields that are set onto r.
|
// Apply writes the fields that are set onto r.
|
||||||
func (f RedirectFields) Apply(r *models.Redirect) {
|
func (f RedirectFields) Apply(r *models.Redirect) {
|
||||||
if f.Source != nil {
|
if f.Source != nil {
|
||||||
@@ -241,6 +271,11 @@ func (s *Service) runBatch(ctx context.Context, opts BatchOptions, build func(ct
|
|||||||
if opts.Via != "" {
|
if opts.Via != "" {
|
||||||
ctx = WithVia(ctx, opts.Via)
|
ctx = WithVia(ctx, opts.Via)
|
||||||
}
|
}
|
||||||
|
// One lock for the whole batch, so a refused change rolled back to
|
||||||
|
// its savepoint does not let another redirect writer in (WR-10).
|
||||||
|
if err := lockRedirectWrites(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
changes, err := build(ctx, tx)
|
changes, err := build(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -375,6 +410,9 @@ func (s *Service) applyChange(ctx context.Context, tx *gorm.DB, index int, ch Ba
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ch.Redirect != nil {
|
if ch.Redirect != nil {
|
||||||
|
if problems := ch.Redirect.Problems(); len(problems) > 0 {
|
||||||
|
return &ProblemsError{Problems: problems}
|
||||||
|
}
|
||||||
ch.Redirect.Apply(&r)
|
ch.Redirect.Apply(&r)
|
||||||
}
|
}
|
||||||
check, err := s.SaveRedirect(ctx, tx, &r)
|
check, err := s.SaveRedirect(ctx, tx, &r)
|
||||||
|
|||||||
@@ -38,19 +38,27 @@ func formulaStart(s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapable reports whether s is a formula cell behind any number of
|
||||||
|
// apostrophes: the cells escapeCell prefixes.
|
||||||
|
func escapable(s string) bool {
|
||||||
|
return formulaStart(strings.TrimLeft(s, "'"))
|
||||||
|
}
|
||||||
|
|
||||||
// escapeCell prefixes a formula cell with an apostrophe (CSV formula
|
// escapeCell prefixes a formula cell with an apostrophe (CSV formula
|
||||||
// injection, T-32-24).
|
// injection, T-32-24). A value that already starts with apostrophes before
|
||||||
|
// a formula character gets one more, so unescapeCell is its exact inverse
|
||||||
|
// and '=x survives a round trip (WR-21).
|
||||||
func escapeCell(s string) string {
|
func escapeCell(s string) string {
|
||||||
if formulaStart(s) {
|
if escapable(s) {
|
||||||
return "'" + s
|
return "'" + s
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// unescapeCell drops the apostrophe escapeCell added, so an exported file
|
// unescapeCell drops the one apostrophe escapeCell added, so an exported
|
||||||
// imports unchanged.
|
// file imports unchanged.
|
||||||
func unescapeCell(s string) string {
|
func unescapeCell(s string) string {
|
||||||
if strings.HasPrefix(s, "'") && formulaStart(s[1:]) {
|
if strings.HasPrefix(s, "'") && escapable(s[1:]) {
|
||||||
return s[1:]
|
return s[1:]
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ func TestCSV(t *testing.T) {
|
|||||||
{"/tab", "/x", "\tlead"},
|
{"/tab", "/x", "\tlead"},
|
||||||
{"/cr", "/x", "\rlead"},
|
{"/cr", "/x", "\rlead"},
|
||||||
{"/ok", "/x", "a=b"},
|
{"/ok", "/x", "a=b"},
|
||||||
|
{"/ap", "/x", "'=x"},
|
||||||
|
{"/ap2", "/x", "''-)"},
|
||||||
|
{"/ap3", "/x", "'plain"},
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("round trip with each delimiter", func(t *testing.T) {
|
t.Run("round trip with each delimiter", func(t *testing.T) {
|
||||||
@@ -63,7 +66,7 @@ func TestCSV(t *testing.T) {
|
|||||||
if lines[1] != "'=1+1" || lines[2] != "'+x" || lines[3] != "'-x" || lines[4] != "'@x" || lines[5] != "a=b" {
|
if lines[1] != "'=1+1" || lines[2] != "'+x" || lines[3] != "'-x" || lines[4] != "'@x" || lines[5] != "a=b" {
|
||||||
t.Fatalf("export = %q", lines)
|
t.Fatalf("export = %q", lines)
|
||||||
}
|
}
|
||||||
for in, want := range map[string]string{"'=x": "=x", "'+x": "+x", "'abc": "'abc", "''=x": "''=x", "plain": "plain", "'": "'"} {
|
for in, want := range map[string]string{"'=x": "=x", "'+x": "+x", "'abc": "'abc", "''=x": "'=x", "plain": "plain", "'": "'"} {
|
||||||
if got := unescapeCell(in); got != want {
|
if got := unescapeCell(in); got != want {
|
||||||
t.Errorf("unescapeCell(%q) = %q, want %q", in, got, want)
|
t.Errorf("unescapeCell(%q) = %q, want %q", in, got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,14 +152,15 @@ func decodeImage(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecod
|
|||||||
return pact.FormFieldDecoded{Value: s}, nil
|
return pact.FormFieldDecoded{Value: s}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidURLRef reports whether v is a path ("/x", not "//x") or an absolute
|
// ValidURLRef reports whether v is a path ("/x", not "//x" or "/\x",
|
||||||
// http(s) URL with a host, without spaces or control characters.
|
// which browsers read as another host) or an absolute http(s) URL with a
|
||||||
|
// host, without spaces or control characters.
|
||||||
func ValidURLRef(v string) bool {
|
func ValidURLRef(v string) bool {
|
||||||
if v == "" || strings.ContainsFunc(v, func(r rune) bool { return r <= ' ' || r == 0x7f }) {
|
if v == "" || strings.ContainsFunc(v, func(r rune) bool { return r <= ' ' || r == 0x7f }) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(v, "/") {
|
if strings.HasPrefix(v, "/") {
|
||||||
return !strings.HasPrefix(v, "//")
|
return !strings.HasPrefix(v, "//") && !strings.HasPrefix(v, `/\`)
|
||||||
}
|
}
|
||||||
u, err := url.Parse(v)
|
u, err := url.Parse(v)
|
||||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ func (s *Service) GenerateEntries(ctx context.Context, typeKey string) (Generate
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refreshEntry creates or refreshes the entry of one record in one
|
// refreshEntry creates or refreshes the entry of one record in one
|
||||||
// locale; created is true for a new row.
|
// locale; created is true for a new row. The job only sees published
|
||||||
|
// records, so an entry whose URL differs from the record's address gets
|
||||||
|
// the address change (with its 301), never a silent overwrite.
|
||||||
func (s *Service) refreshEntry(ctx context.Context, tx *gorm.DB, typ pact.MenuItemType, id string, l pact.ContentLocale, settings models.Settings, ts models.TypeSetting) (bool, error) {
|
func (s *Service) refreshEntry(ctx context.Context, tx *gorm.DB, typ pact.MenuItemType, id string, l pact.ContentLocale, settings models.Settings, ts models.TypeSetting) (bool, error) {
|
||||||
entry, found, err := LoadEntry(tx, typ.Content.Model, id, l.Locale)
|
entry, found, err := LoadEntry(tx, typ.Content.Model, id, l.Locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,12 +125,27 @@ func (s *Service) refreshEntry(ctx context.Context, tx *gorm.DB, typ pact.MenuIt
|
|||||||
}
|
}
|
||||||
return true, tx.Create(&row).Error
|
return true, tx.Create(&row).Error
|
||||||
}
|
}
|
||||||
return false, tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{
|
updates := map[string]any{
|
||||||
"url": l.URL,
|
|
||||||
"type_key": typ.Key,
|
"type_key": typ.Key,
|
||||||
"effective_title": title,
|
"effective_title": title,
|
||||||
"effective_description": description,
|
"effective_description": description,
|
||||||
}).Error
|
}
|
||||||
|
switch {
|
||||||
|
case entry.URL == "":
|
||||||
|
updates["url"] = l.URL
|
||||||
|
case entry.URL != l.URL:
|
||||||
|
// The published record's address changed and no RecordSaved
|
||||||
|
// listener has seen it yet (WR-19): apply it like the listener
|
||||||
|
// would, so its 301 is not lost; AddressChanged moves the entry's
|
||||||
|
// URL.
|
||||||
|
if _, err := s.AddressChanged(ctx, tx, AddressChange{
|
||||||
|
TypeKey: typ.Key, ModelType: typ.Content.Model, RecordID: id, Locale: l.Locale,
|
||||||
|
Old: entry.URL, New: l.URL,
|
||||||
|
}); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(updates).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveValues are a record's effective title and description without
|
// EffectiveValues are a record's effective title and description without
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ func NotFoundPath(raw string) (string, error) {
|
|||||||
if p == "" {
|
if p == "" {
|
||||||
p = "/"
|
p = "/"
|
||||||
}
|
}
|
||||||
return joinQuery(p, u.RawQuery), nil
|
out := joinQuery(p, u.RawQuery)
|
||||||
|
if !safeText(out) {
|
||||||
|
return "", ErrNotFoundURL
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// capWarned keeps when the row-cap warning was last logged.
|
// capWarned keeps when the row-cap warning was last logged.
|
||||||
@@ -94,7 +98,7 @@ func (s *Service) ReportNotFound(ctx context.Context, rawURL, referer string) er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
referer = strings.TrimSpace(referer)
|
referer = strings.TrimSpace(strings.ToValidUTF8(strings.ReplaceAll(referer, "\x00", ""), ""))
|
||||||
if len(referer) > MaxRefererBytes {
|
if len(referer) > MaxRefererBytes {
|
||||||
referer = referer[:MaxRefererBytes]
|
referer = referer[:MaxRefererBytes]
|
||||||
for len(referer) > 0 && !utf8ValidTail(referer) {
|
for len(referer) > 0 && !utf8ValidTail(referer) {
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U
|
|||||||
case column == "canonical" && v != "" && !ValidURLRef(v):
|
case column == "canonical" && v != "" && !ValidURLRef(v):
|
||||||
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
|
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
|
||||||
continue
|
continue
|
||||||
|
case column == "og_image" && !ValidImageURL(v):
|
||||||
|
problems[column] = append(problems[column], models.Problem{Key: errImageURL})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
values[column] = v
|
values[column] = v
|
||||||
}
|
}
|
||||||
@@ -153,7 +156,15 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U
|
|||||||
}
|
}
|
||||||
if owner.WrittenTo == WrittenToRecord {
|
if owner.WrittenTo == WrittenToRecord {
|
||||||
locale := owner.Locale
|
locale := owner.Locale
|
||||||
if l := strings.TrimSpace(in.Locale); l != "" {
|
if l := strings.TrimSpace(in.Locale); l != "" && l != locale {
|
||||||
|
// Another locale must be one the record describes (WR-18): an
|
||||||
|
// entry in any other would never be read by the resolver or the
|
||||||
|
// SEO tab.
|
||||||
|
if _, known := s.recordLocales(ctx, typ.Key, owner.ID)[l]; !known {
|
||||||
|
return URLSEOResult{}, &ProblemsError{Problems: map[string][]models.Problem{
|
||||||
|
"locale": {{Key: "golem15.seo::lang.api.locale_unknown", Params: map[string]string{"locale": l}}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
locale = l
|
locale = l
|
||||||
}
|
}
|
||||||
owner.Locale = locale
|
owner.Locale = locale
|
||||||
|
|||||||
@@ -165,7 +165,14 @@ func classHas(ranges []rune, c rune) bool {
|
|||||||
// matches comes back round or does not end within MaxRedirectHops, a
|
// matches comes back round or does not end within MaxRedirectHops, a
|
||||||
// chain when it takes more than one hop or when another rule's target
|
// chain when it takes more than one hop or when another rule's target
|
||||||
// leads into r. A 410 rule ends every chain and is never a loop.
|
// leads into r. A 410 rule ends every chain and is never a loop.
|
||||||
|
//
|
||||||
|
// db must be the writer's transaction: CheckRedirect takes the redirect
|
||||||
|
// write lock (lockRedirectWrites) before it reads, so two concurrent writes
|
||||||
|
// cannot each pass the simulation without the other's rule.
|
||||||
func (s *Service) CheckRedirect(ctx context.Context, db *gorm.DB, r models.Redirect) (RedirectCheck, error) {
|
func (s *Service) CheckRedirect(ctx context.Context, db *gorm.DB, r models.Redirect) (RedirectCheck, error) {
|
||||||
|
if err := lockRedirectWrites(db.WithContext(ctx)); err != nil {
|
||||||
|
return RedirectCheck{}, err
|
||||||
|
}
|
||||||
if r.Status == models.Status410 {
|
if r.Status == models.Status410 {
|
||||||
return RedirectCheck{}, nil
|
return RedirectCheck{}, nil
|
||||||
}
|
}
|
||||||
@@ -176,6 +183,18 @@ func (s *Service) CheckRedirect(ctx context.Context, db *gorm.DB, r models.Redir
|
|||||||
return checkRedirectRules(s.SiteURL(), rules, r)
|
return checkRedirectRules(s.SiteURL(), rules, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redirectLockKey is the transaction-level advisory lock that serialises
|
||||||
|
// redirect writes ("g15seord").
|
||||||
|
const redirectLockKey int64 = 0x6731357365_6f7264
|
||||||
|
|
||||||
|
// lockRedirectWrites takes the redirect write lock for the rest of tx (it
|
||||||
|
// is released on commit or rollback). Every path that simulates a redirect
|
||||||
|
// write takes it before reading the rules (WR-10): at READ COMMITTED the
|
||||||
|
// next statement after the wait sees what the other writer committed.
|
||||||
|
func lockRedirectWrites(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("SELECT pg_advisory_xact_lock(?)", redirectLockKey).Error
|
||||||
|
}
|
||||||
|
|
||||||
// checkRedirectRules is CheckRedirect over the stored rules given, for the
|
// checkRedirectRules is CheckRedirect over the stored rules given, for the
|
||||||
// site URL site.
|
// site URL site.
|
||||||
func checkRedirectRules(site string, rules []models.Redirect, r models.Redirect) (RedirectCheck, error) {
|
func checkRedirectRules(site string, rules []models.Redirect, r models.Redirect) (RedirectCheck, error) {
|
||||||
|
|||||||
@@ -27,6 +27,27 @@ const rdSite = "https://example.test"
|
|||||||
// (the database writes of AddressChanged are covered by TestSlugChange301
|
// (the database writes of AddressChanged are covered by TestSlugChange301
|
||||||
// and TestAddressChangeFromEvent in the plugin package).
|
// and TestAddressChangeFromEvent in the plugin package).
|
||||||
func TestRedirectGraph(t *testing.T) {
|
func TestRedirectGraph(t *testing.T) {
|
||||||
|
t.Run("a source with a query wins over the path-only source", func(t *testing.T) {
|
||||||
|
for _, rules := range [][]models.Redirect{
|
||||||
|
{rd(1, "/products", "/shop"), rd(2, "/products?id=5", "/shop/item-5")},
|
||||||
|
{rd(1, "/products?id=5", "/shop/item-5"), rd(2, "/products", "/shop")},
|
||||||
|
} {
|
||||||
|
set := NewRedirectSet(rules)
|
||||||
|
hit, ok, err := set.Match("/products", "id=5")
|
||||||
|
if err != nil || !ok || hit.Target != "/shop/item-5" {
|
||||||
|
t.Fatalf("rules %v: /products?id=5 = %+v, %v, %v; want /shop/item-5", rules, hit, ok, err)
|
||||||
|
}
|
||||||
|
hit, ok, err = set.Match("/products", "id=6")
|
||||||
|
if err != nil || !ok || hit.Target != "/shop" {
|
||||||
|
t.Fatalf("rules %v: /products?id=6 = %+v, %v, %v; want /shop", rules, hit, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check, err := checkRedirectRules(rdSite, []models.Redirect{rd(1, "/products", "/shop")}, rd(0, "/products?id=5", "/shop/item-5"))
|
||||||
|
if err != nil || check.Loop || check.Chain {
|
||||||
|
t.Fatalf("check = %+v, %v", check, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("a chain A to B to C is a chain notice from A to C", func(t *testing.T) {
|
t.Run("a chain A to B to C is a chain notice from A to C", func(t *testing.T) {
|
||||||
stored := []models.Redirect{rd(1, "/b", "/c")}
|
stored := []models.Redirect{rd(1, "/b", "/c")}
|
||||||
check, err := checkRedirectRules(rdSite, stored, rd(0, "/a", "/b"))
|
check, err := checkRedirectRules(rdSite, stored, rd(0, "/a", "/b"))
|
||||||
@@ -182,3 +203,34 @@ func TestRedirectGraph(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCycleLogBounded: the runtime cycle log is keyed by the cycle's rules,
|
||||||
|
// not the request URL, and never holds more than maxCycleLog keys (WR-13).
|
||||||
|
func TestCycleLogBounded(t *testing.T) {
|
||||||
|
set := NewRedirectSet([]models.Redirect{rd(1, "/la", "/lb"), rd(2, "/lb", "/la")})
|
||||||
|
ch, err := set.Follow(rdSite, "/la", "", MaxRedirectHops)
|
||||||
|
if err != nil || !ch.Loop {
|
||||||
|
t.Fatalf("chain = %+v, %v; want a loop", ch, err)
|
||||||
|
}
|
||||||
|
cycleLog.Lock()
|
||||||
|
cycleLog.seen = nil
|
||||||
|
cycleLog.Unlock()
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
logRedirectCycle(rdSite+"/la?x="+strconv.Itoa(i), ch)
|
||||||
|
}
|
||||||
|
cycleLog.Lock()
|
||||||
|
n := len(cycleLog.seen)
|
||||||
|
cycleLog.Unlock()
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("cycle log keys after one cycle from 5000 URLs = %d, want 1", n)
|
||||||
|
}
|
||||||
|
for i := 0; i < maxCycleLog+10; i++ {
|
||||||
|
logRedirectCycle("/x", Chain{Hops: []RedirectHit{{Rule: rd(uint(100+i), "/a", "/b")}}})
|
||||||
|
}
|
||||||
|
cycleLog.Lock()
|
||||||
|
n = len(cycleLog.seen)
|
||||||
|
cycleLog.Unlock()
|
||||||
|
if n > maxCycleLog {
|
||||||
|
t.Fatalf("cycle log keys = %d, want at most %d", n, maxCycleLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/diary"
|
"git.golem15.com/golem15/summercms/modules/diary"
|
||||||
@@ -117,7 +118,9 @@ func toggleSlash(p string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// exactKey is the lookup key of an exact source: lowercased, without a
|
// exactKey is the lookup key of an exact source: lowercased, without a
|
||||||
// trailing slash on its path, with its query as written.
|
// trailing slash on its path, with its query as written. The column
|
||||||
|
// golem15_seo_redirects.source_key computes the same key in SQL (migration
|
||||||
|
// 202610280001); change both together.
|
||||||
func exactKey(source string) string {
|
func exactKey(source string) string {
|
||||||
p, q, hasQuery := strings.Cut(source, "?")
|
p, q, hasQuery := strings.Cut(source, "?")
|
||||||
key := strings.ToLower(trimSlash(p))
|
key := strings.ToLower(trimSlash(p))
|
||||||
@@ -127,13 +130,15 @@ func exactKey(source string) string {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
// exactKeys are the lookup keys a request path and query may match.
|
// exactKeys are the lookup keys a request path and query may match, the
|
||||||
|
// most specific first: a source with the request's query wins over the
|
||||||
|
// path-only source (WR-08).
|
||||||
func exactKeys(path, rawQuery string) []string {
|
func exactKeys(path, rawQuery string) []string {
|
||||||
keys := []string{strings.ToLower(trimSlash(path))}
|
key := strings.ToLower(trimSlash(path))
|
||||||
if rawQuery != "" {
|
if rawQuery != "" {
|
||||||
keys = append(keys, keys[0]+"?"+rawQuery)
|
return []string{key + "?" + rawQuery, key}
|
||||||
}
|
}
|
||||||
return keys
|
return []string{key}
|
||||||
}
|
}
|
||||||
|
|
||||||
// exactMatches reports whether an exact rule matches the request: the
|
// exactMatches reports whether an exact rule matches the request: the
|
||||||
@@ -225,8 +230,9 @@ func LoadRedirects(db *gorm.DB) ([]models.Redirect, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadRedirectSet reads the rule set for the resolver: every pattern and
|
// LoadRedirectSet reads the rule set for the resolver: every pattern and
|
||||||
// regex rule now, exact rules per path as the set needs them (indexed on
|
// regex rule now, exact rules per path as the set needs them, by the
|
||||||
// lower(source)).
|
// generated source_key column, which holds exactKey(source) (so the fetch
|
||||||
|
// sees the same rules as an in-memory set).
|
||||||
func LoadRedirectSet(db *gorm.DB) (*RedirectSet, error) {
|
func LoadRedirectSet(db *gorm.DB) (*RedirectSet, error) {
|
||||||
var rows []models.Redirect
|
var rows []models.Redirect
|
||||||
if err := db.Where("match <> ?", models.RedirectExact).Order("id ASC").Find(&rows).Error; err != nil {
|
if err := db.Where("match <> ?", models.RedirectExact).Order("id ASC").Find(&rows).Error; err != nil {
|
||||||
@@ -235,7 +241,7 @@ func LoadRedirectSet(db *gorm.DB) (*RedirectSet, error) {
|
|||||||
s := NewRedirectSet(rows)
|
s := NewRedirectSet(rows)
|
||||||
s.fetch = func(keys []string) ([]models.Redirect, error) {
|
s.fetch = func(keys []string) ([]models.Redirect, error) {
|
||||||
var out []models.Redirect
|
var out []models.Redirect
|
||||||
err := db.Where("match = ? AND lower(source) IN ?", models.RedirectExact, keys).Order("id ASC").Find(&out).Error
|
err := db.Where("match = ? AND source_key IN ?", models.RedirectExact, keys).Order("id ASC").Find(&out).Error
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -524,15 +530,54 @@ func internalTarget(site, target string) (string, string, bool) {
|
|||||||
return p, u.RawQuery, true
|
return p, u.RawQuery, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirectHopsLogged keeps the URLs whose runtime redirect cycle was
|
// cycleLog keeps when each runtime redirect cycle was last logged, keyed
|
||||||
// logged, so each is logged once.
|
// by the rules it runs through (not by the request URL, which an anonymous
|
||||||
var redirectHopsLogged sync.Map
|
// client controls), and holds at most maxCycleLog keys (WR-13).
|
||||||
|
var cycleLog struct {
|
||||||
|
sync.Mutex
|
||||||
|
seen map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// logRedirectCycle logs a redirect cycle met at runtime, once per URL.
|
// maxCycleLog bounds cycleLog; when it is full it starts over.
|
||||||
|
const maxCycleLog = 1024
|
||||||
|
|
||||||
|
// cycleLogEvery is how often one cycle is logged again.
|
||||||
|
const cycleLogEvery = time.Hour
|
||||||
|
|
||||||
|
// cycleKey identifies a cycle by the sorted ids of the rules it fired.
|
||||||
|
func cycleKey(ch Chain) string {
|
||||||
|
ids := make([]int, 0, len(ch.Hops))
|
||||||
|
for _, h := range ch.Hops {
|
||||||
|
ids = append(ids, int(h.Rule.ID))
|
||||||
|
}
|
||||||
|
sort.Ints(ids)
|
||||||
|
var b strings.Builder
|
||||||
|
for i, id := range ids {
|
||||||
|
if i > 0 && ids[i-1] == id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(strconv.Itoa(id))
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// logRedirectCycle logs a redirect cycle met at runtime, at most once an
|
||||||
|
// hour per set of rules.
|
||||||
func logRedirectCycle(url string, ch Chain) {
|
func logRedirectCycle(url string, ch Chain) {
|
||||||
if _, seen := redirectHopsLogged.LoadOrStore(url, true); seen {
|
key := cycleKey(ch)
|
||||||
|
now := time.Now()
|
||||||
|
cycleLog.Lock()
|
||||||
|
if cycleLog.seen == nil || len(cycleLog.seen) >= maxCycleLog {
|
||||||
|
cycleLog.seen = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
at, seen := cycleLog.seen[key]
|
||||||
|
if seen && now.Sub(at) < cycleLogEvery {
|
||||||
|
cycleLog.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cycleLog.seen[key] = now
|
||||||
|
cycleLog.Unlock()
|
||||||
log.Warn("golem15.seo: redirect cycle, answering not_found", "url", url, "path", strings.Join(ch.Visited, " → "))
|
log.Warn("golem15.seo: redirect cycle, answering not_found", "url", url, "path", strings.Join(ch.Visited, " → "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
@@ -215,7 +216,28 @@ func parseTarget(raw string) (string, url.Values, error) {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
path = "/"
|
path = "/"
|
||||||
}
|
}
|
||||||
return path, u.Query(), nil
|
query := u.Query()
|
||||||
|
if !safeText(path) || !safeText(u.RawQuery) {
|
||||||
|
return "", nil, ErrBadURL
|
||||||
|
}
|
||||||
|
for k, vs := range query {
|
||||||
|
if !safeText(k) {
|
||||||
|
return "", nil, ErrBadURL
|
||||||
|
}
|
||||||
|
for _, v := range vs {
|
||||||
|
if !safeText(v) {
|
||||||
|
return "", nil, ErrBadURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path, query, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeText reports whether s can be stored and compared as Postgres text:
|
||||||
|
// valid UTF-8 without NUL bytes. A decoded path such as /%00 or /%FF is
|
||||||
|
// not (WR-12).
|
||||||
|
func safeText(s string) bool {
|
||||||
|
return utf8.ValidString(s) && !strings.ContainsRune(s, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// contentLocale returns the record's description in locale, else its first
|
// contentLocale returns the record's description in locale, else its first
|
||||||
|
|||||||
@@ -108,6 +108,17 @@ func TestRobotsValueMalformed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRobotsValueSigned: a signed number is accepted and stored without
|
||||||
|
// its plus sign, as the element's grammar reads it (WR-24).
|
||||||
|
func TestRobotsValueSigned(t *testing.T) {
|
||||||
|
if got, err := NormalizeRobots("max-snippet:+5"); err != nil || got != "max-snippet:5" {
|
||||||
|
t.Fatalf("NormalizeRobots(max-snippet:+5) = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := NormalizeRobots("unavailable_after:2028-02-29"); err != nil {
|
||||||
|
t.Fatalf("leap day: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRobotsValueInherit(t *testing.T) {
|
func TestRobotsValueInherit(t *testing.T) {
|
||||||
parse := func(v string) Robots {
|
parse := func(v string) Robots {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func LoadRule(db *gorm.DB, id uint) (models.Rule, error) {
|
|||||||
|
|
||||||
// SaveRule validates and stores r inside tx and records it in history: the
|
// SaveRule validates and stores r inside tx and records it in history: the
|
||||||
// address is trimmed and the match normalised, robots values are brought
|
// address is trimmed and the match normalised, robots values are brought
|
||||||
// to the C5 grammar and a canonical must be a path or an http(s) URL. Field
|
// to the C5 grammar, and a canonical and the social image must be a path
|
||||||
// problems are refused with a *ProblemsError.
|
// or an http(s) URL. Field problems are refused with a *ProblemsError.
|
||||||
func (s *Service) SaveRule(ctx context.Context, tx *gorm.DB, r *models.Rule) error {
|
func (s *Service) SaveRule(ctx context.Context, tx *gorm.DB, r *models.Rule) error {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return errors.New("golem15.seo: no rule")
|
return errors.New("golem15.seo: no rule")
|
||||||
@@ -47,6 +47,10 @@ func (s *Service) SaveRule(ctx context.Context, tx *gorm.DB, r *models.Rule) err
|
|||||||
if v := strings.TrimSpace(r.Canonical); v != "" && !ValidURLRef(v) {
|
if v := strings.TrimSpace(r.Canonical); v != "" && !ValidURLRef(v) {
|
||||||
problems["canonical"] = append(problems["canonical"], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
|
problems["canonical"] = append(problems["canonical"], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
|
||||||
}
|
}
|
||||||
|
r.OGImage = strings.TrimSpace(r.OGImage)
|
||||||
|
if !ValidImageURL(r.OGImage) {
|
||||||
|
problems["og_image"] = append(problems["og_image"], models.Problem{Key: errImageURL})
|
||||||
|
}
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
return &ProblemsError{Problems: problems}
|
return &ProblemsError{Problems: problems}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ func (s *Service) SaveSettings(ctx context.Context, tx *gorm.DB, in SettingsWrit
|
|||||||
oneOf("url_host", row.URLHost, models.HostKeep, models.HostWWW, models.HostBare)
|
oneOf("url_host", row.URLHost, models.HostKeep, models.HostWWW, models.HostBare)
|
||||||
oneOf("url_slash", row.URLSlash, models.SlashKeep, models.SlashAdd, models.SlashDrop)
|
oneOf("url_slash", row.URLSlash, models.SlashKeep, models.SlashAdd, models.SlashDrop)
|
||||||
oneOf("csv_delimiter", row.CSVDelimiter, "", ",", ";")
|
oneOf("csv_delimiter", row.CSVDelimiter, "", ",", ";")
|
||||||
|
if _, sent := in.Values["default_og_image"]; sent && !ValidImageURL(row.DefaultOGImage) {
|
||||||
|
problems["default_og_image"] = append(problems["default_og_image"], models.Problem{Key: errImageURL})
|
||||||
|
}
|
||||||
for field, list := range row.Problems() {
|
for field, list := range row.Problems() {
|
||||||
problems[field] = append(problems[field], list...)
|
problems[field] = append(problems[field], list...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,9 +160,11 @@ func (s *Service) Export(ctx context.Context, dataset, delimiter string, page, p
|
|||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
out.Page = page
|
out.Page = page
|
||||||
start := (page - 1) * perPage
|
// A page past the last is empty; compared before multiplying, so a
|
||||||
if start > len(rows) {
|
// huge page cannot overflow into a negative start (WR-14).
|
||||||
start = len(rows)
|
start := len(rows)
|
||||||
|
if page <= out.Pages {
|
||||||
|
start = (page - 1) * perPage
|
||||||
}
|
}
|
||||||
end := min(start+perPage, len(rows))
|
end := min(start+perPage, len(rows))
|
||||||
rows = rows[start:end]
|
rows = rows[start:end]
|
||||||
@@ -420,6 +422,16 @@ func (s *Service) planRedirect(ctx context.Context, tx *gorm.DB, row CSVRow) (pl
|
|||||||
f.TrailingSlash = &b
|
f.TrailingSlash = &b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if problems := f.Problems(); len(problems) > 0 {
|
||||||
|
p.status = "refused"
|
||||||
|
for _, column := range []string{"status", "match", "query"} {
|
||||||
|
if _, bad := problems[column]; bad {
|
||||||
|
v, _ := cell(row, column)
|
||||||
|
p.notes = append(p.notes, s.PhraseCtx(ctx, "golem15.seo::lang.transfer.row_invalid", map[string]string{"field": column, "message": v}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil, nil
|
||||||
|
}
|
||||||
var rows []models.Redirect
|
var rows []models.Redirect
|
||||||
if err := tx.Where("source = ? AND match = ?", strings.TrimSpace(source), strings.TrimSpace(match)).Limit(1).Find(&rows).Error; err != nil {
|
if err := tx.Where("source = ? AND match = ?", strings.TrimSpace(source), strings.TrimSpace(match)).Limit(1).Find(&rows).Error; err != nil {
|
||||||
return p, nil, err
|
return p, nil, err
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The undo refusals (D-13, T-32-27).
|
// The undo refusals (D-13, T-32-27).
|
||||||
@@ -68,8 +69,11 @@ func Undoable(r models.Revision) bool {
|
|||||||
// later change touched the same values (undo_stale, naming who and when),
|
// later change touched the same values (undo_stale, naming who and when),
|
||||||
// when the restore would create a redirect loop (undo_loop) or when the
|
// when the restore would create a redirect loop (undo_loop) or when the
|
||||||
// record it belongs to is gone (undo_gone).
|
// record it belongs to is gone (undo_gone).
|
||||||
|
//
|
||||||
|
// The entry is locked (FOR UPDATE) until tx ends, so a second undo of the
|
||||||
|
// same entry waits and is then refused as already undone (WR-11).
|
||||||
func (s *Service) Undo(ctx context.Context, tx *gorm.DB, id uint) ([]uint, error) {
|
func (s *Service) Undo(ctx context.Context, tx *gorm.DB, id uint) ([]uint, error) {
|
||||||
rev, err := LoadRevision(tx, id)
|
rev, err := LoadRevision(tx.Clauses(clause.Locking{Strength: "UPDATE"}), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -87,7 +91,7 @@ func (s *Service) Undo(ctx context.Context, tx *gorm.DB, id uint) ([]uint, error
|
|||||||
// has nothing left to undo.
|
// has nothing left to undo.
|
||||||
func (s *Service) UndoBatch(ctx context.Context, tx *gorm.DB, batch string) ([]uint, error) {
|
func (s *Service) UndoBatch(ctx context.Context, tx *gorm.DB, batch string) ([]uint, error) {
|
||||||
var revs []models.Revision
|
var revs []models.Revision
|
||||||
if err := tx.Where("batch_id = ? AND undone_at IS NULL", batch).Order("id DESC").Find(&revs).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("batch_id = ? AND undone_at IS NULL", batch).Order("id DESC").Find(&revs).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(revs) == 0 {
|
if len(revs) == 0 {
|
||||||
@@ -339,7 +343,14 @@ func (s *Service) undoOne(ctx context.Context, tx *gorm.DB, rev models.Revision)
|
|||||||
} else if p, ok := mcpPrincipal(ctx); ok && p.ID != 0 {
|
} else if p, ok := mcpPrincipal(ctx); ok && p.ID != 0 {
|
||||||
updates["undone_by_id"] = p.ID
|
updates["undone_by_id"] = p.ID
|
||||||
}
|
}
|
||||||
return tx.Model(&models.Revision{}).Where("id = ?", rev.ID).Updates(updates).Error
|
marked := tx.Model(&models.Revision{}).Where("id = ? AND undone_at IS NULL", rev.ID).Updates(updates)
|
||||||
|
if marked.Error != nil {
|
||||||
|
return marked.Error
|
||||||
|
}
|
||||||
|
if marked.RowsAffected == 0 {
|
||||||
|
return &UndoRefusal{Code: UndoDone, Key: "golem15.seo::lang.history.undo_done"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// kindLabels are the phrase keys of the history subjects in the undo_gone
|
// kindLabels are the phrase keys of the history subjects in the undo_gone
|
||||||
|
|||||||
30
classes/urlref_test.go
Normal file
30
classes/urlref_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestValidURLRef: a path or an absolute http(s) URL; a path that browsers
|
||||||
|
// read as another host (// or /\) is refused (WR-20).
|
||||||
|
func TestValidURLRef(t *testing.T) {
|
||||||
|
for v, want := range map[string]bool{
|
||||||
|
"/media/a.png": true,
|
||||||
|
"https://cdn.example/a.png": true,
|
||||||
|
"http://cdn.example/a.png": true,
|
||||||
|
"//evil.example/x.png": false,
|
||||||
|
`/\evil.example/x.png`: false,
|
||||||
|
"javascript:alert(1)": false,
|
||||||
|
"data:image/png;base64,AA": false,
|
||||||
|
"a.png": false,
|
||||||
|
"/a b.png": false,
|
||||||
|
"": false,
|
||||||
|
} {
|
||||||
|
if got := ValidURLRef(v); got != want {
|
||||||
|
t.Errorf("ValidURLRef(%q) = %v, want %v", v, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ValidImageURL("") {
|
||||||
|
t.Error("ValidImageURL(\"\") = false, want true (no image)")
|
||||||
|
}
|
||||||
|
if ValidImageURL(`/\evil.example/x.png`) {
|
||||||
|
t.Error(`ValidImageURL("/\evil.example/x.png") = true, want false`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,10 +184,12 @@ func read(ctx context.Context, svc *classes.Service) (*gorm.DB, error) {
|
|||||||
return svc.DB(ctx)
|
return svc.DB(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The page sizes of the list endpoints.
|
// The page sizes of the list endpoints, and the highest page number read
|
||||||
|
// (a later page is empty anyway).
|
||||||
const (
|
const (
|
||||||
defaultPerPage = 50
|
defaultPerPage = 50
|
||||||
maxPerPage = 200
|
maxPerPage = 200
|
||||||
|
maxPage = 1_000_000
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListInput is the paging and search query every list tool shares.
|
// ListInput is the paging and search query every list tool shares.
|
||||||
@@ -206,6 +208,10 @@ func listParams(r *http.Request) ListInput {
|
|||||||
if in.Page < 1 {
|
if in.Page < 1 {
|
||||||
in.Page = 1
|
in.Page = 1
|
||||||
}
|
}
|
||||||
|
// (Page-1)*PerPage must not overflow into a negative offset (WR-14).
|
||||||
|
if in.Page > maxPage {
|
||||||
|
in.Page = maxPage
|
||||||
|
}
|
||||||
if in.PerPage < 1 {
|
if in.PerPage < 1 {
|
||||||
in.PerPage = defaultPerPage
|
in.PerPage = defaultPerPage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ type ListNotFoundInput struct {
|
|||||||
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
|
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
|
||||||
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
|
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
|
||||||
Search string `json:"search,omitempty" query:"search" jsonschema:"text the address contains"`
|
Search string `json:"search,omitempty" query:"search" jsonschema:"text the address contains"`
|
||||||
Status string `json:"status,omitempty" query:"status" jsonschema:"open (no redirect answers the address yet) or resolved"`
|
Status string `json:"status,omitempty" query:"status" jsonschema:"open or resolved: whether an exact redirect or 410 answers the address, with its case, trailing-slash and query options; an address only a pattern or regex rule answers stays open here but has resolved true in its row"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotFoundIDInput names one 404 log entry.
|
// NotFoundIDInput names one 404 log entry.
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ func CreateRedirect(service func() *classes.Service) http.HandlerFunc {
|
|||||||
if err := decode(r, maxBody, &body); err != nil {
|
if err := decode(r, maxBody, &body); err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
|
if problems := body.Problems(); len(problems) > 0 {
|
||||||
|
return 0, nil, &classes.ProblemsError{Problems: problems}
|
||||||
|
}
|
||||||
row := models.NewRedirect()
|
row := models.NewRedirect()
|
||||||
row.Origin = classes.RedirectOrigin(ctx)
|
row.Origin = classes.RedirectOrigin(ctx)
|
||||||
body.Apply(&row)
|
body.Apply(&row)
|
||||||
@@ -165,6 +168,9 @@ func UpdateRedirect(service func() *classes.Service) http.HandlerFunc {
|
|||||||
if err := decode(r, maxBody, &body); err != nil {
|
if err := decode(r, maxBody, &body); err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
|
if problems := body.Problems(); len(problems) > 0 {
|
||||||
|
return 0, nil, &classes.ProblemsError{Problems: problems}
|
||||||
|
}
|
||||||
var row models.Redirect
|
var row models.Redirect
|
||||||
var check classes.RedirectCheck
|
var check classes.RedirectCheck
|
||||||
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (v SEOValues) Map() map[string]string {
|
|||||||
// SetSEOInput is the body of PUT /_seo/api/v1/seo.
|
// SetSEOInput is the body of PUT /_seo/api/v1/seo.
|
||||||
type SetSEOInput struct {
|
type SetSEOInput struct {
|
||||||
URL string `json:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
|
URL string `json:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
|
||||||
Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO; the locale the URL matches when omitted"`
|
Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO, one of the record's locales (422 locale_unknown otherwise); the locale the URL matches when omitted"`
|
||||||
Values SEOValues `json:"values" jsonschema:"the values to change"`
|
Values SEOValues `json:"values" jsonschema:"the values to change"`
|
||||||
ForceRule bool `json:"force_rule,omitempty" jsonschema:"write a URL rule even when a record is behind the URL"`
|
ForceRule bool `json:"force_rule,omitempty" jsonschema:"write a URL rule even when a record is behind the URL"`
|
||||||
}
|
}
|
||||||
|
|||||||
8
go.mod
8
go.mod
@@ -3,7 +3,7 @@ module git.golem15.com/golem15/sm-seo-plugin
|
|||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.golem15.com/golem15/summercms v0.5.1
|
git.golem15.com/golem15/summercms v0.6.0
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
github.com/goccy/go-yaml v1.19.2
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
@@ -18,7 +18,6 @@ require (
|
|||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.2.1 // indirect
|
charm.land/bubbles/v2 v2.2.1 // indirect
|
||||||
charm.land/bubbletea/v2 v2.1.0 // indirect
|
charm.land/bubbletea/v2 v2.1.0 // indirect
|
||||||
charm.land/fang/v2 v2.0.1 // indirect
|
|
||||||
charm.land/huh/v2 v2.0.3 // indirect
|
charm.land/huh/v2 v2.0.3 // indirect
|
||||||
charm.land/lipgloss/v2 v2.0.6 // indirect
|
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
@@ -52,7 +51,6 @@ require (
|
|||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
|
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
@@ -122,10 +120,6 @@ require (
|
|||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/mango v0.1.0 // indirect
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 // indirect
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 // indirect
|
|
||||||
github.com/muesli/roff v0.1.0 // indirect
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
|||||||
12
go.sum
12
go.sum
@@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
|||||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
|
|
||||||
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
|
|
||||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
@@ -110,8 +108,6 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
|
|||||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
|
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||||
@@ -314,14 +310,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
|
|||||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
|
||||||
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
|
||||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
|
||||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ api:
|
|||||||
canonical_invalid: The canonical URL must be a path starting with / or an absolute http(s) URL.
|
canonical_invalid: The canonical URL must be a path starting with / or an absolute http(s) URL.
|
||||||
bot_unknown: This is not an AI bot of the robots.txt screen.
|
bot_unknown: This is not an AI bot of the robots.txt screen.
|
||||||
value_invalid: This value is not valid here.
|
value_invalid: This value is not valid here.
|
||||||
|
locale_unknown: "The record behind this address has no locale “:locale”."
|
||||||
type_unknown: No content type with this key is registered.
|
type_unknown: No content type with this key is registered.
|
||||||
batch_too_large: "A batch takes at most :max changes."
|
batch_too_large: "A batch takes at most :max changes."
|
||||||
change_not_found: The record this change names does not exist.
|
change_not_found: The record this change names does not exist.
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ api:
|
|||||||
canonical_invalid: Adres kanoniczny musi być ścieżką zaczynającą się od / lub pełnym adresem http(s).
|
canonical_invalid: Adres kanoniczny musi być ścieżką zaczynającą się od / lub pełnym adresem http(s).
|
||||||
bot_unknown: To nie jest bot AI z ekranu robots.txt.
|
bot_unknown: To nie jest bot AI z ekranu robots.txt.
|
||||||
value_invalid: Ta wartość jest tu nieprawidłowa.
|
value_invalid: Ta wartość jest tu nieprawidłowa.
|
||||||
|
locale_unknown: "Rekord pod tym adresem nie ma wersji językowej „:locale”."
|
||||||
type_unknown: Nie zarejestrowano typu treści o tym kluczu.
|
type_unknown: Nie zarejestrowano typu treści o tym kluczu.
|
||||||
batch_too_large: "Partia może mieć najwyżej :max zmian."
|
batch_too_large: "Partia może mieć najwyżej :max zmian."
|
||||||
change_not_found: Rekord wskazany w tej zmianie nie istnieje.
|
change_not_found: Rekord wskazany w tej zmianie nie istnieje.
|
||||||
|
|||||||
13
manifest_test.go
Normal file
13
manifest_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package seo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/kiosk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPluginManifest fails when plugin.yaml is missing or no longer matches
|
||||||
|
// what `summer plugin:manifest` derives from this repository.
|
||||||
|
func TestPluginManifest(t *testing.T) {
|
||||||
|
kiosk.CheckT(t, ".")
|
||||||
|
}
|
||||||
45
mcp_test.go
45
mcp_test.go
@@ -242,6 +242,42 @@ func TestManagementAPI(t *testing.T) {
|
|||||||
t.Fatalf("bad url = %d, want 422", rec.Code)
|
t.Fatalf("bad url = %d, want 422", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A social image is a path or an http(s) URL on every write path, as
|
||||||
|
// in the admin field (WR-16).
|
||||||
|
for _, bad := range []string{"javascript:alert(1)", "data:image/png;base64,AAAA", "//evil.example/x.png", "a b.png"} {
|
||||||
|
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"og_image": bad}}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"og_image"`) {
|
||||||
|
t.Fatalf("set_url_seo og_image %q = %d %s, want 422 on og_image", bad, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/img-rule", "og_image": bad}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"og_image"`) {
|
||||||
|
t.Fatalf("create_rule og_image %q = %d %s, want 422 on og_image", bad, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"default_og_image": bad}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"default_og_image"`) {
|
||||||
|
t.Fatalf("update_settings default_og_image %q = %d %s, want 422", bad, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"og_image": "https://cdn.example/a.png"}})
|
||||||
|
|
||||||
|
// A locale the record does not describe is refused (WR-18); one it
|
||||||
|
// does is written.
|
||||||
|
for _, bad := range []string{"xx", "EN "} {
|
||||||
|
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "locale": bad, "values": map[string]any{"title": "Lost"}}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"locale"`) {
|
||||||
|
t.Fatalf("set_url_seo locale %q = %d %s, want 422 on locale", bad, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "locale": "pl", "values": map[string]any{"title": "Po polsku"}}); got["locale"] != "pl" {
|
||||||
|
t.Fatalf("set_url_seo locale pl = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rule address is capped like a redirect source, so a long one is a
|
||||||
|
// field refusal, not a database error from the unique index (WR-23).
|
||||||
|
long := "/" + strings.Repeat("x", 3000)
|
||||||
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": long, "title": "Long"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"url"`) {
|
||||||
|
t.Fatalf("create_rule with a 3001-byte url = %d %s, want 422 on url", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": long, "values": map[string]any{"title": "Long"}}); rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("set_url_seo on a 3001-byte url = %d %s, want 422", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
// URL rules.
|
// URL rules.
|
||||||
rule := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern", "robots": "noindex"})
|
rule := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern", "robots": "noindex"})
|
||||||
data, _ := rule["data"].(map[string]any)
|
data, _ := rule["data"].(map[string]any)
|
||||||
@@ -266,6 +302,15 @@ func TestManagementAPI(t *testing.T) {
|
|||||||
t.Fatalf("deleted rule = %d, want 404", rec.Code)
|
t.Fatalf("deleted rule = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A status, match or query outside the allowed values is refused, not
|
||||||
|
// replaced by the default (WR-17).
|
||||||
|
for field, value := range map[string]any{"status": 308, "match": "Regex", "query": "keep"} {
|
||||||
|
rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/coerced", "target": "/y", field: value})
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+field+`"`) {
|
||||||
|
t.Fatalf("create_redirect %s=%v = %d %s, want 422 on %s", field, value, rec.Code, rec.Body.String(), field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redirects: a chain is saved with a warning, a loop refused.
|
// Redirects: a chain is saved with a warning, a loop refused.
|
||||||
first := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/x", "target": "/y"})
|
first := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/x", "target": "/y"})
|
||||||
if d, _ := first["data"].(map[string]any); d["origin"] != "mcp" || d["status"] != float64(301) {
|
if d, _ := first["data"].(map[string]any); d["origin"] != "mcp" || d["status"] != float64(301) {
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ const (
|
|||||||
// FilterScopes lists the 404 log's filter scopes.
|
// FilterScopes lists the 404 log's filter scopes.
|
||||||
func (NotFound) FilterScopes() []string { return []string{FilterNotFoundStatus} }
|
func (NotFound) FilterScopes() []string { return []string{FilterNotFoundStatus} }
|
||||||
|
|
||||||
// coveredSQL is true for an entry an exact redirect or 410 answers now
|
// coveredSQL is true for an entry an exact redirect or 410 answers now,
|
||||||
// (the list filter; the Resolved badge also counts patterns and regex).
|
// with the rule's case, trailing-slash and query options as the runtime
|
||||||
const coveredSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS r WHERE r.match = 'exact' AND (r.source = golem15_seo_notfound.url OR (r.ignore_case AND lower(r.source) = lower(golem15_seo_notfound.url))))`
|
// matcher applies them (WR-22). Pattern and regex rules are left to the
|
||||||
|
// Resolved badge, which runs the compiled rule set.
|
||||||
|
var coveredSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS r WHERE ` + coversSQL("r", "golem15_seo_notfound.url") + `)`
|
||||||
|
|
||||||
// FilterScope narrows the 404 log to open or resolved entries; an unknown
|
// FilterScope narrows the 404 log to open or resolved entries; an unknown
|
||||||
// value matches nothing.
|
// value matches nothing.
|
||||||
|
|||||||
@@ -224,9 +224,42 @@ func (Redirect) FilterScopes() []string {
|
|||||||
return []string{FilterRedirectStatus, FilterRedirectMatch, FilterRedirectOrigin, FilterRedirectChain}
|
return []string{FilterRedirectStatus, FilterRedirectMatch, FilterRedirectOrigin, FilterRedirectChain}
|
||||||
}
|
}
|
||||||
|
|
||||||
// chainSQL is true for a redirect whose target is another redirect's
|
// coversSQL is an SQL condition, true when the exact redirect aliased
|
||||||
// source.
|
// r matches the address addr (an SQL expression of a path with an optional
|
||||||
const chainSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS next WHERE next.source = golem15_seo_redirects.target AND next.id <> golem15_seo_redirects.id)`
|
// query) the way the runtime matcher does (classes.exactMatches): the
|
||||||
|
// source's path with its ignore_case and trailing_slash options, and, when
|
||||||
|
// the source has a query, the address's query exactly (WR-22). The
|
||||||
|
// source_key test (the generated column of classes.exactKey) is a
|
||||||
|
// necessary condition that lets Postgres use its index; the rest is exact.
|
||||||
|
// Pattern and regex rules are not covered: their expressions are RE2 and
|
||||||
|
// only the compiled RedirectSet evaluates them.
|
||||||
|
func coversSQL(r, addr string) string {
|
||||||
|
return strings.NewReplacer("R.", r+".", "ADDR", addr).Replace(exactCoversSQL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exactCoversSQL is coversSQL with the placeholders R. and ADDR.
|
||||||
|
const exactCoversSQL = `(R.match = 'exact'
|
||||||
|
AND R.source_key IN (
|
||||||
|
lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END),
|
||||||
|
lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END)
|
||||||
|
|| '?' || (CASE WHEN strpos(ADDR, '?') > 0 THEN substr(ADDR, strpos(ADDR, '?') + 1) ELSE '' END))
|
||||||
|
AND (strpos(R.source, '?') = 0
|
||||||
|
OR substr(R.source, strpos(R.source, '?') + 1) = (CASE WHEN strpos(ADDR, '?') > 0 THEN substr(ADDR, strpos(ADDR, '?') + 1) ELSE '' END))
|
||||||
|
AND (split_part(R.source, '?', 1) = split_part(ADDR, '?', 1)
|
||||||
|
OR (R.ignore_case AND lower(split_part(R.source, '?', 1)) = lower(split_part(ADDR, '?', 1)))
|
||||||
|
OR (R.trailing_slash AND (
|
||||||
|
(CASE WHEN length(split_part(R.source, '?', 1)) > 1 AND right(split_part(R.source, '?', 1), 1) = '/' THEN left(split_part(R.source, '?', 1), -1) ELSE split_part(R.source, '?', 1) END)
|
||||||
|
= (CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END)
|
||||||
|
OR (R.ignore_case AND lower(CASE WHEN length(split_part(R.source, '?', 1)) > 1 AND right(split_part(R.source, '?', 1), 1) = '/' THEN left(split_part(R.source, '?', 1), -1) ELSE split_part(R.source, '?', 1) END)
|
||||||
|
= lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END))))))`
|
||||||
|
|
||||||
|
// chainSQL is true for a redirect whose target leads into another
|
||||||
|
// redirect: a path target that another rule's source names literally or
|
||||||
|
// an exact rule matches with its options (WR-22). Absolute targets on the
|
||||||
|
// site URL and pattern or regex next hops are only seen by the save-time
|
||||||
|
// check (the chain notice).
|
||||||
|
var chainSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS next WHERE next.id <> golem15_seo_redirects.id AND (next.source = golem15_seo_redirects.target OR (golem15_seo_redirects.target LIKE '/%' AND golem15_seo_redirects.target NOT LIKE '//%' AND ` +
|
||||||
|
coversSQL("next", "golem15_seo_redirects.target") + `)))`
|
||||||
|
|
||||||
// FilterScope narrows the redirects list; an unknown value matches
|
// FilterScope narrows the redirects list; an unknown value matches
|
||||||
// nothing.
|
// nothing.
|
||||||
|
|||||||
@@ -96,14 +96,20 @@ func NormalizeMatch(m string) string {
|
|||||||
return MatchExact
|
return MatchExact
|
||||||
}
|
}
|
||||||
|
|
||||||
// Problems refuses an address that does not start with "/" or carries * in
|
// MaxRuleURL caps a URL rule's address in bytes, like MaxRedirectURL: the
|
||||||
// an exact rule, and an address and match another rule already has (db is
|
// (url, match) UNIQUE btree index refuses rows over about 2.7 KB with a
|
||||||
// the save transaction; nil skips the uniqueness check).
|
// database error (WR-23).
|
||||||
|
const MaxRuleURL = 2048
|
||||||
|
|
||||||
|
// Problems refuses an address that does not start with "/", is longer than
|
||||||
|
// MaxRuleURL or carries * in an exact rule, and an address and match
|
||||||
|
// another rule already has (db is the save transaction; nil skips the
|
||||||
|
// uniqueness check).
|
||||||
func (r Rule) Problems(db *gorm.DB) map[string][]Problem {
|
func (r Rule) Problems(db *gorm.DB) map[string][]Problem {
|
||||||
out := map[string][]Problem{}
|
out := map[string][]Problem{}
|
||||||
url := strings.TrimSpace(r.URL)
|
url := strings.TrimSpace(r.URL)
|
||||||
match := NormalizeMatch(r.Match)
|
match := NormalizeMatch(r.Match)
|
||||||
if !strings.HasPrefix(url, "/") || strings.HasPrefix(url, "//") || strings.ContainsAny(url, " \t\r\n") || (match == MatchExact && strings.Contains(url, "*")) {
|
if len(url) > MaxRuleURL || !strings.HasPrefix(url, "/") || strings.HasPrefix(url, "//") || strings.ContainsAny(url, " \t\r\n") || (match == MatchExact && strings.Contains(url, "*")) {
|
||||||
out["url"] = []Problem{{Key: errRuleURLInvalid}}
|
out["url"] = []Problem{{Key: errRuleURLInvalid}}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
10
plugin.yaml
Normal file
10
plugin.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Generated by summer plugin:manifest. Do not edit; rerun the command.
|
||||||
|
schema: 1
|
||||||
|
id: golem15.seo
|
||||||
|
module: git.golem15.com/golem15/sm-seo-plugin
|
||||||
|
package: git.golem15.com/golem15/sm-seo-plugin
|
||||||
|
name: SEO
|
||||||
|
description: "The SEO plugin for SummerCMS applications: an SEO tab on every content type, per-URL rules, redirects and the 404 log, robots.txt with AI bot switches, global and per-type SEO settings, change history, and the redirect decision and effective SEO of any URL for the frontend."
|
||||||
|
framework: v0.6.0
|
||||||
|
overrides:
|
||||||
|
name: SEO
|
||||||
@@ -331,6 +331,144 @@ func TestRedirectEngine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExactRedirectKeys checks that the resolver's on-demand fetch of
|
||||||
|
// exact rules finds every source the in-memory matcher accepts (CR-03): a
|
||||||
|
// source stored with a trailing slash fires for both forms of the path, and
|
||||||
|
// one with upper-case letters in its query fires for that query.
|
||||||
|
func TestExactRedirectKeys(t *testing.T) {
|
||||||
|
env := bootEnv(t)
|
||||||
|
env.mustRedirect(t, "/old-page/", "exact", "/new-page", 301)
|
||||||
|
env.mustRedirect(t, "/list?Page=2", "exact", "/list-2", 301)
|
||||||
|
env.mustRedirect(t, "/Mixed/Case/", "exact", "/mixed-new", 301, func(r *models.Redirect) { r.IgnoreCase = true })
|
||||||
|
|
||||||
|
env.expectRedirect(t, "/old-page", 301, siteURL+"/new-page")
|
||||||
|
env.expectRedirect(t, "/old-page/", 301, siteURL+"/new-page")
|
||||||
|
env.expectRedirect(t, "/list?Page=2", 301, siteURL+"/list-2")
|
||||||
|
env.expectAction(t, "/list?page=2", "not_found")
|
||||||
|
env.expectRedirect(t, "/mixed/case", 301, siteURL+"/mixed-new")
|
||||||
|
|
||||||
|
// The generated column holds the same key as classes.exactKey.
|
||||||
|
for source, want := range map[string]string{
|
||||||
|
"/old-page/": "/old-page",
|
||||||
|
"/list?Page=2": "/list?Page=2",
|
||||||
|
"/Mixed/Case/": "/mixed/case",
|
||||||
|
} {
|
||||||
|
var key string
|
||||||
|
if err := env.db.Raw(`SELECT source_key FROM golem15_seo_redirects WHERE source = ?`, source).Scan(&key).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if key != want {
|
||||||
|
t.Fatalf("source_key of %q = %q, want %q", source, key, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.seedRedirect(t, "/", "exact", "/home", 301)
|
||||||
|
env.seedRedirect(t, "/A/?Q=1", "exact", "/b", 301)
|
||||||
|
for source, want := range map[string]string{"/": "/", "/A/?Q=1": "/a?Q=1"} {
|
||||||
|
var key string
|
||||||
|
if err := env.db.Raw(`SELECT source_key FROM golem15_seo_redirects WHERE source = ?`, source).Scan(&key).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if key != want {
|
||||||
|
t.Fatalf("source_key of %q = %q, want %q", source, key, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddressChangeGuards checks that an address change never stores a
|
||||||
|
// redirect loop or an unusable source (WR-09): a pattern rule that sends
|
||||||
|
// the new address back to the old one stops the 301, an exact rule that
|
||||||
|
// matches the new address with its case or trailing-slash options is
|
||||||
|
// removed, an absolute old address on the site is stored as its path, and
|
||||||
|
// a new address that is not a path on the site creates nothing.
|
||||||
|
func TestAddressChangeGuards(t *testing.T) {
|
||||||
|
env := bootEnv(t)
|
||||||
|
svc := env.seoPlugin(t).Service()
|
||||||
|
change := func(old, new string) classes.AddressResult {
|
||||||
|
t.Helper()
|
||||||
|
var res classes.AddressResult
|
||||||
|
if err := env.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
res, err = svc.AddressChanged(t.Context(), tx, classes.AddressChange{Old: old, New: new})
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
env.mustRedirect(t, "/a/*", "pattern", "/b/*", 301)
|
||||||
|
if res := change("/b/x", "/a/x"); res.Redirect != nil {
|
||||||
|
t.Fatalf("/b/x -> /a/x under /a/* -> /b/*: redirect = %+v, want none (a loop)", res.Redirect)
|
||||||
|
}
|
||||||
|
if got := redirectTo(env.redirects(t), "/b/x"); got != "" {
|
||||||
|
t.Fatalf("/b/x has a redirect to %q, want none", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.mustRedirect(t, "/Moved/", "exact", "/elsewhere", 301, func(r *models.Redirect) { r.IgnoreCase = true })
|
||||||
|
if res := change("/before", "/moved"); res.Redirect == nil {
|
||||||
|
t.Fatal("/before -> /moved: no redirect")
|
||||||
|
}
|
||||||
|
rows := env.redirects(t)
|
||||||
|
if got := redirectTo(rows, "/Moved/"); got != "" {
|
||||||
|
t.Fatalf("/Moved/ still redirects to %q; it shadows the record's new address", got)
|
||||||
|
}
|
||||||
|
env.expectRedirect(t, "/before", 301, siteURL+"/moved")
|
||||||
|
|
||||||
|
if res := change(siteURL+"/abs-old", "/abs-new"); res.Redirect == nil || res.Redirect.Source != "/abs-old" {
|
||||||
|
t.Fatalf("absolute old address: result = %+v, want a redirect from /abs-old", res)
|
||||||
|
}
|
||||||
|
env.expectRedirect(t, "/abs-old", 301, siteURL+"/abs-new")
|
||||||
|
|
||||||
|
before := env.redirectCount(t)
|
||||||
|
if res := change("/plain", "//evil.example/x"); res.Redirect != nil {
|
||||||
|
t.Fatalf("protocol-relative new address: redirect = %+v, want none", res.Redirect)
|
||||||
|
}
|
||||||
|
if got := env.redirectCount(t); got != before {
|
||||||
|
t.Fatalf("redirects = %d after a refused change, want %d", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentRedirectWrites checks that redirect writes are serialised
|
||||||
|
// (WR-10): while one transaction holds A -> B uncommitted, a concurrent
|
||||||
|
// B -> A waits, then sees A -> B and is refused as a loop.
|
||||||
|
func TestConcurrentRedirectWrites(t *testing.T) {
|
||||||
|
env := bootEnv(t)
|
||||||
|
svc := env.seoPlugin(t).Service()
|
||||||
|
newRule := func(source, target string) *models.Redirect {
|
||||||
|
r := models.NewRedirect()
|
||||||
|
r.Source, r.Target = source, target
|
||||||
|
return &r
|
||||||
|
}
|
||||||
|
first := env.db.Begin()
|
||||||
|
if _, err := svc.SaveRedirect(t.Context(), first, newRule("/cw-a", "/cw-b")); err != nil {
|
||||||
|
first.Rollback()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- env.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
_, err := svc.SaveRedirect(context.Background(), tx, newRule("/cw-b", "/cw-a"))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
first.Rollback()
|
||||||
|
t.Fatalf("B -> A finished while A -> B was uncommitted: %v", err)
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := first.Commit().Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var loop *classes.LoopError
|
||||||
|
if err := <-done; !errors.As(err, &loop) {
|
||||||
|
t.Fatalf("B -> A after A -> B committed: err = %v, want a loop", err)
|
||||||
|
}
|
||||||
|
if got := redirectTo(env.redirects(t), "/cw-b"); got != "" {
|
||||||
|
t.Fatalf("/cw-b -> %q was stored", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// redirectCount is the number of stored redirects.
|
// redirectCount is the number of stored redirects.
|
||||||
func (e *testEnv) redirectCount(t *testing.T) int64 {
|
func (e *testEnv) redirectCount(t *testing.T) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -518,6 +656,25 @@ func TestNotFoundLog(t *testing.T) {
|
|||||||
if rec := env.report(t, secret, map[string]string{"url": "/" + strings.Repeat("a", 2100)}); rec.Code != http.StatusUnprocessableEntity {
|
if rec := env.report(t, secret, map[string]string{"url": "/" + strings.Repeat("a", 2100)}); rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("report of a long URL = %d, want 422", rec.Code)
|
t.Fatalf("report of a long URL = %d, want 422", rec.Code)
|
||||||
}
|
}
|
||||||
|
// A decoded NUL or invalid UTF-8 byte is a 422, never a database error
|
||||||
|
// (WR-12); so is the public resolve answer.
|
||||||
|
// The 404 log keeps the query escaped, so only the path is refused.
|
||||||
|
for _, bad := range []string{"/%00", "/%FF", "/a?x=%00", "/a?%FF=1"} {
|
||||||
|
if rec := env.report(t, secret, map[string]string{"url": bad}); strings.HasPrefix(bad, "/%") && rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("report of %s = %d %s, want 422", bad, rec.Code, rec.Body.String())
|
||||||
|
} else if rec.Code >= 500 {
|
||||||
|
t.Fatalf("report of %s = %d %s", bad, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := env.call(t, http.MethodGet, "/_seo/api/v1/resolve?url="+urlQueryEscape(bad), "", nil); rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("resolve of %s = %d %s, want 422", bad, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rec := env.report(t, secret, map[string]string{"url": "/bad-referer", "referer": "https://ref.example/\x00\xff"}); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("report with a NUL in the referer = %d %s, want 204", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if err := env.db.Exec(`DELETE FROM golem15_seo_notfound`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if rec := env.report(t, secret, map[string]string{"url": "/missing", "referer": "https://ref.example/a"}); rec.Code != http.StatusNoContent || rec.Header().Get("X-Robots-Tag") != "noindex" {
|
if rec := env.report(t, secret, map[string]string{"url": "/missing", "referer": "https://ref.example/a"}); rec.Code != http.StatusNoContent || rec.Header().Get("X-Robots-Tag") != "noindex" {
|
||||||
t.Fatalf("report = %d %v, want 204 with X-Robots-Tag", rec.Code, rec.Header())
|
t.Fatalf("report = %d %v, want 204 with X-Robots-Tag", rec.Code, rec.Header())
|
||||||
}
|
}
|
||||||
@@ -679,3 +836,107 @@ func TestAddressChangeFromEvent(t *testing.T) {
|
|||||||
t.Fatalf("redirects after the admin save = %+v, want both old addresses on /pages/delta", rows)
|
t.Fatalf("redirects after the admin save = %+v, want both old addresses on /pages/delta", rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConcurrentUndo checks that undo is idempotent under concurrent
|
||||||
|
// requests (WR-11): while one transaction holds the undo of a deleted
|
||||||
|
// redirect uncommitted, a second undo of the same entry waits, then is
|
||||||
|
// refused as already undone; the redirect is re-created once.
|
||||||
|
func TestConcurrentUndo(t *testing.T) {
|
||||||
|
env := bootEnv(t)
|
||||||
|
svc := env.seoPlugin(t).Service()
|
||||||
|
r := models.NewRedirect()
|
||||||
|
r.Source, r.Target = "/undo-twice", "/x"
|
||||||
|
if err := env.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if _, err := svc.SaveRedirect(t.Context(), tx, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return svc.DeleteRedirect(t.Context(), tx, r)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var revID uint
|
||||||
|
if err := env.db.Raw(`SELECT id FROM golem15_seo_revisions WHERE subject_kind = 'redirect' ORDER BY id DESC LIMIT 1`).Scan(&revID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := env.db.Begin()
|
||||||
|
if _, err := svc.Undo(t.Context(), first, revID); err != nil {
|
||||||
|
first.Rollback()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- env.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
_, err := svc.Undo(context.Background(), tx, revID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
first.Rollback()
|
||||||
|
t.Fatalf("second undo finished while the first was uncommitted: %v", err)
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := first.Commit().Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var refusal *classes.UndoRefusal
|
||||||
|
if err := <-done; !errors.As(err, &refusal) || refusal.Code != classes.UndoDone {
|
||||||
|
t.Fatalf("second undo: err = %v, want already_undone", err)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_redirects WHERE source = '/undo-twice'`).Scan(&n).Error; err != nil || n != 1 {
|
||||||
|
t.Fatalf("redirects of /undo-twice = %d (err %v), want 1", n, err)
|
||||||
|
}
|
||||||
|
var undos int64
|
||||||
|
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions WHERE via = 'undo'`).Scan(&undos).Error; err != nil || undos != 1 {
|
||||||
|
t.Fatalf("undo history rows = %d (err %v), want 1", undos, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCoverAndChainFilters checks that the 404 log's Open/Resolved filter
|
||||||
|
// and the redirects list's Chains filter apply an exact rule's
|
||||||
|
// trailing-slash, case and query options like the runtime matcher (WR-22).
|
||||||
|
func TestCoverAndChainFilters(t *testing.T) {
|
||||||
|
env := bootEnv(t)
|
||||||
|
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
||||||
|
env.mustRedirect(t, "/missing", "exact", "/found", 301)
|
||||||
|
env.mustRedirect(t, "/Case", "exact", "/found", 301, func(r *models.Redirect) { r.IgnoreCase = true })
|
||||||
|
env.mustRedirect(t, "/q?id=1", "exact", "/found", 301)
|
||||||
|
for _, u := range []string{"/missing/", "/missing?utm=x", "/case/", "/q?id=1", "/q?id=2", "/MISSING", "/other"} {
|
||||||
|
if err := env.db.Exec(`INSERT INTO golem15_seo_notfound (url, hits, first_seen, last_hit) VALUES (?, 1, NOW(), NOW())`, u).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
urls := func(status string) []string {
|
||||||
|
t.Helper()
|
||||||
|
got := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/notfound?per_page=200&status="+status, agent, nil)
|
||||||
|
var out []string
|
||||||
|
for _, row := range got["data"].([]any) {
|
||||||
|
out = append(out, row.(map[string]any)["url"].(string))
|
||||||
|
if resolved := row.(map[string]any)["resolved"].(bool); resolved != (status == "resolved") {
|
||||||
|
t.Errorf("%s: row %v disagrees with its filter", status, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if got := strings.Join(urls("resolved"), ","); got != "/case/,/missing/,/missing?utm=x,/q?id=1" {
|
||||||
|
t.Fatalf("resolved = %s", got)
|
||||||
|
}
|
||||||
|
if got := strings.Join(urls("open"), ","); got != "/MISSING,/other,/q?id=2" {
|
||||||
|
t.Fatalf("open = %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.mustRedirect(t, "/c2", "exact", "/c3", 301)
|
||||||
|
env.mustRedirect(t, "/c1", "exact", "/c2/", 301)
|
||||||
|
env.mustRedirect(t, "/d1", "exact", "/C2", 301)
|
||||||
|
var chained []string
|
||||||
|
q := (models.Redirect{}).FilterScope(models.FilterRedirectChain, env.db.Model(&models.Redirect{}), models.ChainOnly)
|
||||||
|
if err := q.Order("source").Pluck("source", &chained).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(chained, ","); got != "/c1" {
|
||||||
|
t.Fatalf("chains = %s, want /c1 (/c2/ reaches /c2 with its trailing slash; /C2 does not, /c2 is case-sensitive)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ func noindex(h http.HandlerFunc) http.HandlerFunc {
|
|||||||
// trusted proxies: seo-resolve 600 a minute (the frontend server resolves
|
// trusted proxies: seo-resolve 600 a minute (the frontend server resolves
|
||||||
// every page it renders), seo-notfound 600 a minute (one report per 404
|
// every page it renders), seo-notfound 600 a minute (one report per 404
|
||||||
// page), seo-robots 120 a minute and seo-api 300 a minute (an MCP agent
|
// page), seo-robots 120 a minute and seo-api 300 a minute (an MCP agent
|
||||||
// working through an audit).
|
// working through an audit). The resolve calls and 404 reports come from
|
||||||
|
// the frontend server, so that server must be a trusted proxy forwarding
|
||||||
|
// X-Forwarded-For, or the first two buckets cap the whole site (README,
|
||||||
|
// "Behind the frontend server").
|
||||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||||
trusted := surf.TrustedProxies(nil)
|
trusted := surf.TrustedProxies(nil)
|
||||||
if p.app != nil {
|
if p.app != nil {
|
||||||
|
|||||||
@@ -267,7 +267,8 @@ func TestEntryDeletedWithRecord(t *testing.T) {
|
|||||||
|
|
||||||
// TestGenerateEntries checks the D-94 generation job's work: every
|
// TestGenerateEntries checks the D-94 generation job's work: every
|
||||||
// published record gets an entry per locale with its URL, drafts none, and
|
// published record gets an entry per locale with its URL, drafts none, and
|
||||||
// a second run refreshes addresses without touching editor values.
|
// a second run applies address changes (with their 301) without touching
|
||||||
|
// editor values.
|
||||||
func TestGenerateEntries(t *testing.T) {
|
func TestGenerateEntries(t *testing.T) {
|
||||||
env := bootEnv(t)
|
env := bootEnv(t)
|
||||||
if err := env.db.Exec(`INSERT INTO acme_pages_pages (title, slug, excerpt, published) VALUES ('One', 'one', 'First', TRUE), ('Two', 'two', '', TRUE), ('Draft', 'draft', '', FALSE)`).Error; err != nil {
|
if err := env.db.Exec(`INSERT INTO acme_pages_pages (title, slug, excerpt, published) VALUES ('One', 'one', 'First', TRUE), ('Two', 'two', '', TRUE), ('Draft', 'draft', '', FALSE)`).Error; err != nil {
|
||||||
@@ -312,6 +313,15 @@ func TestGenerateEntries(t *testing.T) {
|
|||||||
if again.Title != "Editor title" || again.URL != "/pages/one-renamed" || again.EffectiveTitle != "Editor title" {
|
if again.Title != "Editor title" || again.URL != "/pages/one-renamed" || again.EffectiveTitle != "Editor title" {
|
||||||
t.Fatalf("after the second run = %+v, want the editor title kept and the URL refreshed", again)
|
t.Fatalf("after the second run = %+v, want the editor title kept and the URL refreshed", again)
|
||||||
}
|
}
|
||||||
|
// The job applied the address change no RecordSaved announced, with
|
||||||
|
// its 301 (WR-19), in both locales.
|
||||||
|
redirects := env.redirects(t)
|
||||||
|
if redirectTo(redirects, "/pages/one") != "/pages/one-renamed" || redirectTo(redirects, "/pl/pages/one") != "/pl/pages/one-renamed" {
|
||||||
|
t.Fatalf("redirects after the job saw the slug change = %+v, want /pages/one and /pl/pages/one to 301", redirects)
|
||||||
|
}
|
||||||
|
if res, err = svc.GenerateEntries(t.Context(), ""); err != nil || res.Updated != 4 || len(env.redirects(t)) != len(redirects) {
|
||||||
|
t.Fatalf("third run = %+v err=%v, want no new redirect", res, err)
|
||||||
|
}
|
||||||
if _, err := svc.GenerateEntries(t.Context(), "nope"); err == nil {
|
if _, err := svc.GenerateEntries(t.Context(), "nope"); err == nil {
|
||||||
t.Fatal("unknown type: want an error")
|
t.Fatal("unknown type: want an error")
|
||||||
}
|
}
|
||||||
|
|||||||
114
tests/elements/host.ts
Normal file
114
tests/elements/host.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// A fake of the admin's widget host (summercms.go
|
||||||
|
// admin/src/components/form/fields/WidgetField.vue), so element tests drive
|
||||||
|
// the real host contract instead of dispatching summer-result by hand:
|
||||||
|
//
|
||||||
|
// - the host listens for summer-action on the element and ignores it
|
||||||
|
// while it has no element reference or is already busy;
|
||||||
|
// - it sets `busy` and clears `state` synchronously, inside the listener;
|
||||||
|
// - the request settles asynchronously: on success it dispatches
|
||||||
|
// summer-result with {data, fill, message}, on failure it sets
|
||||||
|
// state=error; then it removes `busy`.
|
||||||
|
//
|
||||||
|
// `order` reproduces how the host wires the element: 'append-first' is the
|
||||||
|
// v0.5.1 host, which appends the element (running connectedCallback) before
|
||||||
|
// it keeps the reference, so an action sent from connectedCallback is
|
||||||
|
// dropped; 'assign-first' keeps the reference before appending.
|
||||||
|
|
||||||
|
export type Answer = (payload: unknown) => unknown
|
||||||
|
|
||||||
|
export interface FakeHost {
|
||||||
|
node: HTMLElement
|
||||||
|
/** Every summer-action payload the element emitted. */
|
||||||
|
emitted: unknown[]
|
||||||
|
/** The payloads the host took and posted. */
|
||||||
|
posted: unknown[]
|
||||||
|
/** Makes the host ignore the next n actions (as a host without a reference does). */
|
||||||
|
drop(n: number): void
|
||||||
|
/** Whether a request is running. */
|
||||||
|
busy(): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reason the host's request fails with (it sets state=error). */
|
||||||
|
export class HostFailure extends Error {}
|
||||||
|
|
||||||
|
export interface MountOptions {
|
||||||
|
order?: 'append-first' | 'assign-first'
|
||||||
|
attrs?: Record<string, string>
|
||||||
|
/** Answers a posted payload with the response data; throw HostFailure to fail it. */
|
||||||
|
answer: Answer
|
||||||
|
/** When false the host never keeps a reference (every action is dropped). */
|
||||||
|
wired?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountWidget(tag: string, options: MountOptions): FakeHost {
|
||||||
|
const node = document.createElement(tag)
|
||||||
|
for (const [name, value] of Object.entries(options.attrs ?? {})) {
|
||||||
|
node.setAttribute(name, value)
|
||||||
|
}
|
||||||
|
let element: HTMLElement | null = null
|
||||||
|
let busy = false
|
||||||
|
let dropping = 0
|
||||||
|
const emitted: unknown[] = []
|
||||||
|
const posted: unknown[] = []
|
||||||
|
|
||||||
|
async function onAction(payload: unknown): Promise<void> {
|
||||||
|
const target = element
|
||||||
|
if (!target || busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dropping > 0) {
|
||||||
|
dropping--
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy = true
|
||||||
|
target.setAttribute('busy', '')
|
||||||
|
target.removeAttribute('state')
|
||||||
|
posted.push(payload)
|
||||||
|
try {
|
||||||
|
await Promise.resolve()
|
||||||
|
const data = options.answer(payload)
|
||||||
|
target.setAttribute('data', JSON.stringify(data))
|
||||||
|
target.dispatchEvent(new CustomEvent('summer-result', { detail: { data, fill: {}, message: undefined } }))
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof HostFailure)) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
target.setAttribute('state', 'error')
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
element?.removeAttribute('busy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('summer-action', (event) => {
|
||||||
|
const detail = (event as CustomEvent).detail as { payload?: unknown } | undefined
|
||||||
|
emitted.push(detail?.payload)
|
||||||
|
void onAction(detail?.payload)
|
||||||
|
})
|
||||||
|
const wired = options.wired ?? true
|
||||||
|
if (options.order === 'assign-first' && wired) {
|
||||||
|
element = node
|
||||||
|
document.body.append(node)
|
||||||
|
} else {
|
||||||
|
document.body.append(node)
|
||||||
|
if (wired) {
|
||||||
|
element = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
emitted,
|
||||||
|
posted,
|
||||||
|
drop: (n) => {
|
||||||
|
dropping = n
|
||||||
|
},
|
||||||
|
busy: () => busy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets microtasks and zero-delay timers run (the host's request and the elements' retries). */
|
||||||
|
export async function settle(rounds = 5): Promise<void> {
|
||||||
|
for (let i = 0; i < rounds; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
100
tests/elements/image.test.ts
Normal file
100
tests/elements/image.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { safeImageURL } from '../../assets/src/elements/image'
|
||||||
|
import { labelsFor } from '../../assets/src/elements/labels'
|
||||||
|
|
||||||
|
// E7 golem15-seo-image: which stored URLs the preview may load, the
|
||||||
|
// upload round trip through summer-upload / summer-upload-result, errors
|
||||||
|
// and Remove.
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await import('../../assets/src/elements/index')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('E7 safeImageURL', () => {
|
||||||
|
it('accepts a path and an http(s) URL', () => {
|
||||||
|
for (const url of ['/media/a.png', 'https://cdn.example/a.png', 'HTTP://cdn.example/a.png']) {
|
||||||
|
expect(safeImageURL(url), url).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses addresses a browser reads as another host or another scheme (WR-20)', () => {
|
||||||
|
for (const url of ['//evil.example/x.png', '/\\evil.example/x.png', 'javascript:alert(1)', 'data:image/png;base64,AA', 'a.png', '']) {
|
||||||
|
expect(safeImageURL(url), url).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('E7 element (WR-25)', () => {
|
||||||
|
type ImageNode = HTMLElement & { value: string }
|
||||||
|
|
||||||
|
function mount(value = ''): { node: ImageNode; changes: string[]; uploads: { id: string; file: File }[] } {
|
||||||
|
const node = document.createElement('golem15-seo-image') as ImageNode
|
||||||
|
node.setAttribute('ui-locale', 'en')
|
||||||
|
document.body.append(node)
|
||||||
|
node.value = value
|
||||||
|
const changes: string[] = []
|
||||||
|
const uploads: { id: string; file: File }[] = []
|
||||||
|
node.addEventListener('summer-change', (ev) => changes.push((ev as CustomEvent).detail.value))
|
||||||
|
node.addEventListener('summer-upload', (ev) => uploads.push((ev as CustomEvent).detail))
|
||||||
|
return { node, changes, uploads }
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(node: HTMLElement): File {
|
||||||
|
const input = node.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||||
|
const file = new File(['x'], 'a.png', { type: 'image/png' })
|
||||||
|
Object.defineProperty(input, 'files', { value: [file] })
|
||||||
|
input.dispatchEvent(new Event('change'))
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(node: HTMLElement): string {
|
||||||
|
return node.querySelector('.seo-image__status')?.textContent ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows a safe stored image and hides an unsafe one', () => {
|
||||||
|
expect(mount('/media/a.png').node.querySelector('img')?.getAttribute('src')).toBe('/media/a.png')
|
||||||
|
expect(mount('/\\evil.example/x.png').node.querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads through summer-upload and takes only the answer to its own id', () => {
|
||||||
|
const { node, changes, uploads } = mount()
|
||||||
|
const file = choose(node)
|
||||||
|
expect(uploads).toHaveLength(1)
|
||||||
|
expect(uploads[0].file).toBe(file)
|
||||||
|
expect(status(node)).toBe(labelsFor('en').imageUploading)
|
||||||
|
node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: 'other', url: '/media/wrong.png' } }))
|
||||||
|
expect(changes).toEqual([])
|
||||||
|
expect(status(node)).toBe(labelsFor('en').imageUploading)
|
||||||
|
node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[0].id, url: '/media/new.png' } }))
|
||||||
|
expect(changes).toEqual(['/media/new.png'])
|
||||||
|
expect(node.value).toBe('/media/new.png')
|
||||||
|
expect(node.querySelector('img')?.getAttribute('src')).toBe('/media/new.png')
|
||||||
|
expect(status(node)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the upload error, or imageFailed when the answer names none', () => {
|
||||||
|
const { node, changes, uploads } = mount()
|
||||||
|
choose(node)
|
||||||
|
node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[0].id, error: 'Too big.' } }))
|
||||||
|
expect(status(node)).toBe('Too big.')
|
||||||
|
choose(node)
|
||||||
|
node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[1].id } }))
|
||||||
|
expect(status(node)).toBe(labelsFor('en').imageFailed)
|
||||||
|
expect(node.querySelector('.seo-image__status--error')).not.toBeNull()
|
||||||
|
expect(changes).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Remove clears the value; a disabled element offers no actions', () => {
|
||||||
|
const { node, changes } = mount('/media/a.png')
|
||||||
|
node.querySelector<HTMLButtonElement>('.seo-image__button--secondary')!.click()
|
||||||
|
expect(changes).toEqual([''])
|
||||||
|
expect(node.querySelector('img')).toBeNull()
|
||||||
|
const off = mount('/media/a.png').node
|
||||||
|
off.setAttribute('disabled', '')
|
||||||
|
expect(off.querySelector('.seo-image__actions')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -73,11 +73,37 @@ describe('E2 value grammar', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('malformed values are refused like the Go grammar', () => {
|
it('malformed values are refused like the Go grammar', () => {
|
||||||
for (const bad of [',', 'index,', 'index,noindex', 'follow,nofollow', 'noarchive,noarchive', 'index:1', 'noarchive:yes', 'max-snippet', 'max-snippet:abc', 'max-snippet:-2', 'max-snippet:1.5', 'max-image-preview:huge', 'unavailable_after:2027-13-01', 'unavailable_after:27-01-01', 'none', 'noodp']) {
|
for (const bad of [',', 'index,', 'index,noindex', 'follow,nofollow', 'noarchive,noarchive', 'index:1', 'noarchive:yes', 'max-snippet', 'max-snippet:abc', 'max-snippet:-2', 'max-snippet:1.5', 'max-image-preview:huge', 'unavailable_after:2027-13-01', 'unavailable_after:27-01-01', 'unavailable_after:2027-02-30', 'unavailable_after:2027-02-29', 'unavailable_after:2027-04-31', 'unavailable_after:2027-01-00', 'max-snippet:+-1', 'none', 'noodp']) {
|
||||||
expect(parseRobots(bad), bad).toBeNull()
|
expect(parseRobots(bad), bad).toBeNull()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts what the Go grammar accepts (WR-24): a leap day and a signed number', () => {
|
||||||
|
expect(serializeRobots(parseRobots('unavailable_after:2028-02-29') as Robots)).toBe('unavailable_after:2028-02-29')
|
||||||
|
expect(serializeRobots(parseRobots('max-snippet:+5') as Robots)).toBe('max-snippet:5')
|
||||||
|
expect(serializeRobots(parseRobots('max-video-preview:-1') as Robots)).toBe('max-video-preview:-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a stored value it cannot parse is kept, shown and replaced only by Reset (WR-24)', () => {
|
||||||
|
const node = document.createElement('golem15-seo-robots') as HTMLElement & { value: string }
|
||||||
|
node.setAttribute('ui-locale', 'en')
|
||||||
|
document.body.append(node)
|
||||||
|
node.value = 'noindex,unavailable_after:2027-02-30'
|
||||||
|
const seen: string[] = []
|
||||||
|
node.addEventListener('summer-change', (ev) => seen.push((ev as CustomEvent).detail.value))
|
||||||
|
expect(node.querySelector('.seo-robots__unparsed')?.textContent).toContain('noindex,unavailable_after:2027-02-30')
|
||||||
|
const segment = node.querySelector('[data-focus-key="follow-2"]') as HTMLButtonElement
|
||||||
|
expect(segment.disabled).toBe(true)
|
||||||
|
segment.click()
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
expect(node.value).toBe('noindex,unavailable_after:2027-02-30')
|
||||||
|
;(node.querySelector('.seo-robots__reset') as HTMLButtonElement).click()
|
||||||
|
expect(seen).toEqual([''])
|
||||||
|
expect(node.querySelector('.seo-robots__unparsed')).toBeNull()
|
||||||
|
;(node.querySelector('[data-focus-key="index-2"]') as HTMLButtonElement).click()
|
||||||
|
expect(seen).toEqual(['', 'noindex'])
|
||||||
|
})
|
||||||
|
|
||||||
it('the element emits the fixed-order value and "" when both groups inherit', () => {
|
it('the element emits the fixed-order value and "" when both groups inherit', () => {
|
||||||
const node = document.createElement('golem15-seo-robots') as HTMLElement & { value: string }
|
const node = document.createElement('golem15-seo-robots') as HTMLElement & { value: string }
|
||||||
document.body.append(node)
|
document.body.append(node)
|
||||||
|
|||||||
144
tests/elements/robotstester.test.ts
Normal file
144
tests/elements/robotstester.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { labelsFor } from '../../assets/src/elements/labels'
|
||||||
|
import { HostFailure, mountWidget, settle, type MountOptions } from './host'
|
||||||
|
|
||||||
|
// E5 golem15-seo-robotstester (UI-SPEC C10) through the widget host
|
||||||
|
// contract: the context request from connectedCallback, the queue behind
|
||||||
|
// `busy`, state=error, the address check and a host that drops actions.
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await import('../../assets/src/elements/index')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
type Payload = { op: 'context' } | { op: 'test'; url: string; agent: string }
|
||||||
|
|
||||||
|
function answer(payload: unknown): unknown {
|
||||||
|
const p = payload as Payload
|
||||||
|
if (p.op === 'context') {
|
||||||
|
return { op: 'context', agents: ['Googlebot', 'Bingbot', 'GPTBot', '*'], bots: ['GPTBot'], staging: false }
|
||||||
|
}
|
||||||
|
return { op: 'test', url: p.url, allowed: p.url !== '/admin', line: p.url === '/admin' ? 2 : 0, rule: p.url === '/admin' ? 'Disallow: /admin' : '', bot: false, agent: p.agent, staging: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tester(options: Partial<MountOptions> = {}) {
|
||||||
|
return mountWidget('golem15-seo-robotstester', {
|
||||||
|
attrs: { 'ui-locale': 'en', label: 'Test URL', 'busy-label': 'Testing…' },
|
||||||
|
answer,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function testURL(node: HTMLElement, url: string): void {
|
||||||
|
const input = node.querySelector<HTMLInputElement>('.seo-robotstester__input')!
|
||||||
|
input.value = url
|
||||||
|
node.querySelector<HTMLButtonElement>('.seo-robotstester__button')!.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultLine(node: HTMLElement): string {
|
||||||
|
return node.querySelector('.seo-robotstester__result')?.textContent ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function agents(node: HTMLElement): string[] {
|
||||||
|
return Array.from(node.querySelectorAll('.seo-robotstester__select option')).map((o) => o.textContent ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('E5 host protocol', () => {
|
||||||
|
it('loads the crawler list from a host that appends the element before wiring it (CR-04)', async () => {
|
||||||
|
const host = tester({ order: 'append-first' })
|
||||||
|
await settle()
|
||||||
|
expect(host.posted[0]).toEqual({ op: 'context' })
|
||||||
|
expect(agents(host.node)).toEqual(['Googlebot', 'Bingbot', 'GPTBot', '*'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a test asked while the context loads is queued and sent after busy clears', async () => {
|
||||||
|
const host = tester({ order: 'assign-first' })
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(host.busy()).toBe(true)
|
||||||
|
// The button is disabled while busy; Enter in the address field still asks.
|
||||||
|
const input = host.node.querySelector<HTMLInputElement>('.seo-robotstester__input')!
|
||||||
|
input.value = '/admin'
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
|
expect(host.posted).toEqual([{ op: 'context' }])
|
||||||
|
await settle()
|
||||||
|
expect(host.posted).toEqual([{ op: 'context' }, { op: 'test', url: '/admin', agent: 'Googlebot' }])
|
||||||
|
expect(resultLine(host.node)).toContain('Disallow: /admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the button shows the busy label while a test runs', async () => {
|
||||||
|
const host = tester()
|
||||||
|
await settle()
|
||||||
|
host.node.setAttribute('busy', '')
|
||||||
|
expect(host.node.querySelector('.seo-robotstester__button')?.textContent).toBe('Testing…')
|
||||||
|
host.node.removeAttribute('busy')
|
||||||
|
expect(host.node.querySelector('.seo-robotstester__button')?.textContent).toBe('Test URL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('state=error unblocks the next test', async () => {
|
||||||
|
let failNext = true
|
||||||
|
const host = tester({
|
||||||
|
answer: (payload) => {
|
||||||
|
if ((payload as Payload).op === 'test' && failNext) {
|
||||||
|
failNext = false
|
||||||
|
throw new HostFailure('test')
|
||||||
|
}
|
||||||
|
return answer(payload)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await settle()
|
||||||
|
testURL(host.node, '/admin')
|
||||||
|
await settle()
|
||||||
|
expect(host.node.getAttribute('state')).toBe('error')
|
||||||
|
testURL(host.node, '/blog')
|
||||||
|
await settle()
|
||||||
|
expect(host.posted.slice(-1)).toEqual([{ op: 'test', url: '/blog', agent: 'Googlebot' }])
|
||||||
|
expect(resultLine(host.node)).not.toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries a test the host dropped once', async () => {
|
||||||
|
const host = tester()
|
||||||
|
await settle()
|
||||||
|
host.drop(1)
|
||||||
|
testURL(host.node, '/admin')
|
||||||
|
await settle()
|
||||||
|
expect(host.emitted.filter((p) => (p as Payload).op === 'test')).toHaveLength(2)
|
||||||
|
expect(host.posted.slice(-1)).toEqual([{ op: 'test', url: '/admin', agent: 'Googlebot' }])
|
||||||
|
expect(resultLine(host.node)).toContain('Disallow: /admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says the test failed when the host never takes it', async () => {
|
||||||
|
const host = tester({ wired: false })
|
||||||
|
await settle()
|
||||||
|
expect(agents(host.node)).toEqual(['Googlebot', 'Googlebot-Image', 'Bingbot', '*'])
|
||||||
|
testURL(host.node, '/admin')
|
||||||
|
await settle()
|
||||||
|
expect(resultLine(host.node)).toBe(labelsFor('en').testFailed)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an address without a leading slash is refused without an action', async () => {
|
||||||
|
const host = tester()
|
||||||
|
await settle()
|
||||||
|
const before = host.emitted.length
|
||||||
|
testURL(host.node, 'admin')
|
||||||
|
await settle()
|
||||||
|
expect(host.emitted.length).toBe(before)
|
||||||
|
expect(resultLine(host.node)).toBe(labelsFor('en').urlRequired)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a request in flight when the element is disconnected does not block it after reconnecting', async () => {
|
||||||
|
const host = tester()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(host.busy()).toBe(true)
|
||||||
|
host.node.remove()
|
||||||
|
await settle()
|
||||||
|
document.body.append(host.node)
|
||||||
|
await settle()
|
||||||
|
expect(agents(host.node)).toEqual(['Googlebot', 'Bingbot', 'GPTBot', '*'])
|
||||||
|
testURL(host.node, '/admin')
|
||||||
|
await settle()
|
||||||
|
expect(resultLine(host.node)).toContain('Disallow: /admin')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { HostFailure, mountWidget, settle } from './host'
|
||||||
|
|
||||||
// E3 golem15-seo-serp (UI-SPEC C6): the effective title, truncation at a
|
// E3 golem15-seo-serp (UI-SPEC C6): the effective title, truncation at a
|
||||||
// word boundary within the pixel budget (mocked canvas: 10 px per
|
// word boundary within the pixel budget (mocked canvas: 10 px per
|
||||||
@@ -23,17 +24,22 @@ afterEach(() => {
|
|||||||
document.body.replaceChildren()
|
document.body.replaceChildren()
|
||||||
})
|
})
|
||||||
|
|
||||||
/** A serp element answered with one context locale. */
|
type ContextLocale = import('../../assets/src/elements/serp').ContextLocale
|
||||||
function preview(locale: Partial<import('../../assets/src/elements/serp').ContextLocale>, fill: Record<string, unknown> = {}): HTMLElement {
|
|
||||||
|
function contextLocale(locale: Partial<ContextLocale> = {}): ContextLocale {
|
||||||
|
return { locale: 'en', url: '/blog/post', h1: null, siteName: 'Atlas', titleTemplate: '', defaultTitle: 'Post', defaultDescription: null, descriptionSource: 'content', bodyWords: null, ...locale }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A serp element given one context locale directly (rendering tests); the
|
||||||
|
* host protocol is covered by 'E3 host protocol' below.
|
||||||
|
*/
|
||||||
|
function preview(locale: Partial<ContextLocale>, fill: Record<string, unknown> = {}): HTMLElement {
|
||||||
const node = document.createElement('golem15-seo-serp')
|
const node = document.createElement('golem15-seo-serp')
|
||||||
node.setAttribute('ui-locale', 'en')
|
node.setAttribute('ui-locale', 'en')
|
||||||
node.setAttribute('fill-values', JSON.stringify(fill))
|
node.setAttribute('fill-values', JSON.stringify(fill))
|
||||||
const actions: unknown[] = []
|
|
||||||
node.addEventListener('summer-action', (ev) => actions.push((ev as CustomEvent).detail.payload))
|
|
||||||
document.body.append(node)
|
document.body.append(node)
|
||||||
expect(actions).toEqual([{ op: 'context' }])
|
node.dispatchEvent(new CustomEvent('summer-result', { detail: { data: { op: 'context', host: 'example.test', locales: [contextLocale(locale)] } } }))
|
||||||
const loc = { locale: 'en', url: '/blog/post', h1: null, siteName: 'Atlas', titleTemplate: '', defaultTitle: 'Post', defaultDescription: null, descriptionSource: 'content', bodyWords: null, ...locale }
|
|
||||||
node.dispatchEvent(new CustomEvent('summer-result', { detail: { data: { op: 'context', host: 'example.test', locales: [loc] } } }))
|
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,3 +152,100 @@ describe('E3 keyphrase stem matching (Q-4)', () => {
|
|||||||
expect(serp.wordsMatch('kot', 'kot')).toBe(true)
|
expect(serp.wordsMatch('kot', 'kot')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('E3 host protocol', () => {
|
||||||
|
const answer = (payload: unknown): unknown => {
|
||||||
|
const p = payload as { op: string; locale?: string }
|
||||||
|
if (p.op === 'context') {
|
||||||
|
return { op: 'context', host: 'example.test', locales: [contextLocale()] }
|
||||||
|
}
|
||||||
|
return { op: 'duplicates', locale: p.locale, title: [], description: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('loads its context from a host that appends the element before wiring it (CR-04)', async () => {
|
||||||
|
const host = mountWidget('golem15-seo-serp', { order: 'append-first', attrs: { 'ui-locale': 'en' }, answer })
|
||||||
|
await settle()
|
||||||
|
expect(host.posted[0]).toEqual({ op: 'context' })
|
||||||
|
expect(host.node.hasAttribute('aria-busy')).toBe(false)
|
||||||
|
expect(shownTitle(host.node)).toBe('Post')
|
||||||
|
expect(host.posted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads its context from a host that wires the element first, once', async () => {
|
||||||
|
const host = mountWidget('golem15-seo-serp', { order: 'assign-first', attrs: { 'ui-locale': 'en' }, answer })
|
||||||
|
await settle()
|
||||||
|
expect(host.emitted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(1)
|
||||||
|
expect(shownTitle(host.node)).toBe('Post')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries an action the host dropped once', async () => {
|
||||||
|
const host = mountWidget('golem15-seo-serp', { order: 'assign-first', attrs: { 'ui-locale': 'en' }, answer })
|
||||||
|
host.drop(1)
|
||||||
|
await settle()
|
||||||
|
expect(host.emitted.slice(0, 2)).toEqual([{ op: 'context' }, { op: 'context' }])
|
||||||
|
expect(host.posted[0]).toEqual({ op: 'context' })
|
||||||
|
expect(shownTitle(host.node)).toBe('Post')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the context error when the host never takes the action', async () => {
|
||||||
|
const host = mountWidget('golem15-seo-serp', { wired: false, attrs: { 'ui-locale': 'en' }, answer })
|
||||||
|
await settle()
|
||||||
|
expect(host.emitted).toEqual([{ op: 'context' }, { op: 'context' }])
|
||||||
|
expect(host.node.hasAttribute('aria-busy')).toBe(false)
|
||||||
|
expect(host.node.querySelector('.seo-serp__error')?.textContent).toBe(labels.labelsFor('en').contextFailed)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a duplicate check asked while a request runs is sent after busy clears', async () => {
|
||||||
|
const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en', 'fill-values': JSON.stringify({ seo_title: { en: 'First' } }) }, answer })
|
||||||
|
await settle()
|
||||||
|
const before = host.posted.length
|
||||||
|
expect(host.posted[before - 1]).toMatchObject({ op: 'duplicates', title: 'First' })
|
||||||
|
host.node.setAttribute('busy', '')
|
||||||
|
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Second' } }))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 900))
|
||||||
|
expect(host.posted.length).toBe(before)
|
||||||
|
host.node.removeAttribute('busy')
|
||||||
|
await settle()
|
||||||
|
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Second' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('state=error unblocks the next request', async () => {
|
||||||
|
let failNext = false
|
||||||
|
const host = mountWidget('golem15-seo-serp', {
|
||||||
|
attrs: { 'ui-locale': 'en', 'fill-values': JSON.stringify({ seo_title: { en: 'First' } }) },
|
||||||
|
answer: (payload) => {
|
||||||
|
if (failNext && (payload as { op: string }).op === 'duplicates') {
|
||||||
|
failNext = false
|
||||||
|
throw new HostFailure('duplicates')
|
||||||
|
}
|
||||||
|
return answer(payload)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await settle()
|
||||||
|
failNext = true
|
||||||
|
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Second' } }))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 900))
|
||||||
|
await settle()
|
||||||
|
expect(host.node.getAttribute('state')).toBe('error')
|
||||||
|
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Second' })
|
||||||
|
expect(host.node.querySelector('.seo-serp__checking')).toBeNull()
|
||||||
|
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Third' } }))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 900))
|
||||||
|
await settle()
|
||||||
|
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Third' })
|
||||||
|
expect(host.node.hasAttribute('state')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a request in flight when the element is disconnected does not block it after reconnecting', async () => {
|
||||||
|
const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en' }, answer })
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(host.busy()).toBe(true)
|
||||||
|
host.node.remove()
|
||||||
|
await settle()
|
||||||
|
expect(host.node.hasAttribute('aria-busy')).toBe(true)
|
||||||
|
document.body.append(host.node)
|
||||||
|
await settle()
|
||||||
|
expect(host.posted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(2)
|
||||||
|
expect(shownTitle(host.node)).toBe('Post')
|
||||||
|
})
|
||||||
|
}, 5000)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
import { applyState, ROW_CAP, shownRows, type PreviewData, type PreviewRow } from '../../assets/src/elements/transfer'
|
import { applyState, ROW_CAP, shownRows, type PreviewData, type PreviewRow } from '../../assets/src/elements/transfer'
|
||||||
import { labelsFor } from '../../assets/src/elements/labels'
|
import { labelsFor } from '../../assets/src/elements/labels'
|
||||||
|
import { HostFailure, mountWidget, settle } from './host'
|
||||||
|
|
||||||
// E6 golem15-seo-transfer (UI-SPEC C13): Apply is enabled only after a
|
// E6 golem15-seo-transfer (UI-SPEC C13): Apply is enabled only after a
|
||||||
// preview with no refused row and at least one change.
|
// preview with no refused row and at least one change.
|
||||||
@@ -82,3 +83,50 @@ describe('E6 apply gating', () => {
|
|||||||
expect(node.querySelector('.seo-transfer__actions')).toBeNull()
|
expect(node.querySelector('.seo-transfer__actions')).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('E6 host protocol (WR-25)', () => {
|
||||||
|
function downloadButton(node: HTMLElement): HTMLButtonElement {
|
||||||
|
return node.querySelector<HTMLButtonElement>('.seo-transfer__section button')!
|
||||||
|
}
|
||||||
|
|
||||||
|
it('an export goes through the host and the buttons are busy until it settles', async () => {
|
||||||
|
const host = mountWidget('golem15-seo-transfer', {
|
||||||
|
attrs: { 'ui-locale': 'en', 'busy-label': 'Working…' },
|
||||||
|
answer: () => ({ op: 'export', filename: 'seo-redirects.csv', csv: 'source\n' }),
|
||||||
|
})
|
||||||
|
URL.createObjectURL = () => 'blob:x'
|
||||||
|
URL.revokeObjectURL = () => undefined
|
||||||
|
downloadButton(host.node).click()
|
||||||
|
expect(host.posted).toEqual([{ op: 'export', dataset: 'redirects' }])
|
||||||
|
expect(downloadButton(host.node).disabled).toBe(true)
|
||||||
|
expect(downloadButton(host.node).textContent).toBe('Working…')
|
||||||
|
downloadButton(host.node).click()
|
||||||
|
expect(host.emitted).toHaveLength(1)
|
||||||
|
await settle()
|
||||||
|
expect(downloadButton(host.node).disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('state=error unblocks the next request', async () => {
|
||||||
|
let fail = true
|
||||||
|
const host = mountWidget('golem15-seo-transfer', {
|
||||||
|
attrs: { 'ui-locale': 'en' },
|
||||||
|
answer: () => {
|
||||||
|
if (fail) {
|
||||||
|
fail = false
|
||||||
|
throw new HostFailure('export')
|
||||||
|
}
|
||||||
|
return { op: 'export', filename: 'seo-redirects.csv', csv: 'source\n' }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
URL.createObjectURL = () => 'blob:x'
|
||||||
|
URL.revokeObjectURL = () => undefined
|
||||||
|
downloadButton(host.node).click()
|
||||||
|
await settle()
|
||||||
|
expect(host.node.getAttribute('state')).toBe('error')
|
||||||
|
expect(downloadButton(host.node).disabled).toBe(false)
|
||||||
|
downloadButton(host.node).click()
|
||||||
|
await settle()
|
||||||
|
expect(host.posted).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -73,12 +73,26 @@ func TestCSVTransfer(t *testing.T) {
|
|||||||
t.Fatalf("filename = %v", exp["filename"])
|
t.Fatalf("filename = %v", exp["filename"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A huge page number is an empty page, not a panic (WR-14); so is a
|
||||||
|
// list page.
|
||||||
|
far := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/export?dataset=redirects&page=9223372036854777", agent, nil)
|
||||||
|
if c, _ := far["csv"].(string); strings.Contains(c, "/e1") {
|
||||||
|
t.Fatalf("export of a huge page = %v, want no rows", far)
|
||||||
|
}
|
||||||
|
if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects?page=9223372036854775807&per_page=200", agent, nil); len(l["data"].([]any)) != 0 {
|
||||||
|
t.Fatalf("list of a huge page = %v, want no rows", l)
|
||||||
|
}
|
||||||
|
|
||||||
preview := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": csv, "delimiter": ";", "dry_run": true})
|
preview := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": csv, "delimiter": ";", "dry_run": true})
|
||||||
if s := summary(t, preview); s["unchanged"] != 2 || s["new"] != 0 || s["changed"] != 0 || s["refused"] != 0 {
|
if s := summary(t, preview); s["unchanged"] != 2 || s["new"] != 0 || s["changed"] != 0 || s["refused"] != 0 {
|
||||||
t.Fatalf("re-import of the export = %v, want 2 unchanged", preview)
|
t.Fatalf("re-import of the export = %v, want 2 unchanged", preview)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refusals of the file as a whole.
|
// Refusals of the file as a whole.
|
||||||
|
odd := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": "source;match;target;query\n/g1;glob;/x;ignore\n/g2;exact;/x;keep\n", "delimiter": ";", "dry_run": true})
|
||||||
|
if s := summary(t, odd); s["refused"] != 2 {
|
||||||
|
t.Fatalf("import with an unknown match and query = %v, want 2 refused rows (WR-17)", odd)
|
||||||
|
}
|
||||||
rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": "target;status\n/x;301\n", "delimiter": ";", "dry_run": true})
|
rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": "target;status\n/x;301\n", "delimiter": ";", "dry_run": true})
|
||||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"error":"missing_column"`) || !strings.Contains(rec.Body.String(), "source") {
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"error":"missing_column"`) || !strings.Contains(rec.Body.String(), "source") {
|
||||||
t.Fatalf("missing column = %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("missing column = %d %s", rec.Code, rec.Body.String())
|
||||||
|
|||||||
42
updates/202610280001_add_golem15_seo_redirects_source_key.go
Normal file
42
updates/202610280001_add_golem15_seo_redirects_source_key.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// redirectSourceKey is the SQL form of classes.exactKey: the source's path
|
||||||
|
// lowercased without one trailing slash (the root stays "/"), then its
|
||||||
|
// query as written. The resolver fetches exact rules by this key, so a
|
||||||
|
// source stored as "/old/" or "/list?Page=2" is found for every request the
|
||||||
|
// in-memory matcher accepts.
|
||||||
|
const redirectSourceKey = `(CASE
|
||||||
|
WHEN length(split_part(source, '?', 1)) > 1 AND right(split_part(source, '?', 1), 1) = '/'
|
||||||
|
THEN lower(left(split_part(source, '?', 1), -1))
|
||||||
|
ELSE lower(split_part(source, '?', 1))
|
||||||
|
END) || (CASE WHEN strpos(source, '?') > 0 THEN substr(source, strpos(source, '?')) ELSE '' END)`
|
||||||
|
|
||||||
|
// addRedirectSourceKey adds golem15_seo_redirects.source_key, a stored
|
||||||
|
// generated column holding the exact-match lookup key, and indexes it for
|
||||||
|
// exact rules. Every write path fills it, because Postgres computes it.
|
||||||
|
var addRedirectSourceKey = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202610280001_add_golem15_seo_redirects_source_key",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE golem15_seo_redirects ADD COLUMN source_key TEXT GENERATED ALWAYS AS (` + redirectSourceKey + `) STORED`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`CREATE INDEX golem15_seo_redirects_source_key ON golem15_seo_redirects (source_key) WHERE match = 'exact'`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`DROP INDEX IF EXISTS golem15_seo_redirects_source_key`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`ALTER TABLE golem15_seo_redirects DROP COLUMN IF EXISTS source_key`).Error
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(addRedirectSourceKey...)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user