From 5cdf68f2d07e255de06025db4b028039a70770c3 Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 6 Oct 2025 14:09:42 -0600 Subject: [PATCH] feat: introduce web UI Co-Authored-By: Crush --- pkg/web/gen_syntax_css.go | 51 ++++++ pkg/web/git.go | 31 +++- pkg/web/git_lfs.go | 28 ++-- pkg/web/goget.go | 58 +++---- pkg/web/logging.go | 4 +- pkg/web/server.go | 4 + pkg/web/static/syntax.css | 157 ++++++++++++++++++ pkg/web/templates/about.html | 12 ++ pkg/web/templates/base.html | 61 +++++++ pkg/web/templates/blob.html | 55 +++++++ pkg/web/templates/branches.html | 62 +++++++ pkg/web/templates/commit.html | 61 +++++++ pkg/web/templates/commits.html | 58 +++++++ pkg/web/templates/error.html | 9 + pkg/web/templates/home.html | 59 +++++++ pkg/web/templates/overview.html | 48 ++++++ pkg/web/templates/tags.html | 49 ++++++ pkg/web/templates/tree.html | 49 ++++++ pkg/web/util.go | 2 +- pkg/web/webui.go | 270 ++++++++++++++++++++++++++++++ pkg/web/webui_about.go | 44 +++++ pkg/web/webui_blob.go | 228 ++++++++++++++++++++++++++ pkg/web/webui_branches.go | 111 +++++++++++++ pkg/web/webui_commit.go | 74 +++++++++ pkg/web/webui_commits.go | 105 ++++++++++++ pkg/web/webui_git.go | 280 ++++++++++++++++++++++++++++++++ pkg/web/webui_helpers.go | 78 +++++++++ pkg/web/webui_home.go | 169 +++++++++++++++++++ pkg/web/webui_overview.go | 68 ++++++++ pkg/web/webui_tags.go | 131 +++++++++++++++ pkg/web/webui_tree.go | 88 ++++++++++ 31 files changed, 2451 insertions(+), 53 deletions(-) create mode 100644 pkg/web/gen_syntax_css.go create mode 100644 pkg/web/static/syntax.css create mode 100644 pkg/web/templates/about.html create mode 100644 pkg/web/templates/base.html create mode 100644 pkg/web/templates/blob.html create mode 100644 pkg/web/templates/branches.html create mode 100644 pkg/web/templates/commit.html create mode 100644 pkg/web/templates/commits.html create mode 100644 pkg/web/templates/error.html create mode 100644 pkg/web/templates/home.html create mode 100644 pkg/web/templates/overview.html create mode 100644 pkg/web/templates/tags.html create mode 100644 pkg/web/templates/tree.html create mode 100644 pkg/web/webui.go create mode 100644 pkg/web/webui_about.go create mode 100644 pkg/web/webui_blob.go create mode 100644 pkg/web/webui_branches.go create mode 100644 pkg/web/webui_commit.go create mode 100644 pkg/web/webui_commits.go create mode 100644 pkg/web/webui_git.go create mode 100644 pkg/web/webui_helpers.go create mode 100644 pkg/web/webui_home.go create mode 100644 pkg/web/webui_overview.go create mode 100644 pkg/web/webui_tags.go create mode 100644 pkg/web/webui_tree.go diff --git a/pkg/web/gen_syntax_css.go b/pkg/web/gen_syntax_css.go new file mode 100644 index 0000000000000000000000000000000000000000..d96a4440a9b125481814beaaf58e84dff1b4538b --- /dev/null +++ b/pkg/web/gen_syntax_css.go @@ -0,0 +1,51 @@ +//go:build ignore + +package main + +import ( + "bytes" + "log" + "os" + "path/filepath" + + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/styles" +) + +func main() { + lightStyle := styles.Get("github") + if lightStyle == nil { + log.Fatal("github style not found") + } + + darkStyle := styles.Get("github-dark") + if darkStyle == nil { + log.Fatal("github-dark style not found") + } + + formatter := html.New(html.WithClasses(true)) + + var buf bytes.Buffer + + buf.WriteString("/* Auto-generated syntax highlighting CSS */\n") + buf.WriteString("/* Generated by go generate - do not edit manually */\n\n") + + buf.WriteString("@media (prefers-color-scheme: light) {\n") + if err := formatter.WriteCSS(&buf, lightStyle); err != nil { + log.Fatalf("failed to write light style CSS: %v", err) + } + buf.WriteString("}\n\n") + + buf.WriteString("@media (prefers-color-scheme: dark) {\n") + if err := formatter.WriteCSS(&buf, darkStyle); err != nil { + log.Fatalf("failed to write dark style CSS: %v", err) + } + buf.WriteString("}\n") + + outputPath := filepath.Join("static", "syntax.css") + if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil { + log.Fatalf("failed to write CSS file: %v", err) + } + + log.Printf("Generated %s successfully", outputPath) +} diff --git a/pkg/web/git.go b/pkg/web/git.go index b01f3aa519efc87d0081aac6e417832f58cd388a..f6b431b86ef8ee3c27e086cb95639af599f727b0 100644 --- a/pkg/web/git.go +++ b/pkg/web/git.go @@ -103,6 +103,24 @@ func withParams(next http.Handler) http.Handler { }) } +// withRepoVars is a lighter middleware for Web UI routes that sets repo vars +// without rewriting the URL path (which would break Web UI route matching). +func withRepoVars(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + vars := mux.Vars(r) + logger.Debug("withRepoVars called", "path", r.URL.Path, "repo_raw", vars["repo"]) + repo := utils.SanitizeRepo(vars["repo"]) + vars["repo"] = repo + vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git") + r = mux.SetURLVars(r, vars) + logger.Debug("withRepoVars set", "repo", repo, "dir", vars["dir"]) + next.ServeHTTP(w, r) + }) +} + // GitController is a router for git services. func GitController(_ context.Context, r *mux.Router) { basePrefix := "/{repo:.*}" @@ -112,8 +130,10 @@ func GitController(_ context.Context, r *mux.Router) { r.Handle(basePrefix+route.path, withParams(withAccess(route))) } - // Handle go-get - r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet) + // Handle go-get (only when explicitly requested with ?go-get=1) + r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))). + Methods(http.MethodGet). + Queries("go-get", "1") } var gitRoutes = []GitRoute{ @@ -208,7 +228,8 @@ func withAccess(next http.Handler) http.HandlerFunc { // We're not checking for errors here because we want to allow // repo creation on the fly. repoName := mux.Vars(r)["repo"] - repo, _ := be.Repository(ctx, repoName) + logger.Debug("withAccess loading repo", "repoName", repoName) + repo, err := be.Repository(ctx, repoName) ctx = proto.WithRepositoryContext(ctx, repo) r = r.WithContext(ctx) @@ -453,7 +474,7 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) { } // Handle buffered output -// Useful when using proxies +// Useful when using proxies. type flushResponseWriter struct { http.ResponseWriter } @@ -468,7 +489,7 @@ func (f *flushResponseWriter) ReadFrom(r io.Reader) (int64, error) { if err == io.EOF { break } - nWrite, err := f.ResponseWriter.Write(p[:nRead]) + nWrite, err := f.Write(p[:nRead]) if err != nil { return n, err } diff --git a/pkg/web/git_lfs.go b/pkg/web/git_lfs.go index 9149d4f8ee7a56290b33daf53cec104f4e94c6ab..ae12994d5f2c14199aaf7be895ca1e87a6baffa1 100644 --- a/pkg/web/git_lfs.go +++ b/pkg/web/git_lfs.go @@ -29,7 +29,7 @@ import ( // serviceLfsBatch handles a Git LFS batch requests. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md // TODO: support refname -// POST: /.git/info/lfs/objects/batch +// POST: /.git/info/lfs/objects/batch. func serviceLfsBatch(w http.ResponseWriter, r *http.Request) { ctx := r.Context() logger := log.FromContext(ctx).WithPrefix("http.lfs") @@ -41,7 +41,7 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) { } var batchRequest lfs.BatchRequest - defer r.Body.Close() // nolint: errcheck + defer r.Body.Close() //nolint: errcheck if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil { logger.Errorf("error decoding json: %s", err) renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ @@ -249,7 +249,7 @@ func serviceLfsBasic(w http.ResponseWriter, r *http.Request) { } } -// GET: /.git/info/lfs/objects/basic/ +// GET: /.git/info/lfs/objects/basic/. func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) { ctx := r.Context() oid := mux.Vars(r)["oid"] @@ -282,7 +282,7 @@ func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10)) - defer f.Close() // nolint: errcheck + defer f.Close() //nolint: errcheck if _, err := io.Copy(w, f); err != nil { logger.Error("error copying object to response", "oid", oid, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ @@ -292,7 +292,7 @@ func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) { } } -// PUT: /.git/info/lfs/objects/basic/ +// PUT: /.git/info/lfs/objects/basic/. func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) { if !isBinary(r) { renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{ @@ -313,7 +313,7 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) { strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) name := mux.Vars(r)["repo"] - defer r.Body.Close() // nolint: errcheck + defer r.Body.Close() //nolint: errcheck repo, err := be.Repository(ctx, name) if err != nil { renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ @@ -326,7 +326,7 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) { // partial error, so we need to skip existing objects. if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil { // Object exists, skip request - io.Copy(io.Discard, r.Body) // nolint: errcheck + io.Copy(io.Discard, r.Body) //nolint: errcheck renderStatus(http.StatusOK)(w, nil) return } else if !errors.Is(err, db.ErrRecordNotFound) { @@ -366,7 +366,7 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) { renderStatus(http.StatusOK)(w, nil) } -// POST: /.git/info/lfs/objects/basic/verify +// POST: /.git/info/lfs/objects/basic/verify. func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { renderNotAcceptable(w) @@ -385,7 +385,7 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) { return } - defer r.Body.Close() // nolint: errcheck + defer r.Body.Close() //nolint: errcheck if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil { logger.Error("error decoding json", "err", err) renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ @@ -454,7 +454,7 @@ func serviceLfsLocks(w http.ResponseWriter, r *http.Request) { } } -// POST: /.git/info/lfs/objects/locks +// POST: /.git/info/lfs/objects/locks. func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { renderNotAcceptable(w) @@ -555,7 +555,7 @@ func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) { }) } -// GET: /.git/info/lfs/objects/locks +// GET: /.git/info/lfs/objects/locks. func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept") if !strings.HasPrefix(accept, lfs.MediaType) { @@ -578,7 +578,7 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) { limit, _ = strconv.Atoi(limitStr) } refspec = values.Get("refspec") - return + return path, id, cursor, limit, refspec } ctx := r.Context() @@ -730,7 +730,7 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) { renderJSON(w, http.StatusOK, resp) } -// POST: /.git/info/lfs/objects/locks/verify +// POST: /.git/info/lfs/objects/locks/verify. func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { renderNotAcceptable(w) @@ -827,7 +827,7 @@ func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) { renderJSON(w, http.StatusOK, resp) } -// POST: /.git/info/lfs/objects/locks/:lockID/unlock +// POST: /.git/info/lfs/objects/locks/:lockID/unlock. func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { renderNotAcceptable(w) diff --git a/pkg/web/goget.go b/pkg/web/goget.go index b6678ff9184dd323a232a02d0ee41eb37feffa7e..e81508e8b61de6e5a4cc2e86da4f3e3d93d5459b 100644 --- a/pkg/web/goget.go +++ b/pkg/web/goget.go @@ -44,6 +44,7 @@ func GoGetHandler(w http.ResponseWriter, r *http.Request) { repo := mux.Vars(r)["repo"] // Handle go get requests. + // This handler is only reached when ?go-get=1 is present due to route matcher. // // Always return a 200 status code, even if the repo path doesn't exist. // It will try to find the repo by walking up the path until it finds one. @@ -51,45 +52,40 @@ func GoGetHandler(w http.ResponseWriter, r *http.Request) { // // https://golang.org/cmd/go/#hdr-Remote_import_paths // https://go.dev/ref/mod#vcs-branch - if r.URL.Query().Get("go-get") == "1" { - repo := repo - importRoot, err := url.Parse(cfg.HTTP.PublicURL) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - // find the repo - for { - if _, err := be.Repository(ctx, repo); err == nil { - break - } - - if repo == "" || repo == "." || repo == "/" { - renderNotFound(w, r) - return - } + importRoot, err := url.Parse(cfg.HTTP.PublicURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - repo = path.Dir(repo) + // find the repo + for { + if _, err := be.Repository(ctx, repo); err == nil { + break } - if err := repoIndexHTMLTpl.Execute(w, struct { - Repo string - Config *config.Config - ImportRoot string - }{ - Repo: utils.SanitizeRepo(repo), - Config: cfg, - ImportRoot: importRoot.Host, - }); err != nil { - logger.Error("failed to render go get template", "err", err) - renderInternalServerError(w, r) + if repo == "" || repo == "." || repo == "/" { + renderNotFound(w, r) return } - goGetCounter.WithLabelValues(repo).Inc() + repo = path.Dir(repo) + } + + if err := repoIndexHTMLTpl.Execute(w, struct { + Repo string + Config *config.Config + ImportRoot string + }{ + Repo: utils.SanitizeRepo(repo), + Config: cfg, + ImportRoot: importRoot.Host, + }); err != nil { + logger.Debug("failed to render go get template", "err", err) + renderInternalServerError(w, r) return } - renderNotFound(w, r) + goGetCounter.WithLabelValues(repo).Inc() } diff --git a/pkg/web/logging.go b/pkg/web/logging.go index b9a264f55988d5b6697bae25e38afba276033ca9..612eb4ccf98ec2c14e12946f11f26a22e977574f 100644 --- a/pkg/web/logging.go +++ b/pkg/web/logging.go @@ -24,7 +24,7 @@ var _ http.Flusher = (*logWriter)(nil) var _ http.Hijacker = (*logWriter)(nil) -var _ http.CloseNotifier = (*logWriter)(nil) // nolint: staticcheck +var _ http.CloseNotifier = (*logWriter)(nil) //nolint: staticcheck // Write implements http.ResponseWriter. func (r *logWriter) Write(p []byte) (int, error) { @@ -54,7 +54,7 @@ func (r *logWriter) Flush() { // CloseNotify implements http.CloseNotifier. func (r *logWriter) CloseNotify() <-chan bool { - if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok { // nolint: staticcheck + if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok { //nolint: staticcheck return cn.CloseNotify() } return nil diff --git a/pkg/web/server.go b/pkg/web/server.go index e9d5fb5f3969f8b2722ff7cbb06108c2c7f8106a..5b6d51e2a80579adc42e73cb344f7a39d72bed94 100644 --- a/pkg/web/server.go +++ b/pkg/web/server.go @@ -20,6 +20,10 @@ func NewRouter(ctx context.Context) http.Handler { // Git routes GitController(ctx, router) + // Web UI routes (must be after Git routes) + WebUIController(ctx, router) + + // Catch-all route (must be last) router.PathPrefix("/").HandlerFunc(renderNotFound) // Context handler diff --git a/pkg/web/static/syntax.css b/pkg/web/static/syntax.css new file mode 100644 index 0000000000000000000000000000000000000000..813d04b6161597cb853344cb08d15be6672dd27e --- /dev/null +++ b/pkg/web/static/syntax.css @@ -0,0 +1,157 @@ +/* Auto-generated syntax highlighting CSS */ +/* Generated by go generate - do not edit manually */ + +@media (prefers-color-scheme: light) { +/* Background */ .bg { background-color: #ffffff; } +/* PreWrapper */ .chroma { background-color: #ffffff; } +/* Error */ .chroma .err { color: #f6f8fa; background-color: #82071e } +/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } +/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } +/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } +/* LineHighlight */ .chroma .hl { background-color: #e5e5e5 } +/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } +/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } +/* Line */ .chroma .line { display: flex; } +/* Keyword */ .chroma .k { color: #cf222e } +/* KeywordConstant */ .chroma .kc { color: #cf222e } +/* KeywordDeclaration */ .chroma .kd { color: #cf222e } +/* KeywordNamespace */ .chroma .kn { color: #cf222e } +/* KeywordPseudo */ .chroma .kp { color: #cf222e } +/* KeywordReserved */ .chroma .kr { color: #cf222e } +/* KeywordType */ .chroma .kt { color: #cf222e } +/* NameAttribute */ .chroma .na { color: #1f2328 } +/* NameClass */ .chroma .nc { color: #1f2328 } +/* NameConstant */ .chroma .no { color: #0550ae } +/* NameDecorator */ .chroma .nd { color: #0550ae } +/* NameEntity */ .chroma .ni { color: #6639ba } +/* NameLabel */ .chroma .nl { color: #990000; font-weight: bold } +/* NameNamespace */ .chroma .nn { color: #24292e } +/* NameOther */ .chroma .nx { color: #1f2328 } +/* NameTag */ .chroma .nt { color: #0550ae } +/* NameBuiltin */ .chroma .nb { color: #6639ba } +/* NameBuiltinPseudo */ .chroma .bp { color: #6a737d } +/* NameVariable */ .chroma .nv { color: #953800 } +/* NameVariableClass */ .chroma .vc { color: #953800 } +/* NameVariableGlobal */ .chroma .vg { color: #953800 } +/* NameVariableInstance */ .chroma .vi { color: #953800 } +/* NameVariableMagic */ .chroma .vm { color: #953800 } +/* NameFunction */ .chroma .nf { color: #6639ba } +/* NameFunctionMagic */ .chroma .fm { color: #6639ba } +/* LiteralString */ .chroma .s { color: #0a3069 } +/* LiteralStringAffix */ .chroma .sa { color: #0a3069 } +/* LiteralStringBacktick */ .chroma .sb { color: #0a3069 } +/* LiteralStringChar */ .chroma .sc { color: #0a3069 } +/* LiteralStringDelimiter */ .chroma .dl { color: #0a3069 } +/* LiteralStringDoc */ .chroma .sd { color: #0a3069 } +/* LiteralStringDouble */ .chroma .s2 { color: #0a3069 } +/* LiteralStringEscape */ .chroma .se { color: #0a3069 } +/* LiteralStringHeredoc */ .chroma .sh { color: #0a3069 } +/* LiteralStringInterpol */ .chroma .si { color: #0a3069 } +/* LiteralStringOther */ .chroma .sx { color: #0a3069 } +/* LiteralStringRegex */ .chroma .sr { color: #0a3069 } +/* LiteralStringSingle */ .chroma .s1 { color: #0a3069 } +/* LiteralStringSymbol */ .chroma .ss { color: #032f62 } +/* LiteralNumber */ .chroma .m { color: #0550ae } +/* LiteralNumberBin */ .chroma .mb { color: #0550ae } +/* LiteralNumberFloat */ .chroma .mf { color: #0550ae } +/* LiteralNumberHex */ .chroma .mh { color: #0550ae } +/* LiteralNumberInteger */ .chroma .mi { color: #0550ae } +/* LiteralNumberIntegerLong */ .chroma .il { color: #0550ae } +/* LiteralNumberOct */ .chroma .mo { color: #0550ae } +/* Operator */ .chroma .o { color: #0550ae } +/* OperatorWord */ .chroma .ow { color: #0550ae } +/* Punctuation */ .chroma .p { color: #1f2328 } +/* Comment */ .chroma .c { color: #57606a } +/* CommentHashbang */ .chroma .ch { color: #57606a } +/* CommentMultiline */ .chroma .cm { color: #57606a } +/* CommentSingle */ .chroma .c1 { color: #57606a } +/* CommentSpecial */ .chroma .cs { color: #57606a } +/* CommentPreproc */ .chroma .cp { color: #57606a } +/* CommentPreprocFile */ .chroma .cpf { color: #57606a } +/* GenericDeleted */ .chroma .gd { color: #82071e; background-color: #ffebe9 } +/* GenericEmph */ .chroma .ge { color: #1f2328 } +/* GenericInserted */ .chroma .gi { color: #116329; background-color: #dafbe1 } +/* GenericOutput */ .chroma .go { color: #1f2328 } +/* GenericUnderline */ .chroma .gl { text-decoration: underline } +/* TextWhitespace */ .chroma .w { color: #ffffff } +} + +@media (prefers-color-scheme: dark) { +/* Background */ .bg { color: #e6edf3; background-color: #0d1117; } +/* PreWrapper */ .chroma { color: #e6edf3; background-color: #0d1117; } +/* Error */ .chroma .err { color: #f85149 } +/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } +/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } +/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } +/* LineHighlight */ .chroma .hl { background-color: #6e7681 } +/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #737679 } +/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #6e7681 } +/* Line */ .chroma .line { display: flex; } +/* Keyword */ .chroma .k { color: #ff7b72 } +/* KeywordConstant */ .chroma .kc { color: #79c0ff } +/* KeywordDeclaration */ .chroma .kd { color: #ff7b72 } +/* KeywordNamespace */ .chroma .kn { color: #ff7b72 } +/* KeywordPseudo */ .chroma .kp { color: #79c0ff } +/* KeywordReserved */ .chroma .kr { color: #ff7b72 } +/* KeywordType */ .chroma .kt { color: #ff7b72 } +/* NameClass */ .chroma .nc { color: #f0883e; font-weight: bold } +/* NameConstant */ .chroma .no { color: #79c0ff; font-weight: bold } +/* NameDecorator */ .chroma .nd { color: #d2a8ff; font-weight: bold } +/* NameEntity */ .chroma .ni { color: #ffa657 } +/* NameException */ .chroma .ne { color: #f0883e; font-weight: bold } +/* NameLabel */ .chroma .nl { color: #79c0ff; font-weight: bold } +/* NameNamespace */ .chroma .nn { color: #ff7b72 } +/* NameProperty */ .chroma .py { color: #79c0ff } +/* NameTag */ .chroma .nt { color: #7ee787 } +/* NameVariable */ .chroma .nv { color: #79c0ff } +/* NameVariableClass */ .chroma .vc { color: #79c0ff } +/* NameVariableGlobal */ .chroma .vg { color: #79c0ff } +/* NameVariableInstance */ .chroma .vi { color: #79c0ff } +/* NameVariableMagic */ .chroma .vm { color: #79c0ff } +/* NameFunction */ .chroma .nf { color: #d2a8ff; font-weight: bold } +/* NameFunctionMagic */ .chroma .fm { color: #d2a8ff; font-weight: bold } +/* Literal */ .chroma .l { color: #a5d6ff } +/* LiteralDate */ .chroma .ld { color: #79c0ff } +/* LiteralString */ .chroma .s { color: #a5d6ff } +/* LiteralStringAffix */ .chroma .sa { color: #79c0ff } +/* LiteralStringBacktick */ .chroma .sb { color: #a5d6ff } +/* LiteralStringChar */ .chroma .sc { color: #a5d6ff } +/* LiteralStringDelimiter */ .chroma .dl { color: #79c0ff } +/* LiteralStringDoc */ .chroma .sd { color: #a5d6ff } +/* LiteralStringDouble */ .chroma .s2 { color: #a5d6ff } +/* LiteralStringEscape */ .chroma .se { color: #79c0ff } +/* LiteralStringHeredoc */ .chroma .sh { color: #79c0ff } +/* LiteralStringInterpol */ .chroma .si { color: #a5d6ff } +/* LiteralStringOther */ .chroma .sx { color: #a5d6ff } +/* LiteralStringRegex */ .chroma .sr { color: #79c0ff } +/* LiteralStringSingle */ .chroma .s1 { color: #a5d6ff } +/* LiteralStringSymbol */ .chroma .ss { color: #a5d6ff } +/* LiteralNumber */ .chroma .m { color: #a5d6ff } +/* LiteralNumberBin */ .chroma .mb { color: #a5d6ff } +/* LiteralNumberFloat */ .chroma .mf { color: #a5d6ff } +/* LiteralNumberHex */ .chroma .mh { color: #a5d6ff } +/* LiteralNumberInteger */ .chroma .mi { color: #a5d6ff } +/* LiteralNumberIntegerLong */ .chroma .il { color: #a5d6ff } +/* LiteralNumberOct */ .chroma .mo { color: #a5d6ff } +/* Operator */ .chroma .o { color: #ff7b72; font-weight: bold } +/* OperatorWord */ .chroma .ow { color: #ff7b72; font-weight: bold } +/* Comment */ .chroma .c { color: #8b949e; font-style: italic } +/* CommentHashbang */ .chroma .ch { color: #8b949e; font-style: italic } +/* CommentMultiline */ .chroma .cm { color: #8b949e; font-style: italic } +/* CommentSingle */ .chroma .c1 { color: #8b949e; font-style: italic } +/* CommentSpecial */ .chroma .cs { color: #8b949e; font-weight: bold; font-style: italic } +/* CommentPreproc */ .chroma .cp { color: #8b949e; font-weight: bold; font-style: italic } +/* CommentPreprocFile */ .chroma .cpf { color: #8b949e; font-weight: bold; font-style: italic } +/* GenericDeleted */ .chroma .gd { color: #ffa198; background-color: #490202 } +/* GenericEmph */ .chroma .ge { font-style: italic } +/* GenericError */ .chroma .gr { color: #ffa198 } +/* GenericHeading */ .chroma .gh { color: #79c0ff; font-weight: bold } +/* GenericInserted */ .chroma .gi { color: #56d364; background-color: #0f5323 } +/* GenericOutput */ .chroma .go { color: #8b949e } +/* GenericPrompt */ .chroma .gp { color: #8b949e } +/* GenericStrong */ .chroma .gs { font-weight: bold } +/* GenericSubheading */ .chroma .gu { color: #79c0ff } +/* GenericTraceback */ .chroma .gt { color: #ff7b72 } +/* GenericUnderline */ .chroma .gl { text-decoration: underline } +/* TextWhitespace */ .chroma .w { color: #6e7681 } +} diff --git a/pkg/web/templates/about.html b/pkg/web/templates/about.html new file mode 100644 index 0000000000000000000000000000000000000000..23445a439371fc3f69b26ace76b4580d53748d8b --- /dev/null +++ b/pkg/web/templates/about.html @@ -0,0 +1,12 @@ +{{define "content"}} +
+

