Wikis live in the repository !263

merged merged by cmc on 2026-09-05 06:19 UTC · krz/gitbay:wiki-in-repo into main

13 files changed, +144 −163

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
cmd/gitbayd/backup.go +1 −1
@@ -267,7 +267,7 @@ func verifyBackup(path string) error {
267 return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", ")) 267 return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", "))
268 } 268 }
269 if extra > 0 { 269 if extra > 0 {
270 fmt.Printf("%d repositories in the archive that the database does not name (deleted after the snapshot, or a wiki)\n", extra) 270 fmt.Printf("%d repositories in the archive that the database does not name (deleted after the snapshot)\n", extra)
271 } 271 }
272 return nil 272 return nil
273} 273}
e2e/readonly_test.go +1 −1
@@ -152,7 +152,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
152 "wiki show": {"alice/app"}, 152 "wiki show": {"alice/app"},
153 } 153 }
154 // Reads whose subject legitimately does not exist in this fixture. 154 // Reads whose subject legitimately does not exist in this fixture.
155 notFoundOK := map[string]bool{"wiki list": true, "wiki show": true, "repo deps status": true} 155 notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true}
156 156
157 dbPath := filepath.Join(inst.root, "gitbay.db") 157 dbPath := filepath.Join(inst.root, "gitbay.db")
158 before := dbFingerprint(t, dbPath) 158 before := dbFingerprint(t, dbPath)
e2e/wiki_test.go +63 −28
@@ -11,7 +11,8 @@ func TestWikis(t *testing.T) {
11 inst := startInstance(t) 11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice") 12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob") 13 bobKey := inst.newKey(t, "bob")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") 14 inst.admin(t, "admin", "user", "create", "alice",
15 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
15 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") 16 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
16 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { 17 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
17 t.Fatal("repo create failed") 18 t.Fatal("repo create failed")
@@ -20,8 +21,8 @@ func TestWikis(t *testing.T) {
20 t.Fatal("private repo create failed") 21 t.Fatal("private repo create failed")
21 } 22 }
22 23
23 // No wiki yet: the tab is absent, the page shows the push hint, and 24 // No wiki yet: the tab is absent, the page shows the missing hint, and
24 // cloning the companion says so. 25 // listing reports no wiki rather than erroring.
25 _, body := inst.get(t, "/alice/app") 26 _, body := inst.get(t, "/alice/app")
26 if strings.Contains(body, ">Wiki<") { 27 if strings.Contains(body, ">Wiki<") {
27 t.Fatal("wiki tab shown with no wiki") 28 t.Fatal("wiki tab shown with no wiki")
@@ -30,26 +31,30 @@ func TestWikis(t *testing.T) {
30 if !strings.Contains(body, "no wiki yet") { 31 if !strings.Contains(body, "no wiki yet") {
31 t.Fatal("missing-wiki hint absent") 32 t.Fatal("missing-wiki hint absent")
32 } 33 }
33 env := inst.gitEnv(aliceKey) 34 if out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/app", "--json"); code != 0 {
34 if out, code := gitRun(t, t.TempDir(), env, "clone", inst.sshURL("alice/app.wiki"), "w"); code == 0 || !strings.Contains(out, "no wiki yet") { 35 t.Fatalf("wiki list on a repo without one: %s", errOut)
35 t.Fatalf("clone of absent wiki: %d\n%s", code, out) 36 } else if !strings.Contains(out, `"pages":[]`) {
37 t.Errorf("wiki list on a repo without one returned pages: %s", out)
36 } 38 }
37 39
38 // First push creates the wiki. A reader without write cannot push it. 40 // Pages are ordinary files: pushing them creates the wiki.
41 env := inst.gitEnv(aliceKey)
39 work := t.TempDir() 42 work := t.TempDir()
40 mustGit(t, work, env, "init", "-q", "-b", "main", "w") 43 mustGit(t, work, env, "init", "-q", "-b", "main", "w")
41 dir := filepath.Join(work, "w") 44 dir := filepath.Join(work, "w")
42 os.WriteFile(filepath.Join(dir, "Home.md"), []byte( 45 os.MkdirAll(filepath.Join(dir, ".gitbay", "wiki"), 0o755)
46 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "Home.md"), []byte(
43 "# 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)
44 os.WriteFile(filepath.Join(dir, "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)
45 os.WriteFile(filepath.Join(dir, "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)
46 mustGit(t, dir, env, "add", ".") 51 mustGit(t, dir, env, "add", ".")
47 mustGit(t, dir, env, "commit", "-q", "-m", "wiki start") 52 mustGit(t, dir, env, "commit", "-q", "-m", "wiki start")
48 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app.wiki"), "main") 53 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main")
49 54
50 benv := inst.gitEnv(bobKey) 55 benv := inst.gitEnv(bobKey)
51 if out, code := gitRun(t, dir, benv, "push", inst.sshURL("alice/app.wiki"), "main"); code == 0 && !strings.Contains(out, "denied") { 56 if out, code := gitRun(t, dir, benv, "push", inst.sshURL("alice/app"), "main"); code == 0 && !strings.Contains(out, "denied") {
52 t.Fatalf("reader pushed the wiki: %d\n%s", code, out) 57 t.Fatalf("reader pushed the repository: %d\n%s", code, out)
53 } 58 }
54 59
55 // Rendering: home resolves, tab appears, links rewrite to wiki pages 60 // Rendering: home resolves, tab appears, links rewrite to wiki pages
@@ -76,6 +81,13 @@ func TestWikis(t *testing.T) {
76 if status != 200 || !strings.HasPrefix(raw, "\x89PNG") { 81 if status != 200 || !strings.HasPrefix(raw, "\x89PNG") {
77 t.Fatalf("wiki raw: %d", status) 82 t.Fatalf("wiki raw: %d", status)
78 } 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 }
79 91
80 // 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
81 // 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
@@ -115,10 +127,12 @@ func TestWikis(t *testing.T) {
115 if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 { 127 if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 {
116 t.Error("wiki show escaped the repository") 128 t.Error("wiki show escaped the repository")
117 } 129 }
118 // A repository with no wiki says so rather than failing oddly. 130 // A repository with no wiki says so rather than failing oddly, even
119 if _, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/secretive"); code == 0 || 131 // for its owner.
120 !strings.Contains(errOut, "no wiki") { 132 if out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/secretive", "--json"); code != 0 {
121 t.Errorf("wiki list on a repo without one: %d %s", code, errOut) 133 t.Fatalf("wiki list on a repo without one: %s", errOut)
134 } else if !strings.Contains(out, `"pages":[]`) {
135 t.Errorf("wiki list on a repo without one returned pages: %s", out)
122 } 136 }
123 // Wiki access derives from the parent: a stranger gets nothing. 137 // Wiki access derives from the parent: a stranger gets nothing.
124 if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 { 138 if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 {
@@ -126,24 +140,45 @@ func TestWikis(t *testing.T) {
126 } 140 }
127 141
128 // 404-parity: a private repo's wiki is invisible, over web and git. 142 // 404-parity: a private repo's wiki is invisible, over web and git.
129 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main")
130 if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 { 143 if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 {
131 t.Fatalf("private wiki page: %d", status) 144 t.Fatalf("private wiki page: %d", status)
132 } 145 }
133 if out, code := gitRun(t, t.TempDir(), benv, "clone", inst.sshURL("alice/secretive.wiki"), "x"); code == 0 || !strings.Contains(out, "not found") { 146
134 t.Fatalf("private wiki clone by outsider: %d\n%s", code, out) 147 // Pushing to <name>.wiki.git is refused now that the companion route
148 // is gone; there is no such repository.
149 if out, code := gitRun(t, t.TempDir(), env, "clone", inst.sshURL("alice/app.wiki"), "x"); code == 0 {
150 t.Fatalf("cloned a nonexistent companion: %s", out)
151 } else if !strings.Contains(out, "not found") {
152 t.Fatalf("clone of alice/app.wiki: %s", out)
135 } 153 }
136 154
137 // Repo names ending .wiki are refused (companion namespace). 155 // A repository may now be named something.wiki: the suffix is no
138 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 2 || !strings.Contains(errOut, "reserved") { 156 // longer reserved.
139 t.Fatalf(".wiki name allowed: %d %s", code, errOut) 157 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 0 {
158 t.Fatalf("something.wiki repo name refused: %s", errOut)
140 } 159 }
141 160
142 // Deleting the repo removes the wiki companion. 161 // repo commit-file writes a page on a repository that permits
143 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "delete", "alice/secretive", "--yes"); code != 0 { 162 // server-authored commits: it is the command behind the web editor,
144 t.Fatal("repo delete failed") 163 // and there is no wiki-specific write command.
164 if _, errOut, code := inst.ssh(t, aliceKey, "written by commit-file\n",
165 "repo", "commit-file", "alice/app", ".gitbay/wiki/Extra.md",
166 "--ref", "main", "--message", "'add a page'", "--file", "-"); code != 0 {
167 t.Fatalf("repo commit-file: %s", errOut)
168 }
169 out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Extra", "--json")
170 if code != 0 || !strings.Contains(out, "written by commit-file") {
171 t.Errorf("wiki show Extra: %s", out)
145 } 172 }
146 if _, err := os.Stat(filepath.Join(inst.root, "repos", "alice", "secretive.wiki.git")); err == nil { 173
147 t.Fatal("wiki survived repo delete") 174 // A repository requiring verified signatures refuses repo commit-file,
175 // since the server cannot sign on the user's behalf.
176 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/app", "on"); code != 0 {
177 t.Fatal("require-signed failed")
178 }
179 if _, errOut, code := inst.ssh(t, aliceKey, "blocked\n",
180 "repo", "commit-file", "alice/app", ".gitbay/wiki/Blocked.md",
181 "--ref", "main", "--file", "-"); code == 0 || !strings.Contains(errOut, "requires signed commits") {
182 t.Errorf("repo commit-file not refused on a signed-commits repo: %d %s", code, errOut)
148 } 183 }
149} 184}
internal/control/repo.go +1 −7
@@ -392,11 +392,6 @@ func runRepoTransfer(c *Ctx, args []string) int {
392 } 392 }
393 return c.fail(protocol.ExitFailure, "moving repository: %v", err) 393 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
394 } 394 }
395 // The wiki companion follows its repo.
396 oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
397 if _, err := os.Stat(oldWiki); err == nil {
398 os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki"))
399 }
400 newPath := newOwner + "/" + repo.Name 395 newPath := newOwner + "/" + repo.Name
401 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) { 396 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
402 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath) 397 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
@@ -429,7 +424,7 @@ func runRepoDelete(c *Ctx, args []string) int {
429} 424}
430 425
431// deleteRepo removes a repository the caller has already been cleared to 426// deleteRepo removes a repository the caller has already been cleared to
432// delete: the database row, then the directory and its wiki companion. 427// delete: the database row, then the directory.
433// 428//
434// There is deliberately no repo.deleted event. events.repo_id and 429// There is deliberately no repo.deleted event. events.repo_id and
435// webhooks.repo_id both cascade from repos, so recording one would delete 430// webhooks.repo_id both cascade from repos, so recording one would delete
@@ -449,7 +444,6 @@ func deleteRepo(c *Ctx, repo store.Repo) int {
449 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil { 444 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
450 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err) 445 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
451 } 446 }
452 os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
453 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) { 447 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
454 fmt.Fprintf(w, "deleted %s\n", repo.Path()) 448 fmt.Fprintf(w, "deleted %s\n", repo.Path())
455 }) 449 })
internal/control/wiki.go +24 −28
@@ -4,7 +4,6 @@ import (
4 "encoding/base64" 4 "encoding/base64"
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "os"
8 "path" 7 "path"
9 "strings" 8 "strings"
10 9
@@ -31,39 +30,36 @@ func init() {
31 }) 30 })
32} 31}
33 32
34// A wiki lives in a companion bare repo beside its parent, so prose 33// Wiki pages are files under .gitbay/wiki on the repository's default
35// edits stay out of the code repository's history, its protected 34// branch, alongside ci.yml and CODEOWNERS. They have no store row of
36// branches and its builds. The companion has no store row of its own — 35// their own — access derives from the parent, exactly as it does for any
37// access derives from the parent, exactly as it does for git over SSH — 36// other path in the repository.
38// which is why it needs commands rather than being addressable as a
39// repository.
40// 37//
41// Editing stays a push to <repo>.wiki.git. That is the whole write 38// Writing is a push, or repo commit-file, like any other file in the
42// interface, on every surface, and there is nothing for a command to 39// repository. There is no wiki-specific write command.
43// add.
44 40
45// wikiExts are the page formats the web renders, in resolution order. 41// wikiExts are the page formats the web renders, in resolution order.
46var wikiExts = []string{".md", ".org", ".markdown"} 42var wikiExts = []string{".md", ".org", ".markdown"}
47 43
48// wikiDir resolves the parent, checks read access, and returns the 44// wikiTreePath is where wiki pages live in a repository's tree.
49// companion's path. A parent you cannot read has no wiki you can read. 45const wikiTreePath = ".gitbay/wiki"
50func wikiDir(c *Ctx, spec string) (repo store.Repo, dir string, code int) { 46
47// wikiDir resolves the parent, checks read access, and returns its
48// directory and default branch. A parent you cannot read has no wiki you
49// can read.
50func wikiDir(c *Ctx, spec string) (repo store.Repo, dir, branch string, code int) {
51 parent, code := resolveRepo(c, spec, policy.CanRead) 51 parent, code := resolveRepo(c, spec, policy.CanRead)
52 if code >= 0 { 52 if code >= 0 {
53 return store.Repo{}, "", code 53 return store.Repo{}, "", "", code
54 }
55 d := RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name+".wiki")
56 if _, err := os.Stat(d); err != nil {
57 return store.Repo{}, "", c.fail(protocol.ExitNotFound, "%s has no wiki", parent.Path())
58 } 54 }
59 return parent, d, -1 55 return parent, RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name), parent.DefaultBranch, -1
60} 56}
61 57
62// wikiPages lists the page names in the companion, without extensions. 58// wikiPages lists the page names under .gitbay/wiki, without extensions.
63func wikiPages(dir string) []string { 59func wikiPages(dir, branch string) []string {
64 entries, err := gitutil.ListTree(dir, "main", "") 60 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
65 if err != nil { 61 if err != nil {
66 return nil // the companion exists but has no commits yet 62 return nil // no .gitbay/wiki tree on this branch
67 } 63 }
68 var pages []string 64 var pages []string
69 for _, e := range entries { 65 for _, e := range entries {
@@ -85,11 +81,11 @@ func runWikiList(c *Ctx, args []string) int {
85 if len(args) != 1 { 81 if len(args) != 1 {
86 return c.fail(protocol.ExitUsage, "usage: wiki list <owner/name>") 82 return c.fail(protocol.ExitUsage, "usage: wiki list <owner/name>")
87 } 83 }
88 repo, dir, code := wikiDir(c, args[0]) 84 repo, dir, branch, code := wikiDir(c, args[0])
89 if code >= 0 { 85 if code >= 0 {
90 return code 86 return code
91 } 87 }
92 pages := wikiPages(dir) 88 pages := wikiPages(dir, branch)
93 type out struct { 89 type out struct {
94 Path string `json:"path"` 90 Path string `json:"path"`
95 Home string `json:"home,omitempty"` 91 Home string `json:"home,omitempty"`
@@ -126,11 +122,11 @@ func runWikiShow(c *Ctx, args []string) int {
126 if len(args) < 1 || len(args) > 2 { 122 if len(args) < 1 || len(args) > 2 {
127 return c.fail(protocol.ExitUsage, "usage: wiki show <owner/name> [<page>]") 123 return c.fail(protocol.ExitUsage, "usage: wiki show <owner/name> [<page>]")
128 } 124 }
129 repo, dir, code := wikiDir(c, args[0]) 125 repo, dir, branch, code := wikiDir(c, args[0])
130 if code >= 0 { 126 if code >= 0 {
131 return code 127 return code
132 } 128 }
133 pages := wikiPages(dir) 129 pages := wikiPages(dir, branch)
134 page := wikiHome(pages) 130 page := wikiHome(pages)
135 if len(args) == 2 { 131 if len(args) == 2 {
136 page = strings.TrimSuffix(args[1], path.Ext(args[1])) 132 page = strings.TrimSuffix(args[1], path.Ext(args[1]))
@@ -144,7 +140,7 @@ func runWikiShow(c *Ctx, args []string) int {
144 } 140 }
145 141
146 for _, ext := range wikiExts { 142 for _, ext := range wikiExts {
147 raw, err := gitutil.ReadBlob(dir, "main", page+ext, c.Cfg.Limits.MaxBlobBytes) 143 raw, err := gitutil.ReadBlob(dir, branch, wikiTreePath+"/"+page+ext, c.Cfg.Limits.MaxBlobBytes)
148 if err != nil { 144 if err != nil {
149 continue 145 continue
150 } 146 }
internal/gitutil/gitutil.go +3 −7
@@ -25,13 +25,9 @@ func InitBare(path, defaultBranch, hooksPath string) error {
25 if out, err := cmd.CombinedOutput(); err != nil { 25 if out, err := cmd.CombinedOutput(); err != nil {
26 return fmt.Errorf("git init: %v\n%s", err, out) 26 return fmt.Errorf("git init: %v\n%s", err, out)
27 } 27 }
28 // An empty hooksPath leaves the bare repo with no hooks — used for 28 cmd = exec.Command(toolpath.Look("git"), "-C", path, "config", "core.hooksPath", hooksPath)
29 // companion repos (wikis) that carry no ref policy. 29 if out, err := cmd.CombinedOutput(); err != nil {
30 if hooksPath != "" { 30 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
31 cmd = exec.Command(toolpath.Look("git"), "-C", path, "config", "core.hooksPath", hooksPath)
32 if out, err := cmd.CombinedOutput(); err != nil {
33 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
34 }
35 } 31 }
36 return nil 32 return nil
37} 33}
internal/httpd/web.go +1 −1
@@ -333,7 +333,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
333 Mirrors: mirrors, 333 Mirrors: mirrors,
334 Pinned: pinned, 334 Pinned: pinned,
335 Watch: watch, 335 Watch: watch,
336 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", 336 HasWiki: s.hasWiki(repo),
337 Host: s.cfg.SiteHost(), 337 Host: s.cfg.SiteHost(),
338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), 338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
339 Repo: repo, 339 Repo: repo,
internal/httpd/wiki.go +40 −22
@@ -3,7 +3,6 @@ package httpd
3import ( 3import (
4 "html/template" 4 "html/template"
5 "net/http" 5 "net/http"
6 "os"
7 "path" 6 "path"
8 "strings" 7 "strings"
9 8
@@ -15,16 +14,45 @@ import (
15 "gitbay.org/gitbay/internal/store" 14 "gitbay.org/gitbay/internal/store"
16) 15)
17 16
18// wikiDir returns the companion repo path, or "" when the repo has none. 17// wikiTreePath is where wiki pages live in a repository's tree.
19func (s *Server) wikiDir(owner, name string) string { 18const wikiTreePath = ".gitbay/wiki"
20 dir := control.RepoDir(s.cfg.Server.Root, owner, name+".wiki") 19
21 if _, err := os.Stat(dir); err != nil { 20// wikiExts are the page formats that count as wiki content.
22 return "" 21var wikiExts = []string{".md", ".org", ".markdown"}
22
23// wikiDir returns repo's directory and default branch, where wiki pages
24// are resolved from .gitbay/wiki.
25func (s *Server) wikiDir(repo store.Repo) (dir, branch string) {
26 return control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), repo.DefaultBranch
27}
28
29// hasWiki reports whether repo's default branch holds a wiki page.
30func (s *Server) hasWiki(repo store.Repo) bool {
31 dir, branch := s.wikiDir(repo)
32 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
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 }
23 } 51 }
24 return dir 52 return false
25} 53}
26 54
27// wiki renders a page from the repo's wiki companion. The home page is 55// wiki renders a page from the repo's .gitbay/wiki tree. The home page is
28// Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and 56// Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and
29// .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs. 57// .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs.
30func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { 58func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
@@ -33,17 +61,6 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
33 return 61 return
34 } 62 }
35 p.Tab = "wiki" 63 p.Tab = "wiki"
36 dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name)
37 if dir == "" {
38 s.render(w, "wiki.html", struct {
39 repoPage
40 Page string
41 PageHTML template.HTML
42 Pages []string
43 Missing bool
44 }{repoPage: p, Missing: true})
45 return
46 }
47 var listing struct { 64 var listing struct {
48 Home string `json:"home"` 65 Home string `json:"home"`
49 Pages []string `json:"pages"` 66 Pages []string `json:"pages"`
@@ -97,12 +114,13 @@ func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
97 if !ok { 114 if !ok {
98 return 115 return
99 } 116 }
100 dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name) 117 dir, branch := s.wikiDir(p.Repo)
101 if dir == "" { 118 rel := path.Join(wikiTreePath, strings.Trim(r.PathValue("path"), "/"))
119 if !strings.HasPrefix(rel, wikiTreePath+"/") {
102 s.notFound(w, r) 120 s.notFound(w, r)
103 return 121 return
104 } 122 }
105 data, err := gitutil.ReadBlob(dir, "main", strings.Trim(r.PathValue("path"), "/"), s.cfg.Limits.MaxBlobBytes) 123 data, err := gitutil.ReadBlob(dir, branch, rel, s.cfg.Limits.MaxBlobBytes)
106 if err != nil { 124 if err != nil {
107 s.notFound(w, r) 125 s.notFound(w, r)
108 return 126 return
internal/policy/names.go −3
@@ -59,9 +59,6 @@ func ValidateName(name string) error {
59 if len(name) > 4 && name[len(name)-4:] == ".git" { 59 if len(name) > 4 && name[len(name)-4:] == ".git" {
60 return fmt.Errorf("invalid name %q: must not end in .git", name) 60 return fmt.Errorf("invalid name %q: must not end in .git", name)
61 } 61 }
62 if len(name) > 5 && name[len(name)-5:] == ".wiki" {
63 return fmt.Errorf("invalid name %q: .wiki names are reserved for wiki companion repositories", name)
64 }
65 return nil 62 return nil
66} 63}
67 64
internal/sshd/sshd.go −60
@@ -16,7 +16,6 @@ import (
16 "os" 16 "os"
17 "path/filepath" 17 "path/filepath"
18 "strconv" 18 "strconv"
19 "strings"
20 "sync" 19 "sync"
21 "sync/atomic" 20 "sync/atomic"
22 "time" 21 "time"
@@ -356,12 +355,6 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
356 } 355 }
357 write := service == "git-receive-pack" 356 write := service == "git-receive-pack"
358 357
359 // Wiki companion repos: ssh://.../owner/name.wiki.git. Access derives
360 // from the parent repo; the bare repo is created on first push.
361 if base, ok := strings.CutSuffix(strings.TrimSuffix(argv[1], ".git"), ".wiki"); ok {
362 return runWikiGit(cfg, st, user, scope, service, base, write, stdin, stdout, stderr)
363 }
364
365 repo, err := st.RepoByPath(argv[1]) 358 repo, err := st.RepoByPath(argv[1])
366 if err != nil { 359 if err != nil {
367 fmt.Fprintln(stderr, "repository not found") 360 fmt.Fprintln(stderr, "repository not found")
@@ -433,56 +426,3 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
433 } 426 }
434 return protocol.ExitOK 427 return protocol.ExitOK
435} 428}
436
437// runWikiGit serves a repo's wiki companion. The wiki carries no ref
438// policy (no hooks, no merge requests); access is exactly the parent
439// repo's, and the bare repo is created on the first push. Deploy keys —
440// bound to the repo's own git data for CI — cannot touch the wiki.
441func runWikiGit(cfg config.Config, st *store.Store, user store.User, scope, service, basePath string,
442 write bool, stdin io.Reader, stdout, stderr io.Writer) int {
443 repo, err := st.RepoByPath(basePath)
444 if err != nil {
445 fmt.Fprintln(stderr, "repository not found")
446 return protocol.ExitNotFound
447 }
448 if policy.IsDeployScope(scope) {
449 fmt.Fprintln(stderr, "repository not found")
450 return protocol.ExitNotFound
451 }
452 grant, err := st.AccessRole(repo.ID, user.ID)
453 if err != nil {
454 fmt.Fprintln(stderr, "internal error")
455 return protocol.ExitFailure
456 }
457 if !policy.CanRead(user, repo, grant) {
458 fmt.Fprintln(stderr, "repository not found")
459 return protocol.ExitNotFound
460 }
461 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
462 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on the wiki\n", scope, service)
463 return protocol.ExitDenied
464 }
465 if write && !policy.CanWrite(user, repo, grant) {
466 fmt.Fprintf(stderr, "write access to %s wiki denied\n", repo.Path())
467 return protocol.ExitDenied
468 }
469 if write && repo.Settings.Archived {
470 fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
471 return protocol.ExitDenied
472 }
473 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
474 if _, err := os.Stat(dir); err != nil {
475 if !write {
476 fmt.Fprintln(stderr, "this repository has no wiki yet")
477 return protocol.ExitNotFound
478 }
479 if err := gitutil.InitBare(dir, "main", ""); err != nil {
480 fmt.Fprintln(stderr, "initializing wiki failed")
481 return protocol.ExitFailure
482 }
483 }
484 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, nil, cfg.Limits.MaxPackBytes); err != nil {
485 return protocol.ExitFailure
486 }
487 return protocol.ExitOK
488}
internal/web/templates/wiki.html +3 −3
@@ -1,8 +1,8 @@
1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
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 — create one by pushing pages:<br> 4{{if .Missing}}<p class="empty-note">no wiki yet — add pages under <code>.gitbay/wiki/</code> on the default branch:<br>
5<code>git clone ssh://git@{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}.wiki.git</code> then add <code>Home.md</code> (or .org) and push.</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 @@
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 by push:<br><code>{{.Repo.Path}}.wiki.git</code></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}}