Commit c36365c522

c36365c522cfcbe5ea19a956543057218e41491e

parent: 34a78ef570

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-05 06:03 UTC

wiki: fix raw-route climb-out, filter HasWiki by extension, drop stale claims

wikiRaw joined the request path onto .gitbay/wiki with path.Join,
which resolves .. rather than refusing it; a percent-encoded ..
reached the handler with real .. segments (the mux only cleans the
escaped path, not what PathValue decodes to). Guard it the same way
wiki show already guards page names.

HasWiki counted any blob under .gitbay/wiki, so a tree holding only
an image showed the tab while the page itself said there was no
wiki. Filter by the same page extensions wiki list and wiki show use.

The wiki page's "or use the file editor" hint overclaimed: repo
commit-file refuses repositories requiring signed commits, so the
editor does not work on every repository. Say push only. README.org
still described the wiki as a separate clonable git repository.

This repository's own heavy jobs (test, vuln, sonar) now skip on a
.gitbay/wiki-only push, so migrating the production wiki into this
storage does not itself queue the full suite. build stays
unconditional.

Ref #170

Layout: unified · split

.gitbay/ci.yml +6
@@ -15,6 +15,8 @@ jobs:
15 steps: 15 steps:
16 - missing=""; for t in git git-lfs gpg; do command -v "$t" >/dev/null || missing="$missing $t"; done; test -x /usr/sbin/sshd || missing="$missing sshd"; test -z "$missing" || { echo "runner is missing:$missing"; exit 1; } 16 - missing=""; for t in git git-lfs gpg; do command -v "$t" >/dev/null || missing="$missing $t"; done; test -x /usr/sbin/sshd || missing="$missing sshd"; test -z "$missing" || { echo "runner is missing:$missing"; exit 1; }
17 - go test ./... -count=1 -timeout 20m 17 - go test ./... -count=1 -timeout 20m
18 paths-ignore:
19 - .gitbay/wiki/**
18 # Vulnerability scanning, separate from test so a newly published advisory 20 # Vulnerability scanning, separate from test so a newly published advisory
19 # against unchanged code does not mask a real test failure. It fails the 21 # against unchanged code does not mask a real test failure. It fails the
20 # build on purpose: an advisory that only lands in a report nobody reads is 22 # build on purpose: an advisory that only lands in a report nobody reads is
@@ -23,6 +25,8 @@ jobs:
23 vuln: 25 vuln:
24 steps: 26 steps:
25 - go run golang.org/x/vuln/cmd/govulncheck@latest ./... 27 - go run golang.org/x/vuln/cmd/govulncheck@latest ./...
28 paths-ignore:
29 - .gitbay/wiki/**
26 # SonarCloud static analysis. Report-only: unlike vuln this does not 30 # SonarCloud static analysis. Report-only: unlike vuln this does not
27 # gate, so a first scan of an existing codebase does not turn every 31 # gate, so a first scan of an existing codebase does not turn every
28 # build red before anyone has read what it says. Flip the trailing 32 # build red before anyone has read what it says. Flip the trailing
@@ -66,3 +70,5 @@ jobs:
66 fi 70 fi
67 SONAR_HOST_URL=https://sonarcloud.io \ 71 SONAR_HOST_URL=https://sonarcloud.io \
68 "$SCANNER/bin/sonar-scanner" -Dsonar.scm.revision="${GITBAY_SHA:-}" $BRANCH_ARG || true 72 "$SCANNER/bin/sonar-scanner" -Dsonar.scm.revision="${GITBAY_SHA:-}" $BRANCH_ARG || true
73 paths-ignore:
74 - .gitbay/wiki/**
README.org +1 −2
@@ -104,8 +104,7 @@ via =gitbay man=, completions via =gitbay completion <shell>=.
104 104
105* Documentation 105* Documentation
106 106
107Docs live in [[https://gitbay.org/krz/gitbay/wiki][the wiki]] — itself a git repository 107Docs live in [[https://gitbay.org/krz/gitbay/wiki][the wiki]], dogfooding the
108(=git clone ssh://git@gitbay.org/krz/gitbay.wiki.git=), dogfooding the
109wiki feature: 108wiki feature:
110 109
111- [[https://gitbay.org/krz/gitbay/wiki/Users][user guide]] — accounts, keys, verified commits, repos, issues, MRs, scripting 110- [[https://gitbay.org/krz/gitbay/wiki/Users][user guide]] — accounts, keys, verified commits, repos, issues, MRs, scripting
e2e/wiki_test.go +8
@@ -47,6 +47,7 @@ func TestWikis(t *testing.T) {
47 "# welcome\n\nsee [Setup](Setup.md) and ![shot](shot.png)\n"), 0o644) 47 "# welcome\n\nsee [Setup](Setup.md) and ![shot](shot.png)\n"), 0o644)
48 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "Setup.org"), []byte("* setup\n\nsteps here\n"), 0o644) 48 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "Setup.org"), []byte("* setup\n\nsteps here\n"), 0o644)
49 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644) 49 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644)
50 os.WriteFile(filepath.Join(dir, "top.txt"), []byte("not part of the wiki\n"), 0o644)
50 mustGit(t, dir, env, "add", ".") 51 mustGit(t, dir, env, "add", ".")
51 mustGit(t, dir, env, "commit", "-q", "-m", "wiki start") 52 mustGit(t, dir, env, "commit", "-q", "-m", "wiki start")
52 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main") 53 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main")
@@ -80,6 +81,13 @@ func TestWikis(t *testing.T) {
80 if status != 200 || !strings.HasPrefix(raw, "\x89PNG") { 81 if status != 200 || !strings.HasPrefix(raw, "\x89PNG") {
81 t.Fatalf("wiki raw: %d", status) 82 t.Fatalf("wiki raw: %d", status)
82 } 83 }
84 // The raw route cannot climb out of .gitbay/wiki. A literal ".." is
85 // caught by the mux's own path cleaning, which would make this pass
86 // vacuously; percent-encoding it reaches the handler with real ".."
87 // segments in PathValue, which is what the guard has to refuse.
88 if status, body := inst.get(t, "/alice/app/wiki/_raw/%2e%2e/%2e%2e/top.txt"); status == 200 {
89 t.Fatalf("wiki raw escaped .gitbay/wiki: %d\n%s", status, body)
90 }
83 91
84 // A wiki is readable from every surface, not just a browser: the 92 // A wiki is readable from every surface, not just a browser: the
85 // commands are what the web dispatches, and what the CLI and the 93 // commands are what the web dispatches, and what the CLI and the
internal/httpd/wiki.go +30 −4
@@ -17,18 +17,39 @@ import (
17// wikiTreePath is where wiki pages live in a repository's tree. 17// wikiTreePath is where wiki pages live in a repository's tree.
18const wikiTreePath = ".gitbay/wiki" 18const wikiTreePath = ".gitbay/wiki"
19 19
20// wikiExts are the page formats that count as wiki content.
21var wikiExts = []string{".md", ".org", ".markdown"}
22
20// wikiDir returns repo's directory and default branch, where wiki pages 23// wikiDir returns repo's directory and default branch, where wiki pages
21// are resolved from .gitbay/wiki. 24// are resolved from .gitbay/wiki.
22func (s *Server) wikiDir(repo store.Repo) (dir, branch string) { 25func (s *Server) wikiDir(repo store.Repo) (dir, branch string) {
23 return control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), repo.DefaultBranch 26 return control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), repo.DefaultBranch
24} 27}
25 28
26// hasWiki reports whether repo's default branch holds a non-empty 29// hasWiki reports whether repo's default branch holds a wiki page.
27// .gitbay/wiki tree.
28func (s *Server) hasWiki(repo store.Repo) bool { 30func (s *Server) hasWiki(repo store.Repo) bool {
29 dir, branch := s.wikiDir(repo) 31 dir, branch := s.wikiDir(repo)
30 entries, err := gitutil.ListTree(dir, branch, wikiTreePath) 32 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
31 return err == nil && len(entries) > 0 33 if err != nil {
34 return false
35 }
36 for _, e := range entries {
37 if e.Type == "blob" && wikiExtMatch(e.Name) {
38 return true
39 }
40 }
41 return false
42}
43
44// wikiExtMatch reports whether name has one of the wiki page extensions.
45func wikiExtMatch(name string) bool {
46 ext := strings.ToLower(path.Ext(name))
47 for _, want := range wikiExts {
48 if ext == want {
49 return true
50 }
51 }
52 return false
32} 53}
33 54
34// wiki renders a page from the repo's .gitbay/wiki tree. The home page is 55// wiki renders a page from the repo's .gitbay/wiki tree. The home page is
@@ -94,7 +115,12 @@ func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
94 return 115 return
95 } 116 }
96 dir, branch := s.wikiDir(p.Repo) 117 dir, branch := s.wikiDir(p.Repo)
97 data, err := gitutil.ReadBlob(dir, branch, path.Join(wikiTreePath, strings.Trim(r.PathValue("path"), "/")), s.cfg.Limits.MaxBlobBytes) 118 rel := path.Join(wikiTreePath, strings.Trim(r.PathValue("path"), "/"))
119 if !strings.HasPrefix(rel, wikiTreePath+"/") {
120 s.notFound(w, r)
121 return
122 }
123 data, err := gitutil.ReadBlob(dir, branch, rel, s.cfg.Limits.MaxBlobBytes)
98 if err != nil { 124 if err != nil {
99 s.notFound(w, r) 125 s.notFound(w, r)
100 return 126 return
internal/web/templates/wiki.html +2 −2
@@ -2,7 +2,7 @@
2{{define "content"}} 2{{define "content"}}
3<h1 class="vh">Wiki{{if .Page}}: {{.Page}}{{end}}</h1> 3<h1 class="vh">Wiki{{if .Page}}: {{.Page}}{{end}}</h1>
4{{if .Missing}}<p class="empty-note">no wiki yet — add pages under <code>.gitbay/wiki/</code> on the default branch:<br> 4{{if .Missing}}<p class="empty-note">no wiki yet — add pages under <code>.gitbay/wiki/</code> on the default branch:<br>
5push <code>.gitbay/wiki/Home.md</code> (or .org), or use the file editor.</p> 5push <code>.gitbay/wiki/Home.md</code> (or .org).</p>
6{{else}} 6{{else}}
7<div class="wikilayout"> 7<div class="wikilayout">
8<div class="wikipage"> 8<div class="wikipage">
@@ -14,7 +14,7 @@ push <code>.gitbay/wiki/Home.md</code> (or .org), or use the file editor.</p>
14<nav class="wikinav"> 14<nav class="wikinav">
15<p class="meta">pages</p> 15<p class="meta">pages</p>
16<ul>{{range .Pages}}<li><a {{if eq . $.Page}}class="active" {{end}}href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/wiki/{{.}}">{{.}}</a></li>{{end}}</ul> 16<ul>{{range .Pages}}<li><a {{if eq . $.Page}}class="active" {{end}}href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/wiki/{{.}}">{{.}}</a></li>{{end}}</ul>
17<p class="meta">edit under <code>.gitbay/wiki/</code> — push, or the file editor.</p> 17<p class="meta">edit under <code>.gitbay/wiki/</code> by push.</p>
18</nav> 18</nav>
19</div> 19</div>
20{{end}} 20{{end}}