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