About

+ + {{if .ReadmeHTML}} + {{.ReadmeHTML}} + {{else}} +

No readme found.

+

Create a .soft-serve repository and add a README.md file to display information here.

+ {{end}} +
+{{end}} diff --git a/pkg/web/templates/base.html b/pkg/web/templates/base.html new file mode 100644 index 0000000000000000000000000000000000000000..6c51b0dd3028e4f3ccdde9a006ed3caff876c32d --- /dev/null +++ b/pkg/web/templates/base.html @@ -0,0 +1,61 @@ +{{define "layout"}} + + + + + + + {{if .Repo}}{{if .Repo.ProjectName}}{{.Repo.ProjectName}}{{else}}{{.Repo.Name}}{{end}}{{else}}{{.ServerName}}{{end}} + {{if .Repo}}{{if .Repo.Description}}{{end}}{{end}} + + + + + + + +
+ + {{if .Repo}} + + {{end}} +
+ +
+ {{template "content" .}} +
+ + + + +{{end}} diff --git a/pkg/web/templates/blob.html b/pkg/web/templates/blob.html new file mode 100644 index 0000000000000000000000000000000000000000..c013d91ec71b25e8c669534c53a8b31299170ebf --- /dev/null +++ b/pkg/web/templates/blob.html @@ -0,0 +1,55 @@ +{{define "content"}} + + +
+ {{$parts := splitPath .Path}} +

