aboutsummaryrefslogtreecommitdiff
path: root/wiki.go
diff options
context:
space:
mode:
Diffstat (limited to 'wiki.go')
-rw-r--r--wiki.go303
1 files changed, 303 insertions, 0 deletions
diff --git a/wiki.go b/wiki.go
new file mode 100644
index 0000000..34160ef
--- /dev/null
+++ b/wiki.go
@@ -0,0 +1,303 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+)
+
+// Wiki pages are markdown in {repo}.wiki.git, paths in the git tree. Rendered
+// here rather than by cgit: clean URLs, an index from ls-tree, and a history
+// link out to cgit.
+
+// A wiki path component, for reading and writing alike.
+var wikiNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+const maxWikiDepth = 8
+
+func (f *Forge) wikiDir(path string) string {
+ return filepath.Join(f.store.reposDir, path+".wiki.git")
+}
+
+// wikiFiles maps a URL to the tree paths it could mean: the page itself, then
+// that name as a directory. index.md is served at the root of the wiki and of
+// any subdirectory.
+func wikiFiles(page string) ([]string, bool) {
+ page = strings.TrimPrefix(page, "/")
+ page = strings.TrimSuffix(page, ".md")
+ if page == "" || strings.HasSuffix(page, "/") {
+ page += "index"
+ if !validWikiPage(page) {
+ return nil, false
+ }
+ return []string{page + ".md"}, true
+ }
+ if !validWikiPage(page) {
+ return nil, false
+ }
+ return []string{page + ".md", page + "/index.md"}, true
+}
+
+func validWikiPage(page string) bool {
+ parts := strings.Split(page, "/")
+ if len(parts) > maxWikiDepth {
+ return false
+ }
+ for _, part := range parts {
+ if !wikiNameRE.MatchString(part) {
+ return false
+ }
+ }
+ return true
+}
+
+// wikiPage is the reverse: a tree path as it appears in a URL. Only a whole
+// index component is dropped: myindex.md is a page called myindex.
+func wikiPage(file string) string {
+ page := strings.TrimSuffix(file, ".md")
+ if page == "index" {
+ return ""
+ }
+ if rest, ok := strings.CutSuffix(page, "/index"); ok {
+ return rest + "/"
+ }
+ return page
+}
+
+type wikiView struct {
+ Path string // the repository
+ Page string // the page, "" for the root
+ File string // the path in the tree
+ Rendered template.HTML
+ Pages []string
+ Missing bool // no such page yet
+ Empty bool // no pages at all yet
+ Can bool // may edit
+}
+
+type wikiEdit struct {
+ Path string
+ Page string
+ File string
+ Body string
+ Base string // the blob this edit started from, for the compare and swap
+ Preview template.HTML
+}
+
+// handleWiki routes everything under {repo}/wiki.
+func (f *Forge) handleWiki(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) {
+ if repo.Wiki == featDisabled {
+ f.fail(w, r, http.StatusNotFound, "this repository has its wiki turned off")
+ return
+ }
+ page := strings.TrimPrefix(strings.TrimPrefix(rest, "wiki"), "/")
+ files, ok := wikiFiles(page)
+ if !ok {
+ f.fail(w, r, http.StatusNotFound, "that is not a usable page name")
+ return
+ }
+
+ // Without javascript a form cannot put a name into a path, so it arrives
+ // as a query.
+ if isGet(r) && r.URL.Query().Get("page") != "" {
+ wanted, ok := wikiFiles(r.URL.Query().Get("page"))
+ if !ok {
+ f.fail(w, r, http.StatusBadRequest,
+ "a page name is letters, digits, dot, dash, and underscore, with / for a subdirectory")
+ return
+ }
+ http.Redirect(w, r, "/"+path+"/wiki/"+wikiPage(wanted[0])+"?edit", http.StatusSeeOther)
+ return
+ }
+
+ switch {
+ case isGet(r) && r.URL.Query().Has("edit"):
+ // Editing is always the page itself, never the directory behind it.
+ f.wikiForm(w, r, path, repo, files[0], "", "")
+ case isGet(r):
+ f.wikiShow(w, r, path, repo, files)
+ case r.Method == http.MethodPost:
+ if f.checkPost(w, r) {
+ f.wikiSave(w, r, path, repo, files[0])
+ }
+ default:
+ f.fail(w, r, http.StatusNotFound, "no such page")
+ }
+}
+
+func (f *Forge) wikiShow(w http.ResponseWriter, r *http.Request, path string, repo Repo, files []string) {
+ dir := f.wikiDir(path)
+ view := wikiView{
+ Path: path, Page: wikiPage(files[0]), File: files[0],
+ Can: f.viewer(r).CanWiki(repo),
+ }
+
+ head, err := resolveRef(dir, branch)
+ if err != nil {
+ // An empty wiki and a missing repository must not read the same.
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ if head == "" {
+ view.Empty, view.Missing = true, true
+ } else {
+ view.Pages = f.wikiPages(dir, head)
+ view.Missing = true
+ for _, file := range files {
+ if body, err := catFile(dir, head+":"+file); err == nil {
+ view.Page, view.File = wikiPage(file), file
+ view.Rendered = renderMarkdown(string(body))
+ view.Missing = false
+ break
+ }
+ }
+ }
+
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "wiki", repo)
+ d.Data = view
+ // A wiki with no pages at all is a state, not a 404.
+ status := http.StatusOK
+ if view.Missing && !view.Empty {
+ status = http.StatusNotFound
+ }
+ f.render(w, "wiki.html", status, d)
+}
+
+// wikiPages is the index, straight out of the tree.
+func (f *Forge) wikiPages(dir, head string) []string {
+ paths, err := listTreeRecursive(dir, head)
+ if err != nil {
+ log.Printf("wiki: %v", err)
+ return nil
+ }
+ pages := []string{}
+ for _, p := range paths {
+ if !strings.HasSuffix(p, ".md") {
+ continue
+ }
+ // A push can put a name here that reading refuses, or one that reads
+ // back as another file; a link that does not open its file is worse
+ // than none.
+ page := wikiPage(p)
+ files, ok := wikiFiles(page)
+ if !ok || !slices.Contains(files, p) {
+ continue
+ }
+ pages = append(pages, page)
+ }
+ sort.Strings(pages)
+ return pages
+}
+
+func (f *Forge) wikiForm(w http.ResponseWriter, r *http.Request, path string, repo Repo, file, body, message string) {
+ if !f.viewer(r).CanWiki(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not edit this wiki")
+ return
+ }
+ dir := f.wikiDir(path)
+ base, err := f.wikiBlob(dir, file)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ // An editor opened empty over a page would save it away.
+ if body == "" && base != "" {
+ current, err := catFile(dir, base)
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ body = string(current)
+ }
+
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "wiki", repo)
+ d.Error = message
+ d.Data = wikiEdit{Path: path, Page: wikiPage(file), File: file, Body: body, Base: base}
+ f.render(w, "wiki-edit.html", http.StatusOK, d)
+}
+
+// wikiBlob is the page's blob id now. The editor carries it back and wikiSave
+// compares it inside the commit: a compare and swap on the page, not the
+// branch.
+func (f *Forge) wikiBlob(dir, file string) (string, error) {
+ head, err := resolveRef(dir, branch)
+ if err != nil {
+ return "", err
+ }
+ return blobIn(dir, head, file)
+}
+
+// blobIn is the id of one path's blob in one commit, or "" when the repository
+// is empty or the commit does not hold that path.
+func blobIn(dir, commit, file string) (string, error) {
+ if commit == "" {
+ return "", nil
+ }
+ return resolveRef(dir, commit+":"+file)
+}
+
+// errStale is the comparison failing inside the commit.
+var errStale = errors.New("the page changed while it was being edited")
+
+func (f *Forge) wikiSave(w http.ResponseWriter, r *http.Request, path string, repo Repo, file string) {
+ if !f.viewer(r).CanWiki(repo) {
+ f.fail(w, r, http.StatusForbidden, "you may not edit this wiki")
+ return
+ }
+ body := strings.ReplaceAll(r.PostFormValue("body"), "\r\n", "\n")
+ base := r.PostFormValue("base")
+
+ if len(body) > maxBody {
+ f.fail(w, r, http.StatusBadRequest, "that page is too big")
+ return
+ }
+ if r.PostFormValue("action") == "preview" {
+ d := f.page(r)
+ d.Repo = f.repoBar(r, path, "", "wiki", repo)
+ d.Data = wikiEdit{
+ Path: path, Page: wikiPage(file), File: file, Body: body, Base: base,
+ Preview: renderMarkdown(body),
+ }
+ f.render(w, "wiki-edit.html", http.StatusOK, d)
+ return
+ }
+
+ dir := f.wikiDir(path)
+ verb := "update"
+ if base == "" {
+ verb = "create"
+ }
+ if !strings.HasSuffix(body, "\n") {
+ body += "\n"
+ }
+ // Compared inside the commit, or both saves of one page would land.
+ err := commitWith(dir, f.actor(r), f.store.forgeAuthor(), func(commit string) ([]change, string, error) {
+ now, err := blobIn(dir, commit, file)
+ if err != nil {
+ return nil, "", err
+ }
+ if now != base {
+ return nil, "", errStale
+ }
+ return []change{{path: file, data: []byte(body)}}, fmt.Sprintf("wiki: %s %s", verb, file), nil
+ })
+ if errors.Is(err, errStale) {
+ f.wikiForm(w, r, path, repo, file, body,
+ "somebody else changed this page while you were editing it. open it again in another window, merge your text in, and save")
+ return
+ }
+ if err != nil {
+ f.problem(w, r, http.StatusInternalServerError, err)
+ return
+ }
+ http.Redirect(w, r, "/"+path+"/wiki/"+wikiPage(file), http.StatusSeeOther)
+}