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