internal/control/wiki.go

dd06e80a071d451ebb7b083363e0580550481114
gitbay/internal/control/wiki.go history · blame · raw

196 lines · 5290 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.
 61func wikiPages(dir, branch string) []string {
 62	entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
 63	if err != nil {
 64		return nil // no .gitbay/wiki tree on this branch
 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.usage()
 85	}
 86	repo, dir, branch, code := wikiDir(c, args[0])
 87	if code >= 0 {
 88		return code
 89	}
 90	pages := wikiPages(dir, branch)
 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		tb := c.table(w, "PAGE")
102		for _, p := range d.Pages {
103			tb.row(cRef(p))
104		}
105		tb.flush()
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.usage()
128	}
129	repo, dir, branch, code := wikiDir(c, args[0])
130	if code >= 0 {
131		return code
132	}
133	pages := wikiPages(dir, branch)
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, branch, wikiTreePath+"/"+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		format := "md"
169		if ext == ".org" {
170			format = "org"
171		}
172		return c.emit(d, func(w io.Writer) {
173			// Plain: the page source verbatim, same as any other piped
174			// file read. The title/fields/body layout is terminal-only.
175			if c.Term.Cols == 0 {
176				fmt.Fprint(w, d.Content)
177				return
178			}
179			v := c.view(w)
180			v.title(repo.Path(), page, "")
181			binaryNote := ""
182			if binary {
183				binaryNote = fmt.Sprintf("%d bytes, binary", d.Size)
184			}
185			v.fields(
186				"file", d.File,
187				"binary", binaryNote,
188				"url", c.siteURL(repo.Path(), "wiki", page),
189			)
190			if !binary {
191				v.body(d.Content, format)
192			}
193		})
194	}
195	return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path())
196}