control: wiki list and wiki show !102
3 files changed, +238 −33
Layout: unified · split
e2e/wiki_test.go +48
| @@ -77,6 +77,54 @@ func TestWikis(t *testing.T) { | |||
| 77 | t.Fatalf("wiki raw: %d", status) | 77 | t.Fatalf("wiki raw: %d", status) |
| 78 | } | 78 | } |
| 79 | 79 | ||
| 80 | // A wiki is readable from every surface, not just a browser: the | ||
| 81 | // commands are what the web dispatches, and what the CLI and the | ||
| 82 | // JSON API reach. | ||
| 83 | out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/app", "--json") | ||
| 84 | if code != 0 { | ||
| 85 | t.Fatalf("wiki list: %s", errOut) | ||
| 86 | } | ||
| 87 | if !strings.Contains(out, `"Home"`) || !strings.Contains(out, `"Setup"`) { | ||
| 88 | t.Errorf("wiki list pages: %s", out) | ||
| 89 | } | ||
| 90 | if !strings.Contains(out, `"home":"Home"`) { | ||
| 91 | t.Errorf("wiki list did not name the landing page: %s", out) | ||
| 92 | } | ||
| 93 | // shot.png is not a page. | ||
| 94 | if strings.Contains(out, "shot") { | ||
| 95 | t.Errorf("wiki list included a non-page file: %s", out) | ||
| 96 | } | ||
| 97 | |||
| 98 | // Named page, and the landing page when none is named. | ||
| 99 | out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Setup", "--json") | ||
| 100 | if code != 0 || !strings.Contains(out, "steps here") { | ||
| 101 | t.Errorf("wiki show Setup: %s", out) | ||
| 102 | } | ||
| 103 | out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "--json") | ||
| 104 | if code != 0 || !strings.Contains(out, "welcome") { | ||
| 105 | t.Errorf("wiki show default page: %s", out) | ||
| 106 | } | ||
| 107 | // An extension is accepted and ignored, as the web's routes do. | ||
| 108 | if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Setup.org"); code != 0 { | ||
| 109 | t.Error("wiki show rejected a page named with its extension") | ||
| 110 | } | ||
| 111 | if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Nope"); code == 0 { | ||
| 112 | t.Error("a missing wiki page resolved") | ||
| 113 | } | ||
| 114 | // A page name cannot climb out of the wiki. | ||
| 115 | if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 { | ||
| 116 | t.Error("wiki show escaped the repository") | ||
| 117 | } | ||
| 118 | // A repository with no wiki says so rather than failing oddly. | ||
| 119 | if _, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/secretive"); code == 0 || | ||
| 120 | !strings.Contains(errOut, "no wiki") { | ||
| 121 | t.Errorf("wiki list on a repo without one: %d %s", code, errOut) | ||
| 122 | } | ||
| 123 | // Wiki access derives from the parent: a stranger gets nothing. | ||
| 124 | if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 { | ||
| 125 | t.Error("a stranger listed a private repository's wiki") | ||
| 126 | } | ||
| 127 | |||
| 80 | // 404-parity: a private repo's wiki is invisible, over web and git. | 128 | // 404-parity: a private repo's wiki is invisible, over web and git. |
| 81 | mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main") | 129 | mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main") |
| 82 | if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 { | 130 | if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 { |
internal/control/wiki.go added +171
| @@ -0,0 +1,171 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/base64" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "os" | ||
| 8 | "path" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/gitutil" | ||
| 12 | "gitbay.org/gitbay/internal/policy" | ||
| 13 | "gitbay.org/gitbay/internal/protocol" | ||
| 14 | "gitbay.org/gitbay/internal/store" | ||
| 15 | ) | ||
| 16 | |||
| 17 | func init() { | ||
| 18 | register(Command{ | ||
| 19 | Path: []string{"wiki", "list"}, | ||
| 20 | Summary: "list a repository's wiki pages: wiki list <owner/name>", | ||
| 21 | ReadOnly: true, | ||
| 22 | Run: runWikiList, | ||
| 23 | }) | ||
| 24 | register(Command{ | ||
| 25 | Path: []string{"wiki", "show"}, | ||
| 26 | Summary: "print a wiki page: wiki show <owner/name> [<page>]", | ||
| 27 | ReadOnly: true, | ||
| 28 | Run: runWikiShow, | ||
| 29 | }) | ||
| 30 | } | ||
| 31 | |||
| 32 | // A wiki lives in a companion bare repo beside its parent, so prose | ||
| 33 | // edits stay out of the code repository's history, its protected | ||
| 34 | // branches and its builds. The companion has no store row of its own — | ||
| 35 | // access derives from the parent, exactly as it does for git over SSH — | ||
| 36 | // which is why it needs commands rather than being addressable as a | ||
| 37 | // repository. | ||
| 38 | // | ||
| 39 | // Editing stays a push to <repo>.wiki.git. That is the whole write | ||
| 40 | // interface, on every surface, and there is nothing for a command to | ||
| 41 | // add. | ||
| 42 | |||
| 43 | // wikiExts are the page formats the web renders, in resolution order. | ||
| 44 | var wikiExts = []string{".md", ".org", ".markdown"} | ||
| 45 | |||
| 46 | // wikiDir resolves the parent, checks read access, and returns the | ||
| 47 | // companion's path. A parent you cannot read has no wiki you can read. | ||
| 48 | func wikiDir(c *Ctx, spec string) (repo store.Repo, dir string, code int) { | ||
| 49 | parent, code := resolveRepo(c, spec, policy.CanRead) | ||
| 50 | if code >= 0 { | ||
| 51 | return store.Repo{}, "", code | ||
| 52 | } | ||
| 53 | d := RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name+".wiki") | ||
| 54 | if _, err := os.Stat(d); err != nil { | ||
| 55 | return store.Repo{}, "", c.fail(protocol.ExitNotFound, "%s has no wiki", parent.Path()) | ||
| 56 | } | ||
| 57 | return parent, d, -1 | ||
| 58 | } | ||
| 59 | |||
| 60 | // wikiPages lists the page names in the companion, without extensions. | ||
| 61 | func wikiPages(dir string) []string { | ||
| 62 | entries, err := gitutil.ListTree(dir, "main", "") | ||
| 63 | if err != nil { | ||
| 64 | return nil // the companion exists but has no commits yet | ||
| 65 | } | ||
| 66 | var pages []string | ||
| 67 | for _, e := range entries { | ||
| 68 | if e.Type != "blob" { | ||
| 69 | continue | ||
| 70 | } | ||
| 71 | ext := strings.ToLower(path.Ext(e.Name)) | ||
| 72 | for _, want := range wikiExts { | ||
| 73 | if ext == want { | ||
| 74 | pages = append(pages, strings.TrimSuffix(e.Name, path.Ext(e.Name))) | ||
| 75 | break | ||
| 76 | } | ||
| 77 | } | ||
| 78 | } | ||
| 79 | return pages | ||
| 80 | } | ||
| 81 | |||
| 82 | func runWikiList(c *Ctx, args []string) int { | ||
| 83 | if len(args) != 1 { | ||
| 84 | return c.fail(protocol.ExitUsage, "usage: wiki list <owner/name>") | ||
| 85 | } | ||
| 86 | repo, dir, code := wikiDir(c, args[0]) | ||
| 87 | if code >= 0 { | ||
| 88 | return code | ||
| 89 | } | ||
| 90 | pages := wikiPages(dir) | ||
| 91 | type out struct { | ||
| 92 | Path string `json:"path"` | ||
| 93 | Home string `json:"home,omitempty"` | ||
| 94 | Pages []string `json:"pages"` | ||
| 95 | } | ||
| 96 | d := out{Path: repo.Path(), Home: wikiHome(pages), Pages: pages} | ||
| 97 | if d.Pages == nil { | ||
| 98 | d.Pages = []string{} | ||
| 99 | } | ||
| 100 | return c.emit(d, func(w io.Writer) { | ||
| 101 | for _, p := range d.Pages { | ||
| 102 | fmt.Fprintln(w, p) | ||
| 103 | } | ||
| 104 | }) | ||
| 105 | } | ||
| 106 | |||
| 107 | // wikiHome picks the landing page the way the web does: a conventional | ||
| 108 | // name if one exists, otherwise the first page. | ||
| 109 | func wikiHome(pages []string) string { | ||
| 110 | for _, home := range []string{"Home", "home", "README", "index"} { | ||
| 111 | for _, p := range pages { | ||
| 112 | if p == home { | ||
| 113 | return home | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | if len(pages) > 0 { | ||
| 118 | return pages[0] | ||
| 119 | } | ||
| 120 | return "" | ||
| 121 | } | ||
| 122 | |||
| 123 | func runWikiShow(c *Ctx, args []string) int { | ||
| 124 | if len(args) < 1 || len(args) > 2 { | ||
| 125 | return c.fail(protocol.ExitUsage, "usage: wiki show <owner/name> [<page>]") | ||
| 126 | } | ||
| 127 | repo, dir, code := wikiDir(c, args[0]) | ||
| 128 | if code >= 0 { | ||
| 129 | return code | ||
| 130 | } | ||
| 131 | pages := wikiPages(dir) | ||
| 132 | page := wikiHome(pages) | ||
| 133 | if len(args) == 2 { | ||
| 134 | page = strings.TrimSuffix(args[1], path.Ext(args[1])) | ||
| 135 | } | ||
| 136 | if page == "" { | ||
| 137 | return c.fail(protocol.ExitNotFound, "%s has no wiki pages", repo.Path()) | ||
| 138 | } | ||
| 139 | // A page name is a file name, so it must not climb out of the repo. | ||
| 140 | if cleaned, ok := cleanRepoPath(page); !ok || cleaned != page { | ||
| 141 | return c.fail(protocol.ExitUsage, "page must be a name inside the wiki") | ||
| 142 | } | ||
| 143 | |||
| 144 | for _, ext := range wikiExts { | ||
| 145 | raw, err := gitutil.ReadBlob(dir, "main", page+ext, c.Cfg.Limits.MaxBlobBytes) | ||
| 146 | if err != nil { | ||
| 147 | continue | ||
| 148 | } | ||
| 149 | binary := gitutil.IsBinary(raw) | ||
| 150 | type out struct { | ||
| 151 | Path string `json:"path"` | ||
| 152 | Page string `json:"page"` | ||
| 153 | File string `json:"file"` | ||
| 154 | Size int `json:"size"` | ||
| 155 | Binary bool `json:"binary,omitempty"` | ||
| 156 | Content string `json:"content,omitempty"` | ||
| 157 | Base64 string `json:"base64,omitempty"` | ||
| 158 | } | ||
| 159 | d := out{Path: repo.Path(), Page: page, File: page + ext, | ||
| 160 | Size: len(raw), Binary: binary} | ||
| 161 | if binary { | ||
| 162 | d.Base64 = base64.StdEncoding.EncodeToString(raw) | ||
| 163 | } else { | ||
| 164 | d.Content = string(raw) | ||
| 165 | } | ||
| 166 | return c.emit(d, func(w io.Writer) { | ||
| 167 | fmt.Fprint(w, d.Content) | ||
| 168 | }) | ||
| 169 | } | ||
| 170 | return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path()) | ||
| 171 | } | ||
internal/httpd/wiki.go +19 −33
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | ||
| 13 | "gitbay.org/gitbay/internal/control" | 13 | "gitbay.org/gitbay/internal/control" |
| 14 | "gitbay.org/gitbay/internal/gitutil" | 14 | "gitbay.org/gitbay/internal/gitutil" |
| 15 | "gitbay.org/gitbay/internal/store" | ||
| 15 | ) | 16 | ) |
| 16 | 17 | ||
| 17 | // wikiDir returns the companion repo path, or "" when the repo has none. | 18 | // wikiDir returns the companion repo path, or "" when the repo has none. |
| @@ -43,8 +44,16 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | |||
| 43 | }{repoPage: p, Missing: true}) | 44 | }{repoPage: p, Missing: true}) |
| 44 | return | 45 | return |
| 45 | } | 46 | } |
| 46 | entries, err := gitutil.ListTree(dir, "main", "") | 47 | var listing struct { |
| 47 | if err != nil { // wiki repo exists but has no commits yet | 48 | Home string `json:"home"` |
| 49 | Pages []string `json:"pages"` | ||
| 50 | } | ||
| 51 | var viewer store.User | ||
| 52 | if s.cfg.Web.Mode == "accounts" { | ||
| 53 | viewer = s.viewer(r) | ||
| 54 | } | ||
| 55 | _, listed := s.runControlInto(viewer, []string{"wiki", "list", p.Repo.Path()}, &listing) | ||
| 56 | if !listed || len(listing.Pages) == 0 { // no wiki, or no commits yet | ||
| 48 | s.render(w, "wiki.html", struct { | 57 | s.render(w, "wiki.html", struct { |
| 49 | repoPage | 58 | repoPage |
| 50 | Page string | 59 | Page string |
| @@ -54,47 +63,24 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | |||
| 54 | }{repoPage: p, Missing: true}) | 63 | }{repoPage: p, Missing: true}) |
| 55 | return | 64 | return |
| 56 | } | 65 | } |
| 57 | var pages []string | 66 | pages := listing.Pages |
| 58 | for _, e := range entries { | ||
| 59 | if e.Type != "blob" { | ||
| 60 | continue | ||
| 61 | } | ||
| 62 | ext := strings.ToLower(path.Ext(e.Name)) | ||
| 63 | if ext == ".md" || ext == ".org" || ext == ".markdown" { | ||
| 64 | pages = append(pages, strings.TrimSuffix(e.Name, path.Ext(e.Name))) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | 67 | ||
| 68 | page := strings.Trim(r.PathValue("page"), "/") | 68 | page := strings.Trim(r.PathValue("page"), "/") |
| 69 | if page == "" { | 69 | if page == "" { |
| 70 | for _, home := range []string{"Home", "home", "README", "index"} { | 70 | page = listing.Home |
| 71 | for _, pg := range pages { | ||
| 72 | if pg == home { | ||
| 73 | page = home | ||
| 74 | } | ||
| 75 | } | ||
| 76 | if page != "" { | ||
| 77 | break | ||
| 78 | } | ||
| 79 | } | ||
| 80 | if page == "" && len(pages) > 0 { | ||
| 81 | page = pages[0] | ||
| 82 | } | ||
| 83 | } | 71 | } |
| 84 | var pageHTML template.HTML | 72 | var pageHTML template.HTML |
| 85 | if page != "" { | 73 | if page != "" { |
| 86 | fileName, raw := "", []byte(nil) | 74 | var shown struct { |
| 87 | for _, ext := range []string{".md", ".org", ".markdown"} { | 75 | File string `json:"file"` |
| 88 | if b, err := gitutil.ReadBlob(dir, "main", page+ext, maxRenderBytes); err == nil { | 76 | Content string `json:"content"` |
| 89 | fileName, raw = page+ext, b | ||
| 90 | break | ||
| 91 | } | ||
| 92 | } | 77 | } |
| 93 | if fileName == "" { | 78 | if _, ok := s.runControlInto(viewer, |
| 79 | []string{"wiki", "show", p.Repo.Path(), page}, &shown); !ok { | ||
| 94 | s.notFound(w, r) | 80 | s.notFound(w, r) |
| 95 | return | 81 | return |
| 96 | } | 82 | } |
| 97 | pageHTML = rewriteWikiLinks(renderReadme(fileName, raw), p) | 83 | pageHTML = rewriteWikiLinks(renderReadme(shown.File, []byte(shown.Content)), p) |
| 98 | } | 84 | } |
| 99 | s.render(w, "wiki.html", struct { | 85 | s.render(w, "wiki.html", struct { |
| 100 | repoPage | 86 | repoPage |