josie / simplegit

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