control: wiki list and wiki show !102

merged merged by cmc on 2026-08-28 04:18 UTC · krz/gitbay:wiki-commands into main

3 files changed, +238 −33

Layout: unified · split

e2e/wiki_test.go +48
@@ -77,6 +77,54 @@ func TestWikis(t *testing.T) {
7777 t.Fatalf("wiki raw: %d", status)
7878 }
7979
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
80128 // 404-parity: a private repo's wiki is invisible, over web and git.
81129 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main")
82130 if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 {
internal/control/wiki.go added +171
@@ -0,0 +1,171 @@
1package control
2
3import (
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
17func 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.
44var 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.
48func 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.
61func 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
82func 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.
109func 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
123func 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 (
1212
1313 "gitbay.org/gitbay/internal/control"
1414 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/store"
1516)
1617
1718// 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) {
4344 }{repoPage: p, Missing: true})
4445 return
4546 }
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
4857 s.render(w, "wiki.html", struct {
4958 repoPage
5059 Page string
@@ -54,47 +63,24 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
5463 }{repoPage: p, Missing: true})
5564 return
5665 }
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
6767
6868 page := strings.Trim(r.PathValue("page"), "/")
6969 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
8371 }
8472 var pageHTML template.HTML
8573 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"`
9277 }
93 if fileName == "" {
78 if _, ok := s.runControlInto(viewer,
79 []string{"wiki", "show", p.Repo.Path(), page}, &shown); !ok {
9480 s.notFound(w, r)
9581 return
9682 }
97 pageHTML = rewriteWikiLinks(renderReadme(fileName, raw), p)
83 pageHTML = rewriteWikiLinks(renderReadme(shown.File, []byte(shown.Content)), p)
9884 }
9985 s.render(w, "wiki.html", struct {
10086 repoPage