From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree. --- wiki.go | 303 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 wiki.go (limited to 'wiki.go') 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) +} -- cgit v1.2.3