internal/control/wiki.go
171 lines · 4769 bytes
1package control
2
3import (
4 "encoding/base64"
5 "fmt"
6 "io"
7 "os"
8 "path"
9 "strings"
10
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17func init() {
18 register(Command{
19 Path: []string{"wiki", "list"},
20 Summary: "list a repository's wiki pages: wiki list <owner/name>",
21 ReadOnly: true,
22 Run: runWikiList,
23 })
24 register(Command{
25 Path: []string{"wiki", "show"},
26 Summary: "print a wiki page: wiki show <owner/name> [<page>]",
27 ReadOnly: true,
28 Run: runWikiShow,
29 })
30}
31
32// A wiki lives in a companion bare repo beside its parent, so prose
33// edits stay out of the code repository's history, its protected
34// branches and its builds. The companion has no store row of its own —
35// access derives from the parent, exactly as it does for git over SSH —
36// which is why it needs commands rather than being addressable as a
37// repository.
38//
39// Editing stays a push to <repo>.wiki.git. That is the whole write
40// interface, on every surface, and there is nothing for a command to
41// add.
42
43// wikiExts are the page formats the web renders, in resolution order.
44var wikiExts = []string{".md", ".org", ".markdown"}
45
46// wikiDir resolves the parent, checks read access, and returns the
47// companion's path. A parent you cannot read has no wiki you can read.
48func wikiDir(c *Ctx, spec string) (repo store.Repo, dir string, code int) {
49 parent, code := resolveRepo(c, spec, policy.CanRead)
50 if code >= 0 {
51 return store.Repo{}, "", code
52 }
53 d := RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name+".wiki")
54 if _, err := os.Stat(d); err != nil {
55 return store.Repo{}, "", c.fail(protocol.ExitNotFound, "%s has no wiki", parent.Path())
56 }
57 return parent, d, -1
58}
59
60// wikiPages lists the page names in the companion, without extensions.
61func wikiPages(dir string) []string {
62 entries, err := gitutil.ListTree(dir, "main", "")
63 if err != nil {
64 return nil // the companion exists but has no commits yet
65 }
66 var pages []string
67 for _, e := range entries {
68 if e.Type != "blob" {
69 continue
70 }
71 ext := strings.ToLower(path.Ext(e.Name))
72 for _, want := range wikiExts {
73 if ext == want {
74 pages = append(pages, strings.TrimSuffix(e.Name, path.Ext(e.Name)))
75 break
76 }
77 }
78 }
79 return pages
80}
81
82func runWikiList(c *Ctx, args []string) int {
83 if len(args) != 1 {
84 return c.fail(protocol.ExitUsage, "usage: wiki list <owner/name>")
85 }
86 repo, dir, code := wikiDir(c, args[0])
87 if code >= 0 {
88 return code
89 }
90 pages := wikiPages(dir)
91 type out struct {
92 Path string `json:"path"`
93 Home string `json:"home,omitempty"`
94 Pages []string `json:"pages"`
95 }
96 d := out{Path: repo.Path(), Home: wikiHome(pages), Pages: pages}
97 if d.Pages == nil {
98 d.Pages = []string{}
99 }
100 return c.emit(d, func(w io.Writer) {
101 for _, p := range d.Pages {
102 fmt.Fprintln(w, p)
103 }
104 })
105}
106
107// wikiHome picks the landing page the way the web does: a conventional
108// name if one exists, otherwise the first page.
109func wikiHome(pages []string) string {
110 for _, home := range []string{"Home", "home", "README", "index"} {
111 for _, p := range pages {
112 if p == home {
113 return home
114 }
115 }
116 }
117 if len(pages) > 0 {
118 return pages[0]
119 }
120 return ""
121}
122
123func runWikiShow(c *Ctx, args []string) int {
124 if len(args) < 1 || len(args) > 2 {
125 return c.fail(protocol.ExitUsage, "usage: wiki show <owner/name> [<page>]")
126 }
127 repo, dir, code := wikiDir(c, args[0])
128 if code >= 0 {
129 return code
130 }
131 pages := wikiPages(dir)
132 page := wikiHome(pages)
133 if len(args) == 2 {
134 page = strings.TrimSuffix(args[1], path.Ext(args[1]))
135 }
136 if page == "" {
137 return c.fail(protocol.ExitNotFound, "%s has no wiki pages", repo.Path())
138 }
139 // A page name is a file name, so it must not climb out of the repo.
140 if cleaned, ok := cleanRepoPath(page); !ok || cleaned != page {
141 return c.fail(protocol.ExitUsage, "page must be a name inside the wiki")
142 }
143
144 for _, ext := range wikiExts {
145 raw, err := gitutil.ReadBlob(dir, "main", page+ext, c.Cfg.Limits.MaxBlobBytes)
146 if err != nil {
147 continue
148 }
149 binary := gitutil.IsBinary(raw)
150 type out struct {
151 Path string `json:"path"`
152 Page string `json:"page"`
153 File string `json:"file"`
154 Size int `json:"size"`
155 Binary bool `json:"binary,omitempty"`
156 Content string `json:"content,omitempty"`
157 Base64 string `json:"base64,omitempty"`
158 }
159 d := out{Path: repo.Path(), Page: page, File: page + ext,
160 Size: len(raw), Binary: binary}
161 if binary {
162 d.Base64 = base64.StdEncoding.EncodeToString(raw)
163 } else {
164 d.Content = string(raw)
165 }
166 return c.emit(d, func(w io.Writer) {
167 fmt.Fprint(w, d.Content)
168 })
169 }
170 return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path())
171}