diff --git a/go.mod b/go.mod index e8251ff9c..131df8d29 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/vektah/gqlparser/v2 v2.5.27 github.com/xuri/excelize/v2 v2.9.1 + github.com/yuin/goldmark v1.7.12 go.gearno.de/crypto/uuid v0.1.0 go.gearno.de/kit v0.0.0-20250623163305-45b4f6905899 go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c diff --git a/go.sum b/go.sum index e7e3b48a7..dd5355c6d 100644 --- a/go.sum +++ b/go.sum @@ -187,6 +187,8 @@ github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Q github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s= github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= +github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.gearno.de/crypto/uuid v0.1.0 h1:94BYg7GYItJ6yYZ1GJayb3VYhI9/FjxuR1nFaduR4hE= go.gearno.de/crypto/uuid v0.1.0/go.mod h1:fnIIvKO9QnsyLO3ZJLJT3r8KZv/p0FOeT5eZKilYWXg= go.gearno.de/kit v0.0.0-20250623163305-45b4f6905899 h1:g44W/Fhm5bW7fhMqr874dJKOuHHsRhv6Tr3h6yHLcqY= diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go new file mode 100644 index 000000000..ee23058bf --- /dev/null +++ b/pkg/docgen/generator.go @@ -0,0 +1,107 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package docgen + +import ( + "bytes" + _ "embed" + "fmt" + "html" + "html/template" + "strings" + "time" + + "github.com/getprobo/probo/pkg/coredata" + "github.com/yuin/goldmark" +) + +var ( + //go:embed template.html + htmlTemplateContent string + + templateFuncs = template.FuncMap{ + "now": func() time.Time { return time.Now() }, + "eq": func(a, b string) bool { return a == b }, + "string": func(v fmt.Stringer) string { return v.String() }, + "lower": func(s string) string { return strings.ToLower(s) }, + "classificationString": func(c Classification) string { return string(c) }, + "formatContent": func(content string) template.HTML { + md := goldmark.New() + + var buf bytes.Buffer + if err := md.Convert([]byte(content), &buf); err != nil { + return template.HTML(fmt.Sprintf("

%s

", html.EscapeString(content))) + } + return template.HTML(buf.String()) + }, + } + + documentTemplate = template.Must(template.New("document").Funcs(templateFuncs).Parse(htmlTemplateContent)) +) + +type ( + Generator struct{} + + Classification string + + DocumentData struct { + Title string + Content string + Version int + Classification Classification + Approver string + Description string + PublishedAt *time.Time + PublishedBy *time.Time + Signatures []SignatureData + } + + SignatureData struct { + SignedBy string + SignedAt *time.Time + State coredata.DocumentVersionSignatureState + RequestedAt time.Time + RequestedBy string + } +) + +const ( + ClassificationPublic Classification = "PUBLIC" + ClassificationInternal Classification = "INTERNAL" + ClassificationConfidential Classification = "CONFIDENTIAL" + ClassificationSecret Classification = "SECRET" +) + +func NewGenerator() *Generator { + return &Generator{} +} + +func (g *Generator) GenerateHTML(data DocumentData) ([]byte, error) { + data.Title = html.EscapeString(data.Title) + data.Approver = html.EscapeString(data.Approver) + data.Description = html.EscapeString(data.Description) + + for i := range data.Signatures { + data.Signatures[i].SignedBy = html.EscapeString(data.Signatures[i].SignedBy) + data.Signatures[i].RequestedBy = html.EscapeString(data.Signatures[i].RequestedBy) + } + + var buf bytes.Buffer + if err := documentTemplate.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/pkg/docgen/generator_test.go b/pkg/docgen/generator_test.go new file mode 100644 index 000000000..0977ad2b7 --- /dev/null +++ b/pkg/docgen/generator_test.go @@ -0,0 +1,439 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package docgen + +import ( + "html/template" + "strings" + "testing" + "time" + + "github.com/getprobo/probo/pkg/coredata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGenerator(t *testing.T) { + generator := NewGenerator() + assert.NotNil(t, generator) + assert.IsType(t, &Generator{}, generator) +} + +func TestGenerateHTML(t *testing.T) { + generator := NewGenerator() + now := time.Now() + + tests := []struct { + name string + data DocumentData + wantContains []string + wantNotContains []string + }{ + { + name: "basic document with all fields", + data: DocumentData{ + Title: "Test Document", + Content: "# Main Title\n\nThis is **bold** text with *italic* formatting.", + Version: 1, + Classification: ClassificationPublic, + Approver: "John Doe", + Description: "Test document description", + PublishedAt: &now, + Signatures: []SignatureData{ + { + SignedBy: "Alice Smith", + SignedAt: &now, + State: coredata.DocumentVersionSignatureStateSigned, + RequestedAt: now, + RequestedBy: "Bob Johnson", + }, + }, + }, + wantContains: []string{ + "Test Document", + "

Main Title

", + "bold", + "italic", + "1", + "PUBLIC", + "John Doe", + "Test document description", + "Alice Smith", + "Bob Johnson", + }, + }, + { + name: "document with HTML characters that need escaping", + data: DocumentData{ + Title: "Test & Doe", + Description: "Description with & symbols and ", + Signatures: []SignatureData{ + { + SignedBy: "Alice & ", + RequestedBy: "Carol ", + "Test & ", + Approver: "User & ", + Description: "Text with 'quotes' & \"double quotes\"", + Signatures: []SignatureData{ + { + SignedBy: "tag", + RequestedBy: "User & Company", + State: coredata.DocumentVersionSignatureStateRequested, + }, + }, + } + + result, err := generator.GenerateHTML(dangerousData) + require.NoError(t, err) + + resultStr := string(result) + + // Verify dangerous content is escaped + assert.NotContains(t, resultStr, "") + assert.NotContains(t, resultStr, "tag") + assert.Contains(t, resultStr, "<script>") + assert.Contains(t, resultStr, "&") + assert.Contains(t, resultStr, "'") +} + +func TestMarkdownRendering(t *testing.T) { + generator := NewGenerator() + + tests := []struct { + name string + markdown string + want []string + }{ + { + name: "headers", + markdown: "# H1\n## H2\n### H3", + want: []string{"

H1

", "

H2

", "

H3

"}, + }, + { + name: "emphasis", + markdown: "**bold** and *italic*", + want: []string{"bold", "italic"}, + }, + { + name: "lists", + markdown: "- Item 1\n- Item 2", + want: []string{"
    ", "
  • Item 1
  • ", "
  • Item 2
  • ", "
"}, + }, + { + name: "paragraphs", + markdown: "Paragraph 1\n\nParagraph 2", + want: []string{"

Paragraph 1

", "

Paragraph 2

"}, + }, + { + name: "code", + markdown: "`inline code` and\n```\ncode block\n```", + want: []string{"inline code", "
code block"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			data := DocumentData{
+				Title:   "Markdown Test",
+				Content: tt.markdown,
+			}
+
+			result, err := generator.GenerateHTML(data)
+			require.NoError(t, err)
+
+			resultStr := string(result)
+			for _, want := range tt.want {
+				assert.Contains(t, resultStr, want)
+			}
+		})
+	}
+}
+
+func TestDocumentVersionSignatureStates(t *testing.T) {
+	generator := NewGenerator()
+	now := time.Now()
+
+	states := []coredata.DocumentVersionSignatureState{
+		coredata.DocumentVersionSignatureStateRequested,
+		coredata.DocumentVersionSignatureStateSigned,
+		// Add other states if they exist
+	}
+
+	for _, state := range states {
+		t.Run(string(state), func(t *testing.T) {
+			data := DocumentData{
+				Title: "State Test",
+				Signatures: []SignatureData{
+					{
+						SignedBy:    "Test User",
+						State:       state,
+						RequestedAt: now,
+						RequestedBy: "Requester",
+					},
+				},
+			}
+
+			result, err := generator.GenerateHTML(data)
+			assert.NoError(t, err)
+			assert.NotEmpty(t, result)
+		})
+	}
+}
+
+func TestLargeContent(t *testing.T) {
+	generator := NewGenerator()
+
+	// Create a large markdown content
+	var largeContent strings.Builder
+	for i := 0; i < 1000; i++ {
+		largeContent.WriteString("# Section ")
+		largeContent.WriteString(string(rune('A' + i%26)))
+		largeContent.WriteString("\n\nThis is a paragraph with **bold** and *italic* text.\n\n")
+		largeContent.WriteString("- List item 1\n- List item 2\n- List item 3\n\n")
+	}
+
+	data := DocumentData{
+		Title:   "Large Document",
+		Content: largeContent.String(),
+	}
+
+	result, err := generator.GenerateHTML(data)
+	assert.NoError(t, err)
+	assert.NotEmpty(t, result)
+	assert.True(t, len(result) > 10000) // Should be reasonably large
+}
+
+func BenchmarkGenerateHTML(b *testing.B) {
+	generator := NewGenerator()
+	now := time.Now()
+
+	data := DocumentData{
+		Title:          "Benchmark Document",
+		Content:        "# Title\n\nThis is **bold** text with *italic* formatting.\n\n- Item 1\n- Item 2",
+		Version:        1,
+		Classification: ClassificationPublic,
+		Approver:       "John Doe",
+		Description:    "Benchmark test document",
+		PublishedAt:    &now,
+		Signatures: []SignatureData{
+			{
+				SignedBy:    "Alice Smith",
+				SignedAt:    &now,
+				State:       coredata.DocumentVersionSignatureStateSigned,
+				RequestedAt: now,
+				RequestedBy: "Bob Johnson",
+			},
+		},
+	}
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_, err := generator.GenerateHTML(data)
+		if err != nil {
+			b.Fatal(err)
+		}
+	}
+}
diff --git a/pkg/docgen/template.html b/pkg/docgen/template.html
new file mode 100644
index 000000000..adefe948a
--- /dev/null
+++ b/pkg/docgen/template.html
@@ -0,0 +1,367 @@
+
+
+
+    
+    
+    {{.Title}}
+    
+
+
+    
+
+
+

{{.Title}}

+ +
+ + + + + + + + + + + + + + + + + + {{- if .PublishedAt}} + + + + + {{- end}} +
Classification: + {{.Classification | classificationString}} +
Approver:{{.Approver}}
Description:{{.Description}}
Version:{{.Version}}
Published:{{.PublishedAt.Format "January 2, 2006"}}{{if .PublishedBy}} ({{.PublishedBy.Format "January 2, 2006"}}){{end}}
+
+
+ +
+ {{.Content | formatContent}} +
+ + {{- if .Signatures}} +
+

Document Signatures

+ + + + + + + + + + + + {{- range .Signatures}} + + + + + + + + {{- end}} + +
SignatoryStatusRequested DateSigned DateRequested By
{{.SignedBy}} + {{- if eq (string .State) "SIGNED"}} + {{string .State}} + {{- else}} + {{string .State}} + {{- end}} + {{.RequestedAt.Format "Jan 2, 2006"}} + {{- if .SignedAt}} + {{.SignedAt.Format "Jan 2, 2006"}} + {{- else}} + - + {{- end}} + {{.RequestedBy}}
+
+ {{- end}} + + +
+
+ + \ No newline at end of file