internal/control/wiki.go

7effe3feb4612cffb5ed38c02300299559aa8971
gitbay/internal/control/wiki.go history · blame · raw

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}