Add support for CSS links in generated HTML outputs
This commit is contained in:
@@ -110,6 +110,7 @@ type MarkdownToHTML struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Mode string `yaml:"mode"`
|
||||
Input string `yaml:"input"`
|
||||
CssHref string `yaml:"css_href"`
|
||||
}
|
||||
|
||||
type PathMapping struct {
|
||||
|
||||
@@ -2,8 +2,10 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/link"
|
||||
)
|
||||
@@ -281,9 +283,15 @@ func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform)
|
||||
if transform.MarkdownToHTML.Input != "" && !transform.MarkdownToHTML.Enabled {
|
||||
return fmt.Errorf("transform.markdown_to_html.input requires transform.markdown_to_html.enabled to be true")
|
||||
}
|
||||
if transform.MarkdownToHTML.CssHref != "" && !transform.MarkdownToHTML.Enabled {
|
||||
return fmt.Errorf("transform.markdown_to_html.css_href requires transform.markdown_to_html.enabled to be true")
|
||||
}
|
||||
if transform.MarkdownToHTML.Input != "" && mode != TransformModeIndex {
|
||||
return fmt.Errorf("transform.markdown_to_html.input is only valid when mode is %s", TransformModeIndex)
|
||||
}
|
||||
if err := validateCSSHref(transform.MarkdownToHTML.CssHref); err != nil {
|
||||
return fmt.Errorf("transform.markdown_to_html.css_href %w", err)
|
||||
}
|
||||
if transform.MarkdownToHTML.Enabled && !publish.HTML {
|
||||
return fmt.Errorf("transform.markdown_to_html.enabled requires publish.html to be true")
|
||||
}
|
||||
@@ -293,6 +301,49 @@ func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCSSHref(value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
for _, character := range value {
|
||||
if unicode.IsControl(character) || unicode.IsSpace(character) {
|
||||
return fmt.Errorf("must not contain whitespace or control characters")
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(value, "\\<>\"'") {
|
||||
return fmt.Errorf("must not contain backslashes or HTML-sensitive characters")
|
||||
}
|
||||
if strings.HasPrefix(value, "//") {
|
||||
return fmt.Errorf("must not be scheme-relative")
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be a valid URL reference: %w", err)
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return fmt.Errorf("must not include a fragment")
|
||||
}
|
||||
if parsed.Scheme != "" {
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return fmt.Errorf("scheme must be http or https")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return fmt.Errorf("host is required for absolute URLs")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("must not include userinfo")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if parsed.Host != "" {
|
||||
return fmt.Errorf("must not be scheme-relative")
|
||||
}
|
||||
if parsed.Path == "" {
|
||||
return fmt.Errorf("relative URL path is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePathMapping(errs ValidationErrors, context string, mapping PathMapping) ValidationErrors {
|
||||
if mapping.Mode != PathMappingPreserveRelative && mapping.Mode != PathMappingFixed {
|
||||
errs = append(errs, context+".mode must be "+PathMappingPreserveRelative+" or "+PathMappingFixed)
|
||||
|
||||
@@ -328,6 +328,24 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
|
||||
Input: "report.md",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "html only sidecar css href allowed",
|
||||
publish: PublishPolicy{HTML: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: TransformModeSidecar,
|
||||
CssHref: "/assets/report.css",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "html only index css href allowed",
|
||||
publish: PublishPolicy{HTML: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: TransformModeIndex,
|
||||
CssHref: "assets/report.css?v=20260614",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "source and html sidecar allowed",
|
||||
publish: PublishPolicy{Source: true, HTML: true},
|
||||
@@ -425,6 +443,16 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "disabled markdown css href rejected",
|
||||
publish: PublishPolicy{Source: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: false,
|
||||
Mode: TransformModeSidecar,
|
||||
CssHref: "/assets/report.css",
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "disabled markdown wrong mode rejected",
|
||||
publish: PublishPolicy{Source: true},
|
||||
@@ -436,3 +464,44 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCSSHref(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty"},
|
||||
{name: "root relative", value: "/assets/report.css"},
|
||||
{name: "relative", value: "assets/report.css"},
|
||||
{name: "parent relative", value: "../assets/report.css"},
|
||||
{name: "query", value: "/assets/report.css?v=20260614"},
|
||||
{name: "http", value: "http://example.com/report.css"},
|
||||
{name: "https", value: "https://example.com/assets/report.css?v=1"},
|
||||
{name: "javascript", value: "javascript:alert(1)", wantErr: true},
|
||||
{name: "data", value: "data:text/css,body{}", wantErr: true},
|
||||
{name: "file", value: "file:///tmp/report.css", wantErr: true},
|
||||
{name: "scheme relative", value: "//example.com/report.css", wantErr: true},
|
||||
{name: "userinfo", value: "https://user@example.com/report.css", wantErr: true},
|
||||
{name: "fragment", value: "/assets/report.css#main", wantErr: true},
|
||||
{name: "space", value: "/assets/report css", wantErr: true},
|
||||
{name: "tab", value: "/assets/report\tcss", wantErr: true},
|
||||
{name: "newline", value: "/assets/report\ncss", wantErr: true},
|
||||
{name: "backslash", value: `assets\report.css`, wantErr: true},
|
||||
{name: "less than", value: "/assets/<report>.css", wantErr: true},
|
||||
{name: "double quote", value: `/assets/"report".css`, wantErr: true},
|
||||
{name: "single quote", value: "/assets/'report'.css", wantErr: true},
|
||||
{name: "query only", value: "?v=1", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCSSHref(tt.value)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("validateCSSHref() error = nil, want error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("validateCSSHref() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user