package httpd import ( "context" "errors" "html/template" "net/http" "net/url" "os" "sort" "strconv" "strings" "time" "github.com/barerepo/server/internal/artifact" "github.com/barerepo/server/internal/gitread" "github.com/barerepo/server/internal/gitx" "github.com/barerepo/server/internal/markup" "github.com/barerepo/server/internal/proposal" "github.com/barerepo/server/internal/repo" "github.com/barerepo/server/internal/repocfg" "github.com/barerepo/server/internal/run" "github.com/barerepo/server/internal/search" "github.com/barerepo/server/internal/store" "github.com/barerepo/server/internal/thread" "github.com/barerepo/server/internal/token" "github.com/barerepo/server/internal/transport" "github.com/barerepo/server/internal/workflow" ) // serveWeb is the read-only web interface. Routes are appendix C. func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") switch { case r.URL.Path == "/favicon.ico": // The design has no icon, and an empty answer beats a 404 in every console. w.WriteHeader(http.StatusNoContent) case r.URL.Path == "/": s.serveLanding(w, r) case r.URL.Path == "/runner.sh": s.serveRunnerScript(w, r) case r.URL.Path == "/runner.ps1": s.serveRunnerPS1(w, r) case r.URL.Path == "/runner/binary": s.serveRunnerBinary(w, r) case r.URL.Path == "/runner/attach" && r.Method == http.MethodPost: s.serveRunnerAttach(w, r) case r.URL.Path == "/runner/poll" && r.Method == http.MethodGet: s.serveRunnerPoll(w, r) case r.URL.Path == "/runner/log" && r.Method == http.MethodPost: s.serveRunnerLog(w, r) case r.URL.Path == "/runner/done" && r.Method == http.MethodPost: s.serveRunnerDone(w, r) case r.URL.Path == "/runner/artifact" && r.Method == http.MethodPost: s.serveRunnerArtifact(w, r) case r.URL.Path == "/keys" && isGet(r): s.serveKeys(w, r) case r.URL.Path == "/keys" && r.Method == http.MethodPost: s.serveAddKey(w, r) case r.URL.Path == "/gpgkeys" && r.Method == http.MethodPost: s.serveAddGPGKey(w, r) case r.URL.Path == "/gpgkeys/delete" && r.Method == http.MethodPost: s.serveDeleteGPGKey(w, r) case r.URL.Path == "/keys/delete" && r.Method == http.MethodPost: s.serveDeleteKey(w, r) case r.URL.Path == "/tokens" && r.Method == http.MethodPost: s.serveNewToken(w, r) case r.URL.Path == "/tokens/delete" && r.Method == http.MethodPost: s.serveDeleteToken(w, r) case r.URL.Path == "/inbox": s.serveInbox(w, r) case r.URL.Path == "/inbox.atom": s.serveInboxFeed(w, r) case r.URL.Path == "/search": s.serveSearch(w, r) case r.URL.Path == "/new": s.serveNewRepo(w, r) case r.URL.Path == "/signup": s.serveSignup(w, r) case r.URL.Path == "/signin" && isGet(r): s.serveSignin(w, r) case r.URL.Path == "/auth/challenge" && r.Method == http.MethodPost: s.serveChallenge(w, r) case r.URL.Path == "/auth/verify" && r.Method == http.MethodPost: s.serveVerify(w, r) case r.URL.Path == "/auth/claim" && isGet(r): s.serveClaim(w, r) case strings.HasPrefix(r.URL.Path, "/card/") && isGet(r): s.serveNamedCard(w, r, strings.Split(strings.TrimPrefix(r.URL.Path, "/card/"), "/")) case r.URL.Path == "/signout" && r.Method == http.MethodPost: s.serveSignout(w, r) case len(parts) == 1 && strings.HasSuffix(parts[0], ".keys"): s.serveUserSSHKeys(w, r, strings.TrimSuffix(parts[0], ".keys")) case len(parts) == 1 && strings.HasSuffix(parts[0], ".gpg"): s.serveUserKeys(w, r, strings.TrimSuffix(parts[0], ".gpg")) case len(parts) == 1 && strings.HasSuffix(parts[0], ".atom"): s.serveUserFeed(w, r, strings.TrimSuffix(parts[0], ".atom")) case len(parts) == 1: s.serveProfile(w, r, parts[0]) case len(parts) == 2 && strings.HasSuffix(parts[1], ".atom"): s.serveRepoFeed(w, r, parts[0], strings.TrimSuffix(parts[1], ".atom")) case len(parts) == 2: s.serveRepoLog(w, r, parts[0], parts[1]) case len(parts) == 3 && parts[2] == "config": s.serveRepoConfig(w, r, parts[0], parts[1], "") case len(parts) == 3 && (parts[2] == "rename" || parts[2] == "transfer") && r.Method == http.MethodPost: s.serveRepoMove(w, r, parts[0], parts[1], parts[2]) case len(parts) == 3 && parts[2] == "copy" && r.Method == http.MethodPost: s.serveRepoCopy(w, r, parts[0], parts[1]) case len(parts) == 3 && parts[2] == "delete" && r.Method == http.MethodPost: s.serveRepoDelete(w, r, parts[0], parts[1]) case len(parts) == 3 && parts[2] == "rejected": s.serveRejected(w, r, parts[0], parts[1]) case len(parts) == 3 && parts[2] == "releases": s.serveReleases(w, r, parts[0], parts[1]) case len(parts) == 4 && parts[2] == "release": s.serveRelease(w, r, parts[0], parts[1], parts[3]) case len(parts) == 5 && parts[2] == "release": s.serveReleaseFile(w, r, parts[0], parts[1], parts[3], parts[4]) case len(parts) == 3 && parts[2] == "runs": s.serveRuns(w, r, parts[0], parts[1]) case len(parts) == 4 && parts[2] == "run": s.serveRun(w, r, parts[0], parts[1], parts[3]) case len(parts) == 5 && parts[2] == "run" && parts[4] == "log": s.serveRunLog(w, r, parts[0], parts[1], parts[3]) case len(parts) == 5 && parts[2] == "run" && parts[4] == "rerun" && r.Method == http.MethodPost: s.serveRerun(w, r, parts[0], parts[1], parts[3]) case len(parts) == 3 && parts[2] == "runners": s.serveRunners(w, r, parts[0], parts[1]) case len(parts) == 4 && parts[2] == "runners" && (parts[3] == "new" || parts[3] == "token"): s.serveRunnerSetup(w, r, parts[0], parts[1]) case len(parts) == 4 && parts[2] == "runners" && parts[3] == "forget" && r.Method == http.MethodPost: s.serveForgetRunner(w, r, parts[0], parts[1]) case len(parts) == 3 && parts[2] == "threads.atom": s.serveThreadsFeed(w, r, parts[0], parts[1]) case len(parts) == 3 && parts[2] == "threads" && r.Method == http.MethodPost: s.serveNewThreadPost(w, r, parts[0], parts[1]) case len(parts) == 3 && parts[2] == "threads": s.serveThreads(w, r, parts[0], parts[1]) case len(parts) == 4 && parts[2] == "threads" && parts[3] == "new": s.serveNewThreadForm(w, r, parts[0], parts[1], newThreadPage{}) case len(parts) == 5 && parts[2] == "thread" && parts[4] == "comment": s.serveLineComment(w, r, parts[0], parts[1], parts[3]) case len(parts) == 5 && parts[2] == "thread" && parts[4] == "close" && r.Method == http.MethodPost: s.serveThreadState(w, r, parts[0], parts[1], parts[3]) case len(parts) == 5 && parts[2] == "thread" && parts[4] == "reply" && r.Method == http.MethodPost: s.serveReply(w, r, parts[0], parts[1], parts[3]) case len(parts) == 4 && parts[2] == "thread": s.serveThread(w, r, parts[0], parts[1], parts[3]) case len(parts) == 4 && parts[2] == "commit": s.serveCommit(w, r, parts[0], parts[1], parts[3]) case len(parts) >= 3 && parts[2] == "compare": spec := "" if len(parts) > 3 { spec = strings.Join(parts[3:], "/") } s.serveCompare(w, r, parts[0], parts[1], spec) case len(parts) >= 5 && parts[2] == "raw": s.serveRaw(w, r, parts[0], parts[1], parts[3], strings.Join(parts[4:], "/")) case len(parts) == 3 && parts[2] == "readme": s.serveReadme(w, r, parts[0], parts[1]) case len(parts) >= 5 && parts[2] == "file": s.serveFile(w, r, parts[0], parts[1], parts[3], strings.Join(parts[4:], "/")) case len(parts) >= 3 && parts[2] == "files": // /files, /files/, /files// ref, path := "", "" if len(parts) > 3 { ref = parts[3] } if len(parts) > 4 { path = strings.Join(parts[4:], "/") } s.serveFiles(w, r, parts[0], parts[1], ref, path) default: s.notFound(w, r) } } // visibilityWord is the value the pasted config carries, and only the exact word opens a repository. func visibilityWord(public bool) string { if public { return "public" } return "private" } // oneLine keeps a pasted heredoc to one line of prose, because a newline in it would end the block. func oneLine(s string) string { s = strings.ReplaceAll(s, "\r", " ") s = strings.ReplaceAll(s, "\n", " ") return strings.TrimSpace(s) } // serveLanding is the root: sign-in when signed out, per appendix C, and rule 7 lists nothing. func (s *Server) serveLanding(w http.ResponseWriter, r *http.Request) { if who := s.viewer(r); who != "" { http.Redirect(w, r, "/"+who, http.StatusFound) return } http.Redirect(w, r, "/signin", http.StatusFound) } // notFound names what does exist near the path, per chapter 24, because the server knows. func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) near := s.nearest(r) s.render(w, r, "404", struct { chrome Path string Near string Href string // Missing is the repository name when the account is real and the repository is not. Missing string }{ chrome: newChrome("barerepo · not found", "Not found page."), Path: r.URL.Path, Near: near, Href: s.nearestHref(r), Missing: missingRepo(r.URL.Path, near), }) } // missingRepo names what was asked for under an account that exists, or nothing when it is a file. func missingRepo(path, near string) string { if near == "" || strings.Contains(near, "/") { return "" } parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) < 2 || parts[1] == "" { return "" } return parts[1] } // nearest walks back up the path until it finds something real. func (s *Server) nearest(r *http.Request) string { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) >= 2 && repo.Exists(s.Cfg.Paths.Repos, parts[0], parts[1]) { return parts[0] + "/" + parts[1] } if len(parts) >= 1 && parts[0] != "" { if _, err := s.DB.Account(r.Context(), parts[0]); err == nil { return parts[0] } } return "" } func (s *Server) nearestHref(r *http.Request) string { if n := s.nearest(r); n != "" { return "/" + n } return "/" } // listPage is how many rows a list page draws before it offers an older link. Chapter 25's payload. const listPage = 20 // repoPage is what every repository view needs before it can draw anything. type repoPage struct { chrome Owner string Name string Branch string CloneURL string Tabs []tab // Ends is the right of the tab row, which each mockup fills differently. Empty means the file jump. Ends []tab Older string // Threads is the open count, which the tab strip and the thread page footer both show. Threads int } // openRepo resolves a repository and fills the chrome, and returns false once it has answered. func (s *Server) openRepo(w http.ResponseWriter, r *http.Request, owner, name, active string) (*transport.Result, repoPage, bool) { return s.openRepoFor(w, r, owner, name, active) } func (s *Server) openRepoFor(w http.ResponseWriter, r *http.Request, owner, name, active string) (*transport.Result, repoPage, bool) { ctx := r.Context() res, err := s.Transport.Open(ctx, owner, name, s.viewer(r), transport.Read) var moved transport.Redirect switch { case errors.As(err, &moved): http.Redirect(w, r, s.movedPath(r, moved), http.StatusMovedPermanently) return nil, repoPage{}, false case err != nil: s.notFound(w, r) return nil, repoPage{}, false } branch, err := repo.HeadBranch(ctx, res.Dir) if err != nil { branch = "" } page := repoPage{ chrome: newCardChrome("barerepo · "+owner+"/"+name, "", "/card/"+owner+"/"+name), Owner: owner, Name: name, Branch: branch, CloneURL: s.cloneURL(owner, name), } // A shared link says whose repository it is, and its own description when it has written one. page.Share = owner + "/" + name if cfg, err := repocfg.Load(ctx, res.Dir); err == nil && strings.TrimSpace(cfg.Repo.Description) != "" { page.Share += ". " + strings.TrimSpace(cfg.Repo.Description) } // Every mockup carries the open count in the tab strip, so it is read here and not per page. page.Threads = thread.OpenCount(ctx, res.Dir) page.Tabs = repoTabs(owner, name, active, branch, page.Threads) return res, page, true } // profileChrome names the account in the link preview, because a shared profile is somebody's and not a page type. func profileChrome(name string, visible int) chrome { c := newCardChrome("barerepo · "+name, "User profile listing repositories sorted by last push.", "/card/"+name) c.Share = name + " on barerepo. " + plural(visible, "repository", "repositories") + "." return c } func (s *Server) cloneURL(owner, name string) string { host := s.Cfg.Server.SSHHost if host == "" { return s.Cfg.Server.ExternalURL + "/" + owner + "/" + name } user := s.Cfg.Server.SSHUser if user == "" { user = "git" } if s.Cfg.Server.SSHPort != 0 && s.Cfg.Server.SSHPort != 22 { // scp-style syntax cannot carry a port, so say the whole url. return "ssh://" + user + "@" + host + ":" + itoa(s.Cfg.Server.SSHPort) + "/" + owner + "/" + name } return user + "@" + host + ":" + owner + "/" + name } // serveRepoLog is every repository's landing page: the log with diffs open, not the tree. func (s *Server) serveRepoLog(w http.ResponseWriter, r *http.Request, owner, name string) { res, page, ok := s.openRepo(w, r, owner, name, "log") if !ok { return } if repo.IsEmpty(r.Context(), res.Dir) { page.Summary = "Empty repository page showing a single block of shell commands to paste." page.Title = "barerepo · " + owner + "/" + name // No commits means no .barerepo/config, so it stays private until the paste block says otherwise. wantsPublic := r.URL.Query().Get("visibility") == "public" s.render(w, r, "repo-empty", struct { repoPage Public bool // Description belongs in the paste block, because the server commits nothing. Chapter 11. Description string // Visibility is what the form was told, since the block writes the file and not a hint. Visibility string }{page, res.Config.Public() && !wantsPublic, oneLine(r.URL.Query().Get("description")), visibilityWord(wantsPublic)}) return } // path restricts the log to one file, which is the history link chapter 24 asks the file view for. only := strings.TrimPrefix(strings.TrimSpace(r.URL.Query().Get("path")), "/") // from names where this page starts, so older history is reachable without an offset to keep. from := r.URL.Query().Get("from") start := "HEAD" if from != "" { if !gitx.ValidRev(from) { s.notFound(w, r) return } start = from } commits, err := gitread.Log(r.Context(), res.Dir, start, only, listPage+1) if err != nil { if from != "" { s.notFound(w, r) return } s.oops(w, r, err) return } if len(commits) > listPage { page.Older = "/" + owner + "/" + name + "?from=" + commits[listPage].SHA + pathQuery(only) commits = commits[:listPage] } page.Summary = "Repository log listing every commit, newest first. This is the landing page." if only != "" { page.Summary = "Repository log listing every commit that touched one file, newest first." page.Title += " " + only } views := viewLog(owner, name, commits) s.linkAuthors(r, views) // A readme is not the landing page, per chapter 24, but a repository that has one should say so. readme := "" if len(commits) > 0 { if gitread.Readme(r.Context(), res.Dir, commits[0].SHA) != "" { readme = "/" + owner + "/" + name + "/readme" } } s.render(w, r, "repo-log", struct { repoPage Commits []commitView ReadmeHref string // OnlyPath names the file the log is restricted to, and empty is the whole repository. OnlyPath string FileHref string }{page, views, readme, only, s.fileHrefIfThere(r, res.Dir, owner, name, page.Branch, only)}) } func itoa(n int) string { if n == 0 { return "0" } var b [20]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } return string(b[i:]) } // serveCommit is one commit, whole, because a person who opened a commit came to read it. func (s *Server) serveCommit(w http.ResponseWriter, r *http.Request, owner, name, rev string) { res, page, ok := s.openRepo(w, r, owner, name, "log") if !ok { return } c, err := gitread.Show(r.Context(), res.Dir, rev) if err != nil || c == nil { s.notFound(w, r) return } views := viewCommits(owner, name, []gitread.Commit{*c}) s.linkAuthors(r, views) page.Title += " " + c.Short page.Summary = "Single commit page showing message, metadata and full diff." parent := gitread.ParentShort(r.Context(), res.Dir, c.SHA) parentHref := "" // The key's own name is a claim by whoever made it, so barerepo prefers the account that published it. signer := "" if c.SigKey != "" { signer, _ = s.DB.AccountForGPGKey(r.Context(), c.SigKey) } note, bad := SignatureNote(*c, signer) // The commands that check it without barerepo, the way the thread page prints the ones that read it. verify := "" if c.Signed && signer != "" { verify = "curl " + s.Cfg.Server.ExternalURL + "/" + signer + ".gpg | gpg --import\n" + "git verify-commit " + c.SHA } // The first commit has no parent, and a hash that goes nowhere is worse than plain text. if parent != "" { parentHref = "/" + owner + "/" + name + "/commit/" + parent } s.render(w, r, "repo-commit", struct { repoPage Commit commitView FileCount string Reachable bool Parent string ParentHref string FilesHref string // Signature is what this server can say about the signature, and empty means unsigned. Signature string SigBad bool // SignerHref is the account page, when the key that signed this is published by one. SignerHref string Signer string Verify string }{ repoPage: page, Commit: views[0], FileCount: plural(len(c.Files), "file", "files"), Reachable: gitread.Reachable(r.Context(), res.Dir, c.SHA, "HEAD"), Parent: parent, ParentHref: parentHref, FilesHref: "/" + owner + "/" + name + "/file/" + c.SHA + "/", Signature: note, SigBad: bad, SignerHref: hrefFor(signer), Signer: signer, Verify: verify, }) } // markdownCap is chapter 42.4's cap again, because rendering does not make a huge file safe to send. const markdownCap = 1 << 20 // renderMarkdown draws a .md file the way the readme is drawn, and reports whether it drew it. func (s *Server) renderMarkdown(w http.ResponseWriter, r *http.Request, page repoPage, dir, owner, name, ref, path string) bool { if !gitx.ValidRev(ref) || !gitx.ValidPath(path) { return false } obj, err := gitx.CatFile(r.Context(), dir, ref+":"+path) if err != nil || obj == nil || obj.Type != "blob" { return false } if obj.Size > markdownCap || strings.IndexByte(obj.Body, 0) >= 0 { return false } page.Title += " " + path page.Summary = "A markdown file in the repository, rendered." base := "/" + owner + "/" + name s.render(w, r, "readme", struct { repoPage Path string HTML template.HTML SourceHref string RawHref string }{ repoPage: page, Path: path, HTML: template.HTML(markup.Render(obj.Body)), SourceHref: base + "/file/" + ref + "/" + path + "?source", RawHref: base + "/raw/" + ref + "/" + path, }) return true } // serveReadme renders the one file whose whole purpose is to be read as prose. Chapter 42.1 sanitises it. func (s *Server) serveReadme(w http.ResponseWriter, r *http.Request, owner, name string) { res, page, ok := s.openRepo(w, r, owner, name, "log") if !ok { return } head, err := gitx.ResolveRefOrAsk(r.Context(), res.Dir, "HEAD") if err != nil { s.notFound(w, r) return } path := gitread.Readme(r.Context(), res.Dir, head) if path == "" { s.notFound(w, r) return } obj, err := gitx.CatFile(r.Context(), res.Dir, head+":"+path) if err != nil || obj == nil { s.notFound(w, r) return } page.Title = "barerepo · " + owner + "/" + name + " " + path page.Summary = "The repository's readme, rendered." s.render(w, r, "readme", struct { repoPage Path string // Rule 5 does not reach here, but chapter 42.1's allowlist is what makes any markdown safe. HTML template.HTML SourceHref string RawHref string }{ repoPage: page, Path: path, HTML: template.HTML(markup.Render(obj.Body)), SourceHref: "/" + owner + "/" + name + "/file/" + head + "/" + path, RawHref: "/" + owner + "/" + name + "/raw/" + head + "/" + path, }) } // serveFiles is the file tree, not the landing page. Chapter 24 says why. func (s *Server) serveFiles(w http.ResponseWriter, r *http.Request, owner, name, ref, path string) { res, page, ok := s.openRepo(w, r, owner, name, "files") if !ok { return } if ref == "" { ref = page.Branch } if ref == "" { ref = "HEAD" } entries, err := gitread.Tree(r.Context(), res.Dir, ref, path) if err != nil { s.notFound(w, r) return } page.Title = "barerepo · " + owner + "/" + name + " files" page.Summary = "Repository file tree listing directories and files with last commit information." base := "/" + owner + "/" + name + "/files/" + ref up := "" if path != "" { if i := strings.LastIndex(path, "/"); i >= 0 { up = base + "/" + path[:i] } else { up = base } } // Chapter 25 budgets any page at 15kb, and a directory of a thousand files is not that. views := viewEntries(owner, name, ref, entries) after := r.URL.Query().Get("after") if after != "" { for i, v := range views { if v.Name == after { views = views[i+1:] break } } } more := "" if len(views) > treePage { more = r.URL.Path + "?after=" + url.QueryEscape(views[treePage-1].Name) views = views[:treePage] } s.render(w, r, "repo-files", struct { repoPage Path string Up string Entries []entryView // More carries on from the last name drawn, since a tree is in name order and stays in it. More string }{page, path, up, views, more}) } func plural(n int, one, many string) string { if n == 1 { return "1 " + one } return itoa(n) + " " + many } // renderedHref names the rendered form of a file, or nothing when the file has none. func renderedHref(owner, name, ref, path string) string { if !markdownPath(path) { return "" } return "/" + owner + "/" + name + "/file/" + ref + "/" + path } // markdownPath reports a file the reader wants read rather than inspected. func markdownPath(path string) bool { return strings.HasSuffix(strings.ToLower(path), ".md") } // serveFile is one file, with blame in the gutter on every line, unless it is prose. func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, owner, name, ref, path string) { res, page, ok := s.openRepo(w, r, owner, name, "files") if !ok { return } // Before Open, because rendering wants the blob and blame is the expensive half of Open. if markdownPath(path) && !r.URL.Query().Has("source") { if s.renderMarkdown(w, r, page, res.Dir, owner, name, ref, path) { return } } f, err := gitread.Open(r.Context(), res.Dir, ref, path) if err != nil || f == nil { s.notFound(w, r) return } page.Title += " " + path page.Summary = "File view with blame information shown in the left gutter beside each line." // Chapter 25 budgets this page at 40kb with blame on every line, which is what fits in it. lines := viewFileLines(f.Lines) shown, cut := 0, fitLines(lines) // One line can be the whole budget, so the count may be zero and the page still says why. truncated := cut < len(lines) if truncated { lines = lines[:cut] shown = cut } meta := plural(len(f.Lines), "line", "lines") + " · " + size(f.Size) if ref != "" { meta += " · " + ref } s.render(w, r, "repo-file", struct { repoPage Path string File *gitread.File Lines []fileLineView Meta string SizeText string RawHref string // HistoryHref is the log restricted to this file, which is chapter 24's history link. HistoryHref string // Shown is how many lines the page drew when it could not draw them all. Shown int // Truncated says lines were cut, which Shown cannot, because cutting all of them says zero. Truncated bool // RenderedHref is empty unless this file has a rendered form to go back to. RenderedHref string }{page, path, f, lines, meta, size(f.Size), "/" + owner + "/" + name + "/raw/" + ref + "/" + path, "/" + owner + "/" + name + "?path=" + url.QueryEscape(path), shown, truncated, renderedHref(owner, name, ref, path)}) } // serveCompare is any ref against any ref, with both sides in the path so it can be pasted. func (s *Server) serveCompare(w http.ResponseWriter, r *http.Request, owner, name, spec string) { res, page, ok := s.openRepo(w, r, owner, name, "log") if !ok { return } base := "/" + owner + "/" + name + "/compare" if a, b := r.URL.Query().Get("a"), r.URL.Query().Get("b"); a != "" && b != "" { // url.URL leaves the slashes in a ref name alone, which PathEscape would not. to := &url.URL{Path: base + "/" + a + "..." + b} http.Redirect(w, r, to.String(), http.StatusFound) return } a, b, found := strings.Cut(spec, "...") if !found { // No sides given, so offer the default branch against itself: empty, and a filled form. a, b = page.Branch, page.Branch } c, err := gitread.Compare(r.Context(), res.Dir, a, b) if err != nil { s.oops(w, r, err) return } // A line number links only where a thread can hold the comment, so only a proposal. Chapter 35.3. var commentOn func(string, int) string if n := proposal.Number(b); n > 0 && s.viewer(r) != "" { if _, exists, _ := thread.ReadMeta(r.Context(), res.Dir, n); exists { base := "/" + owner + "/" + name + "/thread/" + itoa(n) + "/comment" commentOn = func(path string, line int) string { q := url.Values{"path": {path}, "line": {itoa(line)}, "rev": {b}} return base + "?" + q.Encode() } } } page.Title += " compare" page.Summary = "Compare view between two arbitrary refs showing combined diff stats." s.render(w, r, "repo-compare", struct { repoPage Comparison *gitread.Comparison Files []fileView Stats string Action string // FilesHref opens a changed file on the b side, which is the side being proposed. FilesHref string // Refs is what exists to type, because a free text field with no visible options is unusable. Refs []refLink }{page, c, viewFiles(c.Files, commentOn), compareStats(c), base, "/" + owner + "/" + name + "/file/" + fileRef(r.Context(), res.Dir, b) + "/", refLinks(r.Context(), res.Dir, base, a)}) } // fileHrefIfThere links a path only where the file is still there, since a deleted one has no page. func (s *Server) fileHrefIfThere(r *http.Request, dir, owner, name, branch, only string) string { if only == "" { return "" } obj, err := gitx.CatFile(r.Context(), dir, branch+":"+only) if err != nil || obj.Type != "blob" { return "" } return "/" + owner + "/" + name + "/file/" + fileRef(r.Context(), dir, branch) + "/" + only } // fileBudget is what the lines on the file view may weigh, under chapter 25's 40kb for the page. const fileBudget = 34 << 10 // fitLines counts the lines that fit, because a long line costs the page more than a short one. func fitLines(lines []fileLineView) int { spent := 0 for i, line := range lines { // The markup around one line is a hundred bytes of it, whatever the line holds. spent += len(line.Text) + len(line.Blame) + 110 if spent > fileBudget { return i } } return len(lines) } // treePage is how many entries one directory draws, because chapter 25 budgets the page and not the tree. const treePage = 50 // pathQuery carries the file restriction onto the next link, or an older page drops back to the whole log. func pathQuery(only string) string { if only == "" { return "" } return "&path=" + url.QueryEscape(only) } // isGet is true for a HEAD too, because HEAD is a GET that stops at the headers. func isGet(r *http.Request) bool { return r.Method == http.MethodGet || r.Method == http.MethodHead } // fileRef resolves a ref for a file url, because a name holding a slash cannot be one path element. func fileRef(ctx context.Context, dir, ref string) string { if !strings.Contains(ref, "/") { return ref } // A short name like release/1.0 is not a ref path, so git is asked when the files cannot say. if sha, err := gitx.ResolveRefOrAsk(ctx, dir, ref); err == nil { return sha } return ref } // refLink is one ref offered on the compare page, as a link that fills the b side and not a dropdown. type refLink struct { Name string Kind string Href string On bool } // refLinks lists what a person could type, keeping the a side and swapping the b side. Chapter 24. func refLinks(ctx context.Context, dir, base, a string) []refLink { refs := gitread.Refs(ctx, dir) out := make([]refLink, 0, len(refs)) for _, ref := range refs { out = append(out, refLink{ Name: ref.Name, Kind: ref.Kind, Href: (&url.URL{Path: base + "/" + a + "..." + ref.Name}).String(), On: ref.Name == a, }) } return out } // compareStats is the one line under the two fields. func compareStats(c *gitread.Comparison) string { if c.Missing != "" { return "no ref named " + c.Missing } if c.A == c.B { return "the same ref on both sides" } conflict := "no conflicts" if c.Conflict { conflict = "conflicts" } return plural(c.Commits, "commit", "commits") + " · " + plural(len(c.Files), "file", "files") + " · " + "+" + itoa(c.Add) + " -" + itoa(c.Del) + " · " + conflict } // serveRaw sends file bytes and nothing else: always a download, never a cookie. Chapter 42.3. func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, owner, name, ref, path string) { // A separate raw host is the real defence; the headers below are the second one. if s.Cfg.Server.RawURL != "" && !s.onRawHost(r) { to := &url.URL{Path: r.URL.Path} http.Redirect(w, r, strings.TrimRight(s.Cfg.Server.RawURL, "/")+to.String(), http.StatusFound) return } // This handler reads no credential, so it answers for public repositories only. res, err := s.Transport.Open(r.Context(), owner, name, "", transport.Read) if err != nil { http.NotFound(w, r) return } if !gitx.ValidRev(ref) || !gitx.ValidPath(path) { http.NotFound(w, r) return } // One read of the blob, because the bytes are the whole answer and blame is no part of it. obj, err := gitx.CatFile(r.Context(), res.Dir, ref+":"+path) if err != nil || obj.Type != "blob" { http.NotFound(w, r) return } h := w.Header() h.Set("Content-Type", "text/plain; charset=utf-8") h.Set("Content-Disposition", "attachment") h.Set("X-Content-Type-Options", "nosniff") h.Set("Content-Security-Policy", "default-src 'none'; sandbox") w.Write([]byte(obj.Body)) } // onRawHost reports whether the request already arrived on the raw host, so the redirect ends. func (s *Server) onRawHost(r *http.Request) bool { u, err := url.Parse(s.Cfg.Server.RawURL) return err == nil && u.Host != "" && r.Host == u.Host } // serveProfile is a user and their repositories, newest push first. func (s *Server) serveProfile(w http.ResponseWriter, r *http.Request, name string) { ctx := r.Context() account, err := s.DB.Account(ctx, name) if err != nil { s.notFound(w, r) return } viewer := s.viewer(r) keys, _ := s.DB.Keys(ctx, name) gpg, _ := s.DB.GPGKeys(ctx, name) names, err := s.DB.ReposOf(ctx, name) if err != nil { s.oops(w, r, err) return } var repos []repoStatView for _, rn := range names { res, err := s.Transport.Open(ctx, name, rn, viewer, transport.Read) if err != nil { continue // private, and this reader may not see it } st := gitread.Stat(ctx, res.Dir) repos = append(repos, repoStatView{ Name: rn, Description: res.Config.Repo.Description, Public: res.Config.Public(), Ago: ago(st.Pushed), Pushed: st.Pushed, Meta: repoMeta(st, thread.OpenProposals(ctx, res.Dir), res.Config.Repo.Archived), }) } // The name settles a tie, because this page shows the first twenty and a reload must not shuffle which twenty those are. sort.Slice(repos, func(i, j int) bool { if repos[i].Pushed.Equal(repos[j].Pushed) { return repos[i].Name < repos[j].Name } return repos[i].Pushed.After(repos[j].Pushed) }) // Counted after the loop that skipped what this reader may not see, so it counts only those. visible := len(repos) // The mockup draws a count and a way past it, because a busy account is a page nobody can hold. shown := "" if r.URL.Query().Get("all") == "" && visible > listPage { shown = "showing " + itoa(listPage) + " of " + itoa(visible) repos = repos[:listPage] } s.render(w, r, "profile", struct { chrome // Who is whose profile this is, and Account is who is reading, which the top bar wants. Who string Account string Me bool Initials string Joined string KeyCount int SigningKeys int RepoCount string Repos []repoStatView // Shown is empty when the whole list is on the page, which is most accounts. Shown string AllHref string }{ chrome: profileChrome(name, visible), Who: name, Account: viewer, Me: viewer == name, Initials: initials(name), Joined: month(account.Created), KeyCount: len(keys), SigningKeys: len(gpg), RepoCount: plural(visible, "repo", "repos"), Repos: repos, Shown: shown, AllHref: "/" + name + "?all=1", }) } type repoStatView struct { Name string Description string Public bool Ago string Pushed time.Time Meta string } // repoMeta is a repository row's third line: what it holds, how big, and its default branch. func repoMeta(st gitread.RepoStat, proposals int, archived bool) string { parts := []string{} if st.Language != "" { parts = append(parts, st.Language) } if st.Size > 0 { parts = append(parts, size(st.Size)) } if st.Branch != "" { parts = append(parts, st.Branch) } // Chapter 24 puts the open proposal count here, and a repository with none says nothing. if proposals > 0 { parts = append(parts, plural(proposals, "proposal", "proposals")) } // An archived repository rejects every push, so a reader sees that before writing anything. if archived { parts = append(parts, "archived") } return strings.Join(parts, " · ") } func initials(name string) string { if name == "" { return "" } if len(name) == 1 { return name } return name[:2] } // serveNewRepo is the form, and the two commands that make it unnecessary. Chapter 24. func (s *Server) serveNewRepo(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } page := struct { chrome Account string Name string Description string Branch string CloneBase string Error string }{ chrome: newChrome("barerepo · new repo", "Form for creating a new repository with a choice of default branch name."), Account: who, Branch: "master", CloneBase: s.cloneBase(who), } if isGet(r) { s.render(w, r, "new-repo", page) return } page.Name = strings.TrimSpace(r.FormValue("name")) page.Description = strings.TrimSpace(r.FormValue("description")) if b := strings.TrimSpace(r.FormValue("default_branch")); b != "" { page.Branch = b } public := r.FormValue("visibility") == "public" if why := s.Transport.Claimed(r.Context(), who, page.Name); why != "" { page.Error = why w.WriteHeader(http.StatusBadRequest) s.render(w, r, "new-repo", page) return } dir, err := repo.Create(r.Context(), s.Cfg.Paths.Repos, who, page.Name, page.Branch, s.Transport.Bin) if err != nil { page.Error = err.Error() w.WriteHeader(http.StatusBadRequest) s.render(w, r, "new-repo", page) return } if _, err := s.DB.ExecContext(r.Context(), `INSERT INTO repos (owner, name, created_at) VALUES (?, ?, ?)`, who, page.Name, time.Now().Unix()); err != nil { os.RemoveAll(dir) s.oops(w, r, err) return } // No tree exists yet to hold either answer, so both are carried to the next page. Chapter 11. q := url.Values{} if public { q.Set("visibility", "public") } if page.Description != "" { q.Set("description", page.Description) } to := "/" + who + "/" + page.Name if len(q) > 0 { to += "?" + q.Encode() } http.Redirect(w, r, to, http.StatusFound) } // cloneBase is the prefix a clone url is built from, for the paste blocks. func (s *Server) cloneBase(owner string) string { if host := s.Cfg.Server.SSHHost; host != "" && s.Cfg.Server.SSHPort == 22 { return "git@" + host + ":" + owner } return s.Cfg.Server.ExternalURL + "/" + owner } // serveThreads is one list: issues and proposals are one object, so no type filter. Chapter 13. func (s *Server) serveThreads(w http.ResponseWriter, r *http.Request, owner, name string) { res, page, ok := s.openRepo(w, r, owner, name, "threads") if !ok { return } list, err := thread.List(r.Context(), res.Dir) if err != nil { s.oops(w, r, err) return } want := threadFilter(r.URL.Query().Get("state")) open, closed := 0, 0 kept := make([]thread.Summary, 0, len(list)) for _, t := range list { if t.Meta.State == thread.Open { open++ } else { closed++ } // The counts are of the whole list, because a count that moves with the filter says nothing. if threadInState(t.Meta.State, want) { kept = append(kept, t) } } // An older link from here, because this page drew every thread a repository had. Chapter 25. if from := r.URL.Query().Get("from"); from != "" { at := -1 for i := range kept { if itoa(kept[i].N) == from { at = i break } } if at < 0 { s.notFound(w, r) return } kept = kept[at:] } if len(kept) > listPage { page.Older = "/" + owner + "/" + name + "/threads?from=" + itoa(kept[listPage].N) + "&state=" + want kept = kept[:listPage] } // Only the rows this page draws, since each one resolves a ref and measures a diff. facts := s.threadRowFacts(r, res.Dir, owner, name, kept) views := make([]threadRow, 0, len(kept)) for _, t := range kept { views = append(views, threadRow{ N: t.N, Title: titleOr(t.Meta.Title, t.N), Author: t.Meta.Author, Ago: ago(t.Updated), Detail: threadDetail(owner, name, t, page.Branch, facts[t.N]), Href: "/" + owner + "/" + name + "/thread/" + itoa(t.N), Dim: t.Meta.State != thread.Open, }) } names := make([]string, 0, len(views)) for _, v := range views { names = append(names, v.Author) } accounts := s.accountSet(r, names...) for i := range views { views[i].AuthorHref = accountHref(views[i].Author, accounts) } page.Title = "barerepo · " + owner + "/" + name + " threads" page.Summary = "Thread list where issues and code proposals appear in one combined list." page.Tabs = repoTabs(owner, name, "threads", page.Branch, open) page.Ends = threadFilterTabs(owner, name, want) s.render(w, r, "threads", struct { repoPage Threads []threadRow Counts string Viewer string Empty string }{page, views, itoa(open) + " open · " + itoa(closed) + " closed", s.viewer(r), threadEmpty(want)}) } // threadFilter reads the state filter chapter 24's thread list names, defaulting to all. func threadFilter(q string) string { switch q { case "open", "merged", "closed": return q default: return "all" } } // threadInState decides one row. Abandoned counts as closed, because the filter names four and not five. func threadInState(state thread.State, want string) bool { switch want { case "open": return state == thread.Open case "merged": return state == thread.Merged case "closed": return state == thread.Closed || state == thread.Abandoned } return true } // threadFilterTabs is the right of the tab row on this page, per the threads mockup. func threadFilterTabs(owner, name, want string) []tab { base := "/" + owner + "/" + name + "/threads" out := make([]tab, 0, 4) for _, state := range []string{"open", "merged", "closed", "all"} { href := base + "?state=" + state if state == "all" { href = base } out = append(out, tab{Label: state, Href: href, On: state == want}) } return out } // runnerEnds is the right of the tab row on the run pages, per the runs and runners mockups. func runnerEnds(owner, name, active string, canAdd bool) []tab { base := "/" + owner + "/" + name + "/runners" out := []tab{} // The runs page reaches the runner list; the runner list is already there and only offers the setup. if active != "runners" { out = append(out, tab{Label: "runners", Href: base}) } // Attaching a machine needs push, so a reader without it is not offered a link that refuses. if canAdd { out = append(out, tab{Label: "add a runner", Href: base + "/new"}) } return out } // threadEmpty says which list is empty, because "no threads yet" is wrong under a filter. func threadEmpty(want string) string { if want == "all" { return "no threads yet." } return "no " + want + " threads." } type threadRow struct { N int Title string Author string AuthorHref string Ago string Detail []bit Href string Dim bool } // threadDetail is the line under a thread's title: what is attached, and what happened to it. func threadDetail(owner, name string, t thread.Summary, branch string, f threadFacts) []bit { base := "/" + owner + "/" + name parts := []bit{} switch t.Meta.State { case thread.Merged: // The words the push printed in the terminal, so the page never differs. Chapter 12. parts = append(parts, plain("merged")) if branch != "" { parts = append(parts, bit{Text: "tip reachable from " + branch, Href: base}) } case thread.Closed: parts = append(parts, plain("closed")) case thread.Abandoned: parts = append(parts, plain("abandoned")) default: if t.Meta.Ref != "" { parts = append(parts, plain("has proposal")) } else { parts = append(parts, plain("no proposal")) } } // Chapter 24: the size, then the build, which is the order the mockup reads in. if f.Add > 0 || f.Del > 0 { parts = append(parts, bit{ Text: "+" + itoa(f.Add) + " -" + itoa(f.Del), Href: base + "/compare?a=HEAD&b=" + url.QueryEscape(t.Meta.Ref), }) } if f.Build != "" { parts = append(parts, bit{Text: f.Build, Href: f.BuildHref, Danger: f.Failed}) } if t.Meta.Merged != "" && len(t.Meta.Merged) >= 7 { parts = append(parts, bit{Text: "at " + t.Meta.Merged[:7], Href: base + "/commit/" + t.Meta.Merged}) } // A count of nothing is not news, and the mockup leaves it off. if t.Replies > 0 { parts = append(parts, plain(plural(t.Replies, "reply", "replies"))) } return parts } // buildSummary reduces a matrix of runs to one status, where any failure is the status. Chapter 15A. func buildSummary(recs []run.Record) (string, bool) { failed := 0 for _, rec := range recs { if rec.Failed() { failed++ } } // One run keeps the mockup's wording, which is the labels the build actually reported. if len(recs) == 1 { return runResult(recs[0]), recs[0].Failed() } // A green row hiding one red build is the failure chapter 19.1 is written against. if failed > 0 { return itoa(failed) + " of " + plural(len(recs), "build", "builds") + " failed", true } return plural(len(recs), "build", "builds") + " ok", false } // threadFacts is what a row needs from outside the note: the proposal's size and its last build. type threadFacts struct { Add int Del int Build string BuildHref string Failed bool } // threadRowFacts reads the size and build of every proposal on the page without a process per row. func (s *Server) threadRowFacts(r *http.Request, dir, owner, name string, list []thread.Summary) map[int]threadFacts { out := map[int]threadFacts{} head, err := gitx.ResolveRefOrAsk(r.Context(), dir, "HEAD") if err != nil { return out } // Two processes for every run in the repository, rather than one for each row's commit. runs, err := run.Recent(r.Context(), dir, 0) if err != nil { runs = nil } for _, t := range list { if t.Meta.Ref == "" || !gitx.ValidRev(t.Meta.Ref) { continue } tip, err := gitx.ResolveRefOrAsk(r.Context(), dir, t.Meta.Ref) if err != nil || tip == "" { continue } f := threadFacts{} // Both ends are object hashes, so the answer is cached forever and costs nothing again. f.Add, f.Del = gitread.DiffStatBetween(r.Context(), dir, head, tip) mine := []run.Record{} for _, rec := range runs[tip] { // A commit lands on a branch and a proposal both, so a run that will not say is not ours. if rec.Ref == t.Meta.Ref { mine = append(mine, rec) } } if len(mine) > 0 { f.Build, f.Failed = buildSummary(mine) f.BuildHref = "/" + owner + "/" + name + "/run/" + tip } out[t.N] = f } return out } // serveThread is one discussion, in order, with the two commands that read it offline. Chapter 24. func (s *Server) serveThread(w http.ResponseWriter, r *http.Request, owner, name, num string) { s.renderThread(w, r, owner, name, num, "") } // renderThread draws the same page with a refusal on it, because the reply box is the one here. func (s *Server) renderThread(w http.ResponseWriter, r *http.Request, owner, name, num, fail string) { res, page, ok := s.openRepo(w, r, owner, name, "threads") if !ok { return } n, err := strconv.Atoi(num) if err != nil || n <= 0 { s.notFound(w, r) return } meta, comments, err := thread.Read(r.Context(), res.Dir, n) if err != nil || meta.State == "" { s.notFound(w, r) return } tip := "HEAD" if meta.Ref != "" && gitx.ValidRev(meta.Ref) { tip = meta.Ref } views := make([]commentRow, 0, len(comments)) for _, c := range comments { row := commentRow{ Author: c.Author, Ago: ago(c.Time), At: c.Time, Anchor: c.Anchor, // Rule 5: anyone wrote this, so chapter 42.1's allowlist is what makes it safe as HTML. HTML: template.HTML(markup.Render(c.Body)), } if a, ok := thread.ParseAnchor(c.Anchor); ok { a.Blob, a.Side = c.Blob, c.Side got := thread.Resolve(r.Context(), res.Dir, a, tip) row.Excerpt = got.Excerpt row.Line = got.Line row.Where = a.Path + ":" + itoa(got.Line) switch got.Placement { case thread.Moved: row.Placement = "moved" case thread.Outdated: row.Placement = "outdated" case thread.Lost: // Chapter 43.5: the original is gone, and saying so beats a meaningless line number. row.Placement = "outdated, original unavailable" row.Where = c.Anchor } // A lost anchor has no line to open, and the others land on the line they resolved to. if got.Placement != thread.Lost { row.WhereHref = "/" + owner + "/" + name + "/file/" + tip + "/" + a.Path + "#L" + itoa(got.Line) } } views = append(views, row) } views = append(views, s.threadRuns(r, res.Dir, owner, name, meta)...) add, del := 0, 0 if meta.Ref != "" { add, del = gitread.DiffStat(r.Context(), res.Dir, page.Branch, meta.Ref) } // A run belongs where it happened, so merge by time, stably, to keep the note's own order. sort.SliceStable(views, func(i, j int) bool { return views[i].At.Before(views[j].At) }) page.Title += " thread " + num page.Summary = "Discussion thread where issues and proposals are one object, stored in git notes." names := []string{meta.Author} for _, v := range views { names = append(names, v.Author) } accounts := s.accountSet(r, names...) for i := range views { views[i].AuthorHref = accountHref(views[i].Author, accounts) } s.render(w, r, "thread", struct { repoPage N int // Heading, not Title: an embedded Title would shadow the chrome's and rename the page. Heading string Detail []bit NotesRef string Comments []commentRow Viewer string // CanClose is the author or the repository owner, which is who chapter 35.6 names. CanClose bool Closed bool Error string }{ repoPage: page, N: n, Heading: titleOr(meta.Title, n), Detail: threadHeadline(owner, name, meta, add, del, accounts), NotesRef: thread.Ref(n), Comments: views, Viewer: s.viewer(r), CanClose: mayClose(s.viewer(r), owner, meta), Closed: meta.State == thread.Closed, Error: fail, }) } // mayClose is the thread's author or the repository's owner, and a merged proposal is neither's. func mayClose(viewer, owner string, meta thread.Meta) bool { if viewer == "" || meta.State == thread.Merged { return false } return viewer == owner || viewer == meta.Author } // serveThreadState closes a thread or opens it again, which chapter 35.6 gives two people. func (s *Server) serveThreadState(w http.ResponseWriter, r *http.Request, owner, name, num string) { res, _, ok := s.openRepo(w, r, owner, name, "threads") if !ok { return } n, err := strconv.Atoi(num) if err != nil || n <= 0 { s.notFound(w, r) return } meta, found, err := thread.ReadMeta(r.Context(), res.Dir, n) if err != nil || !found { s.notFound(w, r) return } if !mayClose(s.viewer(r), owner, meta) { s.notFound(w, r) return } want := thread.Closed if meta.State == thread.Closed { want = thread.Open } if err := thread.SetState(r.Context(), res.Dir, n, want, ""); err != nil { s.oops(w, r, err) return } http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound) } type commentRow struct { Author string // AuthorHref is set only when the name in the note is an account barerepo knows. AuthorHref string // WhereHref opens the file the comment is anchored in, unless the anchor no longer resolves. WhereHref string Ago string // At orders this row against the runs merged into the timeline. Ago is what the page shows. At time.Time Anchor string // Where, Excerpt and Placement describe a line comment after its file moved on. Chapter 43. Where string Line int Excerpt string Placement string HTML template.HTML // Run is set when this row is a build result rather than a comment. Run *runRow } // threadRuns are this proposal's builds, in the comment timeline, on one resolve and one read. func (s *Server) threadRuns(r *http.Request, dir, owner, name string, meta thread.Meta) []commentRow { if meta.Ref == "" || !gitx.ValidRev(meta.Ref) { return nil } sha, err := gitx.ResolveRefOrAsk(r.Context(), dir, meta.Ref) if err != nil || sha == "" { return nil } recs, err := run.For(r.Context(), dir, sha) if err != nil { return nil } rows := make([]commentRow, 0, len(recs)) for _, rec := range recs { // A commit lands on a branch and a proposal both, so a run that will not say is not ours. if rec.Ref != meta.Ref { continue } // Not runRow, which reads the commit subject this thread already shows as its heading. row := runRow{ Short: short(sha), // The matrix combination, so three builds of one proposal are three readable events. Name: rec.Name, Href: "/" + owner + "/" + name + "/run/" + sha, Result: runResult(rec), Failed: rec.Failed(), Took: itoa(rec.Duration) + "s", Ago: ago(rec.StartedAt()), Ref: rec.Ref, Runner: rec.Runner, Exit: rec.Exit, } rows = append(rows, commentRow{ Author: rec.Runner, Ago: ago(rec.StartedAt()), At: rec.StartedAt(), Run: &row, }) } return rows } func threadHeadline(owner, name string, m thread.Meta, add, del int, accounts map[string]bool) []bit { base := "/" + owner + "/" + name parts := []bit{} if m.Author != "" { parts = append(parts, bit{Text: "opened by " + m.Author, Href: accountHref(m.Author, accounts)}) } if !m.Opened.IsZero() { parts = append(parts, plain(ago(m.Opened))) } if m.Ref != "" { // The proposal is a ref, and what a reader wants from it is the diff against the branch. parts = append(parts, plain("has proposal"), bit{Text: m.Ref, Href: base + "/compare?a=HEAD&b=" + url.QueryEscape(m.Ref)}) // The mockup puts the size here, because two numbers answer "should I read this". if add > 0 || del > 0 { parts = append(parts, plain("+"+itoa(add)+" -"+itoa(del))) } } if m.State != thread.Open { parts = append(parts, plain(string(m.State))) } if m.Merged != "" && len(m.Merged) >= 7 { parts = append(parts, bit{Text: "merged at " + m.Merged[:7], Href: base + "/commit/" + m.Merged}) } return parts } // accountHref is the profile page, or nothing, because a name in a note is whoever wrote the note. func accountHref(who string, accounts map[string]bool) string { if accounts[who] { return "/" + who } return "" } // accountSet asks once which of these names are accounts, so a page never links to a 404. func (s *Server) accountSet(r *http.Request, names ...string) map[string]bool { seen := map[string]bool{} var want []string for _, n := range names { if n != "" && !seen[n] { seen[n] = true want = append(want, n) } } if len(want) == 0 || s.DB == nil { return map[string]bool{} } got, err := s.DB.AccountsExist(r.Context(), want) if err != nil { // A name left plain is a smaller failure than a page that will not render. return map[string]bool{} } return got } // titleOr names a thread whose proposal had an empty commit subject, because a row needs a name. func titleOr(title string, n int) string { if strings.TrimSpace(title) != "" { return title } return "thread " + itoa(n) } // newThreadPage is the form and whatever was typed. Heading, not Title, so the chrome keeps its own. type newThreadPage struct { Heading string Body string Ref string Error string } // serveNewThreadForm is one form: no ref is an issue, a ref is a proposal. Chapter 24. func (s *Server) serveNewThreadForm(w http.ResponseWriter, r *http.Request, owner, name string, form newThreadPage) { if _, ok := s.requireViewer(w, r); !ok { return } _, page, ok := s.openRepo(w, r, owner, name, "threads") if !ok { return } page.Title += " new thread" page.Summary = "New thread form with an optional field for attaching a pushed proposal ref." s.render(w, r, "thread-new", struct { repoPage newThreadPage }{page, form}) } func (s *Server) serveNewThreadPost(w http.ResponseWriter, r *http.Request, owner, name string) { who, ok := s.requireViewer(w, r) if !ok { return } res, _, ok := s.openRepo(w, r, owner, name, "threads") if !ok { return } form := newThreadPage{ Heading: strings.TrimSpace(r.FormValue("title")), Body: strings.TrimSpace(r.FormValue("body")), Ref: strings.TrimSpace(r.FormValue("ref")), } fail := func(msg string) { form.Error = msg w.WriteHeader(http.StatusBadRequest) s.serveNewThreadForm(w, r, owner, name, form) } if form.Heading == "" { fail("a thread needs a title.") return } // Anyone who may read may comment. Chapter 18. if !res.Config.MayRead(owner, who) { s.notFound(w, r) return } object := "HEAD" if form.Ref != "" { if !gitx.ValidRev(form.Ref) { fail("that is not a ref name.") return } if _, err := gitx.Run(r.Context(), res.Dir, "rev-parse", "--verify", "--quiet", form.Ref); err != nil { fail("there is no ref named " + form.Ref + ". push it first.") return } object = form.Ref } sha, err := gitx.Run(r.Context(), res.Dir, "rev-parse", "--verify", "--quiet", object) if err != nil { fail("this repository has no commits yet, so there is nothing to attach a thread to.") return } n, err := proposal.Allocate(r.Context(), res.Dir) if err != nil { s.oops(w, r, err) return } meta := thread.Meta{ Title: form.Heading, State: thread.Open, Ref: form.Ref, Author: who, Opened: time.Now(), } first := thread.Comment{Author: who, Time: time.Now(), Body: form.Body} if err := thread.Create(r.Context(), res.Dir, n, meta, strings.TrimSpace(sha), first); err != nil { s.oops(w, r, err) return } s.indexTalk(r, owner, name, res) s.note(r, store.Event{Kind: store.ThreadOpened, Actor: who, Repo: owner + "/" + name, Number: n, Title: form.Heading, Ref: form.Ref}, n) http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+itoa(n), http.StatusFound) } func (s *Server) serveReply(w http.ResponseWriter, r *http.Request, owner, name, num string) { who, ok := s.requireViewer(w, r) if !ok { return } res, _, ok := s.openRepo(w, r, owner, name, "threads") if !ok { return } n, err := strconv.Atoi(num) if err != nil || n <= 0 { s.notFound(w, r) return } body := strings.TrimSpace(r.FormValue("body")) if body == "" { http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound) return } meta, _, err := thread.Read(r.Context(), res.Dir, n) if err != nil || meta.State == "" { s.notFound(w, r) return } if !s.commentAllowed(who, owner+"/"+name, n) { w.WriteHeader(http.StatusTooManyRequests) s.renderThread(w, r, owner, name, num, tooManyComments) return } // Attach to the thread's ref, or the default branch, so git notes find it either way. object := "HEAD" if meta.Ref != "" && gitx.ValidRev(meta.Ref) { object = meta.Ref } sha, err := gitx.Run(r.Context(), res.Dir, "rev-parse", "--verify", "--quiet", object) if err != nil { s.oops(w, r, err) return } if err := thread.Reply(r.Context(), res.Dir, n, strings.TrimSpace(sha), thread.Comment{Author: who, Time: time.Now(), Body: body}); err != nil { s.oops(w, r, err) return } s.indexTalk(r, owner, name, res) s.note(r, store.Event{Kind: store.ThreadReplied, Actor: who, Repo: owner + "/" + name, Number: n, Title: meta.Title}, n) http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound) } // serveLineComment is chapter 35.3, and it records the blob so 43.4 can recover what was read. func (s *Server) serveLineComment(w http.ResponseWriter, r *http.Request, owner, name, num string) { who, ok := s.requireViewer(w, r) if !ok { return } res, page, ok := s.openRepo(w, r, owner, name, "threads") if !ok { return } n, err := strconv.Atoi(num) if err != nil || n <= 0 { s.notFound(w, r) return } meta, _, err := thread.Read(r.Context(), res.Dir, n) if err != nil || meta.State == "" { s.notFound(w, r) return } path := r.FormValue("path") line, _ := strconv.Atoi(r.FormValue("line")) if !gitx.ValidPath(path) || line <= 0 { s.notFound(w, r) return } rev := r.FormValue("rev") if rev == "" || !gitx.ValidRev(rev) { rev = meta.Ref } if rev == "" || !gitx.ValidRev(rev) { rev = "HEAD" } if r.Method == http.MethodPost { body := strings.TrimSpace(r.FormValue("body")) if body == "" { http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound) return } if !s.commentAllowed(who, owner+"/"+name, n) { w.WriteHeader(http.StatusTooManyRequests) s.renderThread(w, r, owner, name, num, tooManyComments) return } sha, err := gitx.Run(r.Context(), res.Dir, "rev-parse", "--verify", "--quiet", rev) if err != nil { s.oops(w, r, err) return } c := thread.Comment{ Author: who, Time: time.Now(), Body: body, Anchor: path + ":" + itoa(line), Blob: strings.TrimSpace(r.FormValue("blob")), Side: "new", // Chapter 26 keeps a revision an anchored comment needs, and this is what names it. Revision: proposal.CurrentRevision(res.Dir, n), } if err := thread.Reply(r.Context(), res.Dir, n, strings.TrimSpace(sha), c); err != nil { s.oops(w, r, err) return } s.indexTalk(r, owner, name, res) s.note(r, store.Event{Kind: store.ThreadReplied, Actor: who, Repo: owner + "/" + name, Number: n, Title: meta.Title}, n) http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound) return } f, err := gitread.Open(r.Context(), res.Dir, rev, path) if err != nil || f == nil { s.notFound(w, r) return } page.Title += " comment on " + path page.Summary = "Form for writing a comment against one line of one file." s.render(w, r, "comment-line", struct { repoPage N int Path string Line int Rev string Blob string Context []contextLine Error string }{page, n, path, line, rev, f.Blob, around(f.Lines, line, 3), ""}) } // tooManyComments is said on both comment paths, because a reader need not know they differ. const tooManyComments = "too many comments on this thread in the last hour. try later." // commentAllowed is chapter 27's note spam barrier, per account per thread, since a comment is cheap. func (s *Server) commentAllowed(who, repo string, n int) bool { limit := s.Cfg.Limits.CommentPerHourPerThread if limit <= 0 { return true } key := who + " " + repo + "#" + itoa(n) cutoff := time.Now().Add(-time.Hour) s.commentsMu.Lock() defer s.commentsMu.Unlock() if s.comments == nil { s.comments = map[string][]time.Time{} } kept := s.comments[key][:0] for _, t := range s.comments[key] { if t.After(cutoff) { kept = append(kept, t) } } if len(kept) >= limit { s.comments[key] = kept return false } s.comments[key] = append(kept, time.Now()) return true } // contextLine is one line of the excerpt shown above the comment box. type contextLine struct { Number int Text string Here bool } // around returns the lines either side of the one being commented on, so the writer sees it. func around(lines []gitread.FileLine, at, span int) []contextLine { var out []contextLine for _, l := range lines { if l.Number < at-span || l.Number > at+span { continue } out = append(out, contextLine{Number: l.Number, Text: l.Text, Here: l.Number == at}) } return out } // serveRunners lists the machines attached to a repository. Chapter 24. func (s *Server) serveRunners(w http.ResponseWriter, r *http.Request, owner, name string) { res, page, ok := s.openRepo(w, r, owner, name, "runs") if !ok { return } list, err := s.DB.RunnersOf(r.Context(), owner+"/"+name) if err != nil { s.oops(w, r, err) return } work, err := s.DB.WorkOf(r.Context(), owner+"/"+name) if err != nil { s.log(r, err) } views := make([]runnerRow, 0, len(list)) for _, rn := range list { offline := rn.Offline(30 * time.Second) // Chapter 24 asks for a status, and a machine with a job in hand is neither idle nor gone. state := "idle" switch { case offline: state = "offline" case work[rn.ID].Busy: state = "busy" } detail := rn.OS + "/" + rn.Arch if len(rn.Labels) > 0 { detail += " · labels " + strings.Join(rn.Labels, ", ") } if n := work[rn.ID].Runs; n > 0 { detail += " · " + plural(n, "run", "runs") } views = append(views, runnerRow{ ID: rn.ID, Hostname: rn.Hostname, State: state, Seen: spoken(ago(rn.LastSeen)), Detail: detail, Offline: offline, }) } page.Title += " runners" page.Summary = "List of attached build machines with labels, platform and last seen time." page.Ends = runnerEnds(owner, name, "runners", res.Config.MayPush(owner, s.viewer(r))) s.render(w, r, "runners", struct { repoPage Runners []runnerRow Attached string CanAdd bool }{page, views, plural(len(views), "attached", "attached"), res.Config.MayPush(owner, s.viewer(r))}) } type runnerRow struct { ID int64 Hostname string State string Seen string Detail string Offline bool } // serveRunnerSetup issues a token inside one copied line. Chapter 15: a second step is a bug. func (s *Server) serveRunnerSetup(w http.ResponseWriter, r *http.Request, owner, name string) { who, ok := s.requireViewer(w, r) if !ok { return } res, page, ok := s.openRepo(w, r, owner, name, "runs") if !ok { return } if !res.Config.MayPush(owner, who) { s.notFound(w, r) return } page.Title += " add a runner" page.Summary = "Runner setup page showing one paste-ready command per operating system with the token already embedded." // Chapter 24: the commands are the page, and a button to reveal them is the second step it forbids. if err := s.DB.DropStaleRunnerTokens(r.Context(), who, owner+"/"+name); err != nil { s.log(r, err) } tok, _, err := s.DB.CreateToken(r.Context(), token.Runner, who, owner+"/"+name, "runner for "+owner+"/"+name) if err != nil { s.oops(w, r, err) return } s.render(w, r, "runner-setup", struct { repoPage Token string ExternalURL string }{page, tok, s.Cfg.Server.ExternalURL}) } func (s *Server) serveForgetRunner(w http.ResponseWriter, r *http.Request, owner, name string) { who, ok := s.requireViewer(w, r) if !ok { return } res, _, ok := s.openRepo(w, r, owner, name, "runs") if !ok { return } if !res.Config.MayPush(owner, who) { s.notFound(w, r) return } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) if err := s.DB.ForgetRunner(r.Context(), owner+"/"+name, id); err != nil { s.log(r, err) } http.Redirect(w, r, "/"+owner+"/"+name+"/runners", http.StatusFound) } // serveRuns lists build results, newest first. Chapter 24. func (s *Server) serveRuns(w http.ResponseWriter, r *http.Request, owner, name string) { res, page, ok := s.openRepo(w, r, owner, name, "runs") if !ok { return } // Every run, because paging needs the ones below the page and the notes are read whole anyway. byCommit, err := run.Recent(r.Context(), res.Dir, 0) if err != nil { s.oops(w, r, err) return } // One process for every built commit, where a git log per row is what this used to cost. specs := make([]string, 0, len(byCommit)) for sha := range byCommit { specs = append(specs, sha) } commits, err := gitx.Batch(r.Context(), res.Dir, specs) if err != nil { s.oops(w, r, err) return } var rows []runRow for sha, recs := range byCommit { subject := "" if c := commits[sha]; c != nil { subject = commitSubject(c.Body) } for _, rec := range recs { rows = append(rows, runRowFor(owner, name, sha, subject, rec)) } } // A matrix starts its jobs in one second, so the commit and the job name settle the order the clock cannot and two requests agree on it. sort.Slice(rows, func(i, j int) bool { if !rows[i].Started.Equal(rows[j].Started) { return rows[i].Started.After(rows[j].Started) } if rows[i].Short != rows[j].Short { return rows[i].Short < rows[j].Short } return rows[i].Name < rows[j].Name }) // By position and not by time, because runs sharing a second would fall in the gap between pages. start := 0 if from := r.URL.Query().Get("from"); from != "" { n, err := strconv.Atoi(from) if err != nil || n < 0 || n > len(rows) { s.notFound(w, r) return } start = n } rows = rows[start:] if len(rows) > listPage { page.Older = "/" + owner + "/" + name + "/runs?from=" + itoa(start+listPage) rows = rows[:listPage] } page.Title += " runs" page.Summary = "List of build runs with status, trigger and duration." page.Ends = runnerEnds(owner, name, "", res.Config.MayPush(owner, s.viewer(r))) // A page that says builds are off while builds are running is the page contradicting itself. command := strings.TrimSpace(res.Config.Build.Command) files := []string{} if command == "" { if head, err := gitx.ResolveRefOrAsk(r.Context(), res.Dir, "HEAD"); err == nil { files = workflow.Files(r.Context(), res.Dir, head) } } s.render(w, r, "runs", struct { repoPage Runs []runRow BuildOn bool // Workflows names what a repository builds from when it is not [build] command. Workflows []string }{page, rows, command != "" || len(files) > 0, files}) } type runRow struct { Short string // Name is the workflow job, empty for a [build] command, which has only one. Chapter 15A. Name string Href string // CommitHref is the commit the run built, which is not the run page. Chapter 24. CommitHref string Result string Failed bool Took string Ago string Subject string // Shown is how much of the log the page holds when it cannot hold all of it. Shown string Ref string // RefHref compares the proposal against the branch, which is what a ref is worth reading as. RefHref string Runner string RunnerHref string Exit int Output string RawHref string Size string Started time.Time } // runRowFor takes the subject, because both callers have the commit already. func runRowFor(owner, name, sha, subject string, rec run.Record) runRow { base := "/" + owner + "/" + name refHref := "" // A branch compares against nothing useful, and only a proposal has another side to show. if strings.HasPrefix(rec.Ref, "refs/proposals/") { refHref = base + "/compare?a=HEAD&b=" + url.QueryEscape(rec.Ref) } return runRow{ Short: short(sha), Name: rec.Name, Href: base + "/run/" + sha, CommitHref: base + "/commit/" + sha, RefHref: refHref, RunnerHref: base + "/runners", Result: runResult(rec), Failed: rec.Failed(), Took: itoa(rec.Duration) + "s", Ago: ago(rec.StartedAt()), Subject: subject, Ref: rec.Ref, Runner: rec.Runner, Exit: rec.Exit, RawHref: "/" + owner + "/" + name + "/run/" + sha + "/log", Started: rec.StartedAt(), } } // commitSubject reads a raw commit: a header block, a blank line, then the message. func commitSubject(body string) string { _, msg, found := strings.Cut(body, "\n\n") if !found { return "" } subject, _, _ := strings.Cut(msg, "\n") return strings.TrimSpace(subject) } // commitAuthor reads the name off a commit object, which is who a build page is about. func commitAuthor(body string) string { for _, line := range strings.Split(body, "\n") { if line == "" { return "" } if rest, ok := strings.CutPrefix(line, "author "); ok { if i := strings.LastIndex(rest, " <"); i > 0 { return rest[:i] } } } return "" } // logOnPage is how much of a build log the page carries, since chapter 25 budgets the whole page. const logOnPage = 12 << 10 // runResult reads as the mockup does: each label that passed, or the failure that stands out. func runResult(rec run.Record) string { if rec.Failed() { if len(rec.Labels) > 0 { return rec.Labels[0] + " failed" } return "failed" } if len(rec.Labels) == 0 { return "ok" } parts := make([]string, 0, len(rec.Labels)) for _, l := range rec.Labels { parts = append(parts, l+" ok") } return strings.Join(parts, " · ") } // serveRun is one commit's builds with the whole log as text, so ctrl-F works. Chapter 16. func (s *Server) serveRun(w http.ResponseWriter, r *http.Request, owner, name, sha string) { res, page, ok := s.openRepo(w, r, owner, name, "runs") if !ok { return } if !gitx.ValidRev(sha) { s.notFound(w, r) return } // A rev that does not resolve is not a commit with no runs, it is a page that does not exist. if _, err := gitx.ResolveRefOrAsk(r.Context(), res.Dir, sha); err != nil { s.notFound(w, r) return } recs, err := run.For(r.Context(), res.Dir, sha) if err != nil { s.oops(w, r, err) return } subject, author := "", "" if c, err := gitx.Batch(r.Context(), res.Dir, []string{sha}); err == nil && c[sha] != nil { subject = commitSubject(c[sha].Body) author = commitAuthor(c[sha].Body) } rows := make([]runRow, 0, len(recs)) for _, rec := range recs { row := runRowFor(owner, name, sha, subject, rec) log, err := run.Log(r.Context(), res.Dir, rec) if err != nil { log = "" } row.Size = size(int64(len(log))) // Chapter 25 budgets this page, and a long build must not be the thing that breaks it. if len(log) > logOnPage { log = log[len(log)-logOnPage:] // The cut lands mid line, so it starts at the next one. if i := strings.IndexByte(log, '\n'); i >= 0 { log = log[i+1:] } row.Shown = size(int64(len(log))) } row.Output = log rows = append(rows, row) } meta := "" // run.html puts the ref that triggered the build in the footer, which is not the default branch. ref := page.Branch if len(rows) > 0 { // The runner is named per run below because a matrix runs on more than one machine, and run.html reads this as a sentence, writing "1d ago" where a column says "1d". meta = rows[0].Took if said := spoken(rows[0].Ago); said != "" { meta += " · " + said } if rows[0].Ref != "" { ref = rows[0].Ref } } page.Title += " run " + short(sha) page.Summary = "Run detail page showing raw build log output as plain scrollable text." s.render(w, r, "run", struct { repoPage SHA string Short string Meta string Ref string Author string Runs []runRow CanRerun bool }{page, sha, short(sha), meta, ref, author, rows, res.Config.MayPush(owner, s.viewer(r))}) } // serveRunLog sends a build log as plain text, for ctrl-F, grep and pipes. func (s *Server) serveRunLog(w http.ResponseWriter, r *http.Request, owner, name, sha string) { res, _, ok := s.openRepo(w, r, owner, name, "runs") if !ok { return } recs, err := run.For(r.Context(), res.Dir, sha) if err != nil || len(recs) == 0 { s.notFound(w, r) return } log, err := run.Log(r.Context(), res.Dir, recs[0]) if err != nil { s.oops(w, r, err) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.Write([]byte(log)) } // serveRerun queues the same commit again. func (s *Server) serveRerun(w http.ResponseWriter, r *http.Request, owner, name, sha string) { who, ok := s.requireViewer(w, r) if !ok { return } res, page, ok := s.openRepo(w, r, owner, name, "runs") if !ok { return } if !res.Config.MayPush(owner, who) || !gitx.ValidRev(sha) { s.notFound(w, r) return } if cmd := strings.TrimSpace(res.Config.Build.Command); cmd != "" { if _, err := s.DB.QueueJob(r.Context(), owner+"/"+name, page.Branch, sha, cmd, res.Config.Build.Image); err != nil { s.oops(w, r, err) return } } http.Redirect(w, r, "/"+owner+"/"+name+"/run/"+sha, http.StatusFound) } func short(sha string) string { if len(sha) > 7 { return sha[:7] } return sha } // serveSearch is one query over code, threads and repositories, cross-repository by default. func (s *Server) serveSearch(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) page := struct { chrome Account string Query string Count string Results []searchRow }{ chrome: newChrome("barerepo · search", "Search results combining code matches, threads and repositories in one list."), Account: s.viewer(r), Query: query, } if query == "" { s.render(w, r, "search", page) return } found := search.Search(r.Context(), s.DB, s.viewer(r), query, 50) for _, res := range found { // The box is the matched source line, so only a code match earns one, per search.html. where := res.Owner + " / " + res.Name switch res.Kind { case search.Code: where += " / " + res.Path + ":" + itoa(res.Line) case search.Thread: where += " " + res.Path + " · " + res.Text } page.Results = append(page.Results, searchRow{ Kind: res.Kind.String(), Where: highlight(where, query), Href: res.Href, Text: highlight(strings.TrimSpace(res.Text), query), Context: highlight(res.Context, query), HasText: res.Kind == search.Code && strings.TrimSpace(res.Text) != "", HasCtx: res.Context != "", }) } page.Count = plural(len(page.Results), "result", "results") s.render(w, r, "search", page) } type searchRow struct { Kind string Where template.HTML Href string Text template.HTML Context template.HTML HasText bool HasCtx bool } // indexTalk keeps chapter 17's index current when a comment is written here instead of pushed. func (s *Server) indexTalk(r *http.Request, owner, name string, res *transport.Result) { if s.DB == nil { return } branch, err := repo.HeadBranch(r.Context(), res.Dir) if err != nil { return } t := search.Target{Owner: owner, Name: name, Dir: res.Dir, Ref: branch, Config: res.Config} if err := search.IndexThreads(r.Context(), s.DB, t); err != nil { s.log(r, err) } } // note records an event, and no failure here is worth losing the comment that caused it. func (s *Server) note(r *http.Request, e store.Event, number int) { if err := s.DB.Record(r.Context(), e); err != nil { s.log(r, err) } if err := s.DB.TookPart(r.Context(), e.Actor, e.Repo, number); err != nil { s.log(r, err) } } // serveInbox has no unread counts: per-user read state would need a new closed-list category. 19.4. func (s *Server) serveInbox(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } events, err := s.DB.Inbox(r.Context(), who, 100) if err != nil { s.oops(w, r, err) return } seen, err := s.DB.LastVisited(r.Context(), who) if err != nil { s.oops(w, r, err) return } rows := make([]eventRow, 0, len(events)) ruled := false for _, e := range events { row := eventRow{ Text: eventLine(e), Detail: eventDetail(e), Ago: ago(e.Created), Href: eventHref(e), } // One rule, at the point the reader had got to last time. if !ruled && !seen.IsZero() && e.Created.Before(seen) { row.Rule = true ruled = true } row.Dim = ruled rows = append(rows, row) } if err := s.DB.Visit(r.Context(), who); err != nil { s.log(r, err) } s.render(w, r, "inbox", struct { chrome Account string Events []eventRow Seen string }{ chrome: newChrome("barerepo · inbox", "Chronological list of events on repositories and threads the user participates in."), Account: who, Events: rows, Seen: agoOr(seen), }) } type eventRow struct { Text string Detail string Ago string Href string Rule bool Dim bool } // eventLine reads as one sentence, because the inbox is one line per event. func eventLine(e store.Event) string { repo := e.Repo if i := strings.Index(repo, "/"); i >= 0 { repo = repo[i+1:] } n := itoa(e.Number) switch e.Kind { case store.ProposalOpened: return e.Actor + " opened proposal " + n + " on " + repo case store.ProposalUpdated: return e.Actor + " updated proposal " + n + " on " + repo case store.ProposalMerged: return e.Actor + " merged proposal " + n + " on " + repo case store.ThreadOpened: return e.Actor + " opened thread " + n + " on " + repo case store.ThreadReplied: return e.Actor + " replied on " + repo + " thread " + n case store.ThreadClosed: return e.Actor + " closed thread " + n + " on " + repo case store.RunFailed: name := e.Title if name == "" { name = "build" } return name + " failed on " + repo + " " + e.Ref case store.Pushed: return e.Actor + " pushed " + e.Ref + " on " + repo default: return e.Actor + " " + e.Kind + " on " + repo } } // eventDetail is the second line, which for a failed build is the runner and the exit code. func eventDetail(e store.Event) string { if e.Kind == store.RunFailed { return e.Detail } return e.Title } func eventHref(e store.Event) string { // A build that failed on a branch has no thread to open, so it goes where the logs are. if e.Kind == store.RunFailed && e.Number == 0 { return "/" + e.Repo + "/runs" } if e.Number > 0 { return "/" + e.Repo + "/thread/" + itoa(e.Number) } return "/" + e.Repo } func agoOr(t time.Time) string { if t.IsZero() { return "" } return "last visited " + spoken(ago(t)) } // serveReleases lists tags, and says notes clone and files do not, before a migration teaches it. func (s *Server) serveReleases(w http.ResponseWriter, r *http.Request, owner, name string) { res, page, ok := s.openRepo(w, r, owner, name, "releases") if !ok { return } list, err := gitread.Releases(r.Context(), res.Dir) if err != nil { s.oops(w, r, err) return } if from := r.URL.Query().Get("from"); from != "" { at := -1 for i := range list { if list[i].Tag == from { at = i break } } if at < 0 { s.notFound(w, r) return } list = list[at:] } if len(list) > listPage { page.Older = "/" + owner + "/" + name + "/releases?from=" + url.QueryEscape(list[listPage].Tag) list = list[:listPage] } // Only the bodies this page draws are read, because a note is a blob and a page is twenty of them. gitread.ReadNotes(r.Context(), res.Dir, list) // One pass over the blob store, so a repository with nothing attached costs one stat. attached, _ := artifact.ListAll(s.Cfg.Paths.Artifacts, owner, name) views := make([]releaseRow, 0, len(list)) names := make([]string, 0, len(list)) for _, rel := range list { files := make([]fileLink, 0, len(attached[rel.Tag])) for _, f := range attached[rel.Tag] { files = append(files, fileLink{Name: f.Name, Size: size(f.Size), Href: "/" + owner + "/" + name + "/release/" + url.PathEscape(rel.Tag) + "/" + f.Name}) } views = append(views, releaseRow{ Tag: rel.Tag, Href: "/" + owner + "/" + name + "/files/" + fileRef(r.Context(), res.Dir, rel.Tag), Tagger: rel.Tagger, Ago: ago(rel.When), Subject: rel.Subject, Notes: template.HTML(markup.Render(rel.Notes)), HasNotes: strings.TrimSpace(rel.Notes) != "", Files: files, }) names = append(names, rel.Tagger) } accounts := s.accountSet(r, names...) for i := range views { views[i].TaggerHref = accountHref(views[i].Tagger, accounts) } page.Title += " releases" page.Summary = "List of tagged releases with notes and attached files." // A statement and not a control, because there is no other order to put them in. page.Ends = []tab{{Label: "newest first"}} s.render(w, r, "releases", struct { repoPage Releases []releaseRow }{page, views}) } // serveRelease opens the list at one tag, because chapter 24 draws no separate page for a release. func (s *Server) serveRelease(w http.ResponseWriter, r *http.Request, owner, name, tag string) { res, _, ok := s.openRepoFor(w, r, owner, name, "") if !ok { return } list, err := gitread.Releases(r.Context(), res.Dir) if err != nil { s.oops(w, r, err) return } for _, rel := range list { if rel.Tag == tag { http.Redirect(w, r, "/"+owner+"/"+name+"/releases?from="+url.QueryEscape(tag), http.StatusFound) return } } s.notFound(w, r) } // serveReleaseFile hands over one attached file, which chapter 22.2 keeps outside git. func (s *Server) serveReleaseFile(w http.ResponseWriter, r *http.Request, owner, name, tag, file string) { if _, _, ok := s.openRepoFor(w, r, owner, name, ""); !ok { return } f, info, err := artifact.Open(s.Cfg.Paths.Artifacts, owner, name, tag, file) if err != nil { s.notFound(w, r) return } defer f.Close() // An attached file is somebody else's bytes, so it downloads and never renders. Chapter 42.3. h := w.Header() h.Set("Content-Type", "application/octet-stream") h.Set("Content-Disposition", "attachment; filename="+strconv.Quote(file)) h.Set("X-Content-Type-Options", "nosniff") h.Set("Content-Security-Policy", "default-src 'none'; sandbox") http.ServeContent(w, r, file, info.ModTime(), f) } type releaseRow struct { Tag string // Href opens the files at the tag, because a release is a tag and a tag is a tree. Href string Tagger string TaggerHref string Ago string Subject string Notes template.HTML HasNotes bool // Files are the attached binaries, which clone --mirror does not take. Chapter 22.3. Files []fileLink } // fileLink is one attached file on the releases page, named and measured as the mockup has it. type fileLink struct { Name string Size string Href string } // serveRepoConfig is one file view plus the three things that move the repository. Chapter 44. func (s *Server) serveRepoConfig(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) { res, page, ok := s.openRepo(w, r, owner, name, "config") if !ok { return } body, err := gitx.Run(r.Context(), res.Dir, "show", "HEAD:"+repocfg.Path) if err != nil { body = "" } // The pieces, not a sentence, because the hash and the name both go somewhere. Chapter 24. edited, editor, editSHA := "", "", "" if out, err := gitx.Run(r.Context(), res.Dir, "log", "--max-count=1", "--format=%ar\x1e%an\x1e%h\x1e%H", "HEAD", "--", repocfg.Path); err == nil { if f := strings.SplitN(strings.TrimSpace(out), "\x1e", 4); len(f) == 4 { edited, editor, editSHA = f[0], f[1], f[3] } } editorHref := "" if editor != "" { if _, err := s.DB.Account(r.Context(), editor); err == nil { editorHref = "/" + editor } } page.Title += " .barerepo/config" page.Summary = "Repository settings shown as a versioned file in the repository rather than a settings form." // Chapter 21.1: copying is the answer to "no forks", so it is offered to a reader, not an owner. copyTo, copyHave, copyURL := "", "", "" // Copying is what a reader does with somebody else's project, so the owner is not offered it. if who := s.viewer(r); who != "" && who != owner { if repo.Exists(s.Cfg.Paths.Repos, who, name) { copyHave = "/" + who + "/" + name } else { copyTo = "/" + who + "/" + name copyURL = s.cloneURL(who, name) } } s.render(w, r, "repo-config", struct { repoPage Config string // The pieces of the last edit, since a hash and a name each go somewhere. Edited string Editor string EditorHref string EditHref string EditShort string RawHref string // The config page is a file view with a fixed path, so it offers a file view's links. 24. HistoryHref string BlameHref string IsOwner bool Hooks []hookRow Error string CopyTo string CopyHave string CopyURL string }{page, body, edited, editor, editorHref, "/" + owner + "/" + name + "/commit/" + editSHA, short(editSHA), "/" + owner + "/" + name + "/raw/" + fileRef(r.Context(), res.Dir, page.Branch) + "/" + repocfg.Path, "/" + owner + "/" + name + "?path=" + url.QueryEscape(repocfg.Path), "/" + owner + "/" + name + "/file/" + fileRef(r.Context(), res.Dir, page.Branch) + "/" + repocfg.Path, s.viewer(r) == owner, s.hookRows(r, owner+"/"+name, res.Config), configError(res.Warning, errMsg), copyTo, copyHave, copyURL}) } // hrefFor is an account page, or nothing at all when there is no account to point at. func hrefFor(account string) string { if account == "" { return "" } return "/" + account } // configError prefers the file's own parse failure, since a reader of this page is reading that file. func configError(warning, errMsg string) string { if warning != "" { // The terminal reads it over two lines and a page reads it as one sentence. return strings.ReplaceAll(warning, "\n", " · ") } return errMsg } // hookRow says what happened to one webhook, because delivery has nowhere else to be reported. 23.4. type hookRow struct { URL string Events string State string Danger bool } // hookRows reads the delivery state of the hooks the file declares, in the order it declares them. func (s *Server) hookRows(r *http.Request, name string, cfg repocfg.Config) []hookRow { if len(cfg.Webhook) == 0 { return nil } state, err := s.DB.HooksOf(r.Context(), name) if err != nil { s.log(r, err) state = map[string]store.HookState{} } rows := make([]hookRow, 0, len(cfg.Webhook)) for _, h := range cfg.Webhook { row := hookRow{URL: h.URL, Events: strings.Join(h.Events, ", ")} st, known := state[h.URL] // A name outside chapter 19.1's list never fires, and the file accepted the line. 23.1. if unknown := unknownEvents(h.Events); len(unknown) > 0 { row.State = strings.Join(unknown, ", ") + " is not an event barerepo sends, so it never fires" if len(unknown) > 1 { row.State = strings.Join(unknown, ", ") + " are not events barerepo sends, so they never fire" } row.Danger = true rows = append(rows, row) continue } switch { case st.Disabled: row.State = "stopped after " + plural(st.Failures, "failure", "failures") + " in a row · " + st.LastError row.Danger = true case st.Failures > 0: row.State = plural(st.Failures, "failure", "failures") + " since the last delivery · " + st.LastError row.Danger = true case known: row.State = "delivered " + ago(st.LastAt) default: row.State = "nothing has happened here yet" } rows = append(rows, row) } return rows } // unknownEvents names what a hook asked for that barerepo will never send. func unknownEvents(events []string) []string { var out []string for _, e := range events { if !store.KnownKind(e) { out = append(out, e) } } return out } // serveRepoMove renames or transfers, which the URL namespace owns, so it is not a git operation. func (s *Server) serveRepoMove(w http.ResponseWriter, r *http.Request, owner, name, action string) { who, ok := s.requireViewer(w, r) if !ok { return } if who != owner { s.notFound(w, r) return } newOwner, newName := owner, name if action == "rename" { newName = strings.TrimSpace(r.FormValue("name")) if newName == name { http.Redirect(w, r, "/"+owner+"/"+name+"/config", http.StatusFound) return } } else { newOwner = strings.TrimSpace(r.FormValue("owner")) if strings.TrimSpace(r.FormValue("confirm")) != name { s.serveRepoConfig(w, r, owner, name, "type "+name+" to confirm the transfer.") return } } // Chapter 44.1 confirms the target before anything moves, and this moved the directory first. if newOwner != owner { if _, err := s.DB.Account(r.Context(), newOwner); err != nil { s.serveRepoConfig(w, r, owner, name, "there is no account named "+newOwner) return } } // A transfer is the third door into a namespace, and the other two ask this before opening. if why := s.Transport.Claimed(r.Context(), newOwner, newName); why != "" { s.serveRepoConfig(w, r, owner, name, why) return } if err := repo.Move(s.Cfg.Paths.Repos, owner, name, newOwner, newName); err != nil { s.serveRepoConfig(w, r, owner, name, err.Error()) return } if err := s.DB.Move(r.Context(), owner, name, newOwner, newName); err != nil { // Put the directory back, so disk and database do not disagree. _ = repo.Move(s.Cfg.Paths.Repos, newOwner, newName, owner, name) s.serveRepoConfig(w, r, owner, name, err.Error()) return } // A transfer changes the owner, and the owner is half of who may read it. Chapter 18. s.reindexReadable(r, newOwner, newName) // Attached files sit under owner and name, so a move that forgets them orphans every one. 22.2. if err := artifact.Move(s.Cfg.Paths.Artifacts, owner, name, newOwner, newName); err != nil { s.log(r, err) } if action != "rename" { s.note(r, store.Event{Kind: store.RepoTransferred, Actor: who, Repo: newOwner + "/" + newName, Title: "was " + owner + "/" + name}, 0) } http.Redirect(w, r, "/"+newOwner+"/"+newName+"/config", http.StatusFound) } // serveRejected is the page the hook prints a url to, because terminals scroll. Chapter 24. func (s *Server) serveRejected(w http.ResponseWriter, r *http.Request, owner, name string) { res, page, ok := s.openRepo(w, r, owner, name, "") if !ok { return } ref := r.URL.Query().Get("ref") if !gitx.ValidRef(ref) { s.notFound(w, r) return } // push-rejected.html heads the page with the refused commit and how long ago, which the hook put on the link because a request arriving here knows neither. head := ref // A delete carries the all-zero id, which names no commit and would head the page with noughts. if sha := r.URL.Query().Get("sha"); len(sha) == 40 && strings.Trim(sha, "0") != "" && gitx.ValidRev(sha) { head = short(sha) if at, err := strconv.ParseInt(r.URL.Query().Get("at"), 10, 64); err == nil && at > 0 { if said := spoken(ago(time.Unix(at, 0))); said != "" { head += " · " + said } } } who := s.viewer(r) page.Title += " push rejected" page.Summary = "Page explaining exactly which server hook rejected a push and how to proceed." s.render(w, r, "push-rejected", struct { repoPage Ref string // Head is the commit and the moment, which the bar shows instead of repeating the ref. Head string Push string You string MayPush bool }{page, ref, head, repocfg.List(res.Config.Access.Push), repocfg.Who(who), res.Config.MayPush(owner, who)}) } // serveRepoCopy is chapter 21.1, which is taking a project somewhere its maintainer will not go. func (s *Server) serveRepoCopy(w http.ResponseWriter, r *http.Request, owner, name string) { who, ok := s.requireViewer(w, r) if !ok { return } // Read access is the whole permission, because chapter 12 removed asking as a step. if _, _, ok := s.openRepoFor(w, r, owner, name, "config"); !ok { return } // A copy makes a repository in your namespace, so it asks what the other doors ask. 21.2 and 44.4. if why := s.Transport.Claimed(r.Context(), who, name); why != "" { s.serveRepoConfig(w, r, owner, name, why) return } if _, err := repo.Copy(r.Context(), s.Cfg.Paths.Repos, owner, name, who, name, s.Transport.Bin); err != nil { s.serveRepoConfig(w, r, owner, name, err.Error()) return } if _, err := s.DB.ExecContext(r.Context(), `INSERT INTO repos (owner, name, created_at) VALUES (?, ?, ?)`, who, name, time.Now().Unix()); err != nil { if dir, derr := repo.Dir(s.Cfg.Paths.Repos, who, name); derr == nil { os.RemoveAll(dir) } s.oops(w, r, err) return } s.indexCopy(r, who, name) http.Redirect(w, r, "/"+who+"/"+name, http.StatusFound) } // reindexReadable rewrites the index's read set from the tree, for a change no push announced. func (s *Server) reindexReadable(r *http.Request, owner, name string) { res, err := s.Transport.Open(r.Context(), owner, name, owner, transport.Read) if err != nil { s.log(r, err) return } if err := s.DB.SetReadable(r.Context(), owner+"/"+name, res.Config.Public(), store.Readers(owner, res.Config.Access.Push)); err != nil { s.log(r, err) } } // indexCopy gives a fresh copy its own documents, since the index is keyed by repository. func (s *Server) indexCopy(r *http.Request, owner, name string) { res, err := s.Transport.Open(r.Context(), owner, name, owner, transport.Read) if err != nil { s.log(r, err) return } branch, err := repo.HeadBranch(r.Context(), res.Dir) if err != nil { return } t := search.Target{Owner: owner, Name: name, Dir: res.Dir, Ref: branch, Config: res.Config} if err := search.IndexAll(r.Context(), s.DB, t); err != nil { s.log(r, err) } } // serveRepoDelete stops serving a repository and starts its window. func (s *Server) serveRepoDelete(w http.ResponseWriter, r *http.Request, owner, name string) { who, ok := s.requireViewer(w, r) if !ok { return } if who != owner { s.notFound(w, r) return } if strings.TrimSpace(r.FormValue("confirm")) != name { s.serveRepoConfig(w, r, owner, name, "type "+name+" to confirm the delete.") return } // A copy on alternates borrows these objects, so give it its own or the delete takes them. if dir, err := repo.Dir(s.Cfg.Paths.Repos, owner, name); err == nil { for _, dependent := range repo.Dependents(s.Cfg.Paths.Repos, dir) { if err := repo.Detach(r.Context(), dependent); err != nil { s.serveRepoConfig(w, r, owner, name, "a copy of this repository still borrows its objects and could not be detached: "+err.Error()) return } } } if _, err := repo.Trash(s.Cfg.Paths.Repos, owner, name, time.Now()); err != nil { s.serveRepoConfig(w, r, owner, name, err.Error()) return } if err := s.DB.Forget(r.Context(), owner, name); err != nil { s.log(r, err) } // Attached files are not in git, so nothing else takes them when the repository goes. 22.2. if err := artifact.Forget(s.Cfg.Paths.Artifacts, owner, name); err != nil { s.log(r, err) } http.Redirect(w, r, "/"+owner, http.StatusFound) }