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