package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sirupsen/logrus"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"sourcecraft.dev/bigbes/sr-ht-compare/authz"
"sourcecraft.dev/bigbes/sr-ht-compare/core"
"sourcecraft.dev/bigbes/sr-ht-compare/gitx"
)
// recentCommitLimit bounds the first-parent history shown on the repo page.
const recentCommitLimit = 20
// compareLogLimit bounds the commit list on the compare page.
const compareLogLimit = 50
// ---- JSON transport (consumed by the front-end bundle) --------------------
// jsonFile mirrors one gitx.FileChange for the browser. path is the plain repo
// path with NO a/ or b/ prefix.
type jsonFile struct {
Path string `json:"path"`
OldPath string `json:"oldPath"`
Status string `json:"status"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Binary bool `json:"binary"`
}
type jsonSpec struct {
Base string `json:"base"`
Head string `json:"head"`
ThreeDot bool `json:"threeDot"`
}
type compareData struct {
Mode string `json:"mode"`
Patch string `json:"patch"`
Truncated bool `json:"truncated"`
Files []jsonFile `json:"files"`
Spec jsonSpec `json:"spec"`
}
// buildCompareJSON marshals the browser payload. json.Marshal escapes <, > and &
// (Go's default HTML-safe mode), so the result is safe to drop verbatim inside a
// <script> element even when a file path contains "</script>". The bytes are
// returned as template.JS: any <script> is a JS context to html/template, so a
// plain string (or template.HTML) would be JS-escaped and corrupted; template.JS
// is emitted verbatim, and the marshaler's escaping already blocks a breakout.
func buildCompareJSON(mode string, patch *gitx.Patch, files []gitx.FileChange, spec jsonSpec) (template.JS, error) {
cd := compareData{
Mode: mode,
Patch: patch.Text,
Truncated: patch.Truncated,
Files: []jsonFile{},
Spec: spec,
}
for _, f := range files {
cd.Files = append(cd.Files, jsonFile{
Path: f.Path,
OldPath: f.OldPath,
Status: f.Status,
Additions: f.Additions,
Deletions: f.Deletions,
Binary: f.Binary,
})
}
b, err := json.Marshal(cd)
if err != nil {
return "", err
}
return template.JS(b), nil
}
// ---- error mapping --------------------------------------------------------
// httpStatusFor maps a domain error to an HTTP status. Repo visibility uses
// core.ErrNotFound so a private repo is a 404, never a 403.
func httpStatusFor(err error) int {
switch {
case errors.Is(err, core.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, core.ErrBadRef):
return http.StatusBadRequest
case errors.Is(err, core.ErrForbidden):
return http.StatusForbidden
default:
return http.StatusInternalServerError
}
}
// fail renders the chrome error page for err, logging 5xx causes.
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
status := httpStatusFor(err)
if status >= 500 {
logrus.WithError(err).WithField("path", r.URL.Path).Error("web: request failed")
s.renderError(w, r, status, "an internal error occurred")
return
}
s.renderError(w, r, status, err.Error())
}
// resolve authorizes and opens a repository, returning the git handle and the
// authz metadata. Any error is already mapped to the right HTTP status by the
// caller via fail.
func (s *Server) resolve(ctx context.Context, owner, repo string) (*gitx.Repo, *authz.RepoInfo, error) {
viewer := authz.ForContext(ctx)
info, err := s.authorizer.Repo(ctx, viewer, owner, repo)
if err != nil {
return nil, nil, err
}
g, err := gitx.Open(s.reposRoot, owner, repo)
if err != nil {
return nil, nil, err
}
return g, info, nil
}
// ---- index ----------------------------------------------------------------
type indexData struct {
LoggedIn bool
// Repos is the viewer's own repositories in the shape ecore's
// "srht-repo-list" partial renders, so the landing's listing is the same
// event-list card the sibling services draw for their own projects.
Repos chrome.RepoList
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
username := authz.ForContext(ctx)
// The title is built from the chrome's own brand fields rather than from a
// second read of site-name, so the tab and the nav cannot name the instance
// differently.
vd := s.view(r, "")
vd.Title = vd.SiteName + " " + vd.SiteLabel
if username == "" {
vd.Data = indexData{LoggedIn: false}
s.render(w, http.StatusOK, "index", vd)
return
}
repos, err := s.authorizer.MyRepos(ctx, username)
if err != nil {
s.fail(w, r, err)
return
}
vd.Data = indexData{LoggedIn: true, Repos: repoList(username, repos)}
s.render(w, http.StatusOK, "index", vd)
}
// repoList turns the authorizer's repositories into the listing ecore renders.
// The owner is always the viewer — MyRepos answers for one account — so the
// "~owner/name" title and the link are built from the same name and cannot point
// at somebody else's repository.
func repoList(owner string, repos []authz.RepoInfo) chrome.RepoList {
items := make([]chrome.ListItem, 0, len(repos))
for _, info := range repos {
items = append(items, chrome.ListItem{
Href: "/~" + owner + "/" + info.Name,
Title: "~" + owner + "/" + info.Name,
Visibility: info.Visibility,
Description: info.Description,
})
}
return chrome.RepoList{Items: items, Empty: "You have no repositories yet."}
}
// ---- repo page ------------------------------------------------------------
type repoData struct {
Owner string
Info *authz.RepoInfo
DefaultBranch string
Branches []gitx.Ref
Tags []gitx.Ref
Commits []gitx.CommitInfo
}
func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
owner := chi.URLParam(r, "owner")
repo := chi.URLParam(r, "repo")
g, info, err := s.resolve(ctx, owner, repo)
if err != nil {
s.fail(w, r, err)
return
}
branches, tags, err := g.Refs(ctx)
if err != nil {
s.fail(w, r, err)
return
}
def, _ := g.DefaultBranch(ctx)
commits, _ := recentCommits(ctx, g, def, recentCommitLimit)
vd := s.view(r, "~"+owner+"/"+repo)
vd.Data = repoData{
Owner: owner,
Info: info,
DefaultBranch: def,
Branches: branches,
Tags: tags,
Commits: commits,
}
s.render(w, http.StatusOK, "repo", vd)
}
// recentCommits walks first-parent history from rev, returning up to limit
// commits. It relies only on gitx.ResolveCommit so it needs no dedicated log
// range. An unresolvable starting revision (e.g. an empty repository) yields an
// empty slice rather than an error.
func recentCommits(ctx context.Context, g *gitx.Repo, rev string, limit int) ([]gitx.CommitInfo, error) {
if rev == "" {
return nil, nil
}
var out []gitx.CommitInfo
cur := rev
for i := 0; i < limit; i++ {
ci, err := g.ResolveCommit(ctx, cur)
if err != nil {
if i == 0 {
return nil, nil
}
break
}
out = append(out, *ci)
if len(ci.ParentSHAs) == 0 {
break
}
cur = ci.ParentSHAs[0]
}
return out, nil
}
// ---- compare page ---------------------------------------------------------
type compareView struct {
Owner string
RepoName string
Info *authz.RepoInfo
Spec core.CompareSpec
MergeBase string
Commits []gitx.CommitInfo
Files []gitx.FileChange
Truncated bool
CompareURL string
PatchURL string
JSON template.JS
}
func (s *Server) handleCompare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
owner := chi.URLParam(r, "owner")
repo := chi.URLParam(r, "repo")
raw := chi.URLParam(r, "*")
// Empty wildcard: this is the compare form's GET target. Canonicalize the
// base/head/mode query into a clean compare URL and redirect.
if raw == "" {
s.compareRedirect(w, r, owner, repo)
return
}
patchMode := strings.HasSuffix(raw, ".patch")
specRaw := strings.TrimSuffix(raw, ".patch")
spec, err := core.ParseCompareSpec(specRaw)
if err != nil {
s.fail(w, r, err)
return
}
g, info, err := s.resolve(ctx, owner, repo)
if err != nil {
s.fail(w, r, err)
return
}
compareURL := fmt.Sprintf("/~%s/%s/compare/%s", owner, repo, specRaw)
if patchMode {
patch, err := g.RawDiff(ctx, spec)
if err != nil {
s.fail(w, r, err)
return
}
s.writePatch(w, patch.Text)
return
}
patch, err := g.Diff(ctx, spec)
if err != nil {
s.fail(w, r, err)
return
}
files, err := g.DiffStat(ctx, spec)
if err != nil {
s.fail(w, r, err)
return
}
commits, err := g.Log(ctx, spec.Base, spec.Head, compareLogLimit)
if err != nil {
s.fail(w, r, err)
return
}
var mergeBase string
if spec.ThreeDot {
mergeBase, _ = g.MergeBase(ctx, spec.Base, spec.Head)
}
jsonPayload, err := buildCompareJSON("compare", patch, files, jsonSpec{
Base: spec.Base,
Head: spec.Head,
ThreeDot: spec.ThreeDot,
})
if err != nil {
s.fail(w, r, err)
return
}
vd := s.view(r, fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head))
vd.ContainerClass = "container-fluid"
vd.Data = compareView{
Owner: owner,
RepoName: repo,
Info: info,
Spec: spec,
MergeBase: mergeBase,
Commits: commits,
Files: files,
Truncated: patch.Truncated,
CompareURL: compareURL,
PatchURL: compareURL + ".patch",
JSON: jsonPayload,
}
s.render(w, http.StatusOK, "compare", vd)
}
// compareRedirect turns ?base=&head=&mode= into a canonical compare URL. mode
// "two" selects the two-dot range; anything else (the default) is three-dot.
func (s *Server) compareRedirect(w http.ResponseWriter, r *http.Request, owner, repo string) {
q := r.URL.Query()
base := strings.TrimSpace(q.Get("base"))
head := strings.TrimSpace(q.Get("head"))
if base == "" || head == "" {
s.renderError(w, r, http.StatusBadRequest, "both base and head are required")
return
}
sep := "..."
if q.Get("mode") == "two" {
sep = ".."
}
http.Redirect(w, r, fmt.Sprintf("/~%s/%s/compare/%s%s%s", owner, repo, base, sep, head), http.StatusFound)
}
// ---- commit page ----------------------------------------------------------
type commitView struct {
Owner string
RepoName string
Info *authz.RepoInfo
Commit *gitx.CommitInfo
Files []gitx.FileChange
IsMerge bool
Truncated bool
CommitURL string
PatchURL string
JSON template.JS
}
func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
owner := chi.URLParam(r, "owner")
repo := chi.URLParam(r, "repo")
rev := chi.URLParam(r, "rev")
patchMode := strings.HasSuffix(rev, ".patch")
rev = strings.TrimSuffix(rev, ".patch")
g, info, err := s.resolve(ctx, owner, repo)
if err != nil {
s.fail(w, r, err)
return
}
patch, files, ci, err := g.CommitPatch(ctx, rev)
if err != nil {
s.fail(w, r, err)
return
}
if patchMode {
s.writePatch(w, patch.Text)
return
}
commitURL := fmt.Sprintf("/~%s/%s/commit/%s", owner, repo, rev)
base := ""
if len(ci.ParentSHAs) > 0 {
base = ci.ParentSHAs[0]
}
jsonPayload, err := buildCompareJSON("commit", patch, files, jsonSpec{
Base: base,
Head: ci.SHA,
ThreeDot: false,
})
if err != nil {
s.fail(w, r, err)
return
}
vd := s.view(r, fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA))
vd.ContainerClass = "container-fluid"
vd.Data = commitView{
Owner: owner,
RepoName: repo,
Info: info,
Commit: ci,
Files: files,
IsMerge: len(ci.ParentSHAs) > 1,
Truncated: patch.Truncated,
CommitURL: commitURL,
PatchURL: commitURL + ".patch",
JSON: jsonPayload,
}
s.render(w, http.StatusOK, "commit", vd)
}
// writePatch emits a raw unified diff as an inline text/plain document.
func (s *Server) writePatch(w http.ResponseWriter, text string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", "inline")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(text))
}