internal/control/wiki.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/control/wiki.go history · blame · raw

197 lines · 5369 bytes

  1package control
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"io"
  7	"path"
  8	"strings"
  9
 10	"gitbay.org/gitbay/internal/gitutil"
 11	"gitbay.org/gitbay/internal/policy"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16func init() {
 17	register(Command{
 18		Path:     []string{"wiki", "list"},
 19		Summary:  "list a repository's wiki pages",
 20		Usage:    "wiki list <owner/name>",
 21		Examples: []string{"wiki list krz/gitbay"},
 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		Examples: []string{"wiki show krz/gitbay Quickstart"},
 30		ReadOnly: true,
 31		Run:      runWikiShow,
 32	})
 33}
 34
 35// Wiki pages are files under .gitbay/wiki on the repository's default
 36// branch, alongside ci.yml and CODEOWNERS. They have no store row of
 37// their own — access derives from the parent, exactly as it does for any
 38// other path in the repository.
 39//
 40// Writing is a push, or repo commit-file, like any other file in the
 41// repository. There is no wiki-specific write command.
 42
 43// wikiExts are the page formats the web renders, in resolution order.
 44var wikiExts = []string{".md", ".org", ".markdown"}
 45
 46// wikiTreePath is where wiki pages live in a repository's tree.
 47const wikiTreePath = ".gitbay/wiki"
 48
 49// wikiDir resolves the parent, checks read access, and returns its
 50// directory and default branch. A parent you cannot read has no wiki you
 51// can read.
 52func wikiDir(c *Ctx, spec string) (repo store.Repo, dir, branch string, code int) {
 53	parent, code := resolveRepo(c, spec, policy.CanRead)
 54	if code >= 0 {
 55		return store.Repo{}, "", "", code
 56	}
 57	return parent, RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name), parent.DefaultBranch, -1
 58}
 59
 60// wikiPages lists the page names under .gitbay/wiki, without extensions.
 61// A page in a subfolder is named by its path: Architecture/Identity.
 62func wikiPages(dir, branch string) []string {
 63	entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath)
 64	if err != nil {
 65		return nil // no .gitbay/wiki tree on this branch
 66	}
 67	var pages []string
 68	for _, e := range entries {
 69		if e.Type != "blob" {
 70			continue
 71		}
 72		ext := strings.ToLower(path.Ext(e.Name))
 73		for _, want := range wikiExts {
 74			if ext == want {
 75				pages = append(pages, strings.TrimSuffix(e.Name, path.Ext(e.Name)))
 76				break
 77			}
 78		}
 79	}
 80	return pages
 81}
 82
 83func runWikiList(c *Ctx, args []string) int {
 84	if len(args) != 1 {
 85		return c.usage()
 86	}
 87	repo, dir, branch, code := wikiDir(c, args[0])
 88	if code >= 0 {
 89		return code
 90	}
 91	pages := wikiPages(dir, branch)
 92	type out struct {
 93		Path  string   `json:"path"`
 94		Home  string   `json:"home,omitempty"`
 95		Pages []string `json:"pages"`
 96	}
 97	d := out{Path: repo.Path(), Home: wikiHome(pages), Pages: pages}
 98	if d.Pages == nil {
 99		d.Pages = []string{}
100	}
101	return c.emit(d, func(w io.Writer) {
102		tb := c.table(w, "PAGE")
103		for _, p := range d.Pages {
104			tb.row(cRef(p))
105		}
106		tb.flush()
107	})
108}
109
110// wikiHome picks the landing page the way the web does: a conventional
111// name if one exists, otherwise the first page.
112func wikiHome(pages []string) string {
113	for _, home := range []string{"Home", "home", "README", "index"} {
114		for _, p := range pages {
115			if p == home {
116				return home
117			}
118		}
119	}
120	if len(pages) > 0 {
121		return pages[0]
122	}
123	return ""
124}
125
126func runWikiShow(c *Ctx, args []string) int {
127	if len(args) < 1 || len(args) > 2 {
128		return c.usage()
129	}
130	repo, dir, branch, code := wikiDir(c, args[0])
131	if code >= 0 {
132		return code
133	}
134	pages := wikiPages(dir, branch)
135	page := wikiHome(pages)
136	if len(args) == 2 {
137		page = strings.TrimSuffix(args[1], path.Ext(args[1]))
138	}
139	if page == "" {
140		return c.fail(protocol.ExitNotFound, "%s has no wiki pages", repo.Path())
141	}
142	// A page name is a file name, so it must not climb out of the repo.
143	if cleaned, ok := cleanRepoPath(page); !ok || cleaned != page {
144		return c.fail(protocol.ExitUsage, "page must be a name inside the wiki")
145	}
146
147	for _, ext := range wikiExts {
148		raw, err := gitutil.ReadBlob(dir, branch, wikiTreePath+"/"+page+ext, c.Cfg.Limits.MaxBlobBytes)
149		if err != nil {
150			continue
151		}
152		binary := gitutil.IsBinary(raw)
153		type out struct {
154			Path    string `json:"path"`
155			Page    string `json:"page"`
156			File    string `json:"file"`
157			Size    int    `json:"size"`
158			Binary  bool   `json:"binary,omitempty"`
159			Content string `json:"content,omitempty"`
160			Base64  string `json:"base64,omitempty"`
161		}
162		d := out{Path: repo.Path(), Page: page, File: page + ext,
163			Size: len(raw), Binary: binary}
164		if binary {
165			d.Base64 = base64.StdEncoding.EncodeToString(raw)
166		} else {
167			d.Content = string(raw)
168		}
169		format := "md"
170		if ext == ".org" {
171			format = "org"
172		}
173		return c.emit(d, func(w io.Writer) {
174			// Plain: the page source verbatim, same as any other piped
175			// file read. The title/fields/body layout is terminal-only.
176			if c.Term.Cols == 0 {
177				fmt.Fprint(w, d.Content)
178				return
179			}
180			v := c.view(w)
181			v.title(repo.Path(), page, "")
182			binaryNote := ""
183			if binary {
184				binaryNote = fmt.Sprintf("%d bytes, binary", d.Size)
185			}
186			v.fields(
187				"file", d.File,
188				"binary", binaryNote,
189				"url", c.siteURL(repo.Path(), "wiki", page),
190			)
191			if !binary {
192				v.body(d.Content, format)
193			}
194		})
195	}
196	return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path())
197}