package httpd
import (
"html/template"
"net/http"
"path"
"sort"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
"gitbay.org/gitbay/internal/control"
"gitbay.org/gitbay/internal/gitutil"
"gitbay.org/gitbay/internal/store"
)
// wikiTreePath is where wiki pages live in a repository's tree.
const wikiTreePath = ".gitbay/wiki"
// wikiExts are the page formats that count as wiki content.
var wikiExts = []string{".md", ".org", ".markdown"}
// wikiDir returns repo's directory and default branch, where wiki pages
// are resolved from .gitbay/wiki.
func (s *Server) wikiDir(repo store.Repo) (dir, branch string) {
return control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), repo.DefaultBranch
}
// hasWiki reports whether repo's default branch holds a wiki page.
func (s *Server) hasWiki(repo store.Repo) bool {
dir, branch := s.wikiDir(repo)
entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath)
if err != nil {
return false
}
for _, e := range entries {
if e.Type == "blob" && wikiExtMatch(e.Name) {
return true
}
}
return false
}
// wikiExtMatch reports whether name has one of the wiki page extensions.
func wikiExtMatch(name string) bool {
ext := strings.ToLower(path.Ext(name))
for _, want := range wikiExts {
if ext == want {
return true
}
}
return false
}
// wiki renders a page from the repo's .gitbay/wiki tree. The home page is
// Home. (or README.); /wiki/ resolves with .md and
// .org fallbacks, and a name may be a path into a subfolder. Rendering
// reuses the same sanitized pipeline as READMEs.
func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
p, ok := s.repoFor(w, r, "")
if !ok {
return
}
p.Tab = "wiki"
var listing struct {
Home string `json:"home"`
Pages []string `json:"pages"`
}
var viewer store.User
if s.cfg.Web.Mode == "accounts" {
viewer = s.viewer(r)
}
_, listed := s.runControlInto(viewer, []string{"wiki", "list", p.Repo.Path()}, &listing)
if !listed || len(listing.Pages) == 0 { // no wiki, or no commits yet
s.render(w, "wiki.html", struct {
repoPage
Page string
PageHTML template.HTML
Nav []wikiNavGroup
Missing bool
}{repoPage: p, Missing: true})
return
}
pages := listing.Pages
page := strings.Trim(r.PathValue("page"), "/")
if page == "" {
page = listing.Home
}
var pageHTML template.HTML
if page != "" {
var shown struct {
File string `json:"file"`
Content string `json:"content"`
}
if _, ok := s.runControlInto(viewer,
[]string{"wiki", "show", p.Repo.Path(), page}, &shown); !ok {
s.notFound(w, r)
return
}
isPage := map[string]bool{}
for _, pg := range pages {
isPage[pg] = true
}
isFile := map[string]bool{}
dir, branch := s.wikiDir(p.Repo)
if entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath); err == nil {
for _, e := range entries {
isFile[e.Name] = e.Type == "blob"
}
}
pageHTML = rewriteWikiLinks(renderReadme(shown.File, []byte(shown.Content)), p, page,
func(t string) bool { return isPage[t] }, func(t string) bool { return isFile[t] })
}
s.render(w, "wiki.html", struct {
repoPage
Page string
PageHTML template.HTML
Nav []wikiNavGroup
Missing bool
}{p, page, pageHTML, wikiNav(pages), false})
}
// wikiNavGroup is one folder of the wiki sidebar; Dir is empty for the
// top level, which comes first.
type wikiNavGroup struct {
Dir string
Pages []wikiNavPage
}
type wikiNavPage struct {
Name string // the page's path, as routed
Label string // its last segment
}
// wikiNav groups page names by folder, keeping the listing's order within
// each folder.
func wikiNav(pages []string) []wikiNavGroup {
var groups []wikiNavGroup
at := map[string]int{}
for _, pg := range pages {
d := path.Dir(pg)
if d == "." {
d = ""
}
i, ok := at[d]
if !ok {
i = len(groups)
at[d] = i
groups = append(groups, wikiNavGroup{Dir: d})
}
groups[i].Pages = append(groups[i].Pages, wikiNavPage{Name: pg, Label: path.Base(pg)})
}
sort.SliceStable(groups, func(a, b int) bool {
if (groups[a].Dir == "") != (groups[b].Dir == "") {
return groups[a].Dir == ""
}
return groups[a].Dir < groups[b].Dir
})
return groups
}
// wikiRaw serves non-page files from the wiki (images referenced by pages).
func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
p, ok := s.repoFor(w, r, "")
if !ok {
return
}
dir, branch := s.wikiDir(p.Repo)
rel := path.Join(wikiTreePath, strings.Trim(r.PathValue("path"), "/"))
if !strings.HasPrefix(rel, wikiTreePath+"/") {
s.notFound(w, r)
return
}
data, err := gitutil.ReadBlob(dir, branch, rel, s.cfg.Limits.MaxBlobBytes)
if err != nil {
s.notFound(w, r)
return
}
ct := "application/octet-stream"
if t, ok := imageTypes[strings.ToLower(path.Ext(rel))]; ok {
ct = t
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write(data)
}
// wikiResolve maps a relative link on page to a path inside the wiki. A
// page in a subfolder looks in its own folder first and then at the wiki
// root, so [[Admin]] still reaches a top-level page; when neither exists
// the folder-relative path is kept, so a broken link points where its
// author wrote it. A top-level page resolves exactly as before.
func wikiResolve(page, v string, exists func(string) bool) (string, bool) {
dir := path.Dir(page)
local := path.Clean(path.Join(dir, v))
if local == ".." || strings.HasPrefix(local, "../") {
return "", false
}
if dir == "." || exists(local) {
return local, true
}
if root := path.Clean(v); root != ".." && !strings.HasPrefix(root, "../") && exists(root) {
return root, true
}
return local, true
}
// trimPageExt drops a page extension from a link target.
func trimPageExt(target string) string {
switch strings.ToLower(path.Ext(target)) {
case ".md", ".org", ".markdown", ".html":
return strings.TrimSuffix(target, path.Ext(target))
}
return target
}
// rewriteWikiLinks makes relative links resolve inside the wiki: page
// links (with or without .md/.org/.html extensions) go to /wiki/,
// other relative targets (images) to the wiki raw route. Targets resolve
// from the current page's folder, as wikiResolve describes.
func rewriteWikiLinks(rendered template.HTML, p repoPage, page string, isPage, isFile func(string) bool) template.HTML {
ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
nodes, err := html.ParseFragment(strings.NewReader(string(rendered)), ctx)
if err != nil {
return rendered
}
base := "/" + p.Repo.Path() + "/wiki"
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
for i, a := range n.Attr {
isHref := a.Key == "href" && n.Data == "a"
isSrc := a.Key == "src" && (n.Data == "img" || n.Data == "video" || n.Data == "source")
if !isHref && !isSrc {
continue
}
v := a.Val
if v == "" || strings.Contains(v, "://") || strings.HasPrefix(v, "/") ||
strings.HasPrefix(v, "#") || strings.HasPrefix(v, "mailto:") ||
strings.HasPrefix(v, "data:") {
continue
}
if isSrc {
if target, ok := wikiResolve(page, v, isFile); ok {
n.Attr[i].Val = base + "/_raw/" + target
}
continue
}
if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
n.Attr[i].Val = base + "/" + target
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
var out strings.Builder
for _, n := range nodes {
walk(n)
if err := html.Render(&out, n); err != nil {
return rendered
}
}
return template.HTML(out.String())
}