internal/httpd/wiki.go
268 lines · 7695 bytes
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 if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
252 n.Attr[i].Val = base + "/" + target
253 }
254 }
255 }
256 for c := n.FirstChild; c != nil; c = c.NextSibling {
257 walk(c)
258 }
259 }
260 var out strings.Builder
261 for _, n := range nodes {
262 walk(n)
263 if err := html.Render(&out, n); err != nil {
264 return rendered
265 }
266 }
267 return template.HTML(out.String())
268}