Wire repo browsing routes and handlers
DDavid committed on 2026-06-28 01:20 commit 75208f6
cmd/server/main.go +3 −1
package main
import (
"log"
"net/http"
"gitgud/internal/app"
"gitgud/internal/infra/config"
"gitgud/internal/infra/git"
"gitgud/internal/infra/persistence/sqlite"
"gitgud/internal/infra/security"
"gitgud/internal/infra/session"
"gitgud/internal/interface/web"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db, err := sqlite.Open(cfg.DBPath())
if err != nil {
log.Fatal(err)
}
defer db.Close()
hasher := security.NewBcryptHasher()
userRepo := sqlite.NewUserRepo(db)
userService := app.NewUserService(userRepo, hasher)
gitSvc := git.NewCLIGit(cfg.ReposDir())
+ gitReader := git.NewGoGitReader(cfg.ReposDir())
repoRepo := sqlite.NewRepoRepo(db)
repoService := app.NewRepoService(repoRepo, gitSvc)
+ browseService := app.NewBrowseService(repoRepo, gitReader)
gitAccess := app.NewGitAccessService(repoRepo)
gitBackend, err := git.NewBackend(cfg.ReposDir())
if err != nil {
log.Fatal(err)
}
sm := session.NewSessionManager(db)
- handlers := web.NewHandlers(userService, repoService, gitAccess, gitBackend, sm)
+ handlers := web.NewHandlers(userService, repoService, browseService, gitAccess, gitBackend, sm)
handler := web.NewRouter(cfg, handlers)
log.Printf("listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, handler))
}
internal/interface/web/browse_handler.go +161 −0
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi"
+
+ "gitgud/internal/domain"
+ "gitgud/internal/interface/web/presenter"
+ "gitgud/internal/interface/web/templates"
+)
+
+func (h *Handlers) repoTreeRoot(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ h.renderTree(w, r, repo, "", "")
+}
+
+func (h *Handlers) repoTree(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ h.renderTree(w, r, repo, chi.URLParam(r, "ref"), chi.URLParam(r, "*"))
+}
+
+func (h *Handlers) renderTree(w http.ResponseWriter, r *http.Request, repo *domain.Repository, ref, path string) {
+ ctx := r.Context()
+
+ empty, err := h.browse.IsEmpty(ctx, repo)
+ if err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ if empty {
+ render(w, r, http.StatusOK, templates.RepoHome(currentUser(ctx), repo))
+ return
+ }
+
+ if ref == "" {
+ ref = repo.DefaultBranch
+ }
+
+ branches, err := h.browse.Branches(ctx, repo)
+ if err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ entries, err := h.browse.Tree(ctx, repo, ref, path)
+ if err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+
+ readme := ""
+ for _, e := range entries {
+ if !e.IsDir && strings.EqualFold(e.Name, "README.md") {
+ if blob, err := h.browse.Blob(ctx, repo, ref, e.Path); err == nil && !blob.IsBinary {
+ readme = presenter.RenderMarkdown(blob.Content)
+ }
+ break
+ }
+ }
+
+ render(w, r, http.StatusOK, templates.BrowseTree(currentUser(ctx), repo, ref, path, branches, entries, readme))
+}
+
+func (h *Handlers) repoBlob(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref := chi.URLParam(r, "ref")
+ path := chi.URLParam(r, "*")
+
+ blob, err := h.browse.Blob(r.Context(), repo, ref, path)
+ if err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ if ref == "" {
+ ref = repo.DefaultBranch
+ }
+
+ highlighted := ""
+ if !blob.IsBinary {
+ highlighted = presenter.Highlight(string(blob.Content), blob.Path)
+ }
+ render(w, r, http.StatusOK, templates.BrowseBlob(currentUser(r.Context()), repo, ref, blob, highlighted))
+}
+
+func (h *Handlers) repoRaw(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ blob, err := h.browse.Blob(r.Context(), repo, chi.URLParam(r, "ref"), chi.URLParam(r, "*"))
+ if err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+
+ ctype := http.DetectContentType(blob.Content)
+ if !blob.IsBinary {
+ ctype = "text/plain; charset=utf-8"
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Write(blob.Content)
+}
+
+func (h *Handlers) repoCommits(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref := chi.URLParam(r, "ref")
+
+ commits, err := h.browse.Log(r.Context(), repo, ref, 50, 0)
+ if err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ if ref == "" {
+ ref = repo.DefaultBranch
+ }
+ render(w, r, http.StatusOK, templates.Commits(currentUser(r.Context()), repo, ref, commits))
+}
+
+func (h *Handlers) repoCommit(w http.ResponseWriter, r *http.Request) {
+ repo := h.viewRepo(w, r)
+ if repo == nil {
+ return
+ }
+ commit, diffs, err := h.browse.CommitDiff(r.Context(), repo, chi.URLParam(r, "hash"))
+ if err != nil {
+ h.gitError(w, r, err)
+ return
+ }
+ render(w, r, http.StatusOK, templates.CommitDetail(currentUser(r.Context()), repo, commit, diffs))
+}
+
+func (h *Handlers) viewRepo(w http.ResponseWriter, r *http.Request) *domain.Repository {
+ repo, err := h.browse.Repo(r.Context(), chi.URLParam(r, "owner"), chi.URLParam(r, "repo"), currentUser(r.Context()))
+ if err != nil {
+ h.notFound(w, r)
+ return nil
+ }
+ return repo
+}
+
+func (h *Handlers) gitError(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, domain.ErrNotFound) {
+ h.notFound(w, r)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+}
internal/interface/web/middleware.go +3 −2
package web
import (
"context"
"net/http"
"strconv"
"github.com/alexedwards/scs/v2"
"gitgud/internal/app"
"gitgud/internal/domain"
"gitgud/internal/infra/git"
)
const sessionUserIDKey = "user_id"
type ctxKey int
const userCtxKey ctxKey = iota
type Handlers struct {
users *app.UserService
repos *app.RepoService
+ browse *app.BrowseService
gitAccess *app.GitAccessService
gitBackend *git.Backend
sm *scs.SessionManager
}
-func NewHandlers(users *app.UserService, repos *app.RepoService, gitAccess *app.GitAccessService, gitBackend *git.Backend, sm *scs.SessionManager) *Handlers {
- return &Handlers{users: users, repos: repos, gitAccess: gitAccess, gitBackend: gitBackend, sm: sm}
+func NewHandlers(users *app.UserService, repos *app.RepoService, browse *app.BrowseService, gitAccess *app.GitAccessService, gitBackend *git.Backend, sm *scs.SessionManager) *Handlers {
+ return &Handlers{users: users, repos: repos, browse: browse, gitAccess: gitAccess, gitBackend: gitBackend, sm: sm}
}
func (h *Handlers) withUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if id := h.sm.GetInt64(r.Context(), sessionUserIDKey); id != 0 {
if u, err := h.users.ByID(r.Context(), strconv.FormatInt(id, 10)); err == nil {
r = r.WithContext(context.WithValue(r.Context(), userCtxKey, u))
}
}
next.ServeHTTP(w, r)
})
}
func (h *Handlers) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if currentUser(r.Context()) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func currentUser(ctx context.Context) *domain.User {
u, _ := ctx.Value(userCtxKey).(*domain.User)
return u
}
internal/interface/web/repo_handler.go +0 −25
package web
import (
"errors"
"net/http"
"strings"
"github.com/go-chi/chi"
"gitgud/internal/app"
"gitgud/internal/domain"
"gitgud/internal/interface/web/templates"
)
func (h *Handlers) dashboard(w http.ResponseWriter, r *http.Request) {
user := currentUser(r.Context())
if user == nil {
render(w, r, http.StatusOK, templates.Home(nil))
return
}
repos, err := h.repos.ListByOwner(r.Context(), user.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, r, http.StatusOK, templates.Dashboard(user, repos))
}
func (h *Handlers) showNewRepo(w http.ResponseWriter, r *http.Request) {
render(w, r, http.StatusOK, templates.NewRepo(currentUser(r.Context()), "", "", false, ""))
}
func (h *Handlers) createRepo(w http.ResponseWriter, r *http.Request) {
user := currentUser(r.Context())
name := r.FormValue("name")
description := r.FormValue("description")
private := r.FormValue("private") != ""
repo, err := h.repos.CreateRepo(r.Context(), user, name, description, private)
if err != nil {
status := http.StatusBadRequest
msg := "could not create repository"
switch {
case errors.Is(err, domain.ErrConflict):
status = http.StatusConflict
msg = "a repository with that name already exists"
case errors.Is(err, domain.ErrValidation):
msg = strings.TrimSuffix(err.Error(), ": "+domain.ErrValidation.Error())
}
render(w, r, status, templates.NewRepo(user, name, description, private, msg))
return
}
http.Redirect(w, r, "/"+repo.OwnerName+"/"+repo.Name, http.StatusSeeOther)
}
func (h *Handlers) profile(w http.ResponseWriter, r *http.Request) {
viewer := currentUser(r.Context())
ownerName := chi.URLParam(r, "owner")
owner, err := h.users.ByUsername(r.Context(), ownerName)
if err != nil {
h.notFound(w, r)
return
}
repos, err := h.repos.ListByOwner(r.Context(), owner.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
visible := repos[:0]
for _, repo := range repos {
if app.CanView(repo, viewer) == nil {
visible = append(visible, repo)
}
}
render(w, r, http.StatusOK, templates.Profile(viewer, owner.Username, visible))
}
-func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
- repo := h.loadViewableRepo(w, r)
- if repo == nil {
- return
- }
- render(w, r, http.StatusOK, templates.RepoHome(currentUser(r.Context()), repo))
-}
-
-func (h *Handlers) loadViewableRepo(w http.ResponseWriter, r *http.Request) *domain.Repository {
- viewer := currentUser(r.Context())
- owner := chi.URLParam(r, "owner")
- name := chi.URLParam(r, "repo")
-
- repo, err := h.repos.Get(r.Context(), owner, name)
- if err != nil {
- h.notFound(w, r)
- return nil
- }
- if err := app.CanView(repo, viewer); err != nil {
- h.notFound(w, r)
- return nil
- }
- return repo
-}
-
func (h *Handlers) notFound(w http.ResponseWriter, r *http.Request) {
render(w, r, http.StatusNotFound, templates.NotFound(currentUser(r.Context())))
}
internal/interface/web/router.go +7 −1
package web
import (
"embed"
"net/http"
"github.com/a-h/templ"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"gitgud/internal/infra/config"
)
var staticFS embed.FS
func NewRouter(cfg config.Config, h *Handlers) http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer)
r.Use(h.sm.LoadAndSave)
r.Use(h.withUser)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
r.Handle("/static/*", http.StripPrefix("/static", http.FileServer((http.FS(staticFS)))))
r.Get("/register", h.showRegister)
r.Post("/register", h.doRegister)
r.Get("/login", h.showLogin)
r.Post("/login", h.doLogin)
r.Post("/logout", h.doLogout)
r.With(h.requireAuth).Get("/new", h.showNewRepo)
r.With(h.requireAuth).Post("/new", h.createRepo)
r.Get("/{owner}/{repo}/info/refs", h.gitHTTP)
r.Post("/{owner}/{repo}/git-upload-pack", h.gitHTTP)
r.Post("/{owner}/{repo}/git-receive-pack", h.gitHTTP)
r.Get("/", h.dashboard)
r.Get("/{owner}", h.profile)
- r.Get("/{owner}/{repo}", h.repoHome)
+ r.Get("/{owner}/{repo}", h.repoTreeRoot)
+ r.Get("/{owner}/{repo}/tree/{ref}", h.repoTree)
+ r.Get("/{owner}/{repo}/tree/{ref}/*", h.repoTree)
+ r.Get("/{owner}/{repo}/blob/{ref}/*", h.repoBlob)
+ r.Get("/{owner}/{repo}/raw/{ref}/*", h.repoRaw)
+ r.Get("/{owner}/{repo}/commits/{ref}", h.repoCommits)
+ r.Get("/{owner}/{repo}/commit/{hash}", h.repoCommit)
return r
}
func render(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := c.Render(r.Context(), w); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}