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/pkg/proto"
19 "github.com/gorilla/mux"
20)
21
22//go:embed templates/*.html
23var templatesFS embed.FS
24
25//go:embed static/*
26var staticFS embed.FS
27
28const (
29 // defaultCommitsPerPage is the number of commits shown per page in commit history view.
30 defaultCommitsPerPage = 50
31 // defaultReposPerPage is the number of repositories shown per page on the home page.
32 defaultReposPerPage = 20
33 // defaultTagsPerPage is the number of tags shown per page on the tags page.
34 defaultTagsPerPage = 20
35 // defaultBranchesPerPage is the number of branches shown per page on the branches page.
36 defaultBranchesPerPage = 20
37)
38
39// BaseData contains common fields for all web UI pages.
40type BaseData struct {
41 ServerName string
42 ActiveTab string
43}
44
45// RepoBaseData contains common fields for repository-specific pages.
46type RepoBaseData struct {
47 BaseData
48 Repo proto.Repository
49 DefaultBranch string
50}
51
52// PaginationData contains common fields for paginated views.
53type PaginationData struct {
54 Page int
55 TotalPages int
56 HasPrevPage bool
57 HasNextPage bool
58}
59
60// templateFuncs defines template helper functions available in HTML templates.
61// Functions include: splitPath (split path into components), joinPath (join path components),
62// parentPath (get parent directory), shortHash (truncate commit hash), formatDate (format timestamp),
63// and humanizeSize (format file size in human-readable format).
64var templateFuncs = template.FuncMap{
65 "splitPath": func(path string) []string {
66 if path == "." {
67 return []string{}
68 }
69 return strings.Split(path, "/")
70 },
71 "joinPath": func(index int, data interface{}) string {
72 var path string
73
74 switch v := data.(type) {
75 case BlobData:
76 path = v.Path
77 case TreeData:
78 path = v.Path
79 default:
80 return ""
81 }
82
83 parts := strings.Split(path, "/")
84 if index >= len(parts) {
85 return path
86 }
87 return strings.Join(parts[:index+1], "/")
88 },
89 "parentPath": func(path string) string {
90 if path == "." || !strings.Contains(path, "/") {
91 return "."
92 }
93 return filepath.Dir(path)
94 },
95 "shortHash": func(hash interface{}) string {
96 var hashStr string
97 switch v := hash.(type) {
98 case string:
99 hashStr = v
100 case fmt.Stringer:
101 hashStr = v.String()
102 default:
103 hashStr = fmt.Sprintf("%v", hash)
104 }
105 if len(hashStr) > 8 {
106 return hashStr[:8]
107 }
108 return hashStr
109 },
110 "formatDate": func(t interface{}) string {
111 if time, ok := t.(fmt.Stringer); ok {
112 return time.String()
113 }
114 return fmt.Sprintf("%v", t)
115 },
116 "humanizeSize": func(size int64) string {
117 const unit = 1024
118 if size < unit {
119 return fmt.Sprintf("%d B", size)
120 }
121 div, exp := int64(unit), 0
122 for n := size / unit; n >= unit; n /= unit {
123 div *= unit
124 exp++
125 }
126 return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
127 },
128 "inc": func(i int) int {
129 return i + 1
130 },
131 "dec": func(i int) int {
132 return i - 1
133 },
134 "commitSubject": func(message string) string {
135 lines := strings.Split(message, "\n")
136 if len(lines) > 0 {
137 return lines[0]
138 }
139 return message
140 },
141 "commitBody": func(message string) string {
142 lines := strings.Split(message, "\n")
143 if len(lines) <= 1 {
144 return ""
145 }
146 // Skip the subject line and join the rest
147 body := strings.Join(lines[1:], "\n")
148 return strings.TrimSpace(body)
149 },
150 "rfc3339": func(t interface{}) string {
151 switch v := t.(type) {
152 case time.Time:
153 return v.Format(time.RFC3339)
154 default:
155 return ""
156 }
157 },
158}
159
160// renderHTML renders an HTML template with the given data.
161func renderHTML(w http.ResponseWriter, templateName string, data interface{}) {
162 tmpl, err := template.New("").Funcs(templateFuncs).ParseFS(templatesFS, "templates/base.html", "templates/"+templateName)
163 if err != nil {
164 log.Debug("failed to parse template", "template", templateName, "err", err)
165 renderInternalServerError(w, nil)
166 return
167 }
168
169 w.Header().Set("Content-Type", "text/html; charset=utf-8")
170 if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
171 log.Debug("template execution failed", "template", templateName, "err", err)
172 // Already started writing response, so we can't render an error page
173 }
174}
175
176// staticFiles handles static file serving.
177func staticFiles(w http.ResponseWriter, r *http.Request) {
178 // Strip /static/ prefix
179 path := strings.TrimPrefix(r.URL.Path, "/static/")
180
181 data, err := staticFS.ReadFile("static/" + path)
182 if err != nil {
183 renderNotFound(w, r)
184 return
185 }
186
187 // Set cache headers
188 w.Header().Set("Cache-Control", "public, max-age=31536000")
189
190 // Detect content type from extension
191 contentType := mime.TypeByExtension(filepath.Ext(path))
192 if contentType != "" {
193 w.Header().Set("Content-Type", contentType)
194 }
195
196 w.Write(data)
197}
198
199// withWebUIAccess wraps withAccess and hides 401/403 as 404 for Web UI routes.
200func withWebUIAccess(next http.Handler) http.Handler {
201 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
202 ctx := r.Context()
203 logger := log.FromContext(ctx)
204 logger.Debug("withWebUIAccess called", "path", r.URL.Path)
205
206 // Wrap the writer to suppress 401/403
207 hrw := &hideAuthWriter{ResponseWriter: w}
208 // Run access first so repo/user are injected into context
209 withAccess(next).ServeHTTP(hrw, r)
210
211 if hrw.suppressed {
212 logger.Debug("suppressed 401/403, rendering 404")
213 // Remove auth challenge headers so we don't leak private info
214 w.Header().Del("WWW-Authenticate")
215 w.Header().Del("LFS-Authenticate")
216 // Now render 404 once; no double WriteHeader occurs
217 renderNotFound(w, r)
218 }
219 })
220}
221
222// hideAuthWriter suppresses 401/403 responses to convert them to 404.
223type hideAuthWriter struct {
224 http.ResponseWriter
225 suppressed bool
226}
227
228func (w *hideAuthWriter) WriteHeader(code int) {
229 if code == http.StatusUnauthorized || code == http.StatusForbidden {
230 // Suppress original status/body; we'll render 404 afterwards
231 w.suppressed = true
232 return
233 }
234 w.ResponseWriter.WriteHeader(code)
235}
236
237func (w *hideAuthWriter) Write(p []byte) (int, error) {
238 if w.suppressed {
239 // Drop body of 401/403
240 return len(p), nil
241 }
242 return w.ResponseWriter.Write(p)
243}
244
245// WebUIController registers HTTP routes for the web-based repository browser.
246// It provides HTML views for repository overview, file browsing, commits, and references.
247func WebUIController(ctx context.Context, r *mux.Router) {
248 basePrefix := "/{repo:.*}"
249
250 // Static files (most specific, should be first)
251 r.PathPrefix("/static/").HandlerFunc(staticFiles).Methods(http.MethodGet)
252
253 // Home page (root path, before other routes)
254 r.HandleFunc("/", home).Methods(http.MethodGet)
255
256 // About page
257 r.HandleFunc("/about", about).Methods(http.MethodGet)
258
259 // More specific routes must be registered before catch-all patterns
260 // Middleware order: withRepoVars (set vars) -> withWebUIAccess (auth + load context) -> handler
261 // Tree routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
262 r.Handle(basePrefix+"/tree/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
263 Methods(http.MethodGet)
264 r.Handle(basePrefix+"/tree", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
265 Methods(http.MethodGet)
266
267 // Blob routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
268 r.Handle(basePrefix+"/blob/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBlob)))).
269 Methods(http.MethodGet)
270
271 // Commits routes - use catch-all pattern to support refs with slashes
272 r.Handle(basePrefix+"/commits/{ref:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
273 Methods(http.MethodGet)
274 r.Handle(basePrefix+"/commits", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
275 Methods(http.MethodGet)
276
277 // Commit route
278 r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommit)))).
279 Methods(http.MethodGet)
280
281 // Commit patch and diff routes
282 r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}.patch", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommitPatch)))).
283 Methods(http.MethodGet)
284 r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}.diff", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommitDiff)))).
285 Methods(http.MethodGet)
286
287 // Branches route
288 r.Handle(basePrefix+"/branches", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBranches)))).
289 Methods(http.MethodGet)
290
291 // Tags route
292 r.Handle(basePrefix+"/tags", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTags)))).
293 Methods(http.MethodGet)
294
295 // Repository overview (catch-all, must be last)
296 r.Handle(basePrefix, withRepoVars(withWebUIAccess(http.HandlerFunc(repoOverview)))).
297 Methods(http.MethodGet)
298}