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