{{index $parts (dec (len $parts))}}

+ + + + {{if .IsBinary}} +

This is a binary file and cannot be displayed as text.

+ {{else if .IsMarkdown}} + {{if .ShowSource}} +
+ {{.RenderedHTML}} +
+ {{else}} +
+ {{.RenderedHTML}} +
+ {{end}} + {{else if .RenderedHTML}} +
+ {{.RenderedHTML}} +
+ {{else}} +
{{.Content}}
+ {{end}} +
+{{end}} diff --git a/pkg/web/templates/branches.html b/pkg/web/templates/branches.html new file mode 100644 index 0000000000000000000000000000000000000000..465dc1aa7f61b5108b21027487eb63d513be15ee --- /dev/null +++ b/pkg/web/templates/branches.html @@ -0,0 +1,62 @@ +{{define "content"}} +
+

Branches ({{.TotalBranches}})

+ {{if .Branches}} + {{range .Branches}} +
+

+ {{.Ref.Name.Short}} + {{if eq .Ref.Name.Short $.DefaultBranch}} + (default) + {{end}} +

+ {{if .Commit}} +

+ {{.Commit.ID | shortHash}} + {{.Commit.Message | commitSubject}} +

+ {{$body := .Commit.Message | commitBody}} + {{if $body}} +
+ Click to expand commit body +
{{$body}}
+
+ {{end}} +

