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