internal/httpd/wiki.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/httpd/wiki.go history · blame · raw

276 lines · 8066 bytes

13 symbols in this file
  1package httpd
  2
  3import (
  4	"html/template"
  5	"net/http"
  6	"path"
  7	"sort"
  8	"strings"
  9
 10	"golang.org/x/net/html"
 11	"golang.org/x/net/html/atom"
 12
 13	"gitbay.org/gitbay/internal/control"
 14	"gitbay.org/gitbay/internal/gitutil"
 15	"gitbay.org/gitbay/internal/store"
 16)
 17
 18// wikiTreePath is where wiki pages live in a repository's tree.
 19const wikiTreePath = ".gitbay/wiki"
 20
 21// wikiExts are the page formats that count as wiki content.
 22var wikiExts = []string{".md", ".org", ".markdown"}
 23
 24// wikiDir returns repo's directory and default branch, where wiki pages
 25// are resolved from .gitbay/wiki.
 26func (s *Server) wikiDir(repo store.Repo) (dir, branch string) {
 27	return control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), repo.DefaultBranch
 28}
 29
 30// hasWiki reports whether repo's default branch holds a wiki page.
 31func (s *Server) hasWiki(repo store.Repo) bool {
 32	dir, branch := s.wikiDir(repo)
 33	entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath)
 34	if err != nil {
 35		return false
 36	}
 37	for _, e := range entries {
 38		if e.Type == "blob" && wikiExtMatch(e.Name) {
 39			return true
 40		}
 41	}
 42	return false
 43}
 44
 45// wikiExtMatch reports whether name has one of the wiki page extensions.
 46func wikiExtMatch(name string) bool {
 47	ext := strings.ToLower(path.Ext(name))
 48	for _, want := range wikiExts {
 49		if ext == want {
 50			return true
 51		}
 52	}
 53	return false
 54}
 55
 56// wiki renders a page from the repo's .gitbay/wiki tree. The home page is
 57// Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and
 58// .org fallbacks, and a name may be a path into a subfolder. Rendering
 59// reuses the same sanitized pipeline as READMEs.
 60func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
 61	p, ok := s.repoFor(w, r, "")
 62	if !ok {
 63		return
 64	}
 65	p.Tab = "wiki"
 66	var listing struct {
 67		Home  string   `json:"home"`
 68		Pages []string `json:"pages"`
 69	}
 70	var viewer store.User
 71	if s.cfg.Web.Mode == "accounts" {
 72		viewer = s.viewer(r)
 73	}
 74	_, listed := s.runControlInto(viewer, []string{"wiki", "list", p.Repo.Path()}, &listing)
 75	if !listed || len(listing.Pages) == 0 { // no wiki, or no commits yet
 76		s.render(w, "wiki.html", struct {
 77			repoPage
 78			Page     string
 79			PageHTML template.HTML
 80			Nav      []wikiNavGroup
 81			Missing  bool
 82		}{repoPage: p, Missing: true})
 83		return
 84	}
 85	pages := listing.Pages
 86
 87	page := strings.Trim(r.PathValue("page"), "/")
 88	if page == "" {
 89		page = listing.Home
 90	}
 91	var pageHTML template.HTML
 92	if page != "" {
 93		var shown struct {
 94			File    string `json:"file"`
 95			Content string `json:"content"`
 96		}
 97		if _, ok := s.runControlInto(viewer,
 98			[]string{"wiki", "show", p.Repo.Path(), page}, &shown); !ok {
 99			s.notFound(w, r)
100			return
101		}
102		isPage := map[string]bool{}
103		for _, pg := range pages {
104			isPage[pg] = true
105		}
106		isFile := map[string]bool{}
107		dir, branch := s.wikiDir(p.Repo)
108		if entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath); err == nil {
109			for _, e := range entries {
110				isFile[e.Name] = e.Type == "blob"
111			}
112		}
113		pageHTML = rewriteWikiLinks(renderReadme(shown.File, []byte(shown.Content)), p, page,
114			func(t string) bool { return isPage[t] }, func(t string) bool { return isFile[t] })
115	}
116	s.render(w, "wiki.html", struct {
117		repoPage
118		Page     string
119		PageHTML template.HTML
120		Nav      []wikiNavGroup
121		Missing  bool
122	}{p, page, pageHTML, wikiNav(pages), false})
123}
124
125// wikiNavGroup is one folder of the wiki sidebar; Dir is empty for the
126// top level, which comes first.
127type wikiNavGroup struct {
128	Dir   string
129	Pages []wikiNavPage
130}
131
132type wikiNavPage struct {
133	Name  string // the page's path, as routed
134	Label string // its last segment
135}
136
137// wikiNav groups page names by folder, keeping the listing's order within
138// each folder.
139func wikiNav(pages []string) []wikiNavGroup {
140	var groups []wikiNavGroup
141	at := map[string]int{}
142	for _, pg := range pages {
143		d := path.Dir(pg)
144		if d == "." {
145			d = ""
146		}
147		i, ok := at[d]
148		if !ok {
149			i = len(groups)
150			at[d] = i
151			groups = append(groups, wikiNavGroup{Dir: d})
152		}
153		groups[i].Pages = append(groups[i].Pages, wikiNavPage{Name: pg, Label: path.Base(pg)})
154	}
155	sort.SliceStable(groups, func(a, b int) bool {
156		if (groups[a].Dir == "") != (groups[b].Dir == "") {
157			return groups[a].Dir == ""
158		}
159		return groups[a].Dir < groups[b].Dir
160	})
161	return groups
162}
163
164// wikiRaw serves non-page files from the wiki (images referenced by pages).
165func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
166	p, ok := s.repoFor(w, r, "")
167	if !ok {
168		return
169	}
170	dir, branch := s.wikiDir(p.Repo)
171	rel := path.Join(wikiTreePath, strings.Trim(r.PathValue("path"), "/"))
172	if !strings.HasPrefix(rel, wikiTreePath+"/") {
173		s.notFound(w, r)
174		return
175	}
176	data, err := gitutil.ReadBlob(dir, branch, rel, s.cfg.Limits.MaxBlobBytes)
177	if err != nil {
178		s.notFound(w, r)
179		return
180	}
181	ct := "application/octet-stream"
182	if t, ok := imageTypes[strings.ToLower(path.Ext(rel))]; ok {
183		ct = t
184	}
185	w.Header().Set("Content-Type", ct)
186	w.Header().Set("X-Content-Type-Options", "nosniff")
187	w.Write(data)
188}
189
190// wikiResolve maps a relative link on page to a path inside the wiki. A
191// page in a subfolder looks in its own folder first and then at the wiki
192// root, so [[Admin]] still reaches a top-level page; when neither exists
193// the folder-relative path is kept, so a broken link points where its
194// author wrote it. A top-level page resolves exactly as before.
195func wikiResolve(page, v string, exists func(string) bool) (string, bool) {
196	dir := path.Dir(page)
197	local := path.Clean(path.Join(dir, v))
198	if local == ".." || strings.HasPrefix(local, "../") {
199		return "", false
200	}
201	if dir == "." || exists(local) {
202		return local, true
203	}
204	if root := path.Clean(v); root != ".." && !strings.HasPrefix(root, "../") && exists(root) {
205		return root, true
206	}
207	return local, true
208}
209
210// trimPageExt drops a page extension from a link target.
211func trimPageExt(target string) string {
212	switch strings.ToLower(path.Ext(target)) {
213	case ".md", ".org", ".markdown", ".html":
214		return strings.TrimSuffix(target, path.Ext(target))
215	}
216	return target
217}
218
219// rewriteWikiLinks makes relative links resolve inside the wiki: page
220// links (with or without .md/.org/.html extensions) go to /wiki/<page>,
221// other relative targets (images) to the wiki raw route. Targets resolve
222// from the current page's folder, as wikiResolve describes.
223func rewriteWikiLinks(rendered template.HTML, p repoPage, page string, isPage, isFile func(string) bool) template.HTML {
224	ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
225	nodes, err := html.ParseFragment(strings.NewReader(string(rendered)), ctx)
226	if err != nil {
227		return rendered
228	}
229	base := "/" + p.Repo.Path() + "/wiki"
230	var walk func(*html.Node)
231	walk = func(n *html.Node) {
232		if n.Type == html.ElementNode {
233			for i, a := range n.Attr {
234				isHref := a.Key == "href" && n.Data == "a"
235				isSrc := a.Key == "src" && (n.Data == "img" || n.Data == "video" || n.Data == "source")
236				if !isHref && !isSrc {
237					continue
238				}
239				v := a.Val
240				if v == "" || strings.Contains(v, "://") || strings.HasPrefix(v, "/") ||
241					strings.HasPrefix(v, "#") || strings.HasPrefix(v, "mailto:") ||
242					strings.HasPrefix(v, "data:") {
243					continue
244				}
245				if isSrc {
246					if target, ok := wikiResolve(page, v, isFile); ok {
247						n.Attr[i].Val = base + "/_raw/" + target
248					}
249					continue
250				}
251				// A plain link is usually to another page, but a link to
252				// an existing non-page file (an .svg, .txt, .pdf) must
253				// go to _raw the same as an image src, or it 404s
254				// against the page route (#283).
255				if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
256					if raw, rok := wikiResolve(page, v, isFile); rok && isFile(raw) && !isPage(target) {
257						n.Attr[i].Val = base + "/_raw/" + raw
258					} else {
259						n.Attr[i].Val = base + "/" + target
260					}
261				}
262			}
263		}
264		for c := n.FirstChild; c != nil; c = c.NextSibling {
265			walk(c)
266		}
267	}
268	var out strings.Builder
269	for _, n := range nodes {
270		walk(n)
271		if err := html.Render(&out, n); err != nil {
272			return rendered
273		}
274	}
275	return template.HTML(out.String())
276}