Files
summercms/modules/cabana/field_markdown.go
Jakub Zych b492e79f2b 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
2026-10-06 20:53:07 +02:00

90 lines
3.1 KiB
Go

package cabana
import (
"bytes"
"fmt"
"net/http"
"regexp"
"git.golem15.com/golem15/summercms/modules/bouncer"
"github.com/yuin/goldmark"
)
var (
markdownEngine = goldmark.New()
markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
)
// RenderMarkdown converts source to HTML using the pinned goldmark engine
// without html.WithUnsafe. Output that still contains script/iframe tags,
// event handlers, or javascript/vbscript/data URLs is rejected, matching
// postcard's mail HTML gate.
func RenderMarkdown(src string) (string, error) {
var buf bytes.Buffer
if err := markdownEngine.Convert([]byte(src), &buf); err != nil {
return "", fmt.Errorf("cabana: markdown: %w", err)
}
html := buf.String()
if err := rejectUnsafeMarkdownHTML(html); err != nil {
return "", err
}
return html, nil
}
func rejectUnsafeMarkdownHTML(html string) error {
if markdownUnsafeTag.MatchString(html) {
return fmt.Errorf("cabana: rendered HTML contains raw unsafe tags")
}
if markdownEventHandler.MatchString(html) {
return fmt.Errorf("cabana: rendered HTML contains event handlers")
}
if markdownDangerousURL.MatchString(html) {
return fmt.Errorf("cabana: rendered HTML contains a dangerous URL scheme")
}
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)
}