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" ) 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(` {{.Title}}
`)) // mountAPIDocs registers GET (the Swagger UI page) and // GET .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 on // every script tag, the two pinned assets and the inline start-up 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") // The nonce is the only script source: the two jsDelivr scripts // carry it next to their SRI hash, so no other jsDelivr script runs. h.Set("Content-Security-Policy", "script-src 'nonce-"+nonce+"'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") _, _ = w.Write(page.Bytes()) } }