Patch demo
This Patch Does Not Apply To The Current Repository- Title: Patch File Support
- Author: Sarah Jamie Lewis <sarah@openprivacy.ca>
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() }
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) }
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) {
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 }
gen/generate.go
@@ -16,7 +16,7 @@ import (
) type StaticBase struct { - User string + User common.AuthInfo Repo string }
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
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=
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) }
@@ -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) +}
repo/serve.go Deleted
@@ -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) -}
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) }
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; - } + } + + + .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; +}
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>
@@ -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}}
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>
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);}
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>
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>
@@ -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">
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>
@@ -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}} + + +
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>
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>
@@ -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>
@@ -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>
Testing a Reply