+ by {{.Commit.Author.Name}} on + +

+ {{end}} +
+ Files + Commits +
+
+ {{end}} + + {{if or .HasPrevPage .HasNextPage}} + + {{end}} + {{else}} +

No branches found

+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/pkg/web/templates/commit.html b/pkg/web/templates/commit.html new file mode 100644 index 0000000000000000000000000000000000000000..6bf01433219cfb4a6c0c7e8aeac43b139b4a7507 --- /dev/null +++ b/pkg/web/templates/commit.html @@ -0,0 +1,61 @@ +{{define "content"}} + + +
+

{{.Commit.Message | commitSubject}}

+ {{$body := .Commit.Message | commitBody}} + {{if $body}} +
{{$body}}
+ {{end}} +
+
Author
+
{{.Commit.Author.Name}} <{{.Commit.Author.Email}}>
+ +
Date
+
+ +
Commit
+
{{.Commit.ID}}
+ + {{if .ParentIDs}} +
Parent{{if gt (len .ParentIDs) 1}}s{{end}}
+
+ {{range $i, $parent := .ParentIDs}}{{if $i}}, {{end}}{{$parent | shortHash}}{{end}} +
+ {{end}} +
+ + +
+ +
+

Change summary

+
{{.Diff.Stats}}
+
+ +
+

Detailed changes

+ {{range .Diff.Files}} +
+

+ {{if ne .OldName .Name}} + {{.OldName}} → {{.Name}} + {{else}} + {{.Name}} + {{end}} +

+
{{range .Sections}}{{range .Lines}}{{if eq .Type 2}}{{.Content}}{{else if eq .Type 3}}{{.Content}}{{else}}{{.Content}}{{end}}
+{{end}}{{end}}
+
+ {{end}} +
+{{end}} diff --git a/pkg/web/templates/commits.html b/pkg/web/templates/commits.html new file mode 100644 index 0000000000000000000000000000000000000000..f89958d136d94ae92b9f1ee3a40a5b2ea6bd5a0f --- /dev/null +++ b/pkg/web/templates/commits.html @@ -0,0 +1,58 @@ +{{define "content"}} + + +
+

Commit log

+ + {{if .Commits}} + {{range .Commits}} +
+

+ {{.ID | shortHash}} + {{.Message | commitSubject}} +

+ {{$body := .Message | commitBody}} + {{if $body}} +
+ Click to expand commit body +
{{$body}}
+
+ {{end}} +

+ by {{.Author.Name}} on + +

+
+ {{end}} + + {{if or .HasPrevPage .HasNextPage}} + + {{end}} + {{else}} +

No commits yet

+ {{end}} +
+{{end}} diff --git a/pkg/web/templates/error.html b/pkg/web/templates/error.html new file mode 100644 index 0000000000000000000000000000000000000000..4fda0077a16c6c26cdac4e39876f09e0501a934f --- /dev/null +++ b/pkg/web/templates/error.html @@ -0,0 +1,9 @@ +{{define "content"}} +
+

Error {{.ErrorCode}}

+

{{.ErrorMessage}}

+ +
+{{end}} \ No newline at end of file diff --git a/pkg/web/templates/home.html b/pkg/web/templates/home.html new file mode 100644 index 0000000000000000000000000000000000000000..6d4457644199961c521639f4d49ad1d78baeeb84 --- /dev/null +++ b/pkg/web/templates/home.html @@ -0,0 +1,59 @@ +{{define "content"}} +
+

Repositories

+ + {{if .Repositories}} + {{range .Repositories}} + + {{end}} + + {{if or .HasPrevPage .HasNextPage}} + + {{end}} + {{else}} +

No public repositories available.

+ {{end}} +
+{{end}} diff --git a/pkg/web/templates/overview.html b/pkg/web/templates/overview.html new file mode 100644 index 0000000000000000000000000000000000000000..78cfaa1b5232694411660af49cce60e5787b1ec9 --- /dev/null +++ b/pkg/web/templates/overview.html @@ -0,0 +1,48 @@ +{{define "content"}} +{{if .IsEmpty}} +
+

Empty repository

+

This repository is empty. Initialize it with your first commit:

+
git clone {{.SSHURL}}
+cd {{.Repo.Name}}
+touch README.md
+git add README.md
+git commit -m "Initial commit"
+git push -u origin main
+
+{{else}} +
+

Clone

+ +

git clone {{.SSHURL}}

+ +
+ + + +{{if .ReadmeHTML}} +
+

README

+
+ {{.ReadmeHTML}} +
+
+{{end}} +{{end}} +{{end}} diff --git a/pkg/web/templates/tags.html b/pkg/web/templates/tags.html new file mode 100644 index 0000000000000000000000000000000000000000..63fa9dad598a94fc313df37d724bae6a2ddc078e --- /dev/null +++ b/pkg/web/templates/tags.html @@ -0,0 +1,49 @@ +{{define "content"}} +
+

Tags ({{.TotalTags}})

+ {{if .Tags}} + {{range .Tags}} +
+

{{.Ref.Name.Short}}

+ {{if .TagMessage}} +
{{.TagMessage}}
+ {{end}} + {{if .Commit}} +

+ by {{.Commit.Author.Name}} on + +

+ {{end}} +
+ Files + Commits +
+
+ {{end}} + + {{if or .HasPrevPage .HasNextPage}} + + {{end}} + {{else}} +

No tags found

+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/pkg/web/templates/tree.html b/pkg/web/templates/tree.html new file mode 100644 index 0000000000000000000000000000000000000000..f0fd7e8e6cff352c556b56c837ae696600df00f5 --- /dev/null +++ b/pkg/web/templates/tree.html @@ -0,0 +1,49 @@ +{{define "content"}} + + +
+

File tree

+ {{if .Entries}} + + + + + + + + + {{if ne .Path "."}} + + + + + {{end}} + {{range .Entries}} + + + + + {{end}} + +
NameSize
..-
+ + {{.Name}}{{if .IsTree}}/{{end}} + + {{if .IsTree}}-{{else}}{{.Size | humanizeSize}}{{end}}
+ {{else}} +

Empty directory

