Commit 59a6e6bf08
Verified · cmc ci/build: success
e2e/wiki_test.go +48
| @@ -77,6 +77,54 @@ func TestWikis(t *testing.T) { | ||
| 77 | 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 | 128 | // 404-parity: a private repo's wiki is invisible, over web and git. |
| 81 | 129 | mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main") |
| 82 | 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 | 13 | "gitbay.org/gitbay/internal/control" |
| 14 | 14 | "gitbay.org/gitbay/internal/gitutil" |
| 15 | "gitbay.org/gitbay/internal/store" | |
| 15 | 16 | ) |
| 16 | 17 | |
| 17 | 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 | 44 | }{repoPage: p, Missing: true}) |
| 44 | 45 | return |
| 45 | 46 | } |
| 46 | entries, err := gitutil.ListTree(dir, "main", "") | |
| 47 | if err != nil { // wiki repo exists but has no commits yet | |
| 47 | var listing struct { | |
| 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 | 57 | s.render(w, "wiki.html", struct { |
| 49 | 58 | repoPage |
| 50 | 59 | Page string |
| @@ -54,47 +63,24 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | ||
| 54 | 63 | }{repoPage: p, Missing: true}) |
| 55 | 64 | return |
| 56 | 65 | } |
| 57 | var pages []string | |
| 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 | } | |
| 66 | pages := listing.Pages | |
| 67 | 67 | |
| 68 | 68 | page := strings.Trim(r.PathValue("page"), "/") |
| 69 | 69 | if page == "" { |
| 70 | for _, home := range []string{"Home", "home", "README", "index"} { | |
| 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 | } | |
| 70 | page = listing.Home | |
| 83 | 71 | } |
| 84 | 72 | var pageHTML template.HTML |
| 85 | 73 | if page != "" { |
| 86 | fileName, raw := "", []byte(nil) | |
| 87 | for _, ext := range []string{".md", ".org", ".markdown"} { | |
| 88 | if b, err := gitutil.ReadBlob(dir, "main", page+ext, maxRenderBytes); err == nil { | |
| 89 | fileName, raw = page+ext, b | |
| 90 | break | |
| 91 | } | |
| 74 | var shown struct { | |
| 75 | File string `json:"file"` | |
| 76 | Content string `json:"content"` | |
| 92 | 77 | } |
| 93 | if fileName == "" { | |
| 78 | if _, ok := s.runControlInto(viewer, | |
| 79 | []string{"wiki", "show", p.Repo.Path(), page}, &shown); !ok { | |
| 94 | 80 | s.notFound(w, r) |
| 95 | 81 | return |
| 96 | 82 | } |
| 97 | pageHTML = rewriteWikiLinks(renderReadme(fileName, raw), p) | |
| 83 | pageHTML = rewriteWikiLinks(renderReadme(shown.File, []byte(shown.Content)), p) | |
| 98 | 84 | } |
| 99 | 85 | s.render(w, "wiki.html", struct { |
| 100 | 86 | repoPage |