internal/control/wiki.go

93b38a063b3b4b161a1baf976e92f53d02b1a221
gitbay/internal/control/wiki.go history · blame · raw

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}