- 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
211 lines
7.7 KiB
Go
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())
|
|
}
|
|
}
|