+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/pkg/web/util.go b/pkg/web/util.go index 412d0e00ef14b545fc042462b63bf12626ea7cc5..cc2429d633775e7c223c5c11e328c28d0d4647fa 100644 --- a/pkg/web/util.go +++ b/pkg/web/util.go @@ -9,6 +9,6 @@ import ( func renderStatus(code int) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(code) - io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) // nolint: errcheck + io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) //nolint: errcheck } } diff --git a/pkg/web/webui.go b/pkg/web/webui.go new file mode 100644 index 0000000000000000000000000000000000000000..22e2fa4896c4e9f4c47b38285b196873d4c0bdae --- /dev/null +++ b/pkg/web/webui.go @@ -0,0 +1,270 @@ +// Package web implements the HTTP server and web UI for Soft Serve. +package web + +//go:generate go run gen_syntax_css.go + +import ( + "context" + "embed" + "fmt" + "html/template" + "mime" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/log/v2" + "github.com/gorilla/mux" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +const ( + // defaultCommitsPerPage is the number of commits shown per page in commit history view. + defaultCommitsPerPage = 50 + // defaultReposPerPage is the number of repositories shown per page on the home page. + defaultReposPerPage = 20 + // defaultTagsPerPage is the number of tags shown per page on the tags page. + defaultTagsPerPage = 20 + // defaultBranchesPerPage is the number of branches shown per page on the branches page. + defaultBranchesPerPage = 20 +) + +// templateFuncs defines template helper functions available in HTML templates. +// Functions include: splitPath (split path into components), joinPath (join path components), +// parentPath (get parent directory), shortHash (truncate commit hash), formatDate (format timestamp), +// and humanizeSize (format file size in human-readable format). +var templateFuncs = template.FuncMap{ + "splitPath": func(path string) []string { + if path == "." { + return []string{} + } + return strings.Split(path, "/") + }, + "joinPath": func(index int, data interface{}) string { + var path string + + switch v := data.(type) { + case BlobData: + path = v.Path + case TreeData: + path = v.Path + default: + return "" + } + + parts := strings.Split(path, "/") + if index >= len(parts) { + return path + } + return strings.Join(parts[:index+1], "/") + }, + "parentPath": func(path string) string { + if path == "." || !strings.Contains(path, "/") { + return "." + } + return filepath.Dir(path) + }, + "shortHash": func(hash interface{}) string { + var hashStr string + switch v := hash.(type) { + case string: + hashStr = v + case fmt.Stringer: + hashStr = v.String() + default: + hashStr = fmt.Sprintf("%v", hash) + } + if len(hashStr) > 8 { + return hashStr[:8] + } + return hashStr + }, + "formatDate": func(t interface{}) string { + if time, ok := t.(fmt.Stringer); ok { + return time.String() + } + return fmt.Sprintf("%v", t) + }, + "humanizeSize": func(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp]) + }, + "inc": func(i int) int { + return i + 1 + }, + "dec": func(i int) int { + return i - 1 + }, + "commitSubject": func(message string) string { + lines := strings.Split(message, "\n") + if len(lines) > 0 { + return lines[0] + } + return message + }, + "commitBody": func(message string) string { + lines := strings.Split(message, "\n") + if len(lines) <= 1 { + return "" + } + // Skip the subject line and join the rest + body := strings.Join(lines[1:], "\n") + return strings.TrimSpace(body) + }, + "rfc3339": func(t interface{}) string { + switch v := t.(type) { + case time.Time: + return v.Format(time.RFC3339) + default: + return "" + } + }, +} + +// renderHTML renders an HTML template with the given data. +func renderHTML(w http.ResponseWriter, templateName string, data interface{}) { + tmpl, err := template.New("").Funcs(templateFuncs).ParseFS(templatesFS, "templates/base.html", "templates/"+templateName) + if err != nil { + log.Debug("failed to parse template", "template", templateName, "err", err) + renderInternalServerError(w, nil) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { + log.Debug("template execution failed", "template", templateName, "err", err) + // Already started writing response, so we can't render an error page + } +} + +// staticFiles handles static file serving. +func staticFiles(w http.ResponseWriter, r *http.Request) { + // Strip /static/ prefix + path := strings.TrimPrefix(r.URL.Path, "/static/") + + data, err := staticFS.ReadFile("static/" + path) + if err != nil { + renderNotFound(w, r) + return + } + + // Set cache headers + w.Header().Set("Cache-Control", "public, max-age=31536000") + + // Detect content type from extension + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + + w.Write(data) +} + +// withWebUIAccess wraps withAccess and hides 401/403 as 404 for Web UI routes. +func withWebUIAccess(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + logger.Debug("withWebUIAccess called", "path", r.URL.Path) + + // Wrap the writer to suppress 401/403 + hrw := &hideAuthWriter{ResponseWriter: w} + // Run access first so repo/user are injected into context + withAccess(next).ServeHTTP(hrw, r) + + if hrw.suppressed { + logger.Debug("suppressed 401/403, rendering 404") + // Remove auth challenge headers so we don't leak private info + w.Header().Del("WWW-Authenticate") + w.Header().Del("LFS-Authenticate") + // Now render 404 once; no double WriteHeader occurs + renderNotFound(w, r) + } + }) +} + +// hideAuthWriter suppresses 401/403 responses to convert them to 404. +type hideAuthWriter struct { + http.ResponseWriter + suppressed bool +} + +func (w *hideAuthWriter) WriteHeader(code int) { + if code == http.StatusUnauthorized || code == http.StatusForbidden { + // Suppress original status/body; we'll render 404 afterwards + w.suppressed = true + return + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *hideAuthWriter) Write(p []byte) (int, error) { + if w.suppressed { + // Drop body of 401/403 + return len(p), nil + } + return w.ResponseWriter.Write(p) +} + +// WebUIController registers HTTP routes for the web-based repository browser. +// It provides HTML views for repository overview, file browsing, commits, and references. +func WebUIController(ctx context.Context, r *mux.Router) { + basePrefix := "/{repo:.*}" + + // Static files (most specific, should be first) + r.PathPrefix("/static/").HandlerFunc(staticFiles).Methods(http.MethodGet) + + // Home page (root path, before other routes) + r.HandleFunc("/", home).Methods(http.MethodGet) + + // About page + r.HandleFunc("/about", about).Methods(http.MethodGet) + + // More specific routes must be registered before catch-all patterns + // Middleware order: withRepoVars (set vars) -> withWebUIAccess (auth + load context) -> handler + // Tree routes - use catch-all pattern and parse ref/path in handler to support refs with slashes + r.Handle(basePrefix+"/tree/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))). + Methods(http.MethodGet) + r.Handle(basePrefix+"/tree", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))). + Methods(http.MethodGet) + + // Blob routes - use catch-all pattern and parse ref/path in handler to support refs with slashes + r.Handle(basePrefix+"/blob/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBlob)))). + Methods(http.MethodGet) + + // Commits routes - use catch-all pattern to support refs with slashes + r.Handle(basePrefix+"/commits/{ref:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))). + Methods(http.MethodGet) + r.Handle(basePrefix+"/commits", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))). + Methods(http.MethodGet) + + // Commit route + r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommit)))). + Methods(http.MethodGet) + + // Branches route + r.Handle(basePrefix+"/branches", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBranches)))). + Methods(http.MethodGet) + + // Tags route + r.Handle(basePrefix+"/tags", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTags)))). + Methods(http.MethodGet) + + // Repository overview (catch-all, must be last) + r.Handle(basePrefix, withRepoVars(withWebUIAccess(http.HandlerFunc(repoOverview)))). + Methods(http.MethodGet) +} diff --git a/pkg/web/webui_about.go b/pkg/web/webui_about.go new file mode 100644 index 0000000000000000000000000000000000000000..3e9c3c4fcf7e3f71697c955ee18f1b77b5c3cfcc --- /dev/null +++ b/pkg/web/webui_about.go @@ -0,0 +1,44 @@ +package web + +import ( + "html/template" + "net/http" + + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" +) + +type AboutData struct { + ReadmeHTML template.HTML + ActiveTab string + ServerName string +} + +func about(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + be := backend.FromContext(ctx) + + if be == nil { + logger.Debug("backend not found in context") + renderInternalServerError(w, r) + return + } + + readmeHTML, err := getServerReadme(ctx, be) + if err != nil { + logger.Debug("failed to get server README", "err", err) + renderInternalServerError(w, r) + return + } + + data := AboutData{ + ReadmeHTML: readmeHTML, + ActiveTab: "about", + ServerName: cfg.Name, + } + + renderHTML(w, "about.html", data) +} diff --git a/pkg/web/webui_blob.go b/pkg/web/webui_blob.go new file mode 100644 index 0000000000000000000000000000000000000000..3c45469cc85ff0914228aac6064ecb6062e00bfd --- /dev/null +++ b/pkg/web/webui_blob.go @@ -0,0 +1,228 @@ +package web + +import ( + "bytes" + "html/template" + "net/http" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/gorilla/mux" +) + +// BlobData contains data for rendering file content view. +type BlobData struct { + Repo proto.Repository + DefaultBranch string + Ref string + Path string + Content string + RenderedHTML template.HTML + IsBinary bool + IsMarkdown bool + ShowSource bool + ActiveTab string + ServerName string +} + +// repoBlob handles file content view. +func repoBlob(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + + if r.URL.Query().Get("raw") == "1" { + repoBlobRaw(w, r) + return + } + + gr, err := openRepository(repo) + if err != nil { + logger.Debug("failed to open repository", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + vars := mux.Vars(r) + refAndPath := vars["refAndPath"] + ref, path := parseRefAndPath(gr, refAndPath) + + refObj, err := resolveAndBuildRef(gr, ref) + if err != nil { + logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err) + renderNotFound(w, r) + return + } + + tree, err := gr.Tree(refObj) + if err != nil { + logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err) + renderNotFound(w, r) + return + } + + entry, err := tree.TreeEntry(path) + if err != nil { + logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err) + renderNotFound(w, r) + return + } + + if entry.IsTree() { + renderNotFound(w, r) + return + } + + content, err := entry.Contents() + if err != nil { + logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err) + renderInternalServerError(w, r) + return + } + + defaultBranch := getDefaultBranch(gr) + + isMarkdown := isMarkdownFile(path) + showSource := r.URL.Query().Get("source") == "1" + var renderedHTML template.HTML + + if isMarkdown && !isBinaryContent(content) && !showSource { + renderedHTML, _ = renderMarkdown(content) + } else if !isBinaryContent(content) { + renderedHTML = highlightCode(path, content) + } + + data := BlobData{ + Repo: repo, + DefaultBranch: defaultBranch, + Ref: ref, + Path: path, + Content: string(content), + RenderedHTML: renderedHTML, + IsBinary: isBinaryContent(content), + IsMarkdown: isMarkdown, + ShowSource: showSource, + ActiveTab: "tree", + ServerName: cfg.Name, + } + + renderHTML(w, "blob.html", data) +} + +// repoBlobRaw handles raw file download. +func repoBlobRaw(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + + gr, err := openRepository(repo) + if err != nil { + logger.Debug("failed to open repository", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + vars := mux.Vars(r) + refAndPath := vars["refAndPath"] + ref, path := parseRefAndPath(gr, refAndPath) + + refObj, err := resolveAndBuildRef(gr, ref) + if err != nil { + logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err) + renderNotFound(w, r) + return + } + + tree, err := gr.Tree(refObj) + if err != nil { + logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err) + renderNotFound(w, r) + return + } + + entry, err := tree.TreeEntry(path) + if err != nil { + logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err) + renderNotFound(w, r) + return + } + + if entry.IsTree() { + renderNotFound(w, r) + return + } + + content, err := entry.Contents() + if err != nil { + logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err) + renderInternalServerError(w, r) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write(content) +} + +// isBinaryContent detects if file content is binary using a simple heuristic. +// Returns true if content contains null bytes in the first 8KB. +func isBinaryContent(content []byte) bool { + // Check first 8KB for null bytes (Git's detection method) + size := len(content) + if size > 8000 { + size = 8000 + } + for i := 0; i < size; i++ { + if content[i] == 0 { + return true + } + } + return false +} + +// isMarkdownFile checks if a file has a markdown extension. +func isMarkdownFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".md" || ext == ".markdown" +} + +// highlightCode applies syntax highlighting to code and returns HTML. +func highlightCode(path string, content []byte) template.HTML { + lexer := lexers.Match(path) + if lexer == nil { + lexer = lexers.Analyse(string(content)) + } + if lexer == nil { + lexer = lexers.Fallback + } + + style := styles.Get("github") + if style == nil { + style = styles.Fallback + } + + formatter := html.New( + html.WithClasses(true), + html.WithLineNumbers(true), + html.WithLinkableLineNumbers(true, ""), + ) + + iterator, err := lexer.Tokenise(nil, string(content)) + if err != nil { + return template.HTML("
" + template.HTMLEscapeString(string(content)) + "
") + } + + var buf bytes.Buffer + err = formatter.Format(&buf, style, iterator) + if err != nil { + return template.HTML("
" + template.HTMLEscapeString(string(content)) + "
") + } + + return template.HTML(buf.String()) +} diff --git a/pkg/web/webui_branches.go b/pkg/web/webui_branches.go new file mode 100644 index 0000000000000000000000000000000000000000..0f689949467c24e91e003313a29b4dce02401904 --- /dev/null +++ b/pkg/web/webui_branches.go @@ -0,0 +1,111 @@ +package web + +import ( + "math" + "net/http" + "strconv" + + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/proto" +) + +// BranchInfo contains branch reference and its latest commit. +type BranchInfo struct { + Ref *git.Reference + Commit *git.Commit +} + +// BranchesData contains data for rendering branches listing. +type BranchesData struct { + Repo proto.Repository + DefaultBranch string + Branches []BranchInfo + ActiveTab string + Page int + TotalPages int + TotalBranches int + HasPrevPage bool + HasNextPage bool + ServerName string +} + +// repoBranches handles branches listing page. +func repoBranches(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + + gr, err := openRepository(repo) + if err != nil { + logger.Debug("failed to open repository", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + defaultBranch := getDefaultBranch(gr) + + page := 1 + if pageStr := r.URL.Query().Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + // First fetch to get total count and calculate pages + paginatedRefItems, totalBranches, err := FetchRefsPaginated(gr, RefTypeBranch, 0, 1, defaultBranch) + if err != nil { + logger.Debug("failed to fetch branches", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + totalPages := int(math.Ceil(float64(totalBranches) / float64(defaultBranchesPerPage))) + if totalPages < 1 { + totalPages = 1 + } + + // Clamp page before computing offset + if page > totalPages { + page = totalPages + } + if page < 1 { + page = 1 + } + + // Calculate offset for pagination + offset := (page - 1) * defaultBranchesPerPage + + // Fetch only the branches we need for this page, pre-sorted + paginatedRefItems, totalBranches, err = FetchRefsPaginated(gr, RefTypeBranch, offset, defaultBranchesPerPage, defaultBranch) + if err != nil { + logger.Debug("failed to fetch branches", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + var paginatedBranches []BranchInfo + for _, refItem := range paginatedRefItems { + paginatedBranches = append(paginatedBranches, BranchInfo{ + Ref: refItem.Reference, + Commit: refItem.Commit, + }) + } + + data := BranchesData{ + Repo: repo, + DefaultBranch: defaultBranch, + Branches: paginatedBranches, + ActiveTab: "branches", + Page: page, + TotalPages: totalPages, + TotalBranches: totalBranches, + HasPrevPage: page > 1, + HasNextPage: page < totalPages, + ServerName: cfg.Name, + } + + renderHTML(w, "branches.html", data) +} diff --git a/pkg/web/webui_commit.go b/pkg/web/webui_commit.go new file mode 100644 index 0000000000000000000000000000000000000000..407ea0712abe491128ad0cd7df85c4bcb0032525 --- /dev/null +++ b/pkg/web/webui_commit.go @@ -0,0 +1,74 @@ +package web + +import ( + "net/http" + + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/gorilla/mux" +) + +// CommitData contains data for rendering individual commit view. +type CommitData struct { + Repo proto.Repository + DefaultBranch string + Commit *git.Commit + Diff *git.Diff + ParentIDs []string + ActiveTab string + ServerName string +} + +func repoCommit(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + vars := mux.Vars(r) + hash := vars["hash"] + + gr, err := openRepository(repo) + if err != nil { + logger.Debug("failed to open repository", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + commit, err := gr.CatFileCommit(hash) + if err != nil { + logger.Debug("failed to get commit", "repo", repo.Name(), "hash", hash, "err", err) + renderNotFound(w, r) + return + } + + diff, err := gr.Diff(commit) + if err != nil { + logger.Debug("failed to get diff", "repo", repo.Name(), "hash", hash, "err", err) + renderInternalServerError(w, r) + return + } + + defaultBranch := getDefaultBranch(gr) + + parentIDs := make([]string, commit.ParentsCount()) + for i := 0; i < commit.ParentsCount(); i++ { + parentID, err := commit.ParentID(i) + if err == nil && parentID != nil { + parentIDs[i] = parentID.String() + } + } + + data := CommitData{ + Repo: repo, + DefaultBranch: defaultBranch, + Commit: commit, + Diff: diff, + ParentIDs: parentIDs, + ActiveTab: "commits", + ServerName: cfg.Name, + } + + renderHTML(w, "commit.html", data) +} diff --git a/pkg/web/webui_commits.go b/pkg/web/webui_commits.go new file mode 100644 index 0000000000000000000000000000000000000000..082445bee01d0c8323a635f641f130b6fd0b0c02 --- /dev/null +++ b/pkg/web/webui_commits.go @@ -0,0 +1,105 @@ +package web + +import ( + "math" + "net/http" + "strconv" + + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/gorilla/mux" +) + +// CommitsData contains data for rendering commit history. +type CommitsData struct { + Repo proto.Repository + DefaultBranch string + Ref string + Commits git.Commits + ActiveTab string + Page int + TotalPages int + HasPrevPage bool + HasNextPage bool + ServerName string +} + +// repoCommits handles commit history view. +func repoCommits(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + if repo == nil { + renderNotFound(w, r) + return + } + vars := mux.Vars(r) + ref := vars["ref"] + + gr, err := openRepository(repo) + if err != nil { + logger.Debug("failed to open repository", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + refObj, err := resolveAndBuildRef(gr, ref) + if err != nil { + logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err) + renderNotFound(w, r) + return + } + + page := 1 + if pageStr := r.URL.Query().Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + totalCommits, err := gr.CountCommits(refObj) + if err != nil { + logger.Debug("failed to count commits", "repo", repo.Name(), "ref", ref, "err", err) + renderNotFound(w, r) + return + } + + totalPages := int(math.Ceil(float64(totalCommits) / float64(defaultCommitsPerPage))) + if totalPages < 1 { + totalPages = 1 + } + + if page > totalPages { + page = totalPages + } + if page < 1 { + page = 1 + } + + commits, err := gr.CommitsByPage(refObj, page, defaultCommitsPerPage) + if err != nil { + logger.Debug("failed to get commits", "repo", repo.Name(), "ref", ref, "err", err) + renderNotFound(w, r) + return + } + + defaultBranch := getDefaultBranch(gr) + + data := CommitsData{ + Repo: repo, + DefaultBranch: defaultBranch, + Ref: ref, + Commits: commits, + ActiveTab: "commits", + Page: page, + TotalPages: totalPages, + HasPrevPage: page > 1, + HasNextPage: page < totalPages, + ServerName: cfg.Name, + } + + renderHTML(w, "commits.html", data) +} diff --git a/pkg/web/webui_git.go b/pkg/web/webui_git.go new file mode 100644 index 0000000000000000000000000000000000000000..a48d8f174ce1716e9f4ff994bb9a31de41c57f37 --- /dev/null +++ b/pkg/web/webui_git.go @@ -0,0 +1,280 @@ +package web + +import ( + "fmt" + "strings" + + gitmodule "github.com/aymanbagabas/git-module" + "github.com/charmbracelet/soft-serve/git" +) + +// RefType specifies the type of git reference to fetch. +type RefType string + +const ( + RefTypeBranch RefType = "branch" + RefTypeTag RefType = "tag" +) + +// RefItem represents a git reference with its associated metadata. +type RefItem struct { + Reference *git.Reference + Tag *git.Tag + Commit *git.Commit +} + +// resolveRefOrHash resolves a ref name or commit hash to a commit hash. +// Returns the hash and whether it was resolved as a ref (true) or commit hash (false). +func resolveRefOrHash(gr *git.Repository, refOrHash string) (hash string, isRef bool, err error) { + if refOrHash == "" { + return "", false, fmt.Errorf("empty ref or hash") + } + + normalizedRef := refOrHash + if !strings.HasPrefix(refOrHash, "refs/") { + if gr.HasTag(refOrHash) { + normalizedRef = "refs/tags/" + refOrHash + } else { + normalizedRef = "refs/heads/" + refOrHash + } + } + + hash, err = gr.ShowRefVerify(normalizedRef) + if err == nil { + return hash, true, nil + } + + if _, err := gr.CatFileCommit(refOrHash); err == nil { + return refOrHash, false, nil + } + + return "", false, fmt.Errorf("failed to resolve %s as ref or commit", refOrHash) +} + +// parseRefAndPath splits a combined ref+path string into separate ref and path components. +// It tries progressively longer prefixes as the ref name, checking if each is a valid ref or commit. +// This allows branch names with forward slashes (e.g., "feature/branch-name") to work correctly. +// Returns the ref (short name) and path. If no valid ref is found, returns the whole string as ref. +func parseRefAndPath(gr *git.Repository, refAndPath string) (ref string, path string) { + if refAndPath == "" { + return "", "." + } + + parts := strings.Split(refAndPath, "/") + + for i := len(parts); i > 0; i-- { + potentialRef := strings.Join(parts[:i], "/") + potentialPath := "." + if i < len(parts) { + potentialPath = strings.Join(parts[i:], "/") + } + + if _, _, err := resolveRefOrHash(gr, potentialRef); err == nil { + return potentialRef, potentialPath + } + } + + return refAndPath, "." +} + +// resolveAndBuildRef resolves a ref or hash and builds a git.Reference object. +func resolveAndBuildRef(gr *git.Repository, refOrHash string) (*git.Reference, error) { + hash, isRef, err := resolveRefOrHash(gr, refOrHash) + if err != nil { + return nil, err + } + + refSpec := refOrHash + if isRef { + if !strings.HasPrefix(refOrHash, "refs/") { + if gr.HasTag(refOrHash) { + refSpec = "refs/tags/" + refOrHash + } else { + refSpec = "refs/heads/" + refOrHash + } + } + } + + return &git.Reference{ + Reference: &gitmodule.Reference{ + ID: hash, + Refspec: refSpec, + }, + }, nil +} + +// FetchRefsPaginated efficiently fetches a paginated subset of refs sorted by date. +// It uses git for-each-ref to get pre-sorted refs without loading all objects upfront. +// refType specifies whether to fetch branches or tags. +// offset and limit control pagination (set limit to -1 to fetch all remaining refs). +// defaultBranch specifies which branch to pin to the top (empty string to disable pinning). +// Returns the paginated ref items and the total count of refs. +func FetchRefsPaginated(gr *git.Repository, refType RefType, offset, limit int, defaultBranch string) ([]RefItem, int, error) { + var refPattern, sortField, format string + var checkRefFunc func(*git.Reference) bool + + switch refType { + case RefTypeBranch: + refPattern = "refs/heads" + sortField = "-committerdate" + format = "%(refname:short)%09%(objectname)%09%(committerdate:unix)" + checkRefFunc = (*git.Reference).IsBranch + case RefTypeTag: + refPattern = "refs/tags" + sortField = "-creatordate" + format = "%(refname:short)%09%(*objectname)%09%(objectname)%09%(*authordate:unix)%09%(authordate:unix)%09%(contents:subject)" + checkRefFunc = (*git.Reference).IsTag + default: + return nil, 0, fmt.Errorf("unsupported ref type: %s", refType) + } + + args := []string{"for-each-ref", "--sort=" + sortField, "--format=" + format, refPattern} + + cmd := git.NewCommand(args...) + output, err := cmd.RunInDir(gr.Path) + if err != nil { + return nil, 0, err + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 1 && lines[0] == "" { + return []RefItem{}, 0, nil + } + + // Build reference map once + refs, err := gr.References() + if err != nil { + return nil, 0, err + } + + refMap := make(map[string]*git.Reference) + for _, r := range refs { + if checkRefFunc(r) { + refMap[r.Name().Short()] = r + } + } + + // Separate default branch from others if pinning is requested + var defaultBranchLine string + var otherLines []string + + if refType == RefTypeBranch && defaultBranch != "" { + for _, line := range lines { + fields := strings.Split(line, "\t") + if len(fields) < 1 { + continue + } + refName := fields[0] + if refName == defaultBranch { + defaultBranchLine = line + } else { + otherLines = append(otherLines, line) + } + } + } else { + otherLines = lines + } + + // Total count includes default branch if present + totalCount := len(otherLines) + hasDefaultBranch := defaultBranchLine != "" + if hasDefaultBranch { + totalCount++ + } + + items := make([]RefItem, 0) + + // Add default branch to page 1 (offset 0) + if hasDefaultBranch && offset == 0 { + fields := strings.Split(defaultBranchLine, "\t") + if len(fields) >= 2 { + refName := fields[0] + commitID := fields[1] + + if ref := refMap[refName]; ref != nil { + item := RefItem{Reference: ref} + if commitID != "" { + item.Commit, _ = gr.CatFileCommit(commitID) + } + items = append(items, item) + } + } + } + + // Calculate pagination for non-default branches + // On page 1, we have one less slot because default branch takes the first position + adjustedOffset := offset + adjustedLimit := limit + + if hasDefaultBranch { + if offset == 0 { + // Page 1: we already added default branch, so fetch limit-1 items + adjustedLimit = limit - 1 + } else { + // Page 2+: offset needs to account for default branch being removed from the list + adjustedOffset = offset - 1 + } + } + + if adjustedLimit <= 0 { + return items, totalCount, nil + } + + // Apply pagination to non-default branches + start := adjustedOffset + if start >= len(otherLines) { + return items, totalCount, nil + } + + end := len(otherLines) + if adjustedLimit > 0 { + end = start + adjustedLimit + if end > len(otherLines) { + end = len(otherLines) + } + } + + // Process only the paginated subset of non-default branches + for _, line := range otherLines[start:end] { + fields := strings.Split(line, "\t") + + var refName, commitID string + + if refType == RefTypeTag { + if len(fields) < 6 { + continue + } + refName = fields[0] + peeledCommitID := fields[1] + commitID = fields[2] + if peeledCommitID != "" { + commitID = peeledCommitID + } + } else { + if len(fields) < 2 { + continue + } + refName = fields[0] + commitID = fields[1] + } + + ref := refMap[refName] + if ref == nil { + continue + } + + item := RefItem{Reference: ref} + + if refType == RefTypeTag { + item.Tag, _ = gr.Tag(refName) + } + + if commitID != "" { + item.Commit, _ = gr.CatFileCommit(commitID) + } + + items = append(items, item) + } + + return items, totalCount, nil +} diff --git a/pkg/web/webui_helpers.go b/pkg/web/webui_helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..d3d55fba12d4ae97496edfb1a488b6ee2fd9746c --- /dev/null +++ b/pkg/web/webui_helpers.go @@ -0,0 +1,78 @@ +package web + +import ( + "bytes" + "context" + "html/template" + + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + extension "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + goldmarkhtml "github.com/yuin/goldmark/renderer/html" +) + +// renderMarkdown converts markdown content to sanitized HTML. +func renderMarkdown(content []byte) (template.HTML, error) { + var buf bytes.Buffer + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + goldmarkhtml.WithUnsafe(), + ), + ) + + if err := md.Convert(content, &buf); err != nil { + return "", err + } + + policy := bluemonday.UGCPolicy() + policy.AllowStyling() + policy.RequireNoFollowOnLinks(false) + policy.AllowElements("center") + sanitized := policy.SanitizeBytes(buf.Bytes()) + + return template.HTML(sanitized), nil +} + +// getServerReadme loads and renders the README from the .soft-serve repository. +func getServerReadme(ctx context.Context, be *backend.Backend) (template.HTML, error) { + repos, err := be.Repositories(ctx) + if err != nil { + return "", err + } + + for _, r := range repos { + if r.Name() == ".soft-serve" { + readme, _, err := backend.Readme(r, nil) + if err != nil { + return "", err + } + if readme != "" { + return renderMarkdown([]byte(readme)) + } + } + } + + return "", nil +} + +// openRepository opens a git repository. +func openRepository(repo proto.Repository) (*git.Repository, error) { + return repo.Open() +} + +// getDefaultBranch returns the default branch name, or empty string if none exists. +func getDefaultBranch(gr *git.Repository) string { + head, err := gr.HEAD() + if err != nil || head == nil { + return "" + } + return head.Name().Short() +} diff --git a/pkg/web/webui_home.go b/pkg/web/webui_home.go new file mode 100644 index 0000000000000000000000000000000000000000..76cb09b2d26c690045a9375d97df62aba0b3a7bc --- /dev/null +++ b/pkg/web/webui_home.go @@ -0,0 +1,169 @@ +package web + +import ( + "html/template" + "math" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/pkg/access" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/charmbracelet/soft-serve/pkg/ui/common" + "github.com/dustin/go-humanize" +) + +type HomeRepository struct { + Name string + ProjectName string + Description string + IsPrivate bool + CloneURL string + UpdatedAt string +} + +type HomeData struct { + Repo proto.Repository + Repositories []HomeRepository + ReadmeHTML template.HTML + ActiveTab string + ServerName string + Page int + TotalPages int + HasPrevPage bool + HasNextPage bool +} + +type repoItem struct { + repo proto.Repository + lastUpdate *time.Time +} + +func home(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + be := backend.FromContext(ctx) + + if be == nil { + logger.Debug("backend not found in context") + renderInternalServerError(w, r) + return + } + + repos, err := be.Repositories(ctx) + if err != nil { + logger.Debug("failed to get repositories", "err", err) + renderInternalServerError(w, r) + return + } + + var readmeHTML template.HTML + homeRepos := make([]HomeRepository, 0) + items := make([]repoItem, 0) + + readmeHTML, err = getServerReadme(ctx, be) + if err != nil { + logger.Debug("failed to get server README", "err", err) + } + + for _, r := range repos { + if r.IsHidden() { + continue + } + + al := be.AccessLevelByPublicKey(ctx, r.Name(), nil) + if al >= access.ReadOnlyAccess { + var lastUpdate *time.Time + lu := r.UpdatedAt() + if !lu.IsZero() { + lastUpdate = &lu + } + items = append(items, repoItem{ + repo: r, + lastUpdate: lastUpdate, + }) + } + } + + sort.Slice(items, func(i, j int) bool { + if items[i].lastUpdate == nil && items[j].lastUpdate != nil { + return false + } + if items[i].lastUpdate != nil && items[j].lastUpdate == nil { + return true + } + if items[i].lastUpdate == nil && items[j].lastUpdate == nil { + return items[i].repo.Name() < items[j].repo.Name() + } + return items[i].lastUpdate.After(*items[j].lastUpdate) + }) + + page := 1 + if pageStr := r.URL.Query().Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + totalRepos := len(items) + totalPages := int(math.Ceil(float64(totalRepos) / float64(defaultReposPerPage))) + if totalPages < 1 { + totalPages = 1 + } + + if page > totalPages { + page = totalPages + } + if page < 1 { + page = 1 + } + + startIdx := (page - 1) * defaultReposPerPage + endIdx := startIdx + defaultReposPerPage + if endIdx > totalRepos { + endIdx = totalRepos + } + + paginatedItems := items[startIdx:endIdx] + + for _, item := range paginatedItems { + repo := item.repo + name := repo.Name() + projectName := repo.ProjectName() + description := strings.TrimSpace(repo.Description()) + cloneURL := common.RepoURL(cfg.SSH.PublicURL, name) + + var updatedAt string + if item.lastUpdate != nil { + updatedAt = humanize.Time(*item.lastUpdate) + } + + homeRepos = append(homeRepos, HomeRepository{ + Name: name, + ProjectName: projectName, + Description: description, + IsPrivate: repo.IsPrivate(), + CloneURL: cloneURL, + UpdatedAt: updatedAt, + }) + } + + data := HomeData{ + Repositories: homeRepos, + ReadmeHTML: readmeHTML, + ActiveTab: "repositories", + ServerName: cfg.Name, + Page: page, + TotalPages: totalPages, + HasPrevPage: page > 1, + HasNextPage: page < totalPages, + } + + renderHTML(w, "home.html", data) +} diff --git a/pkg/web/webui_overview.go b/pkg/web/webui_overview.go new file mode 100644 index 0000000000000000000000000000000000000000..ea56c1bb466be740491f51e2c35e9a2b7a6bfedb --- /dev/null +++ b/pkg/web/webui_overview.go @@ -0,0 +1,68 @@ +package web + +import ( + "fmt" + "html/template" + "net/http" + + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/charmbracelet/soft-serve/pkg/ui/common" +) + +// OverviewData contains data for rendering repository overview page. +type OverviewData struct { + Repo proto.Repository + DefaultBranch string + IsEmpty bool + SSHURL string + HTTPURL string + ActiveTab string + ReadmeHTML template.HTML + ServerName string +} + +// repoOverview handles repository overview page. +func repoOverview(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + cfg := config.FromContext(ctx) + + gr, err := openRepository(repo) + if err != nil { + logger.Debug("failed to open repository", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + defaultBranch := getDefaultBranch(gr) + isEmpty := defaultBranch == "" + + sshURL := common.RepoURL(cfg.SSH.PublicURL, repo.Name()) + httpURL := fmt.Sprintf("%s/%s.git", cfg.HTTP.PublicURL, repo.Name()) + + var readmeHTML template.HTML + if !isEmpty { + head, _ := gr.HEAD() + readmeContent, _, err := backend.Readme(repo, head) + if err == nil && readmeContent != "" { + readmeHTML, _ = renderMarkdown([]byte(readmeContent)) + } + } + + data := OverviewData{ + Repo: repo, + DefaultBranch: defaultBranch, + IsEmpty: isEmpty, + SSHURL: sshURL, + HTTPURL: httpURL, + ActiveTab: "overview", + ReadmeHTML: readmeHTML, + ServerName: cfg.Name, + } + + renderHTML(w, "overview.html", data) +} diff --git a/pkg/web/webui_tags.go b/pkg/web/webui_tags.go new file mode 100644 index 0000000000000000000000000000000000000000..a126fdbd101e8a6849496bd8ecd49af033a4cf28 --- /dev/null +++ b/pkg/web/webui_tags.go @@ -0,0 +1,131 @@ +package web + +import ( + "math" + "net/http" + "strconv" + "time" + + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/proto" +) + +// TagInfo contains tag reference and its annotation message. +type TagInfo struct { + Ref *git.Reference + TagMessage string + Commit *git.Commit + TagDate time.Time +} + +// TagsData contains data for rendering tags listing. +type TagsData struct { + Repo proto.Repository + DefaultBranch string + Tags []TagInfo + ActiveTab string + Page int + TotalPages int + TotalTags int + HasPrevPage bool + HasNextPage bool + ServerName string +} + +// repoTags handles tags listing page. +func repoTags(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + + gr, err := openRepository(repo) + if err != nil { + logger.Debug("failed to open repository", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + defaultBranch := getDefaultBranch(gr) + + page := 1 + if pageStr := r.URL.Query().Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + // First fetch to get total count and calculate pages + paginatedRefItems, totalTags, err := FetchRefsPaginated(gr, RefTypeTag, 0, 1, "") + if err != nil { + logger.Debug("failed to fetch tags", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + totalPages := int(math.Ceil(float64(totalTags) / float64(defaultTagsPerPage))) + if totalPages < 1 { + totalPages = 1 + } + + // Clamp page before computing offset + if page > totalPages { + page = totalPages + } + if page < 1 { + page = 1 + } + + // Calculate offset for pagination + offset := (page - 1) * defaultTagsPerPage + + // Fetch only the tags we need for this page, pre-sorted + paginatedRefItems, totalTags, err = FetchRefsPaginated(gr, RefTypeTag, offset, defaultTagsPerPage, "") + if err != nil { + logger.Debug("failed to fetch tags", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + var paginatedTags []TagInfo + for _, refItem := range paginatedRefItems { + tagMessage := "" + var tagDate time.Time + + if refItem.Tag != nil { + tagMessage = refItem.Tag.Message() + if tagger := refItem.Tag.Tagger(); tagger != nil { + tagDate = tagger.When + } + } + + // Fallback to commit date if tag date not available + if tagDate.IsZero() && refItem.Commit != nil { + tagDate = refItem.Commit.Author.When + } + + paginatedTags = append(paginatedTags, TagInfo{ + Ref: refItem.Reference, + TagMessage: tagMessage, + Commit: refItem.Commit, + TagDate: tagDate, + }) + } + + data := TagsData{ + Repo: repo, + DefaultBranch: defaultBranch, + Tags: paginatedTags, + ActiveTab: "tags", + Page: page, + TotalPages: totalPages, + TotalTags: totalTags, + HasPrevPage: page > 1, + HasNextPage: page < totalPages, + ServerName: cfg.Name, + } + + renderHTML(w, "tags.html", data) +} diff --git a/pkg/web/webui_tree.go b/pkg/web/webui_tree.go new file mode 100644 index 0000000000000000000000000000000000000000..ba8d2d2c9008c6c33fe4e31d1c821e187c196d04 --- /dev/null +++ b/pkg/web/webui_tree.go @@ -0,0 +1,88 @@ +package web + +import ( + "net/http" + + "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/gorilla/mux" +) + +// TreeData contains data for rendering directory tree view. +type TreeData struct { + Repo proto.Repository + DefaultBranch string + Ref string + Path string + Entries git.Entries + ActiveTab string + ServerName string +} + +func repoTree(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := log.FromContext(ctx) + cfg := config.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + if repo == nil { + renderNotFound(w, r) + return + } + + gr, err := openRepository(repo) + if err != nil { + logger.Debug("failed to open repository", "repo", repo.Name(), "err", err) + renderInternalServerError(w, r) + return + } + + vars := mux.Vars(r) + refAndPath := vars["refAndPath"] + ref, path := parseRefAndPath(gr, refAndPath) + + if ref == "" { + head, err := gr.HEAD() + if err == nil && head != nil { + ref = head.Name().Short() + } + } + + refObj, err := resolveAndBuildRef(gr, ref) + if err != nil { + logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err) + renderNotFound(w, r) + return + } + + tree, err := gr.TreePath(refObj, path) + if err != nil { + logger.Debug("failed to get tree path", "repo", repo.Name(), "ref", ref, "path", path, "err", err) + renderNotFound(w, r) + return + } + + entries, err := tree.Entries() + if err != nil { + logger.Debug("failed to get tree entries", "err", err) + renderInternalServerError(w, r) + return + } + + entries.Sort() + + defaultBranch := getDefaultBranch(gr) + + data := TreeData{ + Repo: repo, + DefaultBranch: defaultBranch, + Ref: ref, + Path: path, + Entries: entries, + ActiveTab: "tree", + ServerName: cfg.Name, + } + + renderHTML(w, "tree.html", data) +}