josie / simplegit

// 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)
	}
}