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