b8622ddd60a831ec5e82333e8c47f4706d65253e / internal/web/issues.go · 17297 bytes · raw
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("<pre>" + template.HTMLEscapeString(source) + "</pre>")
}
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)
}