fierj
PublicTiny personal git forge
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)
}
}