From 2bf91d07a600c711338905b9c414f541c9165a59 Mon Sep 17 00:00:00 2001
From: Sarah Jamie Lewis <sarah@openprivacy.ca>
Date: Sat, 15 Feb 2025 11:45:57 -0800
Subject: [PATCH 1/1] Patch File Support

---
 auth/indieauth.go                   |   6 +-
 common/auth.go                      |  17 +-
 common/config.go                    |  20 +-
 common/request.go                   |  22 ++
 gen/generate.go                     |   2 +-
 go.mod                              |   6 +-
 go.sum                              |   2 +
 repo/repo.go                        |   6 +-
 repo/requests.go                    | 470 ++++++++++++++++++++++++++++
 repo/serve.go                       |  82 -----
 senary.go                           |  15 +-
 static/css/custom.css               |  27 +-
 templates/auth.success.tpl.html     |   9 +-
 templates/diffstat.tpl.html         |  27 ++
 templates/dir.tpl.html              |   9 +-
 templates/header.tpl.html           |   2 +-
 templates/index.tpl.html            |  10 +-
 templates/login.tpl.html            |   9 +-
 templates/prelude.tpl.html          |   9 +
 templates/repos.tpl.html            |   9 +-
 templates/request.element.tpl.html  |  73 +++++
 templates/request.list.tpl.html     |  41 ++-
 templates/request.new.tpl.html      |  11 +-
 templates/request.newpatch.tpl.html |  45 +++
 templates/request.thread.tpl.html   |  54 ++++
 25 files changed, 812 insertions(+), 171 deletions(-)
 create mode 100644 repo/requests.go
 delete mode 100644 repo/serve.go
 create mode 100644 templates/diffstat.tpl.html
 create mode 100644 templates/prelude.tpl.html
 create mode 100644 templates/request.element.tpl.html
 create mode 100644 templates/request.newpatch.tpl.html
 create mode 100644 templates/request.thread.tpl.html

diff --git a/auth/indieauth.go b/auth/indieauth.go
index 9f7c2c9..2163883 100644
--- a/auth/indieauth.go
+++ b/auth/indieauth.go
@@ -29,14 +29,16 @@ func NewAuthClient(config *common.Config) *AuthClient {
 	return &AuthClient{config: config, iac: iac, sessions: make(map[string]common.AuthInfo)}
 }
 
