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) }