// Package web serves simplegit's HTTP interface: routes, handlers, and
// session middleware. All rendering is server-side.
package web
import (
"database/sql"
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"net/url"
"strings"
"time"
"git.josie-c.com/josie/simplegit/internal/config"
"git.josie-c.com/josie/simplegit/internal/render"
)
//go:embed templates/*.html static/*
var filesFS embed.FS
// pages are the page templates, each parsed together with base.html so
// their "content"/"title" defines stay scoped to that page.
var pages = []string{
"home.html", "login.html", "new.html", "created.html", "profile.html",
"repo.html", "blob.html", "commits.html", "commit.html", "settings.html",
"repo_settings.html", "issues.html", "issue.html", "issue_new.html",
"issue_submitted.html", "pulls.html", "pull.html", "pull_new.html",
"pull_submitted.html", "releases.html", "release.html",
}
// loginWindow is the sliding window for login attempts per client IP.
const loginWindow = 5 * time.Minute
// loginAttempts caps failed sign-ins per client IP inside loginWindow; the
// counter resets on a successful login, so real users rarely notice it.
const loginAttempts = 10
// cloneURL builds the HTTPS clone URL for a repo.
func cloneURL(base, owner, repo string) string {
return strings.TrimSuffix(base, "/") + "/" + owner + "/" + repo + ".git"
}
// Server holds the dependencies every handler shares.
type Server struct {
database *sql.DB
cfg config.Config
templates map[string]*template.Template
static http.Handler
chromaCSS []byte
guestThreads *ipLimiter
guestComments *ipLimiter
logins *ipLimiter
}
// New compiles the embedded templates and returns a ready Server.
func New(database *sql.DB, cfg config.Config) (*Server, error) {
funcs := template.FuncMap{
"cloneURL": func(owner, repo string) string { return cloneURL(cfg.BaseURL, owner, repo) },
}
templates := make(map[string]*template.Template, len(pages))
for _, page := range pages {
parsed, err := template.New(page).Funcs(funcs).ParseFS(filesFS, "templates/base.html", "templates/repo_nav.html", "templates/"+page)
if err != nil {
return nil, fmt.Errorf("parse templates %s: %w", page, err)
}
templates[page] = parsed
}
staticFS, err := fs.Sub(filesFS, "static")
if err != nil {
return nil, fmt.Errorf("static fs: %w", err)
}
chromaCSS, err := render.ChromaCSS()
if err != nil {
return nil, err
}
return &Server{
database: database,
cfg: cfg,
templates: templates,
static: http.FileServer(http.FS(staticFS)),
chromaCSS: chromaCSS,
guestThreads: newIPLimiter(guestThreadCap, guestWindow),
guestComments: newIPLimiter(guestCommentCap, guestWindow),
logins: newIPLimiter(loginAttempts, loginWindow),
}, nil
}
// Handler builds the route table, wrapped in the session middleware.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleHome)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /new", s.handleNewRepoForm)
mux.HandleFunc("POST /new", s.handleCreateRepo)
mux.HandleFunc("GET /settings", s.handleSettings)
mux.HandleFunc("POST /settings/password", s.handleChangePassword)
mux.HandleFunc("POST /settings/tokens", s.handleCreateToken)
mux.HandleFunc("POST /settings/tokens/{id}/revoke", s.handleDeleteToken)
mux.HandleFunc("GET /{user}", s.handleProfile)
mux.HandleFunc("GET /{user}/{repo}", s.handleRepoHome)
mux.HandleFunc("GET /{user}/{repo}/tree/{ref...}", s.handleTree)
mux.HandleFunc("GET /{user}/{repo}/blob/{ref}/{path...}", s.handleBlob)
mux.HandleFunc("GET /{user}/{repo}/raw/{ref}/{path...}", s.handleRaw)
mux.HandleFunc("GET /{user}/{repo}/commits", s.handleCommits)
mux.HandleFunc("GET /{user}/{repo}/commits/{ref...}", s.handleCommits)
mux.HandleFunc("GET /{user}/{repo}/commit/{sha}", s.handleCommit)
mux.HandleFunc("GET /{user}/{repo}/settings", s.handleRepoSettings)
mux.HandleFunc("POST /{user}/{repo}/settings/visibility", s.handleRepoVisibility)
mux.HandleFunc("POST /{user}/{repo}/settings/rename", s.handleRepoRename)
mux.HandleFunc("POST /{user}/{repo}/settings/delete", s.handleRepoDelete)
mux.HandleFunc("GET /{user}/{repo}/issues", s.handleIssues)
mux.HandleFunc("GET /{user}/{repo}/issues/new", s.handleIssueNewForm)
mux.HandleFunc("POST /{user}/{repo}/issues/new", s.handleCreateIssue)
mux.HandleFunc("GET /{user}/{repo}/issues/{number}", s.handleIssueView)
mux.HandleFunc("POST /{user}/{repo}/issues/{number}/close", func(w http.ResponseWriter, r *http.Request) {
s.handleIssueState(w, r, stateClosed)
})
mux.HandleFunc("POST /{user}/{repo}/issues/{number}/reopen", func(w http.ResponseWriter, r *http.Request) {
s.handleIssueState(w, r, stateOpen)
})
mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments", s.handleCreateComment)
mux.HandleFunc("POST /{user}/{repo}/issues/{number}/approve", s.handleApproveIssue)
mux.HandleFunc("POST /{user}/{repo}/issues/{number}/delete", s.handleDeleteIssue)
mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments/{id}/approve", func(w http.ResponseWriter, r *http.Request) {
s.handleModerateComment(w, r, true)
})
mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments/{id}/delete", func(w http.ResponseWriter, r *http.Request) {
s.handleModerateComment(w, r, false)
})
mux.HandleFunc("GET /{user}/{repo}/pulls", s.handlePulls)
mux.HandleFunc("GET /{user}/{repo}/pulls/new", s.handlePullNewForm)
mux.HandleFunc("POST /{user}/{repo}/pulls/new", s.handleCreatePull)
mux.HandleFunc("GET /{user}/{repo}/pulls/{number}", s.handlePullView)
mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/merge", s.handlePullMerge)
mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/close", func(w http.ResponseWriter, r *http.Request) {
s.handlePullState(w, r, stateClosed)
})
mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/reopen", func(w http.ResponseWriter, r *http.Request) {
s.handlePullState(w, r, stateOpen)
})
mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments", s.handleCreatePullComment)
mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/approve", s.handleApprovePull)
mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/delete", s.handleDeletePull)
mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments/{id}/approve", func(w http.ResponseWriter, r *http.Request) {
s.handleModeratePullComment(w, r, true)
})
mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments/{id}/delete", func(w http.ResponseWriter, r *http.Request) {
s.handleModeratePullComment(w, r, false)
})
mux.HandleFunc("GET /{user}/{repo}/releases", s.handleReleases)
mux.HandleFunc("POST /{user}/{repo}/releases", s.handleCreateRelease)
mux.HandleFunc("GET /{user}/{repo}/releases/download/{id}/{filename}", s.handleReleaseDownload)
mux.HandleFunc("GET /{user}/{repo}/releases/{tag}", s.handleReleaseView)
mux.HandleFunc("POST /{user}/{repo}/releases/{tag}/delete", s.handleDeleteRelease)
mux.HandleFunc("POST /{user}/{repo}/releases/{tag}/assets/{id}/delete", s.handleDeleteReleaseAsset)
mux.HandleFunc("GET /{user}/{repoGit}/info/refs", s.handleGitRefs)
mux.HandleFunc("POST /{user}/{repoGit}/git-upload-pack", s.handleGitUploadPack)
mux.HandleFunc("POST /{user}/{repoGit}/git-receive-pack", s.handleGitReceivePack)
// Static assets are matched before the mux: /static/ and the
// /{user}/{repo} wildcard both match "/static/" and cannot coexist in
// one ServeMux. chroma.css is generated at startup (palette-tuned), so
// it is served here rather than from the embedded static files.
staticPrefix := http.StripPrefix("/static/", s.static)
root := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Site-wide hardening: pages are never meaningfully frameable, and
// every response carries an explicit type.
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.URL.Path == "/static/chroma.css" {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = w.Write(s.chromaCSS)
return
}
if strings.HasPrefix(r.URL.Path, "/static/") {
staticPrefix.ServeHTTP(w, r)
return
}
if !sameOrigin(r) {
http.Error(w, "cross-origin request rejected", http.StatusForbidden)
return
}
mux.ServeHTTP(w, r)
})
return s.withUser(root)
}
// sameOrigin rejects state-changing requests that carry a cross-site Origin
// header — the belt to the session cookie's SameSite=Lax braces. Browsers
// send Origin on every form/AJAX POST; non-browser clients (git, curl)
// send none and pass, relying on authentication instead.
func sameOrigin(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return u.Host == r.Host
}
// Listen serves the web UI on the configured address. Read and write
// deadlines stay unset on purpose: git pushes stream bodies of arbitrary
// size and duration.
func (s *Server) Listen() error {
srv := &http.Server{
Addr: s.cfg.ListenAddr,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
}
return srv.ListenAndServe()
}
// internalError logs err and writes the generic 500 body.
func (s *Server) internalError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("web: %s %s: %v", r.Method, r.URL.Path, err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
// pageData is the model the sign-in page renders.
type pageData struct {
Username string
Error string
}
// render executes page's "base" template with data.
func (s *Server) render(w http.ResponseWriter, page string, status int, data any) {
tmpl, ok := s.templates[page]
if !ok {
log.Printf("web: unknown template %q", page)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
log.Printf("web: render %s: %v", page, err)
}
}
// renderFragment executes a named define from a page's template set without
// the base layout, for htmx swaps.
func (s *Server) renderFragment(w http.ResponseWriter, page, name string, data any) {
tmpl, ok := s.templates[page]
if !ok {
log.Printf("web: unknown template %q", page)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("web: render fragment %s/%s: %v", page, name, err)
}
}