Commit 9b6ae6d866
Verified · cmc
docs/users.org +13
| @@ -192,6 +192,19 @@ that is why no =owner/name= appears above. Anywhere else, pass it as the | ||
| 192 | 192 | first argument. Long text: =--body= inline, =--file -= from stdin, or |
| 193 | 193 | neither on a terminal and =$EDITOR= opens. |
| 194 | 194 | |
| 195 | Wikis are companion repositories edited by push — no separate storage, | |
| 196 | no web editor, the same rendering pipeline as READMEs (markdown and | |
| 197 | org). Access mirrors the parent repo (read to view, write to push); | |
| 198 | the companion is created on your first push and follows the repo | |
| 199 | through transfer and delete: | |
| 200 | ||
| 201 | #+begin_src sh | |
| 202 | git clone ssh://git@<host>/you/project.wiki.git | |
| 203 | # add Home.md (or .org), more pages, images; relative links between | |
| 204 | # pages just work on the web at /you/project/wiki | |
| 205 | git push | |
| 206 | #+end_src | |
| 207 | ||
| 195 | 208 | Releases anchor notes and binary assets to a pushed tag (write access; |
| 196 | 209 | assets stream over SSH, capped by the instance's =max_asset_bytes=): |
| 197 | 210 | |
e2e/wiki_test.go added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | func TestWikis(t *testing.T) { | |
| 11 | inst := startInstance(t) | |
| 12 | aliceKey := inst.newKey(t, "alice") | |
| 13 | bobKey := inst.newKey(t, "bob") | |
| 14 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 15 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 16 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 17 | t.Fatal("repo create failed") | |
| 18 | } | |
| 19 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secretive", "--private"); code != 0 { | |
| 20 | t.Fatal("private repo create failed") | |
| 21 | } | |
| 22 | ||
| 23 | // No wiki yet: the tab is absent, the page shows the push hint, and | |
| 24 | // cloning the companion says so. | |
| 25 | _, body := inst.get(t, "/alice/app") | |
| 26 | if strings.Contains(body, ">wiki<") { | |
| 27 | t.Fatal("wiki tab shown with no wiki") | |
| 28 | } | |
| 29 | _, body = inst.get(t, "/alice/app/wiki") | |
| 30 | if !strings.Contains(body, "no wiki yet") { | |
| 31 | t.Fatal("missing-wiki hint absent") | |
| 32 | } | |
| 33 | env := inst.gitEnv(aliceKey) | |
| 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("clone of absent wiki: %d\n%s", code, out) | |
| 36 | } | |
| 37 | ||
| 38 | // First push creates the wiki. A reader without write cannot push it. | |
| 39 | work := t.TempDir() | |
| 40 | mustGit(t, work, env, "init", "-q", "-b", "main", "w") | |
| 41 | dir := filepath.Join(work, "w") | |
| 42 | os.WriteFile(filepath.Join(dir, "Home.md"), []byte( | |
| 43 | "# welcome\n\nsee [Setup](Setup.md) and \n"), 0o644) | |
| 44 | os.WriteFile(filepath.Join(dir, "Setup.org"), []byte("* setup\n\nsteps here\n"), 0o644) | |
| 45 | os.WriteFile(filepath.Join(dir, "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644) | |
| 46 | mustGit(t, dir, env, "add", ".") | |
| 47 | mustGit(t, dir, env, "commit", "-q", "-m", "wiki start") | |
| 48 | mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app.wiki"), "main") | |
| 49 | ||
| 50 | 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") { | |
| 52 | t.Fatalf("reader pushed the wiki: %d\n%s", code, out) | |
| 53 | } | |
| 54 | ||
| 55 | // Rendering: home resolves, tab appears, links rewrite to wiki pages | |
| 56 | // and images to the wiki raw route; org pages render too. | |
| 57 | _, body = inst.get(t, "/alice/app") | |
| 58 | if !strings.Contains(body, ">wiki<") { | |
| 59 | t.Fatal("wiki tab missing after push") | |
| 60 | } | |
| 61 | _, body = inst.get(t, "/alice/app/wiki") | |
| 62 | if !strings.Contains(body, "welcome") || | |
| 63 | !strings.Contains(body, `href="/alice/app/wiki/Setup"`) || | |
| 64 | !strings.Contains(body, `src="/alice/app/wiki/_raw/shot.png"`) { | |
| 65 | t.Fatalf("wiki home rendering:\n%s", body) | |
| 66 | } | |
| 67 | _, body = inst.get(t, "/alice/app/wiki/Setup") | |
| 68 | if !strings.Contains(body, "steps here") { | |
| 69 | t.Fatal("org wiki page missing") | |
| 70 | } | |
| 71 | if status, _ := inst.get(t, "/alice/app/wiki/Nope"); status != 404 { | |
| 72 | t.Fatalf("missing page: %d", status) | |
| 73 | } | |
| 74 | // The raw route serves the image bytes. | |
| 75 | status, raw := inst.get(t, "/alice/app/wiki/_raw/shot.png") | |
| 76 | if status != 200 || !strings.HasPrefix(raw, "\x89PNG") { | |
| 77 | t.Fatalf("wiki raw: %d", status) | |
| 78 | } | |
| 79 | ||
| 80 | // 404-parity: a private repo's wiki is invisible, over web and git. | |
| 81 | mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main") | |
| 82 | if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 { | |
| 83 | t.Fatalf("private wiki page: %d", status) | |
| 84 | } | |
| 85 | if out, code := gitRun(t, t.TempDir(), benv, "clone", inst.sshURL("alice/secretive.wiki"), "x"); code == 0 || !strings.Contains(out, "not found") { | |
| 86 | t.Fatalf("private wiki clone by outsider: %d\n%s", code, out) | |
| 87 | } | |
| 88 | ||
| 89 | // Repo names ending .wiki are refused (companion namespace). | |
| 90 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 2 || !strings.Contains(errOut, "reserved") { | |
| 91 | t.Fatalf(".wiki name allowed: %d %s", code, errOut) | |
| 92 | } | |
| 93 | ||
| 94 | // Deleting the repo removes the wiki companion. | |
| 95 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "delete", "alice/secretive", "--yes"); code != 0 { | |
| 96 | t.Fatal("repo delete failed") | |
| 97 | } | |
| 98 | if _, err := os.Stat(filepath.Join(inst.root, "repos", "alice", "secretive.wiki.git")); err == nil { | |
| 99 | t.Fatal("wiki survived repo delete") | |
| 100 | } | |
| 101 | } | |
internal/control/repo.go +6
| @@ -307,6 +307,11 @@ func runRepoTransfer(c *Ctx, args []string) int { | ||
| 307 | 307 | c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID) |
| 308 | 308 | return c.fail(protocol.ExitFailure, "moving repository: %v", err) |
| 309 | 309 | } |
| 310 | // The wiki companion follows its repo. | |
| 311 | oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki") | |
| 312 | if _, err := os.Stat(oldWiki); err == nil { | |
| 313 | os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki")) | |
| 314 | } | |
| 310 | 315 | newPath := newOwner + "/" + repo.Name |
| 311 | 316 | return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) { |
| 312 | 317 | fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath) |
| @@ -346,6 +351,7 @@ func runRepoDelete(c *Ctx, args []string) int { | ||
| 346 | 351 | if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil { |
| 347 | 352 | return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err) |
| 348 | 353 | } |
| 354 | os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")) | |
| 349 | 355 | return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) { |
| 350 | 356 | fmt.Fprintf(w, "deleted %s\n", repo.Path()) |
| 351 | 357 | }) |
internal/gitutil/gitutil.go +7 −3
| @@ -22,9 +22,13 @@ func InitBare(path, defaultBranch, hooksPath string) error { | ||
| 22 | 22 | if out, err := cmd.CombinedOutput(); err != nil { |
| 23 | 23 | return fmt.Errorf("git init: %v\n%s", err, out) |
| 24 | 24 | } |
| 25 | cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath) | |
| 26 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 27 | return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out) | |
| 25 | // An empty hooksPath leaves the bare repo with no hooks — used for | |
| 26 | // companion repos (wikis) that carry no ref policy. | |
| 27 | if hooksPath != "" { | |
| 28 | cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath) | |
| 29 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 30 | return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out) | |
| 31 | } | |
| 28 | 32 | } |
| 29 | 33 | return nil |
| 30 | 34 | } |
internal/httpd/routes.go +3
| @@ -43,6 +43,9 @@ func (s *Server) Routes() []Route { | ||
| 43 | 43 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, |
| 44 | 44 | Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, |
| 45 | 45 | Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones}, |
| 46 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki}, | |
| 47 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw}, | |
| 48 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki}, | |
| 46 | 49 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, |
| 47 | 50 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset}, |
| 48 | 51 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |
internal/httpd/web.go +4
| @@ -216,6 +216,8 @@ type repoPage struct { | ||
| 216 | 216 | Tab string // active tab in the repo header |
| 217 | 217 | Topics []string |
| 218 | 218 | Pinned bool // by the viewer |
| 219 | HasWiki bool | |
| 220 | Host string | |
| 219 | 221 | } |
| 220 | 222 | |
| 221 | 223 | // repoFor resolves the repo for a web request; false means 404 was sent. |
| @@ -253,6 +255,8 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | ||
| 253 | 255 | Site: s.siteName(), |
| 254 | 256 | Viewer: viewer.Username, |
| 255 | 257 | Pinned: pinned, |
| 258 | HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", | |
| 259 | Host: s.cfg.SiteHost(), | |
| 256 | 260 | Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), |
| 257 | 261 | Repo: repo, |
| 258 | 262 | Ref: ref, |
internal/httpd/wiki.go added +182
| @@ -0,0 +1,182 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "html/template" | |
| 5 | "net/http" | |
| 6 | "os" | |
| 7 | "path" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "golang.org/x/net/html" | |
| 11 | "golang.org/x/net/html/atom" | |
| 12 | ||
| 13 | "gitbay.org/gitbay/internal/control" | |
| 14 | "gitbay.org/gitbay/internal/gitutil" | |
| 15 | ) | |
| 16 | ||
| 17 | // wikiDir returns the companion repo path, or "" when the repo has none. | |
| 18 | func (s *Server) wikiDir(owner, name string) string { | |
| 19 | dir := control.RepoDir(s.cfg.Server.Root, owner, name+".wiki") | |
| 20 | if _, err := os.Stat(dir); err != nil { | |
| 21 | return "" | |
| 22 | } | |
| 23 | return dir | |
| 24 | } | |
| 25 | ||
| 26 | // wiki renders a page from the repo's wiki companion. The home page is | |
| 27 | // Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and | |
| 28 | // .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs. | |
| 29 | func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | |
| 30 | p, ok := s.repoFor(w, r, "") | |
| 31 | if !ok { | |
| 32 | return | |
| 33 | } | |
| 34 | p.Tab = "wiki" | |
| 35 | dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name) | |
| 36 | if dir == "" { | |
| 37 | s.render(w, "wiki.html", struct { | |
| 38 | repoPage | |
| 39 | Page string | |
| 40 | PageHTML template.HTML | |
| 41 | Pages []string | |
| 42 | Missing bool | |
| 43 | }{repoPage: p, Missing: true}) | |
| 44 | return | |
| 45 | } | |
| 46 | entries, err := gitutil.ListTree(dir, "main", "") | |
| 47 | if err != nil { // wiki repo exists but has no commits yet | |
| 48 | s.render(w, "wiki.html", struct { | |
| 49 | repoPage | |
| 50 | Page string | |
| 51 | PageHTML template.HTML | |
| 52 | Pages []string | |
| 53 | Missing bool | |
| 54 | }{repoPage: p, Missing: true}) | |
| 55 | return | |
| 56 | } | |
| 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 | } | |
| 67 | ||
| 68 | page := strings.Trim(r.PathValue("page"), "/") | |
| 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 | } | |
| 83 | } | |
| 84 | var pageHTML template.HTML | |
| 85 | 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 | } | |
| 92 | } | |
| 93 | if fileName == "" { | |
| 94 | s.notFound(w, r) | |
| 95 | return | |
| 96 | } | |
| 97 | pageHTML = rewriteWikiLinks(renderReadme(fileName, raw), p) | |
| 98 | } | |
| 99 | s.render(w, "wiki.html", struct { | |
| 100 | repoPage | |
| 101 | Page string | |
| 102 | PageHTML template.HTML | |
| 103 | Pages []string | |
| 104 | Missing bool | |
| 105 | }{p, page, pageHTML, pages, false}) | |
| 106 | } | |
| 107 | ||
| 108 | // wikiRaw serves non-page files from the wiki (images referenced by pages). | |
| 109 | func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) { | |
| 110 | p, ok := s.repoFor(w, r, "") | |
| 111 | if !ok { | |
| 112 | return | |
| 113 | } | |
| 114 | dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name) | |
| 115 | if dir == "" { | |
| 116 | s.notFound(w, r) | |
| 117 | return | |
| 118 | } | |
| 119 | data, err := gitutil.ReadBlob(dir, "main", strings.Trim(r.PathValue("path"), "/"), s.cfg.Limits.MaxBlobBytes) | |
| 120 | if err != nil { | |
| 121 | s.notFound(w, r) | |
| 122 | return | |
| 123 | } | |
| 124 | w.Header().Set("Content-Type", "application/octet-stream") | |
| 125 | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 126 | w.Write(data) | |
| 127 | } | |
| 128 | ||
| 129 | // rewriteWikiLinks makes relative links resolve inside the wiki: page | |
| 130 | // links (with or without .md/.org/.html extensions) go to /wiki/<page>, | |
| 131 | // other relative targets (images) to the wiki raw route. | |
| 132 | func rewriteWikiLinks(rendered template.HTML, p repoPage) template.HTML { | |
| 133 | ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div} | |
| 134 | nodes, err := html.ParseFragment(strings.NewReader(string(rendered)), ctx) | |
| 135 | if err != nil { | |
| 136 | return rendered | |
| 137 | } | |
| 138 | base := "/" + p.Repo.Path() + "/wiki" | |
| 139 | var walk func(*html.Node) | |
| 140 | walk = func(n *html.Node) { | |
| 141 | if n.Type == html.ElementNode { | |
| 142 | for i, a := range n.Attr { | |
| 143 | isHref := a.Key == "href" && n.Data == "a" | |
| 144 | isSrc := a.Key == "src" && (n.Data == "img" || n.Data == "video" || n.Data == "source") | |
| 145 | if !isHref && !isSrc { | |
| 146 | continue | |
| 147 | } | |
| 148 | v := a.Val | |
| 149 | if v == "" || strings.Contains(v, "://") || strings.HasPrefix(v, "/") || | |
| 150 | strings.HasPrefix(v, "#") || strings.HasPrefix(v, "mailto:") || | |
| 151 | strings.HasPrefix(v, "data:") { | |
| 152 | continue | |
| 153 | } | |
| 154 | target := path.Clean(v) | |
| 155 | if strings.HasPrefix(target, "..") { | |
| 156 | continue | |
| 157 | } | |
| 158 | if isSrc { | |
| 159 | n.Attr[i].Val = base + "/_raw/" + target | |
| 160 | continue | |
| 161 | } | |
| 162 | ext := strings.ToLower(path.Ext(target)) | |
| 163 | switch ext { | |
| 164 | case ".md", ".org", ".markdown", ".html": | |
| 165 | target = strings.TrimSuffix(target, path.Ext(target)) | |
| 166 | } | |
| 167 | n.Attr[i].Val = base + "/" + target | |
| 168 | } | |
| 169 | } | |
| 170 | for c := n.FirstChild; c != nil; c = c.NextSibling { | |
| 171 | walk(c) | |
| 172 | } | |
| 173 | } | |
| 174 | var out strings.Builder | |
| 175 | for _, n := range nodes { | |
| 176 | walk(n) | |
| 177 | if err := html.Render(&out, n); err != nil { | |
| 178 | return rendered | |
| 179 | } | |
| 180 | } | |
| 181 | return template.HTML(out.String()) | |
| 182 | } | |
internal/policy/names.go +3
| @@ -55,6 +55,9 @@ func ValidateName(name string) error { | ||
| 55 | 55 | if len(name) > 4 && name[len(name)-4:] == ".git" { |
| 56 | 56 | return fmt.Errorf("invalid name %q: must not end in .git", name) |
| 57 | 57 | } |
| 58 | if len(name) > 5 && name[len(name)-5:] == ".wiki" { | |
| 59 | return fmt.Errorf("invalid name %q: .wiki names are reserved for wiki companion repositories", name) | |
| 60 | } | |
| 58 | 61 | return nil |
| 59 | 62 | } |
| 60 | 63 | |
internal/sshd/sshd.go +60
| @@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | "os" |
| 16 | 16 | "path/filepath" |
| 17 | 17 | "strconv" |
| 18 | "strings" | |
| 18 | 19 | "time" |
| 19 | 20 | |
| 20 | 21 | "golang.org/x/crypto/ssh" |
| @@ -284,6 +285,12 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a | ||
| 284 | 285 | } |
| 285 | 286 | write := service == "git-receive-pack" |
| 286 | 287 | |
| 288 | // Wiki companion repos: ssh://.../owner/name.wiki.git. Access derives | |
| 289 | // from the parent repo; the bare repo is created on first push. | |
| 290 | if base, ok := strings.CutSuffix(strings.TrimSuffix(argv[1], ".git"), ".wiki"); ok { | |
| 291 | return runWikiGit(cfg, st, user, scope, service, base, write, stdin, stdout, stderr) | |
| 292 | } | |
| 293 | ||
| 287 | 294 | repo, err := st.RepoByPath(argv[1]) |
| 288 | 295 | if err != nil { |
| 289 | 296 | fmt.Fprintln(stderr, "repository not found") |
| @@ -339,3 +346,56 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a | ||
| 339 | 346 | } |
| 340 | 347 | return protocol.ExitOK |
| 341 | 348 | } |
| 349 | ||
| 350 | // runWikiGit serves a repo's wiki companion. The wiki carries no ref | |
| 351 | // policy (no hooks, no merge requests); access is exactly the parent | |
| 352 | // repo's, and the bare repo is created on the first push. Deploy keys — | |
| 353 | // bound to the repo's own git data for CI — cannot touch the wiki. | |
| 354 | func runWikiGit(cfg config.Config, st *store.Store, user store.User, scope, service, basePath string, | |
| 355 | write bool, stdin io.Reader, stdout, stderr io.Writer) int { | |
| 356 | repo, err := st.RepoByPath(basePath) | |
| 357 | if err != nil { | |
| 358 | fmt.Fprintln(stderr, "repository not found") | |
| 359 | return protocol.ExitNotFound | |
| 360 | } | |
| 361 | if policy.IsDeployScope(scope) { | |
| 362 | fmt.Fprintln(stderr, "repository not found") | |
| 363 | return protocol.ExitNotFound | |
| 364 | } | |
| 365 | grant, err := st.AccessRole(repo.ID, user.ID) | |
| 366 | if err != nil { | |
| 367 | fmt.Fprintln(stderr, "internal error") | |
| 368 | return protocol.ExitFailure | |
| 369 | } | |
| 370 | if !policy.CanRead(user, repo, grant) { | |
| 371 | fmt.Fprintln(stderr, "repository not found") | |
| 372 | return protocol.ExitNotFound | |
| 373 | } | |
| 374 | if !policy.ScopeAllowsGit(scope, repo.Path(), write) { | |
| 375 | fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on the wiki\n", scope, service) | |
| 376 | return protocol.ExitDenied | |
| 377 | } | |
| 378 | if write && !policy.CanWrite(user, repo, grant) { | |
| 379 | fmt.Fprintf(stderr, "write access to %s wiki denied\n", repo.Path()) | |
| 380 | return protocol.ExitDenied | |
| 381 | } | |
| 382 | if write && repo.Settings.Archived { | |
| 383 | fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path()) | |
| 384 | return protocol.ExitDenied | |
| 385 | } | |
| 386 | dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name+".wiki") | |
| 387 | if _, err := os.Stat(dir); err != nil { | |
| 388 | if !write { | |
| 389 | fmt.Fprintln(stderr, "this repository has no wiki yet") | |
| 390 | return protocol.ExitNotFound | |
| 391 | } | |
| 392 | if err := gitutil.InitBare(dir, "main", ""); err != nil { | |
| 393 | fmt.Fprintln(stderr, "initializing wiki failed") | |
| 394 | return protocol.ExitFailure | |
| 395 | } | |
| 396 | } | |
| 397 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, nil, cfg.Limits.MaxPackBytes); err != nil { | |
| 398 | return protocol.ExitFailure | |
| 399 | } | |
| 400 | return protocol.ExitOK | |
| 401 | } | |
internal/web/static/style.css +13
| @@ -738,6 +738,19 @@ pre.matchline mark { | ||
| 738 | 738 | border-radius: 2px; |
| 739 | 739 | } |
| 740 | 740 | |
| 741 | /* wiki */ | |
| 742 | .wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; } | |
| 743 | .wikipage { flex: 1; min-width: 0; } | |
| 744 | .wikinav { flex: none; width: 13rem; padding-top: var(--sp-4); } | |
| 745 | .wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; } | |
| 746 | .wikinav li a { display: block; padding: 0.15rem 0; color: var(--fg); } | |
| 747 | .wikinav li a:hover { color: var(--accent); text-decoration: none; } | |
| 748 | .wikinav li a.active { color: var(--accent); font-weight: 550; } | |
| 749 | @media (max-width: 40rem) { | |
| 750 | .wikilayout { flex-direction: column; } | |
| 751 | .wikinav { width: auto; padding-top: 0; } | |
| 752 | } | |
| 753 | ||
| 741 | 754 | /* blame */ |
| 742 | 755 | .blame { |
| 743 | 756 | border: 1px solid var(--line); |
internal/web/templates/layout.html +2 −1
| @@ -39,7 +39,8 @@ | ||
| 39 | 39 | <a {{if eq .Tab "releases"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases">releases</a> |
| 40 | 40 | <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a> |
| 41 | 41 | <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a> |
| 42 | <a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a> | |
| 42 | {{if .HasWiki}}<a {{if eq .Tab "wiki"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/wiki">wiki</a> | |
| 43 | {{end}}<a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a> | |
| 43 | 44 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a> |
| 44 | 45 | </nav> |
| 45 | 46 | </div> |
internal/web/templates/wiki.html added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | {{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | {{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<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> | |
| 6 | {{else}} | |
| 7 | <div class="wikilayout"> | |
| 8 | <div class="wikipage"> | |
| 9 | <section class="readme"> | |
| 10 | <div class="cardhead">{{.Page}}</div> | |
| 11 | <div class="rendered">{{.PageHTML}}</div> | |
| 12 | </section> | |
| 13 | </div> | |
| 14 | <nav class="wikinav"> | |
| 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> | |
| 17 | <p class="meta">edit by push:<br><code>{{.Repo.Path}}.wiki.git</code></p> | |
| 18 | </nav> | |
| 19 | </div> | |
| 20 | {{end}} | |
| 21 | {{end}} | |