Commit 34a78ef570
Verified · cmc
Layout: unified · split
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 +55 −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,29 @@ 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 \n"), 0o644) | 47 | "# welcome\n\nsee [Setup](Setup.md) and \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) |
| 46 | mustGit(t, dir, env, "add", ".") | 50 | mustGit(t, dir, env, "add", ".") |
| 47 | mustGit(t, dir, env, "commit", "-q", "-m", "wiki start") | 51 | mustGit(t, dir, env, "commit", "-q", "-m", "wiki start") |
| 48 | mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app.wiki"), "main") | 52 | mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main") |
| 49 | 53 | ||
| 50 | benv := inst.gitEnv(bobKey) | 54 | 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") { | 55 | 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) | 56 | t.Fatalf("reader pushed the repository: %d\n%s", code, out) |
| 53 | } | 57 | } |
| 54 | 58 | ||
| 55 | // Rendering: home resolves, tab appears, links rewrite to wiki pages | 59 | // Rendering: home resolves, tab appears, links rewrite to wiki pages |
| @@ -115,10 +119,12 @@ func TestWikis(t *testing.T) { | |||
| 115 | if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 { | 119 | if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 { |
| 116 | t.Error("wiki show escaped the repository") | 120 | t.Error("wiki show escaped the repository") |
| 117 | } | 121 | } |
| 118 | // A repository with no wiki says so rather than failing oddly. | 122 | // 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 || | 123 | // for its owner. |
| 120 | !strings.Contains(errOut, "no wiki") { | 124 | 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) | 125 | t.Fatalf("wiki list on a repo without one: %s", errOut) |
| 126 | } else if !strings.Contains(out, `"pages":[]`) { | ||
| 127 | t.Errorf("wiki list on a repo without one returned pages: %s", out) | ||
| 122 | } | 128 | } |
| 123 | // Wiki access derives from the parent: a stranger gets nothing. | 129 | // Wiki access derives from the parent: a stranger gets nothing. |
| 124 | if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 { | 130 | if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 { |
| @@ -126,24 +132,45 @@ func TestWikis(t *testing.T) { | |||
| 126 | } | 132 | } |
| 127 | 133 | ||
| 128 | // 404-parity: a private repo's wiki is invisible, over web and git. | 134 | // 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 { | 135 | if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 { |
| 131 | t.Fatalf("private wiki page: %d", status) | 136 | t.Fatalf("private wiki page: %d", status) |
| 132 | } | 137 | } |
| 133 | if out, code := gitRun(t, t.TempDir(), benv, "clone", inst.sshURL("alice/secretive.wiki"), "x"); code == 0 || !strings.Contains(out, "not found") { | 138 | |
| 134 | t.Fatalf("private wiki clone by outsider: %d\n%s", code, out) | 139 | // Pushing to <name>.wiki.git is refused now that the companion route |
| 140 | // is gone; there is no such repository. | ||
| 141 | if out, code := gitRun(t, t.TempDir(), env, "clone", inst.sshURL("alice/app.wiki"), "x"); code == 0 { | ||
| 142 | t.Fatalf("cloned a nonexistent companion: %s", out) | ||
| 143 | } else if !strings.Contains(out, "not found") { | ||
| 144 | t.Fatalf("clone of alice/app.wiki: %s", out) | ||
| 135 | } | 145 | } |
| 136 | 146 | ||
| 137 | // Repo names ending .wiki are refused (companion namespace). | 147 | // 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") { | 148 | // longer reserved. |
| 139 | t.Fatalf(".wiki name allowed: %d %s", code, errOut) | 149 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 0 { |
| 150 | t.Fatalf("something.wiki repo name refused: %s", errOut) | ||
| 140 | } | 151 | } |
| 141 | 152 | ||
| 142 | // Deleting the repo removes the wiki companion. | 153 | // 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 { | 154 | // server-authored commits: it is the command behind the web editor, |
| 144 | t.Fatal("repo delete failed") | 155 | // and there is no wiki-specific write command. |
| 156 | if _, errOut, code := inst.ssh(t, aliceKey, "written by commit-file\n", | ||
| 157 | "repo", "commit-file", "alice/app", ".gitbay/wiki/Extra.md", | ||
| 158 | "--ref", "main", "--message", "'add a page'", "--file", "-"); code != 0 { | ||
| 159 | t.Fatalf("repo commit-file: %s", errOut) | ||
| 160 | } | ||
| 161 | out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Extra", "--json") | ||
| 162 | if code != 0 || !strings.Contains(out, "written by commit-file") { | ||
| 163 | t.Errorf("wiki show Extra: %s", out) | ||
| 145 | } | 164 | } |
| 146 | if _, err := os.Stat(filepath.Join(inst.root, "repos", "alice", "secretive.wiki.git")); err == nil { | 165 | |
| 147 | t.Fatal("wiki survived repo delete") | 166 | // A repository requiring verified signatures refuses repo commit-file, |
| 167 | // since the server cannot sign on the user's behalf. | ||
| 168 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/app", "on"); code != 0 { | ||
| 169 | t.Fatal("require-signed failed") | ||
| 170 | } | ||
| 171 | if _, errOut, code := inst.ssh(t, aliceKey, "blocked\n", | ||
| 172 | "repo", "commit-file", "alice/app", ".gitbay/wiki/Blocked.md", | ||
| 173 | "--ref", "main", "--file", "-"); code == 0 || !strings.Contains(errOut, "requires signed commits") { | ||
| 174 | t.Errorf("repo commit-file not refused on a signed-commits repo: %d %s", code, errOut) | ||
| 148 | } | 175 | } |
| 149 | } | 176 | } |
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. |
| 46 | var wikiExts = []string{".md", ".org", ".markdown"} | 42 | var 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. | 45 | const wikiTreePath = ".gitbay/wiki" |
| 50 | func 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. | ||
| 50 | func 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. |
| 63 | func wikiPages(dir string) []string { | 59 | func 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 +18 −26
| @@ -3,7 +3,6 @@ package httpd | |||
| 3 | import ( | 3 | import ( |
| 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,24 @@ 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. |
| 19 | func (s *Server) wikiDir(owner, name string) string { | 18 | const wikiTreePath = ".gitbay/wiki" |
| 20 | dir := control.RepoDir(s.cfg.Server.Root, owner, name+".wiki") | 19 | |
| 21 | if _, err := os.Stat(dir); err != nil { | 20 | // wikiDir returns repo's directory and default branch, where wiki pages |
| 22 | return "" | 21 | // are resolved from .gitbay/wiki. |
| 23 | } | 22 | func (s *Server) wikiDir(repo store.Repo) (dir, branch string) { |
| 24 | return dir | 23 | return control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), repo.DefaultBranch |
| 24 | } | ||
| 25 | |||
| 26 | // hasWiki reports whether repo's default branch holds a non-empty | ||
| 27 | // .gitbay/wiki tree. | ||
| 28 | func (s *Server) hasWiki(repo store.Repo) bool { | ||
| 29 | dir, branch := s.wikiDir(repo) | ||
| 30 | entries, err := gitutil.ListTree(dir, branch, wikiTreePath) | ||
| 31 | return err == nil && len(entries) > 0 | ||
| 25 | } | 32 | } |
| 26 | 33 | ||
| 27 | // wiki renders a page from the repo's wiki companion. The home page is | 34 | // 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 | 35 | // Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and |
| 29 | // .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs. | 36 | // .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs. |
| 30 | func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | 37 | func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { |
| @@ -33,17 +40,6 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | |||
| 33 | return | 40 | return |
| 34 | } | 41 | } |
| 35 | p.Tab = "wiki" | 42 | 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 { | 43 | var listing struct { |
| 48 | Home string `json:"home"` | 44 | Home string `json:"home"` |
| 49 | Pages []string `json:"pages"` | 45 | Pages []string `json:"pages"` |
| @@ -97,12 +93,8 @@ func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) { | |||
| 97 | if !ok { | 93 | if !ok { |
| 98 | return | 94 | return |
| 99 | } | 95 | } |
| 100 | dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name) | 96 | dir, branch := s.wikiDir(p.Repo) |
| 101 | if dir == "" { | 97 | data, err := gitutil.ReadBlob(dir, branch, path.Join(wikiTreePath, strings.Trim(r.PathValue("path"), "/")), s.cfg.Limits.MaxBlobBytes) |
| 102 | s.notFound(w, r) | ||
| 103 | return | ||
| 104 | } | ||
| 105 | data, err := gitutil.ReadBlob(dir, "main", strings.Trim(r.PathValue("path"), "/"), s.cfg.Limits.MaxBlobBytes) | ||
| 106 | if err != nil { | 98 | if err != nil { |
| 107 | s.notFound(w, r) | 99 | s.notFound(w, r) |
| 108 | return | 100 | 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. | ||
| 441 | func 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> | 5 | push <code>.gitbay/wiki/Home.md</code> (or .org), or use the file editor.</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> — push, or the file editor.</p> |
| 18 | </nav> | 18 | </nav> |
| 19 | </div> | 19 | </div> |
| 20 | {{end}} | 20 | {{end}} |