1package web
2
3import (
4 "bytes"
5 "context"
6 "html/template"
7 "strings"
8 "sync"
9
10 "github.com/charmbracelet/soft-serve/git"
11 "github.com/charmbracelet/soft-serve/pkg/backend"
12 "github.com/charmbracelet/soft-serve/pkg/proto"
13 "github.com/microcosm-cc/bluemonday"
14 "github.com/yuin/goldmark"
15 extension "github.com/yuin/goldmark/extension"
16 "github.com/yuin/goldmark/parser"
17 goldmarkhtml "github.com/yuin/goldmark/renderer/html"
18 "github.com/yuin/goldmark/util"
19)
20
21var (
22 sanitizerPolicy *bluemonday.Policy
23 sanitizerPolicyOnce sync.Once
24)
25
26// getSanitizerPolicy returns a cached sanitizer policy.
27// The policy is safe for concurrent use after initialization.
28// Note: Tests should not use t.Parallel() with functions that call renderMarkdown
29// until we verify bluemonday.Policy initialization is thread-safe during sync.Once.
30func getSanitizerPolicy() *bluemonday.Policy {
31 sanitizerPolicyOnce.Do(func() {
32 policy := bluemonday.NewPolicy()
33
34 // Allowed tags
35 policy.AllowElements("p", "h1", "h2", "h3", "h4", "h5", "h6",
36 "ul", "ol", "li", "pre", "code", "blockquote",
37 "strong", "em", "del", "br", "hr",
38 "table", "thead", "tbody", "tr", "th", "td",
39 "center", "a", "img", "details", "summary")
40
41 // Allow id attributes on headings (generated by AutoHeadingID for anchor links)
42 policy.AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6")
43
44 // Links: only https scheme and relative URLs, add nofollow/noreferrer
45 policy.AllowAttrs("href").OnElements("a")
46 policy.RequireNoFollowOnLinks(true)
47 policy.RequireNoReferrerOnLinks(true)
48
49 // Images: only https scheme or repo-relative paths, allow descriptive attrs
50 policy.AllowAttrs("src", "alt", "title", "width", "height").OnElements("img")
51
52 // URL schemes: only HTTPS for external URLs, relative URLs for repo paths
53 policy.AllowURLSchemes("https")
54 policy.AllowRelativeURLs(true)
55
56 // Table alignment attributes
57 policy.AllowAttrs("align").Matching(bluemonday.SpaceSeparatedTokens).OnElements("th", "td")
58
59 sanitizerPolicy = policy
60 })
61 return sanitizerPolicy
62}
63
64// renderMarkdown converts markdown content to sanitized HTML.
65// If ctx is provided, relative URLs will be rewritten to point to repository files.
66func renderMarkdown(content []byte, ctx *ReadmeContext) (template.HTML, error) {
67 var buf bytes.Buffer
68
69 mdOpts := []goldmark.Option{
70 goldmark.WithExtensions(extension.GFM),
71 goldmark.WithParserOptions(
72 parser.WithAutoHeadingID(),
73 ),
74 goldmark.WithRendererOptions(
75 goldmarkhtml.WithUnsafe(),
76 ),
77 }
78
79 // Add URL rewriter if context is provided
80 if ctx != nil {
81 rewriter := newURLRewriter(*ctx)
82 mdOpts = append(mdOpts, goldmark.WithParserOptions(
83 parser.WithASTTransformers(util.Prioritized(rewriter, 500)),
84 ))
85 }
86
87 md := goldmark.New(mdOpts...)
88
89 if err := md.Convert(content, &buf); err != nil {
90 return "", err
91 }
92
93 // Use cached sanitizer policy
94 sanitized := getSanitizerPolicy().SanitizeBytes(buf.Bytes())
95
96 return template.HTML(sanitized), nil
97}
98
99// getServerReadme loads and renders the README from the .soft-serve repository.
100// Returns both the raw markdown content and the rendered HTML.
101func getServerReadme(ctx context.Context, be *backend.Backend) (raw string, html template.HTML, err error) {
102 repos, err := be.Repositories(ctx)
103 if err != nil {
104 return "", "", err
105 }
106
107 for _, r := range repos {
108 if r.Name() == ".soft-serve" {
109 readme, _, err := backend.Readme(r, nil)
110 if err != nil {
111 return "", "", err
112 }
113 if readme != "" {
114 html, err := renderMarkdown([]byte(readme), nil)
115 if err != nil {
116 return "", "", err
117 }
118 return readme, html, nil
119 }
120 }
121 }
122
123 return "", "", nil
124}
125
126// openRepository opens a git repository.
127func openRepository(repo proto.Repository) (*git.Repository, error) {
128 return repo.Open()
129}
130
131// getDefaultBranch returns the default branch name, or empty string if none exists.
132func getDefaultBranch(gr *git.Repository) string {
133 head, err := gr.HEAD()
134 if err != nil || head == nil {
135 return ""
136 }
137 return head.Name().Short()
138}
139
140// truncateText truncates text to maxLength characters, respecting word boundaries.
141// If truncated, appends "...". Returns empty string if text is empty or maxLength <= 0.
142func truncateText(text string, maxLength int) string {
143 text = strings.TrimSpace(text)
144 if text == "" || maxLength <= 0 {
145 return ""
146 }
147
148 if len(text) <= maxLength {
149 return text
150 }
151
152 // Find last space before maxLength
153 truncated := text[:maxLength]
154 if lastSpace := strings.LastIndex(truncated, " "); lastSpace > 0 {
155 truncated = truncated[:lastSpace]
156 }
157
158 return truncated + "..."
159}
160
161// extractPlainTextFromMarkdown converts markdown to plain text and truncates to maxLength.
162// Strips markdown formatting to produce a clean description suitable for meta tags.
163func extractPlainTextFromMarkdown(markdown string, maxLength int) string {
164 markdown = strings.TrimSpace(markdown)
165 if markdown == "" {
166 return ""
167 }
168
169 // Simple markdown stripping - remove common formatting
170 text := markdown
171
172 // Remove headers
173 text = strings.ReplaceAll(text, "# ", "")
174 text = strings.ReplaceAll(text, "## ", "")
175 text = strings.ReplaceAll(text, "### ", "")
176 text = strings.ReplaceAll(text, "#### ", "")
177 text = strings.ReplaceAll(text, "##### ", "")
178 text = strings.ReplaceAll(text, "###### ", "")
179
180 // Remove bold/italic markers
181 text = strings.ReplaceAll(text, "**", "")
182 text = strings.ReplaceAll(text, "__", "")
183 text = strings.ReplaceAll(text, "*", "")
184 text = strings.ReplaceAll(text, "_", "")
185
186 // Remove code blocks and inline code
187 text = strings.ReplaceAll(text, "`", "")
188
189 // Remove links but keep text: [text](url) -> text
190 for strings.Contains(text, "[") && strings.Contains(text, "](") {
191 start := strings.Index(text, "[")
192 mid := strings.Index(text[start:], "](")
193 if mid == -1 {
194 break
195 }
196 end := strings.Index(text[start+mid+2:], ")")
197 if end == -1 {
198 break
199 }
200 linkText := text[start+1 : start+mid]
201 text = text[:start] + linkText + text[start+mid+2+end+1:]
202 }
203
204 // Replace multiple spaces/newlines with single space
205 text = strings.Join(strings.Fields(text), " ")
206
207 return truncateText(text, maxLength)
208}
209
210// getRepoDescriptionOrFallback returns the repository description if available,
211// otherwise returns the fallback text. Result is truncated to 200 characters.
212func getRepoDescriptionOrFallback(repo proto.Repository, fallback string) string {
213 desc := strings.TrimSpace(repo.Description())
214 if desc == "" {
215 desc = fallback
216 }
217 return truncateText(desc, 200)
218}