New Sign in

fierj

Public

Tiny personal git forge

← fierj / thread_handlers.go
package main

import (
	"fmt"
	"html/template"
	"log/slog"
	"net/http"
	"path/filepath"
	"strings"
)

func repoPath(cfg Config, repoName string) string {
	return filepath.Join(cfg.Dir, repoName+".git")
}

func ThreadsList(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		state := r.URL.Query().Get("state")
		if state == "" {
			state = "open"
		}
		ref := git.DefaultBranch()
		rp := repoPath(cfg, git.Name)
		threads, openCount, closedCount := listThreadsFiltered(rp, state)
		tmpl.ExecuteTemplate(w, "thread_list.html", withUser(r, map[string]any{
			"Repo":        git.Repo(),
			"IsPrivate":   git.IsPrivate(),
			"Ref":         ref,
			"Threads":     threads,
			"State":       state,
			"OpenCount":   openCount,
			"ClosedCount": closedCount,
			"Description": git.Description(),
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "threads",
			"Host":        cfg.Host,
			"APEnabled":   cfg.Host != "",
		}))
	}
}

func ThreadNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		ref := git.DefaultBranch()
		tmpl.ExecuteTemplate(w, "thread_new.html", withUser(r, map[string]any{
			"Repo":        git.Repo(),
			"IsPrivate":   git.IsPrivate(),
			"Ref":         ref,
			"Description": git.Description(),
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "threads",
			"Host":        cfg.Host,
			"APEnabled":   cfg.Host != "",
		}))
	}
}

func ThreadNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		title := strings.TrimSpace(r.FormValue("title"))
		body := strings.TrimSpace(r.FormValue("body"))
		if title == "" {
			renderError(w, r, tmpl, http.StatusBadRequest, "Title is required")
			return
		}
		author, authorName := threadAuthor(r)
		rp := repoPath(cfg, git.Name)
		t, err := createThread(rp, title, body, author, authorName)
		if err != nil {
			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
			return
		}
		// Deliver to ActivityPub followers (non-blocking).
		go deliverThreadCreate(cfg, git.Name, t)
		http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, t.ID), http.StatusSeeOther)
	}
}

func ThreadView(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		threadID := r.PathValue("threadId")
		rp := repoPath(cfg, git.Name)
		t, err := loadThread(rp, threadID)
		if err != nil {
			renderError(w, r, tmpl, http.StatusNotFound, "Thread not found")
			return
		}
		ref := git.DefaultBranch()
		apEnabled := cfg.Host != ""
		actorURLStr := ""
		if apEnabled {
			actorURLStr = actorURL(cfg.Host, git.Name)
		}
		if err := tmpl.ExecuteTemplate(w, "thread_view.html", withUser(r, map[string]any{
			"Repo":        git.Repo(),
			"IsPrivate":   git.IsPrivate(),
			"Ref":         ref,
			"Thread":      t,
			"ReplyViews":  buildReplyViews(t.Replies),
			"Description": git.Description(),
			"Branches":    git.Branches(),
			"Tags":        git.Tags(),
			"Commits":     git.CommitCount(ref),
			"ActiveTab":   "threads",
			"Host":        cfg.Host,
			"APEnabled":   apEnabled,
			"ActorURL":    actorURLStr,
		})); err != nil {
			slog.Error("thread view template", "error", err)
		}
	}
}

func ThreadReplyPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		threadID := r.PathValue("threadId")
		body := strings.TrimSpace(r.FormValue("body"))
		if body == "" {
			http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
			return
		}
		author, authorName := threadAuthor(r)
		rp := repoPath(cfg, git.Name)
		parentID := strings.TrimSpace(r.FormValue("parent_id"))
		if err := addNestedReply(rp, threadID, parentID, body, author, authorName); err != nil {
			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
			return
		}
		// Deliver reply to ActivityPub followers.
		t, _ := loadThread(rp, threadID)
		if t != nil {
			go deliverReplyCreate(cfg, git.Name, t)
		}
		http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
	}
}

// threadAuthor identifies the actor posting a thread/reply. Logged-in users
// are attributed by their account name. Anonymous web visitors are always
// recorded as "anonymous" (no identity is verified), but may attach a
// free-text display name so their comments are distinguishable in the UI.
func threadAuthor(r *http.Request) (author, displayName string) {
	if u := User(r); u != "" {
		return u, u
	}
	name := strings.TrimSpace(r.FormValue("guest_name"))
	if name == "" {
		return "anonymous", "Anonymous"
	}
	if len(name) > 60 {
		name = name[:60]
	}
	return "anonymous", name
}

func closeOrReopenWithReply(cfg Config, repoName string, threadID string, r *http.Request, tmpl *template.Template, w http.ResponseWriter, action func(string, string) error) {
	body := strings.TrimSpace(r.FormValue("body"))
	rp := repoPath(cfg, repoName)

	// If the user wrote a comment, save it before changing state.
	if body != "" {
		author, authorName := threadAuthor(r)
		if err := addReply(rp, threadID, body, author, authorName); err != nil {
			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
			return
		}
	}

	if err := action(rp, threadID); err != nil {
		renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", repoName, threadID), http.StatusSeeOther)
}

func ThreadClosePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		closeOrReopenWithReply(cfg, git.Name, r.PathValue("threadId"), r, tmpl, w, closeThread)
	}
}

func ThreadReopenPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
		if !checkRepoAccess(w, r, git) {
			return
		}
		closeOrReopenWithReply(cfg, git.Name, r.PathValue("threadId"), r, tmpl, w, reopenThread)
	}
}