-func (c *AuthClient) GetAuthInfo(r *http.Request) common.AuthInfo {
+func (c *AuthClient) GetAuthInfo(config *common.Config, r *http.Request) common.AuthInfo {
 	cookie, err := r.Cookie(senaryAuthCookieName)
-	user := common.AuthInfo{Name: "guest", Domain: "guest"}
+	user := common.AuthInfo{Name: "guest", Domain: config.ClientID, IsMaintainer: false}
 	if cookie != nil && err == nil {
 		c.sessionMapLock.Lock()
 		if auth, exists := c.sessions[cookie.Value]; exists {
 			user.Name = auth.Name
 			user.Domain = auth.Domain
+			user.IsMaintainer = config.IsMaintainer(auth.Domain)
+			fmt.Printf("%v %v\n", user.IsMaintainer, auth.Domain)
 		}
 		c.sessionMapLock.Unlock()
 	}
diff --git a/common/auth.go b/common/auth.go
index d7f26cf..521a5e9 100644
--- a/common/auth.go
+++ b/common/auth.go
@@ -1,12 +1,21 @@
 package common
 
-import "fmt"
+import (
+	"fmt"
+	"strings"
+)
 
 type AuthInfo struct {
-	Name   string
-	Domain string
+	Name         string
+	Domain       string
+	IsMaintainer bool
 }
 
 func (ai AuthInfo) String() string {
-	return fmt.Sprintf("%s@%s", ai.Name, ai.Domain)
+	domain, isSecure := strings.CutPrefix(ai.Domain, "https://")
+	if !isSecure {
+		domain, _ = strings.CutPrefix(ai.Domain, "http://")
+	}
+	domain, _ = strings.CutSuffix(domain, "/")
+	return fmt.Sprintf("%s@%s", ai.Name, domain)
 }
diff --git a/common/config.go b/common/config.go
index 1aa8a8d..784fd41 100644
--- a/common/config.go
+++ b/common/config.go
@@ -5,16 +5,22 @@ import (
 	"fmt"
 	"html/template"
 	"os"
+	"slices"
 )
 
 type Config struct {
-	BaseDir     string // the directory where static html/css files are stored
-	RepoDir     string // the directory where static repo html files will be stored
-	RequestsDir string // the directory where saved change requests will be stored
-	ClientID    string
-	CallbackURI string
-	Templates   *template.Template
-	CommitDepth int // the number of commits per repository to statically compile into pages (0 = all)
+	BaseDir           string // the directory where static html/css files are stored
+	RepoDir           string // the directory where static repo html files will be stored
+	RequestsDir       string // the directory where saved change requests will be stored
+	ClientID          string
+	CallbackURI       string
+	Templates         *template.Template
+	CommitDepth       int // the number of commits per repository to statically compile into pages (0 = all)
+	MaintainerDomains []string
+}
+
+func (c *Config) IsMaintainer(domain string) bool {
+	return slices.Contains(c.MaintainerDomains, domain)
 }
 
 func LoadConfig(path string) (*Config, error) {
diff --git a/common/request.go b/common/request.go
index 68bd453..2e5e9c9 100644
--- a/common/request.go
+++ b/common/request.go
@@ -2,15 +2,36 @@ package common
 
 import (
 	"encoding/json"
+	"html/template"
 	"time"
 )
 
+type RequestType int
+
+const (
+	RequestInit = RequestType(iota)
+	RequestReply
+	RequestPatch
+)
+
+type PatchInfo struct {
+	Title  string
+	Author string
+	Body   template.HTML
+}
+
 type IssueRequest struct {
 	Summary     string
 	Description string
 	Created     time.Time
 	ID          string
 	User        AuthInfo
+	PatchRef    string
+	RequestType RequestType
+	Approved    bool
+	Moderated   bool
+
+	PatchInfo PatchInfo
 }
 
 func NewIssueRequest(summary string, description string, user AuthInfo) (IssueRequest, error) {
@@ -21,6 +42,7 @@ func NewIssueRequest(summary string, description string, user AuthInfo) (IssueRe
 		Created:     time.Now(),
 		ID:          ident,
 		User:        user,
+		RequestType: RequestInit,
 	}, err
 }
 
diff --git a/gen/generate.go b/gen/generate.go
index eb82841..5ed7ee7 100644
--- a/gen/generate.go
+++ b/gen/generate.go
@@ -16,7 +16,7 @@ import (
 )
 
 type StaticBase struct {
-	User string
+	User common.AuthInfo
 	Repo string
 }
 
diff --git a/go.mod b/go.mod
index 71da0f4..066f87e 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,11 @@ go 1.23.2
 
 require github.com/go-git/go-git/v5 v5.13.2
 
+require (
+	github.com/bluekeyes/go-gitdiff v0.8.1
+	go.hacdias.com/indielib v0.4.3
+)
+
 require (
 	dario.cat/mergo v1.0.1 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
@@ -20,7 +25,6 @@ require (
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/skeema/knownhosts v1.3.0 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
-	go.hacdias.com/indielib v0.4.3 // indirect
 	golang.org/x/crypto v0.33.0 // indirect
 	golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
 	golang.org/x/net v0.35.0 // indirect
diff --git a/go.sum b/go.sum
index 44f82e9..d83e3fb 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI=
+github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
 github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys=
 github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
 github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM=
diff --git a/repo/repo.go b/repo/repo.go
index f488c16..8f0bd96 100644
--- a/repo/repo.go
+++ b/repo/repo.go
@@ -24,7 +24,7 @@ func NewRepository(config *common.Config, repo string, ac *auth.AuthClient) *Rep
 }
 
 type StaticDir struct {
-	User    string
+	User    common.AuthInfo
 	Table   template.HTML
 	DirName string
 	Repo    string
@@ -56,9 +56,9 @@ func (repo *Repository) ServeTree(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	user := repo.ac.GetAuthInfo(r)
+	user := repo.ac.GetAuthInfo(repo.config, r)
 
-	err = repo.config.Templates.ExecuteTemplate(w, "dir.tpl.html", StaticDir{Table: template.HTML(data), DirName: filepath.Dir(localFile), Repo: repo.repo, User: user.String()})
+	err = repo.config.Templates.ExecuteTemplate(w, "dir.tpl.html", StaticDir{Table: template.HTML(data), DirName: filepath.Dir(localFile), Repo: repo.repo, User: user})
 	if err != nil {
 		http.Error(w, fmt.Errorf("file not found").Error(), http.StatusBadRequest)
 	}
diff --git a/repo/requests.go b/repo/requests.go
new file mode 100644
index 0000000..befed86
--- /dev/null
+++ b/repo/requests.go
@@ -0,0 +1,470 @@
+package repo
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"sync"
+
+	"html/template"
+	"net/http"
+	"os"
+	"path"
+	"path/filepath"
+	"senary/auth"
+	"senary/common"
+	"slices"
+	"strings"
+
+	"github.com/bluekeyes/go-gitdiff/gitdiff"
+)
+
+type RequestList struct {
+	User     common.AuthInfo
+	Repo     string
+	Requests []*common.IssueRequest
+}
+
+type RequestManager struct {
+	config *common.Config
+	repo   string
+	ac     *auth.AuthClient
+	lock   sync.Mutex
+}
+
+type RequestElement struct {
+	Repo    string
+	Request string
+	User    common.AuthInfo
+	Issue   common.IssueRequest
+}
+
+type RequestThread struct {
+	User     common.AuthInfo
+	Repo     string
+	Title    string
+	ID       string
+	Elements []template.HTML
+}
+
+func NewRequestManager(config *common.Config, repo string, ac *auth.AuthClient) *RequestManager {
+	return &RequestManager{config: config, repo: repo, ac: ac}
+}
+
+func (rm *RequestManager) ServePatch(w http.ResponseWriter, r *http.Request) {
+	idString := r.PathValue("id")
+	patchHash := r.PathValue("patch")
+	patchfile, err := os.Open(rm.RequestIssueElement(idString, patchHash))
+	if err == nil {
+		defer patchfile.Close()
+		w.Header().Set("Content-Disposition", "attachment; filename="+patchHash+".patch")
+		w.Header().Set("Content-Type", "text/x-diff")
+		io.Copy(w, patchfile)
+		return
+	}
+	http.Redirect(w, r, "/404", 404)
+}
+
+func (rm *RequestManager) Serve(w http.ResponseWriter, r *http.Request) {
+
+	if strings.HasSuffix(r.URL.Path, "/new") {
+		if value := r.FormValue("type"); value == "issue" {
+			rm.config.Templates.ExecuteTemplate(w, "request.new.tpl.html", RequestList{Repo: rm.repo, User: rm.ac.GetAuthInfo(rm.config, r)})
+			return
+		}
+		if value := r.FormValue("type"); value == "patch" {
+			rm.config.Templates.ExecuteTemplate(w, "request.newpatch.tpl.html", RequestList{Repo: rm.repo, User: rm.ac.GetAuthInfo(rm.config, r)})
+			return
+		}
+	}
+
+	// assume this is an issue thread...
+	if !strings.HasSuffix(r.URL.Path, "/") {
+		http.Redirect(w, r, r.URL.String()+"/", http.StatusMovedPermanently)
+	}
+
+	// list all issues
+	if strings.HasSuffix(r.URL.Path, "requests/") {
+		authuser := rm.ac.GetAuthInfo(rm.config, r)
+		rl := RequestList{Repo: rm.repo, User: authuser}
+
+		// Get a list of issues in creation order...
+		issueList := rm.LoadIssues(rm.RequestLogPath(), func(hash string) (*common.IssueRequest, error) {
+			data, err := os.ReadFile(rm.RequestIssuePath(hash))
+			if err == nil {
+				var issue common.IssueRequest
+				err := json.Unmarshal(data, &issue)
+				return &issue, err
+			}
+			return nil, err
+		},
+			func(ir common.IssueRequest) bool {
+				return authuser.IsMaintainer
+			})
+		slices.Reverse(issueList)
+		rl.Requests = issueList
+
+		err := rm.config.Templates.ExecuteTemplate(w, "request.list.tpl.html", rl)
+		if err != nil {
+			fmt.Printf("error %v\n", err)
+		}
+		return
+	}
+
+	// otherwise list a single issue...
+	authuser := rm.ac.GetAuthInfo(rm.config, r)
+	// otherwise...
+	rt := RequestThread{Repo: rm.repo, User: rm.ac.GetAuthInfo(rm.config, r)}
+	issuehash := path.Base(r.URL.Path)
+	// Get a list of issues in creation order...
+
+	issueList := rm.LoadIssues(rm.RequestIssueLogPath(issuehash), func(hash string) (*common.IssueRequest, error) {
+		data, err := os.ReadFile(rm.RequestIssueElement(issuehash, hash))
+		if err == nil {
+			var issue common.IssueRequest
+			err := json.Unmarshal(data, &issue)
+			return &issue, err
+		}
+		return nil, err
+	},
+		func(ir common.IssueRequest) bool {
+			return ir.RequestType == common.RequestInit || authuser.IsMaintainer
+		})
+
+	for _, issue := range issueList {
+
+		if issue.RequestType == common.RequestInit {
+			rt.Title = issue.Summary
+			rt.ID = issue.ID
+		}
+
+		var doc bytes.Buffer
+		rm.config.Templates.ExecuteTemplate(&doc, "request.element.tpl.html", RequestElement{Request: issuehash, Repo: rm.repo, Issue: *issue, User: authuser})
+		rt.Elements = append(rt.Elements, template.HTML(doc.String()))
+	}
+
+	err := rm.config.Templates.ExecuteTemplate(w, "request.thread.tpl.html", rt)
+	if err != nil {
+		fmt.Printf("error %v\n", err)
+	}
+}
+
+func (rm *RequestManager) LoadIssues(logpath string, loadfn func(hash string) (*common.IssueRequest, error), approve func(common.IssueRequest) bool) []*common.IssueRequest {
+
+	issueMap := make(map[string]*common.IssueRequest)
+
+	common.LogIer(logpath, func(le common.LogEntry) {
+		//isFirst := issuehash == le.Hash
+		if le.Type == common.LOG_APPROVED {
+			issueMap[le.Hash].Approved = true
+			issueMap[le.Hash].Moderated = false
+		} else if le.Type == common.LOG_DELETED {
+			issueMap[le.Hash].Approved = false
+			issueMap[le.Hash].Moderated = false
+		} else {
+
+			issue, err := loadfn(le.Hash)
+			if err == nil {
+
+				issueMap[le.Hash] = issue
+				issueMap[le.Hash].Moderated = true
+				issueMap[le.Hash].Approved = approve(*issue)
+
+				if issue.PatchRef != "" {
+					// NOTE: this currently takes advantage of the fact that patches can only be associated with a top
+					// level issue, this is not the ideal long term...
+					patchfile, err := os.Open(rm.RequestIssueElement(le.Hash, issue.PatchRef))
+					if err == nil {
+						files, prelude, err := gitdiff.Parse(patchfile)
+						if err == nil {
+							header, err := gitdiff.ParsePatchHeader(prelude)
+							if err == nil {
+								patchSummary := common.PatchInfo{}
+								patchSummary.Title = header.Title
+								if header.Author != nil {
+									patchSummary.Author = header.Author.String()
+								}
+
+								var doc bytes.Buffer
+								rm.config.Templates.ExecuteTemplate(&doc, "diffstat.tpl.html", files)
+								patchSummary.Body = template.HTML(doc.String())
+								issue.PatchInfo = patchSummary
+							}
+						}
+					}
+				}
+
+			}
+		}
+	})
+
+	issueList := []*common.IssueRequest{}
+
+	for _, issue := range issueMap {
+		if issue.Approved {
+			issueList = append(issueList, issue)
+		}
+	}
+
+	slices.SortFunc(issueList, func(a *common.IssueRequest, b *common.IssueRequest) int {
+		return a.Created.Compare(b.Created)
+	})
+
+	return issueList
+}
+
+func (rm *RequestManager) RequestLogPath() string {
+	return filepath.Join(rm.config.RequestsDir, rm.repo, "log")
+}
+
+func (rm *RequestManager) RequestIssuePath(issuehash string) string {
+	return filepath.Join(rm.config.RequestsDir, rm.repo, issuehash, issuehash)
+}
+
+func (rm *RequestManager) RequestIssueElement(issuehash string, subhash string) string {
+	return filepath.Join(rm.config.RequestsDir, rm.repo, issuehash, subhash)
+}
+
+func (rm *RequestManager) RequestIssueLogPath(issuehash string) string {
+	return filepath.Join(rm.config.RequestsDir, rm.repo, issuehash, "log")
+}
+
+func (rm *RequestManager) handleApprove(w http.ResponseWriter, r *http.Request) {
+
+	if rm.ac.GetAuthInfo(rm.config, r).IsMaintainer {
+		id := r.FormValue("requestid")
+		eid := r.FormValue("elementid")
+
+		if len(eid) == 0 {
+			// approving a top-level
+			err := rm.updateLog(rm.RequestLogPath(), id, common.LOG_APPROVED)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+		} else {
+			// approving a sub-level
+			err := rm.updateLog(rm.RequestIssueLogPath(id), eid, common.LOG_APPROVED)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+		}
+	}
+	http.Redirect(w, r, r.Header.Get("Referer"), 302)
+}
+
+func (rm *RequestManager) handleTombstone(w http.ResponseWriter, r *http.Request) {
+
+	if rm.ac.GetAuthInfo(rm.config, r).IsMaintainer {
+		id := r.FormValue("requestid")
+		eid := r.FormValue("elementid")
+
+		if len(eid) == 0 {
+			// approving a top-level
+			err := rm.updateLog(rm.RequestLogPath(), id, common.LOG_DELETED)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+		} else {
+			// approving a sub-level
+			err := rm.updateLog(rm.RequestIssueLogPath(id), eid, common.LOG_DELETED)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+		}
+	}
+	http.Redirect(w, r, r.Header.Get("Referer"), 302)
+}
+
+// thread safe logging (note: currently this means a global lock per repo for threads, this is likely fine for the target of smaller sites)
+// as the locks are only used when recording hashes for new files/approvals/deletions
+// TODO: in the future, break this down further to a lock per log?
+func (rm *RequestManager) updateLog(logpath string, hash string, status common.LogType) error {
+	rm.lock.Lock()
+	defer rm.lock.Unlock()
+	return common.WriteToLog(logpath, hash, status)
+}
+
+func (rm *RequestManager) handlePatch(w http.ResponseWriter, r *http.Request) {
+	// Parse our multipart form, 10 << 20 specifies a maximum
+	// upload of 10 MB files.
+	r.ParseMultipartForm(1024 * 1024 * 200)
+	// FormFile returns the first file for the given key `myFile`
+	// it also returns the FileHeader so we can get the Filename,
+	// the Header and the size of the file
+	file, _, err := r.FormFile("patchfile")
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	defer file.Close()
+
+	id, err := common.RandomIdent()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	fileBytes, err := io.ReadAll(file)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	// TODO check that this is a patch file...
+	buf := bytes.NewBuffer(fileBytes)
+	_, description, err := gitdiff.Parse(buf)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	summary := r.FormValue("summary")
+	issueRequest, err := common.NewIssueRequest(summary, description, rm.ac.GetAuthInfo(rm.config, r))
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	issueRequest.PatchRef = id
+
+	patchRef := filepath.Join(rm.config.RequestsDir, rm.repo, issueRequest.ID, issueRequest.PatchRef)
+	os.MkdirAll(filepath.Dir(patchRef), 0755)
+	patchFile, _ := os.Create(patchRef)
+	defer patchFile.Close()
+	// write this byte array to our temporary file
+	patchFile.Write(fileBytes)
+
+	rm.SaveIssue(&issueRequest, w, r)
+
+	http.Redirect(w, r, path.Join("repos", rm.repo, "requests", issueRequest.ID), http.StatusTemporaryRedirect)
+}
+
+func (rm *RequestManager) SaveIssue(issueRequest *common.IssueRequest, w http.ResponseWriter, r *http.Request) {
+	requestName := filepath.Join(rm.config.RequestsDir, rm.repo, issueRequest.ID, issueRequest.ID)
+	os.MkdirAll(filepath.Dir(requestName), 0755)
+	err := os.WriteFile(requestName, issueRequest.Serialize(), 0644)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	// create the parent entry of the requests log
+	err = rm.updateLog(filepath.Join(rm.config.RequestsDir, rm.repo, issueRequest.ID, "log"), issueRequest.ID, common.LOG_CREATED)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	// finally write to our main requests log
+	err = rm.updateLog(filepath.Join(rm.config.RequestsDir, rm.repo, "log"), issueRequest.ID, common.LOG_CREATED)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	if issueRequest.User.IsMaintainer {
+		// Auth Approve Maintainer Created Issues
+		err = rm.updateLog(filepath.Join(rm.config.RequestsDir, rm.repo, "log"), issueRequest.ID, common.LOG_APPROVED)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+	}
+
+}
+
+func (rm *RequestManager) handleNew(w http.ResponseWriter, r *http.Request) {
+
+	summary := r.FormValue("summary")
+	description := r.FormValue("description")
+	replyto := r.FormValue("replyto")
+	newtype := r.FormValue("type")
+	if replyto == "" {
+
+		if newtype == "issue" {
+			if len(summary) == 0 || len(description) == 0 {
+				rm.Serve(w, r)
+				return
+			}
+
+			issueRequest, err := common.NewIssueRequest(summary, description, rm.ac.GetAuthInfo(rm.config, r))
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+
+			rm.SaveIssue(&issueRequest, w, r)
+			http.Redirect(w, r, path.Join("/repos", rm.repo, "requests", issueRequest.ID), http.StatusTemporaryRedirect)
+		} else if newtype == "patch" {
+			if len(summary) == 0 {
+				rm.Serve(w, r)
+				return
+			}
+			rm.handlePatch(w, r)
+		}
+	} else {
+		// we are replying to an issue....
+		if len(description) == 0 {
+			rm.Serve(w, r)
+			return
+		}
+		issueRequest, err := common.NewIssueRequest(summary, description, rm.ac.GetAuthInfo(rm.config, r))
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		issueRequest.RequestType = common.RequestReply
+
+		requestName := filepath.Join(rm.config.RequestsDir, rm.repo, replyto, issueRequest.ID)
+		err = os.WriteFile(requestName, issueRequest.Serialize(), 0644)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		// create the parent entry of the requests log
+		err = rm.updateLog(rm.RequestIssueLogPath(replyto), issueRequest.ID, common.LOG_CREATED)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		if issueRequest.User.IsMaintainer {
+			// Auth Approve Maintainer Created Issues
+			err = rm.updateLog(rm.RequestIssueLogPath(replyto), issueRequest.ID, common.LOG_APPROVED)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+
+		}
+
+		http.Redirect(w, r, path.Join("/repos", rm.repo, "requests", replyto)+"/", http.StatusTemporaryRedirect)
+		return
+	}
+}
+
+func (rm *RequestManager) Submit(w http.ResponseWriter, r *http.Request) {
+
+	if strings.HasSuffix(r.URL.Path, "/approve") {
+		rm.handleApprove(w, r)
+		return
+	}
+
+	if strings.HasSuffix(r.URL.Path, "/tombstone") {
+		rm.handleTombstone(w, r)
+		return
+	}
+
+	if strings.HasSuffix(r.URL.Path, "/new") {
+		rm.handleNew(w, r)
+		return
+	}
+
+	http.Redirect(w, r, "/404", 404)
+}
diff --git a/repo/serve.go b/repo/serve.go
deleted file mode 100644
index a6d68b4..0000000
--- a/repo/serve.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package repo
-
-import (
-	"net/http"
-	"os"
-	"path/filepath"
-	"senary/auth"
-	"senary/common"
-	"strings"
-)
-
-type RequestList struct {
-	User     string
-	Repo     string
-	Requests []Request
-}
-
-type Request struct {
-	Summary string
-}
-
-type RequestManager struct {
-	config *common.Config
-	repo   string
-	ac     *auth.AuthClient
-}
-
-func NewRequestManager(config *common.Config, repo string, ac *auth.AuthClient) *RequestManager {
-	return &RequestManager{config: config, repo: repo, ac: ac}
-}
-
-func (rm *RequestManager) Serve(w http.ResponseWriter, r *http.Request) {
-
-	if strings.HasSuffix(r.URL.Path, "/new") {
-		if value := r.FormValue("type"); value == "issue" {
-			rm.config.Templates.ExecuteTemplate(w, "request.new.tpl.html", RequestList{Repo: rm.repo, User: rm.ac.GetAuthInfo(r).String()})
-			return
-		}
-	}
-
-	rm.config.Templates.ExecuteTemplate(w, "request.list.tpl.html", RequestList{Repo: rm.repo, User: rm.ac.GetAuthInfo(r).String()})
-}
-
-func (rm *RequestManager) Submit(w http.ResponseWriter, r *http.Request) {
-
-	if strings.HasSuffix(r.URL.Path, "/new") {
-		summary := r.FormValue("summary")
-		description := r.FormValue("description")
-		issueRequest, err := common.NewIssueRequest(summary, description, rm.ac.GetAuthInfo(r))
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-
-		requestName := filepath.Join(rm.config.RequestsDir, rm.repo, issueRequest.ID, issueRequest.ID)
-		os.MkdirAll(filepath.Dir(requestName), 0755)
-		err = os.WriteFile(requestName, issueRequest.Serialize(), 0644)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-
-		// create the parent entry of the requests log
-		err = common.WriteToLog(filepath.Join(rm.config.RequestsDir, rm.repo, issueRequest.ID, "log"), issueRequest.ID, common.LOG_CREATED)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-
-		// finally write to our main requests log
-		err = common.WriteToLog(filepath.Join(rm.config.RequestsDir, rm.repo, "log"), issueRequest.ID, common.LOG_CREATED)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-
-		rm.config.Templates.ExecuteTemplate(w, "request.new.tpl.html", RequestList{Repo: rm.repo, User: rm.ac.GetAuthInfo(r).String()})
-		return
-	}
-
-	http.Redirect(w, r, "/404", 404)
-}
diff --git a/senary.go b/senary.go
index e70a30e..d17e56c 100644
--- a/senary.go
+++ b/senary.go
@@ -81,6 +81,8 @@ func main() {
 		os.Exit(1)
 	}
 
+	fmt.Printf("Maintainer Domains:%v\n", config.MaintainerDomains)
+
 	switch os.Args[1] {
 	case "serve":
 
@@ -90,7 +92,7 @@ func main() {
 		http.Handle("/", fs)
 
 		http.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
-			config.Templates.ExecuteTemplate(w, "index.tpl.html", gen.StaticDirListing{StaticBase: gen.StaticBase{User: authClient.GetAuthInfo(r).String()}})
+			config.Templates.ExecuteTemplate(w, "index.tpl.html", gen.StaticDirListing{StaticBase: gen.StaticBase{User: authClient.GetAuthInfo(config, r)}})
 		})
 
 		http.HandleFunc("GET /repos/{$}", func(w http.ResponseWriter, r *http.Request) {
@@ -103,11 +105,11 @@ func main() {
 					entries = append(entries, gen.FileEntry{Dir: true, Name: e.Name(), Path: e.Name()})
 				}
 			}
-			config.Templates.ExecuteTemplate(w, "repos.tpl.html", gen.StaticDirListing{StaticBase: gen.StaticBase{User: authClient.GetAuthInfo(r).String()}, Path: "Repositories", DirName: "Repositories", Contents: entries})
+			config.Templates.ExecuteTemplate(w, "repos.tpl.html", gen.StaticDirListing{StaticBase: gen.StaticBase{User: authClient.GetAuthInfo(config, r)}, Path: "Repositories", DirName: "Repositories", Contents: entries})
 		})
 
 		http.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
-			config.Templates.ExecuteTemplate(w, "login.tpl.html", gen.StaticDirListing{StaticBase: gen.StaticBase{User: authClient.GetAuthInfo(r).String()}})
+			config.Templates.ExecuteTemplate(w, "login.tpl.html", gen.StaticDirListing{StaticBase: gen.StaticBase{User: authClient.GetAuthInfo(config, r)}})
 		})
 		http.HandleFunc("POST /login", authClient.LoginHandler)
 		http.HandleFunc("GET /callback", authClient.CallbackHandler)
@@ -128,8 +130,11 @@ func main() {
 
 				// make a request handler for the repo
 				rm := repo.NewRequestManager(config, e.Name(), authClient)
-				http.HandleFunc("GET "+path.Join("/repos/", e.Name(), "requests")+"/", rm.Serve)
-				http.HandleFunc("POST "+path.Join("/repos/", e.Name(), "requests")+"/", rm.Submit)
+				http.HandleFunc(path.Join("/repos/", e.Name(), "requests")+"/", rm.Serve)
+				http.HandleFunc("POST "+path.Join("/repos/", e.Name(), "requests")+"/new", rm.Submit)
+				http.HandleFunc("POST "+path.Join("/repos/", e.Name(), "requests")+"/approve", rm.Submit)
+				http.HandleFunc("POST "+path.Join("/repos/", e.Name(), "requests")+"/tombstone", rm.Submit)
+				http.HandleFunc("GET "+path.Join("/repos/", e.Name(), "patch/{id}/{patch}")+"/{$}", rm.ServePatch)
 				// allow https cloning by redirecting to git https backend
 				http.HandleFunc(path.Join("/repos/", e.Name()+".git")+"/", gitBackend)
 			}
diff --git a/static/css/custom.css b/static/css/custom.css
index 275912b..9ec2613 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -37,8 +37,31 @@
 
 .repomain {
   display: grid;
-  grid-template-columns: 0.25fr 1fr;
+  grid-template-columns: 20% 80%;
   grid-template-rows: 1fr;
   grid-column-gap: 0px;
   grid-row-gap: 0px;
-  } 
\ No newline at end of file
+  } 
+
+
+  .shrink {
+    flex: unset;
+    margin-right:10px;
+  }
+
+
+
+  .opadd {
+    background-color: var(--pico-color-green-700);
+  }
+
+  .opdelete {
+    background-color:var(--pico-color-red-700);
+  }
+
+.diff {
+  width:100%;
+  margin:auto;
+  text-overflow: scroll;
+  font-size: 0.75em;
+}
\ No newline at end of file
diff --git a/templates/auth.success.tpl.html b/templates/auth.success.tpl.html
index b7ec6f1..a928735 100644
--- a/templates/auth.success.tpl.html
+++ b/templates/auth.success.tpl.html
@@ -1,11 +1,4 @@
-<!doctype html>
-<html>
-<head>
-<link rel="stylesheet" href="/css/pico.min.css">
-<link rel="stylesheet" href="/css/custom.css">
- <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <meta name="color-scheme" content="light dark">
+{{template "prelude.tpl.html" .}}
 
 <title>Senary</title>
 </head>
diff --git a/templates/diffstat.tpl.html b/templates/diffstat.tpl.html
new file mode 100644
index 0000000..5c3c3fa
--- /dev/null
+++ b/templates/diffstat.tpl.html
@@ -0,0 +1,27 @@
+{{range .}}
+
+    {{if eq .OldName .NewName}} 
+        <h4>{{.NewName}}</h4>
+    {{else if .IsDelete}}
+            <!-- file has moved-->
+        <h4>{{.OldName}} <mark>Deleted</mark></h4>
+    {{else if .IsRename}}
+        <h4>{{.OldName}} -> {{.NewName}} <mark>Renamed</mark></h4>
+    {{end}}
+
+    {{$p := .}}
+
+    {{range .TextFragments}}
+   
+     <details {{if $p.IsDelete}}{{else}}open{{end}}>
+        <summary>{{.Header}}</summary>
+            <pre class="diff">{{range .Lines}}{{if eq .Op 2}}<span class="opadd">{{.}}</span>
+            {{else if eq .Op 1}}<span class="opdelete">{{.}}</span>
+            {{else}} <span>{{.}}</span>
+            {{end}}{{end}}</pre>
+    </details>
+       
+
+    {{end}}
+
+{{end}}
\ No newline at end of file
diff --git a/templates/dir.tpl.html b/templates/dir.tpl.html
index 1430658..0e2f502 100644
--- a/templates/dir.tpl.html
+++ b/templates/dir.tpl.html
@@ -1,11 +1,4 @@
-<!doctype html>
-<html>
-<head>
-<link rel="stylesheet" href="/css/pico.min.css">
-<link rel="stylesheet" href="/css/custom.css">
- <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <meta name="color-scheme" content="light dark">
+{{template "prelude.tpl.html" .}}
 
 <title>{{.DirName}} - Senary</title>
 </head>
diff --git a/templates/header.tpl.html b/templates/header.tpl.html
index 1de9961..ee79d78 100644
--- a/templates/header.tpl.html
+++ b/templates/header.tpl.html
@@ -4,7 +4,7 @@
   </ul>
   <ul>
     <li><a href="/repos">Repositories</a></li>
-    <li><a href="/login">{{.User}}</a></li>
+    <li><a href="/login">{{.User.String}}</a></li>
 
     <li><style>
       .theme-toggle{color:var(--pico-primary);}
diff --git a/templates/index.tpl.html b/templates/index.tpl.html
index 77d1eec..a91345e 100644
--- a/templates/index.tpl.html
+++ b/templates/index.tpl.html
@@ -1,12 +1,4 @@
-<!doctype html>
-<html>
-<head>
-<link rel="stylesheet" href="/css/pico.min.css">
-<link rel="stylesheet" href="/css/custom.css">
- <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <meta name="color-scheme" content="light dark">
-
+{{template "prelude.tpl.html" .}}
 <title>Repositories - Senary</title>
 </head>
 <body>
diff --git a/templates/login.tpl.html b/templates/login.tpl.html
index 5dbdab4..de5357f 100644
--- a/templates/login.tpl.html
+++ b/templates/login.tpl.html
@@ -1,11 +1,4 @@
-<!doctype html>
-<html>
-<head>
-<link rel="stylesheet" href="/css/pico.min.css">
-<link rel="stylesheet" href="/css/custom.css">
- <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <meta name="color-scheme" content="light dark">
+{{template "prelude.tpl.html" .}}
 
 <title>Authenticate with IndieAuth - Senary</title>
 </head>
diff --git a/templates/prelude.tpl.html b/templates/prelude.tpl.html
new file mode 100644
index 0000000..e5fac23
--- /dev/null
+++ b/templates/prelude.tpl.html
@@ -0,0 +1,9 @@
+<!doctype html>
+<html>
+<head>
+<link rel="stylesheet" href="/css/pico.min.css">
+<link rel="stylesheet" href="/css/pico.colors.min.css">
+<link rel="stylesheet" href="/css/custom.css">
+ <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <meta name="color-scheme" content="light dark">
diff --git a/templates/repos.tpl.html b/templates/repos.tpl.html
index 72d3359..365e12c 100644
--- a/templates/repos.tpl.html
+++ b/templates/repos.tpl.html
@@ -1,11 +1,4 @@
-<!doctype html>
-<html>
-<head>
-<link rel="stylesheet" href="/css/pico.min.css">
-<link rel="stylesheet" href="/css/custom.css">
- <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <meta name="color-scheme" content="light dark">
+{{template "prelude.tpl.html" .}}
 
 <title>Repositories - Senary</title>
 </head>
diff --git a/templates/request.element.tpl.html b/templates/request.element.tpl.html
new file mode 100644
index 0000000..cec1c03
--- /dev/null
+++ b/templates/request.element.tpl.html
@@ -0,0 +1,73 @@
+
+{{if ne .Issue.PatchRef ""}}
+
+<article class="issueelement">
+  <header>{{.Issue.User}}  submitted a <a href="/repos/{{.Repo}}/patch/{{.Issue.ID}}/{{.Issue.PatchRef}}">patch request</a> {{.Issue.Created.Format "Jan 02, 2006 15:04" }} 
+
+  </header>
+  <ul>
+      <li><strong>Title:</strong> {{.Issue.PatchInfo.Title}}</li>
+      <li><strong>Author:</strong> {{.Issue.PatchInfo.Author}}</li>
+    </ul>
+        
+     {{.Issue.PatchInfo.Body}}
+       
+
+
+
+</article>
+
+
+{{else if eq .Issue.RequestType 0}}
+
+<article class="issueelement">
+  <header>{{.Issue.User}} commented {{.Issue.Created.Format "Jan 02, 2006 15:04" }} 
+
+  </header>
+      <p>{{.Issue.Description}}</p>
+
+
+
+</article>
+
+{{else}}
+
+
+<article class="issueelement">
+  <header>{{.Issue.User}} replied {{.Issue.Created.Format "Jan 02, 2006 15:04" }}
+
+        
+    {{if eq .Issue.Moderated true}}
+     <mark>Pending</mark>
+    {{end}}
+  </header>
+      <p>{{.Issue.Description}}</p>
+
+
+      {{if eq .User.IsMaintainer true}} 
+      <footer>
+       <div role="group">
+
+         
+         <form class="shrink" method="post" action="/repos/{{.Repo}}/requests/approve">
+            <input hidden name="requestid" value="{{.Request}}" />
+            <input hidden name="elementid" value="{{.Issue.ID}}" />
+            <button data-tooltip="Approve Comment">✓</button>
+          </form>
+
+          <form  class="shrink" method="post" action="/repos/{{.Repo}}/requests/tombstone">
+            <input hidden name="requestid" value="{{.Request}}" />
+            <input hidden name="elementid" value="{{.Issue.ID}}" />
+            <button data-tooltip="Delete Comment">🗑</button>
+          </form>
+        </div>
+      </footer>
+      {{end}}
+
+</article>
+
+
+{{end}}
+
+
+
diff --git a/templates/request.list.tpl.html b/templates/request.list.tpl.html
index d461c36..68567c0 100644
--- a/templates/request.list.tpl.html
+++ b/templates/request.list.tpl.html
@@ -1,12 +1,4 @@
-<!doctype html>
-<html>
-<head>
-<link rel="stylesheet" href="/css/pico.min.css">
-<link rel="stylesheet" href="/css/custom.css">
- <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <meta name="color-scheme" content="light dark">
-
+{{template "prelude.tpl.html"}}
 <title>{{.Repo}} Change Requests - Senary</title>
 </head>
 <body>
@@ -18,13 +10,36 @@
 {{template "repomenu.tpl.html" .}}
 <div>
     <section>
-    <a href="./new?type=issue"><button>New Issue</button></a>
-    <a href="./new?type=patch"><button>Submit Patch</button></a>
+        <a href="/repos/{{.Repo}}/requests/new?type=issue"><button>New Issue</button></a>
+    <a href="/repos/{{.Repo}}/requests/new?type=patch"><button>Submit Patch</button></a>
     </section>
 <table class="striped">
-<tr><th>Summary</th></tr>
+<tr><th>Summary</th><th>Created</th>
+{{if .User.IsMaintainer}}
+<th>Maintainer Actions</th>
+{{end}}
+</tr>
+{{$p := .}}
 {{range .Requests}}
-    <tr><td>{{.Summary}}</td></tr>
+    <tr><td><a href="./{{.ID}}">{{.Summary}}</a></td><td>{{.Created.Format "Jan 02, 2006 15:04" }}</td>
+    
+        {{if eq $p.User.IsMaintainer true}}
+        <td  class="grid">
+        
+
+         <form method="post" action="/repos/{{$p.Repo}}/requests/approve">
+            <input hidden name="requestid" value="{{.ID}}" />
+            <button>Approve</button>
+          </form>
+        
+          <form method="post" action="/repos/{{$p.Repo}}/requests/tombstone">
+            <input hidden name="requestid" value="{{.ID}}" />
+            <button>Delete</button>
+          </form>
+
+        </td>
+        {{end}}
+    </tr>
 {{end}}
 </table>
 </div>
diff --git a/templates/request.new.tpl.html b/templates/request.new.tpl.html
index eab3cff..19b2682 100644
--- a/templates/request.new.tpl.html
+++ b/templates/request.new.tpl.html
@@ -1,12 +1,4 @@
-<!doctype html>
-<html>
-<head>
-<link rel="stylesheet" href="/css/pico.min.css">
-<link rel="stylesheet" href="/css/custom.css">
- <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <meta name="color-scheme" content="light dark">
-
+{{template "prelude.tpl.html" .}}
 <title>{{.Repo}} New Change Request - Senary</title>
 </head>
 <body>
@@ -17,6 +9,7 @@
 
 {{template "repomenu.tpl.html" .}}
 <div>
+  <h2>Submit a New Change Request</h2>
 <form method="post" >
   <fieldset>
     <label>
diff --git a/templates/request.newpatch.tpl.html b/templates/request.newpatch.tpl.html
new file mode 100644
index 0000000..d765fb1
--- /dev/null
+++ b/templates/request.newpatch.tpl.html
@@ -0,0 +1,45 @@
+{{template "prelude.tpl.html" .}}
+
+<title>{{.Repo}} New Change Request - Senary</title>
+</head>
+<body>
+    <header class="container">
+        {{ template "header.tpl.html" .}}
+    </header>
+<main class="container repomain">
+
+{{template "repomenu.tpl.html" .}}
+<div>
+  <h2>Submit a New Change Request</h2>
+<form method="post" enctype="multipart/form-data" >
+  <fieldset>
+    <label>
+      Summary
+      <input
+        name="summary"
+        placeholder="A one line summary of the issue/request"
+      />
+    </label>
+        <label>
+      Patch File
+      <input
+        type="file"
+        name="patchfile"
+      />
+    </label>
+
+  </fieldset>
+
+  <input
+    type="submit"
+    value="Suggest Issue"
+  />
+</form>
+</div>
+</main>
+
+<footer>
+</footer>
+ 
+</body>
+</html>
\ No newline at end of file
diff --git a/templates/request.thread.tpl.html b/templates/request.thread.tpl.html
new file mode 100644
index 0000000..4d8532d
--- /dev/null
+++ b/templates/request.thread.tpl.html
@@ -0,0 +1,54 @@
+{{template "prelude.tpl.html" .}}
+<title>{{.Repo}} Change Request - {{.Title}} - Senary</title>
+</head>
+<body>
+    <header class="container">
+        {{ template "header.tpl.html" .}}
+    </header>
+<main class="container repomain">
+
+{{template "repomenu.tpl.html" .}}
+<div>
+    <section>
+    <a href="/repos/{{.Repo}}/requests/new?type=issue"><button>New Issue</button></a>
+    <a href="/repos/{{.Repo}}/requests/new?type=patch"><button>Submit Patch</button></a>
+    </section>
+
+<section>
+    <span class=""> <h3>{{.Title}}</h3></span>
+{{range .Elements}}
+    {{.}}
+{{end}}
+
+
+<form method="post" action="/repos/{{.Repo}}/requests/new?type=reply">
+     <fieldset role="group">
+        <input
+        name="replyto"
+        hidden
+        value="{{.ID}}"
+      />
+      <label>
+        Reply
+        <textarea
+          name="description"
+          placeholder="Add to this Change Request"
+        ></textarea>
+      </label>
+    </fieldset>
+    <input
+      type="submit"
+      value="Reply"
+    />
+  </form>
+
+
+</section>
+</div>
+</main>
+
+<footer>
+</footer>
+ 
+</body>
+</html>
\ No newline at end of file
-- 
2.43.0

