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