1// Package web implements the HTTP server and web UI for Soft Serve.
2package web
3
4//go:generate go run gen_syntax_css.go
5
6import (
7 "context"
8 "embed"
9 "fmt"
10 "html/template"
11 "mime"
12 "net/http"
13 "path/filepath"
14 "strings"
15 "time"
16
17 "github.com/charmbracelet/log/v2"
18 "github.com/charmbracelet/soft-serve/git"
19 "github.com/charmbracelet/soft-serve/pkg/proto"
20 "github.com/dustin/go-humanize"
21 "github.com/gorilla/mux"
22)
23
24//go:embed templates/*.html
25var templatesFS embed.FS
26
27//go:embed static/*
28var staticFS embed.FS
29
30const (
31 // defaultCommitsPerPage is the number of commits shown per page in commit history view.
32 defaultCommitsPerPage = 50
33 // defaultReposPerPage is the number of repositories shown per page on the home page.
34 defaultReposPerPage = 20
35 // defaultTagsPerPage is the number of tags shown per page on the tags page.
36 defaultTagsPerPage = 20
37 // defaultBranchesPerPage is the number of branches shown per page on the branches page.
38 defaultBranchesPerPage = 20
39)
40
41// BaseData contains common fields for all web UI pages.
42type BaseData struct {
43 ServerName string
44 ActiveTab string
45 Title string
46 Description string
47}
48
49// RepoBaseData contains common fields for repository-specific pages.
50type RepoBaseData struct {
51 BaseData
52 Repo proto.Repository
53 DefaultBranch string
54}
55
56// PaginationData contains common fields for paginated views.
57type PaginationData struct {
58 Page int
59 TotalPages int
60 HasPrevPage bool
61 HasNextPage bool
62}
63
64// templateFuncs defines template helper functions available in HTML templates.
65// Functions include: splitPath (split path into components), joinPath (join path components),
66// parentPath (get parent directory), shortHash (truncate commit hash), formatDate (format timestamp),
67// and humanizeSize (format file size in human-readable format).
68var templateFuncs = template.FuncMap{
69 "sub": func(a, b int) int {
70 return a - b
71 },
72 "splitPath": func(path string) []string {
73 if path == "." {
74 return []string{}
75 }
76 return strings.Split(path, "/")
77 },
78 "joinPath": func(index int, data interface{}) string {
79 var path string
80
81 switch v := data.(type) {
82 case BlobData:
83 path = v.Path
84 case TreeData:
85 path = v.Path
86 default:
87 return ""
88 }
89
90 parts := strings.Split(path, "/")
91 if index >= len(parts) {
92 return path
93 }
94 return strings.Join(parts[:index+1], "/")
95 },
96 "parentPath": func(path string) string {
97 if path == "." || !strings.Contains(path, "/") {
98 return "."
99 }
100 return filepath.Dir(path)
101 },
102 "shortHash": func(hash interface{}) string {
103 var hashStr string
104 switch v := hash.(type) {
105 case string:
106 hashStr = v
107 case fmt.Stringer:
108 hashStr = v.String()
109 default:
110 hashStr = fmt.Sprintf("%v", hash)
111 }
112 if len(hashStr) > 7 {
113 return hashStr[:7]
114 }
115 return hashStr
116 },
117 "formatDate": func(t interface{}) string {
118 switch v := t.(type) {
119 case time.Time:
120 return v.Format("2006-01-02 15:04:05 UTC")
121 default:
122 if time, ok := t.(fmt.Stringer); ok {
123 return time.String()
124 }
125 return fmt.Sprintf("%v", t)
126 }
127 },
128 "humanizeSize": func(size int64) string {
129 const unit = 1024
130 if size < unit {
131 return fmt.Sprintf("%d B", size)
132 }
133 div, exp := int64(unit), 0
134 for n := size / unit; n >= unit; n /= unit {
135 div *= unit
136 exp++
137 }
138 return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
139 },
140 "inc": func(i int) int {
141 return i + 1
142 },
143 "dec": func(i int) int {
144 return i - 1
145 },
146 "commitSubject": func(message string) string {
147 lines := strings.Split(message, "\n")
148 if len(lines) > 0 {
149 return lines[0]
150 }
151 return message
152 },
153 "commitBody": func(message string) string {
154 lines := strings.Split(message, "\n")
155 if len(lines) <= 1 {
156 return ""
157 }
158 // Skip the subject line and join the rest
159 body := strings.Join(lines[1:], "\n")
160 return strings.TrimSpace(body)
161 },
162 "rfc3339": func(t interface{}) string {
163 switch v := t.(type) {
164 case time.Time:
165 return v.Format(time.RFC3339)
166 default:
167 return ""
168 }
169 },
170 "relativeTime": func(t interface{}) string {
171 switch v := t.(type) {
172 case time.Time:
173 return humanize.Time(v)
174 default:
175 return ""
176 }
177 },
178 "formatAttribution": func(commit interface{}) string {
179 var authorName, message string
180
181 switch v := commit.(type) {
182 case *git.Commit:
183 if v == nil {
184 return ""
185 }
186 authorName = v.Author.Name
187 message = v.Message
188 default:
189 return ""
190 }
191
192 coAuthors := GetCoAuthors(message)
193
194 if len(coAuthors) == 0 {
195 return authorName
196 }
197
198 if len(coAuthors) == 1 {
199 return authorName + " and " + coAuthors[0]
200 }
201
202 // Multiple co-authors: "Author, Co1, Co2, and Co3"
203 result := authorName
204 for i, coAuthor := range coAuthors {
205 if i == len(coAuthors)-1 {
206 result += ", and " + coAuthor
207 } else {
208 result += ", " + coAuthor
209 }
210 }
211 return result
212 },
213 "attributionNames": func(commit interface{}) []string {
214 switch v := commit.(type) {
215 case *git.Commit:
216 if v == nil {
217 return nil
218 }
219 names := []string{v.Author.Name}
220 for _, n := range GetCoAuthors(v.Message) {
221 n = strings.TrimSpace(n)
222 if n != "" {
223 names = append(names, n)
224 }
225 }
226 return names
227 default:
228 return nil
229 }
230 },
231}
232
233// renderHTML renders an HTML template with the given data.
234func renderHTML(w http.ResponseWriter, templateName string, data interface{}) {
235 tmpl, err := template.New("").Funcs(templateFuncs).ParseFS(templatesFS, "templates/base.html", "templates/"+templateName)
236 if err != nil {
237 log.Debug("failed to parse template", "template", templateName, "err", err)
238 renderInternalServerError(w, nil)
239 return
240 }
241
242 w.Header().Set("Content-Type", "text/html; charset=utf-8")
243
244 // Security headers
245 // Note: style-src 'unsafe-inline' is required for inline styles in templates (tree.html, overview.html)
246 w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; frame-ancestors 'self'; base-uri 'none'")
247 w.Header().Set("Referrer-Policy", "no-referrer")
248 w.Header().Set("X-Content-Type-Options", "nosniff")
249
250 if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
251 log.Debug("template execution failed", "template", templateName, "err", err)
252 // Already started writing response, so we can't render an error page
253 }
254}
255
256// staticFiles handles static file serving.
257func staticFiles(w http.ResponseWriter, r *http.Request) {
258 // Strip /static/ prefix
259 path := strings.TrimPrefix(r.URL.Path, "/static/")
260
261 data, err := staticFS.ReadFile("static/" + path)
262 if err != nil {
263 renderNotFound(w, r)
264 return
265 }
266
267 // Set cache headers
268 w.Header().Set("Cache-Control", "public, max-age=31536000")
269
270 // Detect content type from extension
271 contentType := mime.TypeByExtension(filepath.Ext(path))
272 if contentType != "" {
273 w.Header().Set("Content-Type", contentType)
274 }
275
276 w.Write(data)
277}
278
279// withWebUIAccess wraps withAccess and hides 401/403 as 404 for Web UI routes.
280func withWebUIAccess(next http.Handler) http.Handler {
281 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
282 ctx := r.Context()
283 logger := log.FromContext(ctx)
284 logger.Debug("withWebUIAccess called", "path", r.URL.Path)
285
286 // Wrap the writer to suppress 401/403
287 hrw := &hideAuthWriter{ResponseWriter: w}
288 // Run access first so repo/user are injected into context
289 withAccess(next).ServeHTTP(hrw, r)
290
291 if hrw.suppressed {
292 logger.Debug("suppressed 401/403, rendering 404")
293 // Remove auth challenge headers so we don't leak private info
294 w.Header().Del("WWW-Authenticate")
295 w.Header().Del("LFS-Authenticate")
296 // Now render 404 once; no double WriteHeader occurs
297 renderNotFound(w, r)
298 }
299 })
300}
301
302// hideAuthWriter suppresses 401/403 responses to convert them to 404.
303type hideAuthWriter struct {
304 http.ResponseWriter
305 suppressed bool
306}
307
308func (w *hideAuthWriter) WriteHeader(code int) {
309 if code == http.StatusUnauthorized || code == http.StatusForbidden {
310 // Suppress original status/body; we'll render 404 afterwards
311 w.suppressed = true
312 return
313 }
314 w.ResponseWriter.WriteHeader(code)
315}
316
317func (w *hideAuthWriter) Write(p []byte) (int, error) {
318 if w.suppressed {
319 // Drop body of 401/403
320 return len(p), nil
321 }
322 return w.ResponseWriter.Write(p)
323}
324
325// WebUIController registers HTTP routes for the web-based repository browser.
326// It provides HTML views for repository overview, file browsing, commits, and references.
327func WebUIController(ctx context.Context, r *mux.Router) {
328 basePrefix := "/{repo:.*}"
329
330 // Static files (most specific, should be first)
331 r.PathPrefix("/static/").HandlerFunc(staticFiles).Methods(http.MethodGet)
332
333 // Home page (root path, before other routes)
334 r.HandleFunc("/", home).Methods(http.MethodGet)
335
336 // About page
337 r.HandleFunc("/about", about).Methods(http.MethodGet)
338
339 // More specific routes must be registered before catch-all patterns
340 // Middleware order: withRepoVars (set vars) -> withWebUIAccess (auth + load context) -> handler
341 // Tree routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
342 r.Handle(basePrefix+"/tree/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
343 Methods(http.MethodGet)
344 r.Handle(basePrefix+"/tree", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
345 Methods(http.MethodGet)
346
347 // Blob routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
348 r.Handle(basePrefix+"/blob/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBlob)))).
349 Methods(http.MethodGet)
350
351 // Commits routes - use catch-all pattern to support refs with slashes
352 r.Handle(basePrefix+"/commits/{ref:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
353 Methods(http.MethodGet)
354 r.Handle(basePrefix+"/commits", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
355 Methods(http.MethodGet)
356
357 // Commit route
358 r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommit)))).
359 Methods(http.MethodGet)
360
361 // Commit patch and diff routes
362 r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}.patch", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommitPatch)))).
363 Methods(http.MethodGet)
364 r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}.diff", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommitDiff)))).
365 Methods(http.MethodGet)
366
367 // Branches route
368 r.Handle(basePrefix+"/branches", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBranches)))).
369 Methods(http.MethodGet)
370
371 // Tags route
372 r.Handle(basePrefix+"/tags", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTags)))).
373 Methods(http.MethodGet)
374
375 // Tag detail route
376 r.Handle(basePrefix+"/tag/{tag:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTag)))).
377 Methods(http.MethodGet)
378
379 // Repository overview (catch-all, must be last)
380 r.Handle(basePrefix, withRepoVars(withWebUIAccess(http.HandlerFunc(repoOverview)))).
381 Methods(http.MethodGet)
382}