package web import ( "database/sql" "errors" "html/template" "net" "net/http" "slices" "strconv" "strings" "time" "unicode/utf8" "git.josie-c.com/josie/simplegit/internal/db" "git.josie-c.com/josie/simplegit/internal/render" ) // repoPage resolves {user}/{repo} for the HTML pages with the site-wide // visibility gate. On failure it has written the response. func (s *Server) repoPage(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, bool) { repo, err := db.GetRepoByName(s.database, r.PathValue("user"), r.PathValue("repo")) if errors.Is(err, db.ErrNotFound) { http.NotFound(w, r) return db.Repo{}, nil, false } if err != nil { s.internalError(w, r, err) return db.Repo{}, nil, false } user := currentUser(r) // Private repos 404 for everyone but the owner, so existence is not an // anonymous oracle. if repo.Visibility != visibilityPublic && !isOwner(user, repo) { http.NotFound(w, r) return db.Repo{}, nil, false } return repo, user, true } // canWriteContent reports whether the caller may author issues/comments: // the owner always, a guest only on a public repo. func canWriteContent(user *db.User, repo db.Repo) bool { return isOwner(user, repo) || repo.Visibility == visibilityPublic } func issueBasePath(r *http.Request) string { return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/issues" } func issueNumber(r *http.Request) (int64, bool) { number, err := strconv.ParseInt(r.PathValue("number"), 10, 64) return number, err == nil && number > 0 } // threadHref builds an issue/PR URL from its base path and number. func threadHref(base string, number int64) string { return base + "/" + strconv.FormatInt(number, 10) } // showFilter normalizes the ?show= list filter: anything not in allowed // falls back to "open". state is "" for showAll, so list queries skip the // state predicate. func showFilter(r *http.Request, allowed ...string) (show, state string) { show = r.URL.Query().Get("show") if !slices.Contains(allowed, show) { show = stateOpen } if show == showAll { return show, "" } return show, show } // pathID parses the {id} path value (a comment or asset id). func pathID(r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) return id, err == nil && id > 0 } func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func issuedAt(epoch int64) string { return time.Unix(epoch, 0).UTC().Format("2006-01-02 15:04") } func guestAuthorName(name string) string { if name == "" { return "Anonymous" } return name } // truncate cuts s to max bytes without splitting a UTF-8 rune. func truncate(s string, max int) string { if len(s) <= max { return s } for max > 0 && !utf8.RuneStart(s[max]) { max-- } return s[:max] } // formText reads a form value, trimmed and capped at max bytes. func formText(r *http.Request, name string, max int) string { return truncate(strings.TrimSpace(r.FormValue(name)), max) } func markdownHTML(source string) template.HTML { html, err := render.Markdown([]byte(source)) if err != nil { return template.HTML("
" + template.HTMLEscapeString(source) + "") } return template.HTML(html) } // issueIdentity returns the stored author (id 0 for a guest) for a new row. // An authenticated author is attributed to their account; a guest supplies a // self-claimed display name and optional email. A guest cannot claim the // repo owner's name — an approved row would otherwise read as the owner's. func issueIdentity(r *http.Request, user *db.User, ownerName string) (int64, string, string) { if user != nil { return user.ID, user.Username, "" } name := formText(r, "author_name", maxNameLen) if strings.EqualFold(name, ownerName) { name = "" } email := formText(r, "author_email", maxEmailLen) return 0, guestAuthorName(name), email } type issueListRow struct { Number int64 Title string State string Pending bool Author string AuthorIsOwner bool Created string Href string } type issueListData struct { navData Show string IsOwner bool CanWrite bool PendingCount int Issues []issueListRow } // handleIssues renders the issue list. Anonymous visitors of a public repo // see non-pending issues; the owner's ?show=owner view is the pending // moderation queue. func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) { repo, user, ok := s.repoPage(w, r) if !ok { return } ownerView := isOwner(user, repo) show, state := showFilter(r, stateClosed, showAll) issues, err := db.ListIssues(s.database, repo.ID, ownerView, state) if err != nil { s.internalError(w, r, err) return } data := issueListData{ navData: nav(r, repo, user, "issues"), Show: show, IsOwner: ownerView, CanWrite: canWriteContent(user, repo), } if ownerView { if data.PendingCount, err = db.CountPending(s.database, repo.ID); err != nil { s.internalError(w, r, err) return } } for _, issue := range issues { data.Issues = append(data.Issues, issueListRow{ Number: issue.Number, Title: issue.Title, State: issue.State, Pending: issue.Pending, Author: guestAuthorName(issue.AuthorName), AuthorIsOwner: issue.AuthorID.Valid && issue.AuthorID.Int64 == repo.OwnerID, Created: issuedAt(issue.CreatedAt), Href: threadHref(issueBasePath(r), issue.Number), }) } s.render(w, "issues.html", http.StatusOK, data) } type issueNewData struct { navData IsOwner bool Title string Body string Error string } // handleIssueNewForm renders the issue form. Guests may file on public repos. func (s *Server) handleIssueNewForm(w http.ResponseWriter, r *http.Request) { repo, user, ok := s.repoPage(w, r) if !ok { return } if !canWriteContent(user, repo) { http.NotFound(w, r) return } s.render(w, "issue_new.html", http.StatusOK, issueNewData{ navData: nav(r, repo, user, "issues"), IsOwner: isOwner(user, repo), }) } // handleCreateIssue stores a new issue. The owner's issue is published // immediately; a guest's is pending owner moderation. func (s *Server) handleCreateIssue(w http.ResponseWriter, r *http.Request) { repo, user, ok := s.repoPage(w, r) if !ok { return } if !canWriteContent(user, repo) { http.NotFound(w, r) return } trusted := isOwner(user, repo) if !trusted && !s.guestThreads.allow(clientIP(r)) { http.Error(w, "too many issues filed; try again later", http.StatusTooManyRequests) return } r.Body = http.MaxBytesReader(w, r.Body, issueFormMax) if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } title := formText(r, "title", maxTitleLen) body := formText(r, "body", maxIssueBody) fail := func(msg string) { data := issueNewData{ navData: nav(r, repo, user, "issues"), IsOwner: trusted, Title: title, Body: body, Error: msg, } s.render(w, "issue_new.html", http.StatusUnprocessableEntity, data) } if title == "" { fail("a title is required") return } if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" { // Honeypot: pretend success, store nothing. http.Redirect(w, r, issueBasePath(r)+"?"+submittedParam+"=1", http.StatusSeeOther) return } authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user")) if !trusted { dup, err := db.HasDuplicateIssue(s.database, repo.ID, title, body, authorName, authorEmail) if err != nil { s.internalError(w, r, err) return } if dup { // Identical filing already stored; answer exactly like a fresh // submission so a spammer gets no oracle. s.render(w, "issue_submitted.html", http.StatusOK, nav(r, repo, user, "issues")) return } } issue, err := db.CreateIssue(s.database, repo.ID, title, body, authorID, authorName, authorEmail, !trusted) if err != nil { s.internalError(w, r, err) return } if trusted { http.Redirect(w, r, threadHref(issueBasePath(r), issue.Number), http.StatusSeeOther) return } s.render(w, "issue_submitted.html", http.StatusOK, nav(r, repo, user, "issues")) } type commentView struct { ID int64 BodyHTML template.HTML Pending bool Author string AuthorIsOwner bool AuthorEmail string Created string IsOwner bool Base string } type issueViewData struct { navData Number int64 Title string State string Pending bool Author string AuthorIsOwner bool AuthorEmail string Created string BodyHTML template.HTML Comments []commentView IsOwner bool CanWrite bool Base string Submitted bool } // fetchByNumber loads a thread row by (repo, number), mapping not-found to // 404 and anything else to the generic 500 — the preamble every issue/PR // handler shares. func fetchByNumber[T any](s *Server, w http.ResponseWriter, r *http.Request, repoID, number int64, fetch func(*sql.DB, int64, int64) (T, error)) (T, bool) { var zero T item, err := fetch(s.database, repoID, number) if errors.Is(err, db.ErrNotFound) { http.NotFound(w, r) return zero, false } if err != nil { s.internalError(w, r, err) return zero, false } return item, true } // handleIssueView shows one issue and its comments. A non-owner cannot see a // pending issue; pending comments are visible only to the owner. func (s *Server) handleIssueView(w http.ResponseWriter, r *http.Request) { repo, user, number, ok := s.repoNumber(w, r) if !ok { return } issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber) if !ok { return } ownerView := isOwner(user, repo) if issue.Pending && !ownerView { http.NotFound(w, r) return } comments, err := db.ListComments(s.database, issue.ID, ownerView) if err != nil { s.internalError(w, r, err) return } base := threadHref(issueBasePath(r), number) data := issueViewData{ navData: nav(r, repo, user, "issues"), Number: number, Title: issue.Title, State: issue.State, Pending: issue.Pending, Author: guestAuthorName(issue.AuthorName), AuthorIsOwner: issue.AuthorID.Valid && issue.AuthorID.Int64 == repo.OwnerID, AuthorEmail: issue.AuthorEmail, Created: issuedAt(issue.CreatedAt), BodyHTML: markdownHTML(issue.Body), IsOwner: ownerView, CanWrite: canWriteContent(user, repo), Base: base, Submitted: submitted(r), } data.Comments = commentViews(comments, repo.OwnerID, ownerView, base, issueCommentRow) s.render(w, "issue.html", http.StatusOK, data) } type issueStateData struct { Base string State string IsOwner bool Pending bool } // handleIssueState closes or reopens an issue (owner-only). func (s *Server) handleIssueState(w http.ResponseWriter, r *http.Request, state string) { repo, _, number, ok := s.ownerNumber(w, r) if !ok { return } issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber) if !ok { return } if err := db.SetIssueState(s.database, repo.ID, number, state); err != nil { s.internalError(w, r, err) return } base := threadHref(issueBasePath(r), number) if isHTMX(r) { s.renderFragment(w, "issue.html", "state", issueStateData{ Base: base, State: state, IsOwner: true, Pending: issue.Pending, }) return } http.Redirect(w, r, base, http.StatusSeeOther) } // handleCreateComment stores an issue comment through the shared comment // pipeline: owner comments publish immediately, guest comments are pending // moderation and are never echoed back as a visible comment. func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) { repo, user, number, ok := s.repoNumber(w, r) if !ok { return } base := threadHref(issueBasePath(r), number) issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber) if !ok { return } if issue.Pending && !isOwner(user, repo) { http.NotFound(w, r) return } s.createComment(w, r, repo, user, issue.ID, base, commentFlow{ page: "issue.html", pendingFrag: "comment_pending", commentFrag: "comment", create: func(in commentInput) (commentView, error) { created, err := db.CreateComment(s.database, in.parentID, in.body, in.authorID, in.authorName, in.authorEmail, in.pending) if err != nil { return commentView{}, err } return commentViews([]db.IssueComment{created}, repo.OwnerID, true, base, issueCommentRow)[0], nil }, }) } // commentFlow names the issue/pull-specific template pieces and the comment // constructor used by the shared createComment pipeline. type commentFlow struct { page string pendingFrag string commentFrag string create func(commentInput) (commentView, error) } // commentInput is everything createComment has resolved by the time it is // ready to store the comment. type commentInput struct { parentID int64 body string authorID int64 authorName string authorEmail string pending bool } // createComment is the shared guest/owner comment pipeline for issues and // pull requests. func (s *Server) createComment(w http.ResponseWriter, r *http.Request, repo db.Repo, user *db.User, parentID int64, base string, f commentFlow) { trusted := isOwner(user, repo) if !canWriteContent(user, repo) { http.NotFound(w, r) return } if !trusted && !s.guestComments.allow(clientIP(r)) { http.Error(w, "too many comments; try again later", http.StatusTooManyRequests) return } r.Body = http.MaxBytesReader(w, r.Body, issueFormMax) if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } body := formText(r, "body", maxIssueBody) if body == "" { if isHTMX(r) { http.Error(w, "a comment cannot be empty", http.StatusUnprocessableEntity) } else { http.Redirect(w, r, base, http.StatusSeeOther) } return } if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" { if isHTMX(r) { s.renderFragment(w, f.page, f.pendingFrag, nil) } else { http.Redirect(w, r, base+"?"+submittedParam+"=1", http.StatusSeeOther) } return } authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user")) view, err := f.create(commentInput{parentID, body, authorID, authorName, authorEmail, !trusted}) if err != nil { s.internalError(w, r, err) return } if !trusted { if isHTMX(r) { s.renderFragment(w, f.page, f.pendingFrag, nil) } else { http.Redirect(w, r, base+"?"+submittedParam+"=1", http.StatusSeeOther) } return } if isHTMX(r) { s.renderFragment(w, f.page, f.commentFrag, view) return } http.Redirect(w, r, base, http.StatusSeeOther) } // handleApproveIssue publishes a pending issue (owner-only). func (s *Server) handleApproveIssue(w http.ResponseWriter, r *http.Request) { s.moderateNumber(w, r, func(repo db.Repo, number int64) error { return db.ApproveIssue(s.database, repo.ID, number) }, issueBasePath(r)+"/"+r.PathValue("number")) } // handleDeleteIssue removes an issue (owner-only). func (s *Server) handleDeleteIssue(w http.ResponseWriter, r *http.Request) { s.moderateNumber(w, r, func(repo db.Repo, number int64) error { return db.DeleteIssue(s.database, repo.ID, number) }, issueBasePath(r)) } // moderateNumber resolves the owner-only thread target, runs fn on it, and // redirects to the caller's next page. Shared by issue and PR moderation. func (s *Server) moderateNumber(w http.ResponseWriter, r *http.Request, fn func(db.Repo, int64) error, redirect string) { repo, _, number, ok := s.ownerNumber(w, r) if !ok { return } if err := fn(repo, number); err != nil { s.internalError(w, r, err) return } http.Redirect(w, r, redirect, http.StatusSeeOther) } // repoNumber resolves a repo page and parses the {number} path value. func (s *Server) repoNumber(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, int64, bool) { repo, user, ok := s.repoPage(w, r) if !ok { return db.Repo{}, nil, 0, false } number, ok := issueNumber(r) if !ok { http.NotFound(w, r) return db.Repo{}, nil, 0, false } return repo, user, number, true } // ownerNumber resolves a repo page, requires ownership, and parses {number}. func (s *Server) ownerNumber(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, int64, bool) { repo, user, number, ok := s.repoNumber(w, r) if !ok { return db.Repo{}, nil, 0, false } if !isOwner(user, repo) { http.NotFound(w, r) return db.Repo{}, nil, 0, false } return repo, user, number, true } // handleModerateComment approves or deletes an issue comment (owner-only). func (s *Server) handleModerateComment(w http.ResponseWriter, r *http.Request, approve bool) { repo, _, number, ok := s.ownerNumber(w, r) if !ok { return } base := threadHref(issueBasePath(r), number) issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber) if !ok { return } id, ok := pathID(r) if !ok { http.NotFound(w, r) return } moderate := db.ApproveComment if !approve { moderate = db.DeleteComment } if err := moderate(s.database, issue.ID, id); err != nil { s.internalError(w, r, err) return } http.Redirect(w, r, base, http.StatusSeeOther) }