Files
summercms/modules/surf/apidocs.go
Jakub Zych 20bc1a0795 feat(19.3-02): opt-in /api-docs Swagger UI page with a pinned, SRI-checked swagger-ui-dist
- surf: APIDocsProvider (one plugin) supplies a YAML or JSON OpenAPI document,
  converted once at boot to compact JSON in source key order; zero or several
  providers or a spec that does not convert fail boot
- http.api_docs.enabled/path/title mount GET /api-docs and /api-docs.json
  (summercms.apidocs, public, refusing to replace a plugin route)
- the page loads swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and
  crossorigin on all three assets, a per-request CSP nonce for its inline
  script, nosniff and no-referrer
- docs: API documentation page section, README rows, ExampleAPIDocsProvider
2026-10-15 17:59:54 +02:00

211 lines
7.7 KiB
Go

package surf
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"sort"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/goccy/go-yaml"
)
// APIDocsProvider is implemented by the one plugin that supplies the
// application's OpenAPI document for the opt-in /api-docs page. APIDocsSpec
// returns the document as YAML or JSON, typically an embedded file; surf
// converts it to compact JSON once at boot.
type APIDocsProvider interface {
APIDocsSpec() ([]byte, error)
}
// SwaggerUIVersion is the swagger-ui-dist release the /api-docs page loads
// from jsDelivr. The version is exact so the assets can carry Subresource
// Integrity hashes; a floating version could change under the page.
const SwaggerUIVersion = "5.33.1"
// The pinned swagger-ui-dist assets and their integrity values (checked
// against the npm tarball of the release).
const (
swaggerUICSS = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui.css"
swaggerUICSSIntegrity = "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW"
swaggerUIBundle = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-bundle.js"
swaggerUIBundleIntegrity = "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw"
swaggerUIPreset = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-standalone-preset.js"
swaggerUIPresetIntegrity = "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF"
swaggerUICDN = "https://cdn.jsdelivr.net"
)
const (
// apiDocsPluginID owns the api-docs routes in the route table.
apiDocsPluginID = "summercms.apidocs"
defaultAPIDocsPath = "/api-docs"
defaultAPIDocsName = "API documentation"
)
// apiDocsPage is the Swagger UI shell. The asset URLs and integrity values
// are constants written into the template text; the title, the nonce and the
// spec URL go through html/template escaping.
var apiDocsPage = template.Must(template.New("apidocs").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="stylesheet" href="` + swaggerUICSS + `" integrity="` + swaggerUICSSIntegrity + `" crossorigin="anonymous">
</head>
<body>
<div id="swagger-ui"></div>
<script src="` + swaggerUIBundle + `" integrity="` + swaggerUIBundleIntegrity + `" crossorigin="anonymous"></script>
<script src="` + swaggerUIPreset + `" integrity="` + swaggerUIPresetIntegrity + `" crossorigin="anonymous"></script>
<script nonce="{{.Nonce}}">
window.addEventListener('load', function () {
window.ui = SwaggerUIBundle({
url: {{.SpecURL}},
dom_id: '#swagger-ui',
deepLinking: true,
validatorUrl: null,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: 'StandaloneLayout'
});
});
</script>
</body>
</html>
`))
// mountAPIDocs registers GET <http.api_docs.path> (the Swagger UI page) and
// GET <http.api_docs.path>.json (the spec) when http.api_docs.enabled is
// true. Exactly one plugin must implement APIDocsProvider; its spec is
// converted to compact JSON here, once, and a spec that does not convert
// fails boot naming the plugin. Both routes are public (no middleware) and
// may not replace a route a plugin declared.
func mountAPIDocs(r *Router, app *backpack.App, plugins []party.Plugin) error {
if r == nil || app == nil || app.Config == nil || !app.Config.Bool("http.api_docs.enabled") {
return nil
}
path, err := apiDocsPath(app.Config.String("http.api_docs.path"))
if err != nil {
return err
}
jsonPath := path + ".json"
title := strings.TrimSpace(app.Config.String("http.api_docs.title"))
if title == "" {
title = defaultAPIDocsName
}
var ids []string
var provider APIDocsProvider
for _, p := range plugins {
if dp, ok := p.(APIDocsProvider); ok {
ids = append(ids, p.ID())
provider = dp
}
}
switch len(ids) {
case 0:
return errors.New("surf: http.api_docs.enabled is true but no plugin implements surf.APIDocsProvider")
case 1:
default:
sort.Strings(ids)
return fmt.Errorf("surf: http.api_docs.enabled is true and several plugins implement surf.APIDocsProvider (%s); exactly one may", strings.Join(ids, ", "))
}
raw, err := provider.APIDocsSpec()
if err != nil {
return fmt.Errorf("surf: api docs spec of plugin %q: %w", ids[0], err)
}
spec, err := apiDocsJSON(raw)
if err != nil {
return fmt.Errorf("surf: api docs spec of plugin %q: %w", ids[0], err)
}
for _, p := range []string{path, jsonPath} {
if owner, ok := r.seen[http.MethodGet+" "+p]; ok {
return fmt.Errorf("surf: route GET %s (plugin %q) collides with the api docs page http.api_docs.path mounts", p, owner)
}
}
r.BindPlugin(apiDocsPluginID)
r.GroupRaw("", nil, func(g pact.Router) {
g.Get(path, apiDocsPageHandler(title, jsonPath))
g.Get(jsonPath, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(spec)
})
})
return nil
}
// apiDocsPath validates http.api_docs.path with the uploads prefix rules: an
// absolute URL path without scheme, host, query, wildcard or dot segments.
func apiDocsPath(raw string) (string, error) {
if strings.TrimSpace(raw) == "" {
return defaultAPIDocsPath, nil
}
path, err := uploadsPrefix(raw)
if err != nil {
return "", fmt.Errorf("surf: http.api_docs.path must be an absolute URL path such as /api-docs (no scheme, host, query or wildcard), got %q", raw)
}
return path, nil
}
// apiDocsJSON converts a YAML or JSON OpenAPI document to compact JSON with
// the source key order. A body starting with { must be valid JSON; anything
// else is read as YAML (key order kept). The result must be a JSON object.
func apiDocsJSON(raw []byte) ([]byte, error) {
src := bytes.TrimSpace(raw)
if len(src) == 0 {
return nil, errors.New("the spec is empty")
}
var converted []byte
if src[0] == '{' {
if !json.Valid(src) {
return nil, errors.New("the spec starts with { but is not valid JSON")
}
converted = src
} else {
out, err := yaml.YAMLToJSON(src)
if err != nil {
return nil, fmt.Errorf("the spec is not valid YAML: %w", err)
}
converted = out
}
var buf bytes.Buffer
if err := json.Compact(&buf, converted); err != nil {
return nil, fmt.Errorf("the spec does not convert to JSON: %w", err)
}
if buf.Len() == 0 || buf.Bytes()[0] != '{' {
return nil, errors.New("the spec is not an object")
}
return buf.Bytes(), nil
}
// apiDocsPageHandler renders the Swagger UI page with a fresh CSP nonce for
// its one inline script.
func apiDocsPageHandler(title, specURL string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
nonceBytes := make([]byte, 18)
if _, err := rand.Read(nonceBytes); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
nonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
var page bytes.Buffer
if err := apiDocsPage.Execute(&page, struct{ Title, Nonce, SpecURL string }{title, nonce, specURL}); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", "script-src 'nonce-"+nonce+"' "+swaggerUICDN+"; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
_, _ = w.Write(page.Bytes())
}
}