feat(cabana): add markdown preview admin route
- POST {prefix}/api/v1/markdown/preview renders {markdown} through
cabana.RenderMarkdown in the backend-guarded group behind requireAjax
- refused output is a 422 validation_failed on markdown with a fixed message
- swag annotation, regenerated admin.json and schema.d.ts
- route inventories, CSRF walk (26) and OpenAPI conformance learn the route
- README and docs/backend/forms.md document the route
This commit is contained in:
@@ -105,6 +105,28 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminMarkdownPreviewRequest": {
|
||||
"properties": {
|
||||
"markdown": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"markdown"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminMarkdownPreviewResult": {
|
||||
"properties": {
|
||||
"html": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"html"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminProfile": {
|
||||
"properties": {
|
||||
"email": {
|
||||
@@ -361,6 +383,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminMarkdownPreviewResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminMarkdownPreviewResult"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminProfile": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -2211,6 +2248,93 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/markdown/preview": {
|
||||
"post": {
|
||||
"description": "Renders the markdown source through cabana.RenderMarkdown (goldmark without unsafe HTML) and answers the sanitized HTML. Output the renderer's gate refuses is a 422 on markdown. Any backend session may call it.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminMarkdownPreviewRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Markdown source",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminMarkdownPreviewResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request Entity Too Large"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Render a markdown preview",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/navigation": {
|
||||
"get": {
|
||||
"responses": {
|
||||
|
||||
99
admin/src/api/schema.d.ts
vendored
99
admin/src/api/schema.d.ts
vendored
@@ -279,6 +279,95 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/markdown/preview": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Render a markdown preview
|
||||
* @description Renders the markdown source through cabana.RenderMarkdown (goldmark without unsafe HTML) and answers the sanitized HTML. Output the renderer's gate refuses is a 422 on markdown. Any backend session may call it.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Markdown source */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminMarkdownPreviewRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminMarkdownPreviewResult"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Request Entity Too Large */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Entity */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/navigation": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4240,6 +4329,12 @@ export interface components {
|
||||
"cabana.AdminLogoutData": {
|
||||
status: string;
|
||||
};
|
||||
"cabana.AdminMarkdownPreviewRequest": {
|
||||
markdown: string;
|
||||
};
|
||||
"cabana.AdminMarkdownPreviewResult": {
|
||||
html: string;
|
||||
};
|
||||
"cabana.AdminProfile": {
|
||||
email: string;
|
||||
first_name: string;
|
||||
@@ -4307,6 +4402,10 @@ export interface components {
|
||||
data: components["schemas"]["cabana.AdminLogoutData"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminMarkdownPreviewResult": {
|
||||
data: components["schemas"]["cabana.AdminMarkdownPreviewResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminProfile": {
|
||||
data: components["schemas"]["cabana.AdminProfile"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
|
||||
@@ -323,7 +323,7 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
|
||||
|
||||
## Markdown and multilingual fields
|
||||
|
||||
`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor and may preview HTML from `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content.
|
||||
`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor and may preview HTML from `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content. `POST <prefix>/api/v1/markdown/preview` renders a `{markdown}` source through `cabana.RenderMarkdown` for any signed-in administrator and answers `{html}`, or a 422 `validation_failed` on `markdown` when the output is refused.
|
||||
|
||||
`type: mltext` and `type: mlmarkdown` reuse the ordinary text and markdown editors with a locale selector. `mlmarkdown` composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Selector options come from `cabana.FormMeta.EnabledLocales` on the form schema (filled from `cabana.TranslationWriter.EnabledLocales` after Lookup; `FormSchema.Localize` stays cache-only). Create seeds `{[code]: ""}` for every enabled code. A GET or save of a host scalar is merged onto that seed so sibling locales are not dropped.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
|
||||
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
|
||||
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
|
||||
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
|
||||
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
|
||||
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
|
||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||
@@ -48,6 +48,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
|
||||
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. |
|
||||
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
|
||||
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
|
||||
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
|
||||
| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record (needs a form and `create` in `toolbar.buttons`). |
|
||||
| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record (update and delete need a form). |
|
||||
@@ -236,6 +237,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). |
|
||||
| `cabana.FileMutationResult` | Answer of the file removal route: `removed`. |
|
||||
| `cabana.AdminFileCaptionRequest` | Body of the file caption route: optional `title` and `description`. Unknown keys are refused. |
|
||||
| `cabana.AdminMarkdownPreviewRequest` | Body of the markdown preview route: the `markdown` source. Unknown keys are refused. |
|
||||
| `cabana.AdminMarkdownPreviewResult` | Answer of the markdown preview route: the `html` that `cabana.RenderMarkdown` produced. |
|
||||
| `cabana.AdminMarkdownPreview` | Swag annotation of the markdown preview route. |
|
||||
| `cabana.AdminFileList` / `cabana.AdminFileUpload` / `cabana.AdminFileUpdate` / `cabana.AdminFileRemove` / `cabana.AdminFileReorder` / `cabana.AdminFileDownload` / `cabana.AdminFileThumb` | Swag annotations of the file routes. |
|
||||
| `cabana.AdminRelationChildFileList` / `cabana.AdminRelationChildFileUpload` / `cabana.AdminRelationChildFileUpdate` / `cabana.AdminRelationChildFileRemove` / `cabana.AdminRelationChildFileReorder` / `cabana.AdminRelationChildFileDownload` / `cabana.AdminRelationChildFileThumb` | Swag annotations of the relation child file routes. |
|
||||
| `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |
|
||||
|
||||
@@ -245,6 +245,24 @@ func AdminSettingsGet() {}
|
||||
// @Router /settings/{code} [put]
|
||||
func AdminSettingsPut() {}
|
||||
|
||||
// AdminMarkdownPreview documents POST /markdown/preview.
|
||||
//
|
||||
// @Summary Render a markdown preview
|
||||
// @Description Renders the markdown source through cabana.RenderMarkdown (goldmark without unsafe HTML) and answers the sanitized HTML. Output the renderer's gate refuses is a 422 on markdown. Any backend session may call it.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param body body AdminMarkdownPreviewRequest true "Markdown source"
|
||||
// @Success 200 {object} Envelope[AdminMarkdownPreviewResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /markdown/preview [post]
|
||||
func AdminMarkdownPreview() {}
|
||||
|
||||
// AdminListSchema documents the list schema route.
|
||||
//
|
||||
// @Summary Admin list schema
|
||||
|
||||
@@ -3,8 +3,10 @@ package cabana
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
@@ -44,3 +46,44 @@ func rejectUnsafeMarkdownHTML(html string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// msgMarkdownPreviewRefused is the fixed 422 detail of a preview whose
|
||||
// rendered HTML the RenderMarkdown gate refuses. The renderer's error text is
|
||||
// never written.
|
||||
const msgMarkdownPreviewRefused = "The rendered HTML contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL and cannot be previewed."
|
||||
|
||||
// AdminMarkdownPreviewRequest is the body of POST /markdown/preview: the
|
||||
// markdown source of a form field.
|
||||
type AdminMarkdownPreviewRequest struct {
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
// AdminMarkdownPreviewResult is the data of a POST /markdown/preview answer:
|
||||
// the HTML RenderMarkdown produced for the source.
|
||||
type AdminMarkdownPreviewResult struct {
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
// markdownPreview serves POST /markdown/preview: it renders the source
|
||||
// through RenderMarkdown for the admin form preview. Any signed-in backend
|
||||
// administrator may call it; it reads and writes no records. Output the
|
||||
// RenderMarkdown gate refuses is a 422 on markdown with a fixed message.
|
||||
func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
var body AdminMarkdownPreviewRequest
|
||||
if err := s.decodeStrictBody(w, r, &body); err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
html, err := RenderMarkdown(body.Markdown)
|
||||
if err != nil {
|
||||
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed",
|
||||
map[string]any{"markdown": []string{msgMarkdownPreviewRefused}})
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, AdminMarkdownPreviewResult{HTML: html}, nil)
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ func (s *service) mount(r pact.Router) {
|
||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||
g.Get("/auth/me", s.me)
|
||||
g.Get("/navigation", s.navigation)
|
||||
// Form previews render through RenderMarkdown; nothing is stored.
|
||||
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
|
||||
g.Get("/settings", s.settingsList)
|
||||
g.Get("/settings/{code}/schema", s.settingsSchema)
|
||||
constrainSetting(g)
|
||||
|
||||
33
modules/cabana/markdown_preview_route_test.go
Normal file
33
modules/cabana/markdown_preview_route_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMarkdownPreviewRoute drives POST /markdown/preview through the
|
||||
// assembled router: without credentials the backend guard answers 401, and a
|
||||
// signed-in administrator gets the sanitized rendering with raw script tags
|
||||
// stripped by the renderer.
|
||||
func TestMarkdownPreviewRoute(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
|
||||
anon := env.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Hello"}, false)
|
||||
if anon.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous status=%d body=%s", anon.Code, anon.Body.String())
|
||||
}
|
||||
|
||||
env.loginAs(t, env.login)
|
||||
rec := env.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Hello\n\n<script>alert(1)</script>"}, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := strings.ToLower(rec.Body.String())
|
||||
if strings.Contains(body, "<script") || strings.Contains(body, `<script`) {
|
||||
t.Fatalf("script survived: %s", rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(body, "hello") || !strings.Contains(body, "h1") {
|
||||
t.Fatalf("heading missing: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
183
modules/cabana/markdown_preview_test.go
Normal file
183
modules/cabana/markdown_preview_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// previewRequest builds a POST /markdown/preview request with a raw body and
|
||||
// an optional principal in the context.
|
||||
func previewRequest(body string, principal *bouncer.Principal) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, adminAPI("/markdown/preview"), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if principal != nil {
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func previewJSON(t *testing.T, markdown string) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(AdminMarkdownPreviewRequest{Markdown: markdown})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func previewBackend() *bouncer.Principal {
|
||||
return &bouncer.Principal{ID: 1, Backend: true}
|
||||
}
|
||||
|
||||
// decodePreview decodes a 200 answer strictly into the documented envelope.
|
||||
func decodePreview(t *testing.T, raw []byte) AdminMarkdownPreviewResult {
|
||||
t.Helper()
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
var out Envelope[AdminMarkdownPreviewResult]
|
||||
if err := dec.Decode(&out); err != nil {
|
||||
t.Fatalf("decode %s: %v", raw, err)
|
||||
}
|
||||
return out.Data
|
||||
}
|
||||
|
||||
// previewDetails returns the error code and details of an error answer.
|
||||
func previewDetails(t *testing.T, raw []byte) (string, map[string][]string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Details map[string][]string `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode %s: %v", raw, err)
|
||||
}
|
||||
return body.Error.Code, body.Error.Details
|
||||
}
|
||||
|
||||
func TestMarkdownPreviewRequiresBackendPrincipal(t *testing.T) {
|
||||
for name, principal := range map[string]*bouncer.Principal{
|
||||
"none": nil,
|
||||
"frontend": {ID: 7, Backend: false},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
(&service{}).markdownPreview(rec, previewRequest(previewJSON(t, "# Hello"), principal))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPreviewRendersHTML(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
(&service{}).markdownPreview(rec, previewRequest(`{"markdown":"# Hello"}`, previewBackend()))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := decodePreview(t, rec.Body.Bytes()); !strings.Contains(got.HTML, "<h1>Hello</h1>") {
|
||||
t.Fatalf("html=%q", got.HTML)
|
||||
}
|
||||
|
||||
empty := httptest.NewRecorder()
|
||||
(&service{}).markdownPreview(empty, previewRequest(`{"markdown":""}`, previewBackend()))
|
||||
if empty.Code != http.StatusOK {
|
||||
t.Fatalf("empty status=%d body=%s", empty.Code, empty.Body.String())
|
||||
}
|
||||
if got := decodePreview(t, empty.Body.Bytes()); got.HTML != "" {
|
||||
t.Fatalf("empty html=%q", got.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPreviewStripsUnsafeHTML(t *testing.T) {
|
||||
needles := []string{"<script", "<iframe", "onerror", "javascript:", "vbscript:", "data:"}
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
must200 bool
|
||||
mustMiss string
|
||||
}{
|
||||
{"script", "<script>alert(1)</script>", true, "<script"},
|
||||
{"iframe", `<iframe src="https://evil.test"></iframe>`, false, "<iframe"},
|
||||
{"onerror", `<img src=x onerror="alert(1)">`, false, "onerror"},
|
||||
{"javascript", "[x](javascript:alert(1))", false, "javascript:"},
|
||||
{"vbscript", "[x](vbscript:msgbox(1))", false, "vbscript:"},
|
||||
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", false, "data:"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
(&service{}).markdownPreview(rec, previewRequest(previewJSON(t, tc.src), previewBackend()))
|
||||
switch rec.Code {
|
||||
case http.StatusOK:
|
||||
html := strings.ToLower(decodePreview(t, rec.Body.Bytes()).HTML)
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(html, needle) {
|
||||
t.Fatalf("unsafe %q survived: %s", needle, html)
|
||||
}
|
||||
}
|
||||
case http.StatusUnprocessableEntity:
|
||||
if tc.must200 {
|
||||
t.Fatalf("%s must be stripped and answered 200, got 422: %s", tc.name, rec.Body.String())
|
||||
}
|
||||
code, details := previewDetails(t, rec.Body.Bytes())
|
||||
if code != "validation_failed" || len(details["markdown"]) == 0 {
|
||||
t.Fatalf("code=%q details=%v", code, details)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(rec.Body.String()), tc.mustMiss) {
|
||||
t.Fatalf("refusal echoes %q: %s", tc.mustMiss, rec.Body.String())
|
||||
}
|
||||
default:
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPreviewRefusesGatedOutput(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
(&service{}).markdownPreview(rec, previewRequest(previewJSON(t, "see data: here"), previewBackend()))
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
code, details := previewDetails(t, rec.Body.Bytes())
|
||||
if code != "validation_failed" || len(details["markdown"]) == 0 || details["markdown"][0] == "" {
|
||||
t.Fatalf("code=%q details=%v", code, details)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "cabana:") || strings.Contains(rec.Body.String(), "dangerous URL scheme") {
|
||||
t.Fatalf("refusal echoes the renderer error: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPreviewRefusesInvalidBodies(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"unknown key": `{"markdown":"x","extra":1}`,
|
||||
"malformed": `{"markdown":`,
|
||||
"trailing": `{"markdown":"x"} {}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
(&service{}).markdownPreview(rec, previewRequest(body, previewBackend()))
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "validation_failed")
|
||||
})
|
||||
}
|
||||
|
||||
big := httptest.NewRecorder()
|
||||
(&service{defaultBytes: 64}).markdownPreview(big, previewRequest(previewJSON(t, strings.Repeat("a", 200)), previewBackend()))
|
||||
if big.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized status=%d body=%s", big.Code, big.Body.String())
|
||||
}
|
||||
assertErrorCode(t, big.Body.Bytes(), "payload_too_large")
|
||||
}
|
||||
@@ -160,6 +160,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
{"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
|
||||
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
|
||||
{"POST /markdown/preview", 200, "cabana.Envelope-cabana_AdminMarkdownPreviewResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Conform"}, true)
|
||||
}, into[cabana.Envelope[cabana.AdminMarkdownPreviewResult]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
|
||||
var body cabana.Envelope[cabana.ListSchema]
|
||||
|
||||
@@ -94,6 +94,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"POST /auth/login",
|
||||
"POST /auth/logout",
|
||||
"POST /auth/refresh",
|
||||
"POST /markdown/preview",
|
||||
"POST /{vendor}/{plugin}/{controller}",
|
||||
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
|
||||
"POST /{vendor}/{plugin}/{controller}/bulk/{action}",
|
||||
@@ -116,7 +117,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||
}
|
||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 25 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 26 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
}
|
||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||
// nested pattern serving field options, filter options and relation lists.
|
||||
|
||||
@@ -66,14 +66,14 @@ func TestPhase10CSRF(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
// refresh, logout, settings put, create, bulk-delete, bulk action,
|
||||
// refresh, logout, markdown preview, settings put, create, bulk-delete, bulk action,
|
||||
// widget action, toolbar action, update, delete, record action, link,
|
||||
// unlink, file
|
||||
// upload, file reorder, file caption, file remove, relation child
|
||||
// create, update and delete, pivot update, child file upload, reorder,
|
||||
// caption and remove
|
||||
if unsafe != 25 {
|
||||
t.Fatalf("walked %d state-changing routes, want 25: %v", unsafe, router.order)
|
||||
if unsafe != 26 {
|
||||
t.Fatalf("walked %d state-changing routes, want 26: %v", unsafe, router.order)
|
||||
}
|
||||
|
||||
loginHandler := router.handlers[login]
|
||||
|
||||
@@ -44,6 +44,7 @@ var phase09Routes = []adminRoute{
|
||||
{key: "GET /settings/{code}/schema"},
|
||||
{key: "GET /settings/{code}"},
|
||||
{key: "PUT /settings/{code}"},
|
||||
{key: "POST /markdown/preview"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/list"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
|
||||
|
||||
Reference in New Issue
Block a user