Files

334 lines
11 KiB
Go

package editor
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/check"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/gitx"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/isolate"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// A workspace is one person's checkout of one branch of one site, made with
// their own token, so the editor can only ever read what the platform lets
// them read. Checkouts are a cache: deleting the folder loses nothing.
type workspaces struct {
root string
mu sync.Mutex
locks map[string]*sync.Mutex
built map[string]*BuildResult // by checkout and commit
}
// BuildResult is a branch built and checked.
type BuildResult struct {
Commit string `json:"commit"`
Pages int `json:"pages"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Problems []check.Problem `json:"problems"`
Failed string `json:"failed,omitempty"` // the build itself failed
At time.Time `json:"at"`
}
func newWorkspaces(root string) *workspaces {
return &workspaces{root: root, locks: map[string]*sync.Mutex{}, built: map[string]*BuildResult{}}
}
func (ws *workspaces) dir(sess *Session, sc *SiteConfig, branch string) string {
sum := sha256.Sum256([]byte(sess.Forge + "\x00" + sess.Login + "\x00" + sc.Repo))
b := sha256.Sum256([]byte(branch))
return filepath.Join(ws.root, hex.EncodeToString(sum[:12]), hex.EncodeToString(b[:6]))
}
func (ws *workspaces) lock(dir string) func() {
ws.mu.Lock()
l := ws.locks[dir]
if l == nil {
l = &sync.Mutex{}
ws.locks[dir] = l
}
ws.mu.Unlock()
l.Lock()
return l.Unlock
}
// checkout brings the person's copy of a branch up to date and loads the site.
func (ws *workspaces) checkout(sess *Session, kind forge.Kind, sc *SiteConfig, repo, branch string) (*site.Site, string, string, error) {
dir := ws.dir(sess, sc, branch)
defer ws.lock(dir)()
if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil {
return nil, "", "", err
}
commit, err := gitx.Checkout(gitx.Auth{User: kind.TokenUser(), Token: sess.Token}, repo, branch, dir)
if err != nil {
return nil, "", "", err
}
siteDir := filepath.Join(dir, sc.Subdir)
s, err := site.Load(siteDir, site.Options{Drafts: true})
if err != nil {
return nil, siteDir, commit, err
}
return s, siteDir, commit, nil
}
// buildAndCheck builds a checkout and runs the site's checks on it, once per
// commit.
func (ws *workspaces) buildAndCheck(siteDir, commit string) *BuildResult {
key := siteDir + "@" + commit
ws.mu.Lock()
if r, ok := ws.built[key]; ok {
ws.mu.Unlock()
return r
}
ws.mu.Unlock()
out := siteDir + ".editor-build"
r := &BuildResult{Commit: commit, At: time.Now()}
res, err := isolate.Build(build.Options{SiteDir: siteDir, Out: out, Drafts: true})
var cfg *site.Config
if err == nil {
cfg, err = site.LoadConfig(siteDir)
}
if err != nil {
r.Failed = err.Error()
} else {
r.Pages = res.Pages
if rep, err := check.Run(siteDir, out, cfg); err != nil {
r.Failed = err.Error()
} else {
r.Errors = rep.Errors()
r.Warnings = len(rep.Problems) - r.Errors
r.Problems = rep.Problems
}
}
ws.mu.Lock()
ws.built[key] = r
ws.mu.Unlock()
return r
}
// PageInfo is one page of a site, as the editor lists it.
type PageInfo struct {
Path string `json:"path"`
Title string `json:"title"`
Source string `json:"source,omitempty"`
Collection string `json:"collection,omitempty"`
Kind string `json:"kind"`
Date string `json:"date,omitempty"`
Draft bool `json:"draft,omitempty"`
Redirect string `json:"redirect,omitempty"`
}
// CollectionInfo is one folder of pages.
type CollectionInfo struct {
Name string `json:"name"`
Title string `json:"title"`
Count int `json:"count"`
}
func pagesOf(s *site.Site) ([]PageInfo, []CollectionInfo) {
var pages []PageInfo
for _, p := range s.Pages {
if p.Source == "" {
continue // generated: taxonomy pages, later pages of a list
}
pi := PageInfo{Path: p.Path, Title: p.Title, Source: "content/" + p.Source, Collection: p.Collection, Kind: p.Kind, Draft: p.Draft, Redirect: p.RedirectTo}
if !p.Date.IsZero() {
pi.Date = p.Date.Format("2006-01-02")
}
pages = append(pages, pi)
}
sort.Slice(pages, func(i, j int) bool {
if pages[i].Collection != pages[j].Collection {
return pages[i].Collection < pages[j].Collection
}
if pages[i].Date != pages[j].Date {
return pages[i].Date > pages[j].Date
}
return pages[i].Path < pages[j].Path
})
var cols []CollectionInfo
for name, ps := range s.Collections {
title := name
if l := s.Lists[name]; l != nil && l.Title != "" {
title = l.Title
}
cols = append(cols, CollectionInfo{Name: name, Title: title, Count: len(ps)})
}
sort.Slice(cols, func(i, j int) bool { return cols[i].Name < cols[j].Name })
return pages, cols
}
// readSource reads a content file from a checkout, refusing anything that
// isn't a Markdown file under content/.
func readSource(siteDir, source string) ([]byte, error) {
clean := filepath.ToSlash(filepath.Clean("/" + source))[1:]
if clean != source || !strings.HasPrefix(clean, "content/") || !strings.HasSuffix(clean, ".md") {
return nil, fmt.Errorf("not a page source: %q", source)
}
return readIn(siteDir, clean)
}
// readIn reads rel (slash-separated) inside dir. Nothing outside dir can be
// reached, even through a symbolic link, and a link itself isn't read.
func readIn(dir, rel string) ([]byte, error) {
r, err := os.OpenRoot(dir)
if err != nil {
return nil, err
}
defer r.Close()
name := filepath.FromSlash(rel)
if fi, err := r.Lstat(name); err == nil && !fi.Mode().IsRegular() {
return nil, fmt.Errorf("%s isn't a regular file", rel)
}
return r.ReadFile(name)
}
// writeIn writes rel inside dir, making folders as needed, without ever
// writing outside dir or through a link.
func writeIn(dir, rel string, data []byte) error {
r, err := os.OpenRoot(dir)
if err != nil {
return err
}
defer r.Close()
name := filepath.FromSlash(rel)
if fi, err := r.Lstat(name); err == nil && !fi.Mode().IsRegular() {
return fmt.Errorf("%s isn't a regular file", rel)
}
if err := r.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return err
}
return r.WriteFile(name, data, 0o644)
}
// errConflict means the branch moved since the person started editing.
var errConflict = errors.New("this branch changed since you opened it")
// save commits files to a branch as the signed-in person and pushes it with
// their token. baseBranch is the branch the change starts from; target is the
// branch it goes to (a new draft branch when create is set). If baseCommit is
// given and the branch has moved past it, nothing is written: someone else's
// change is never silently overwritten.
func (ws *workspaces) save(sess *Session, kind forge.Kind, sc *SiteConfig, repo, baseBranch, target string, create bool, baseCommit string, files map[string][]byte, message string) (string, error) {
if !gitx.ValidBranch(target) {
return "", fmt.Errorf("%q isn't a branch name the editor uses", target)
}
dir := ws.dir(sess, sc, baseBranch)
defer ws.lock(dir)()
if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil {
return "", err
}
auth := gitx.Auth{User: kind.TokenUser(), Token: sess.Token}
tip, err := gitx.Checkout(auth, repo, baseBranch, dir)
if err != nil {
return "", err
}
if baseCommit != "" && tip != baseCommit {
return "", errConflict
}
for rel, data := range files {
clean := filepath.ToSlash(filepath.Clean("/" + rel))[1:]
if clean != rel || strings.HasPrefix(clean, ".git/") || clean == ".git" {
return "", fmt.Errorf("refusing to write %q", rel)
}
if data == nil { // nil: delete the file
if _, err := gitx.Run(auth, dir, "rm", "--quiet", "--ignore-unmatch", "--", clean); err != nil {
return "", err
}
continue
}
if err := writeIn(dir, clean, data); err != nil {
return "", err
}
if _, err := gitx.Run(auth, dir, "add", "--", clean); err != nil {
return "", err
}
}
if _, err := gitx.Run(auth, dir, "-c", "user.name="+sess.Name, "-c", "user.email="+sess.Email, "-c", "commit.gpgsign=false",
"commit", "--quiet", "--allow-empty-message", "-m", message); err != nil {
if strings.Contains(err.Error(), "nothing to commit") {
return tip, nil
}
return "", err
}
commit, err := gitx.Run(auth, dir, "rev-parse", "HEAD")
if err != nil {
return "", err
}
// A plain push: never --force, so a branch someone else moved meanwhile is
// refused by the platform rather than overwritten.
_, pushErr := gitx.Run(auth, dir, "push", "--quiet", "origin", "HEAD:refs/heads/"+target)
if create || pushErr != nil {
// Leave the base branch's checkout as it was.
_, _ = gitx.Run(auth, dir, "reset", "--quiet", "--hard", tip)
}
if pushErr != nil {
if strings.Contains(pushErr.Error(), "rejected") || strings.Contains(pushErr.Error(), "non-fast-forward") {
return "", errConflict
}
if strings.Contains(pushErr.Error(), "403") || strings.Contains(pushErr.Error(), "denied") {
return "", errors.New("the platform didn't let you push to this repository")
}
return "", pushErr
}
return commit, nil
}
// errRevertConflict means later changes overlap the one being undone.
var errRevertConflict = errors.New("later changes touch the same lines, so this can't be undone by itself; change it back in the editor instead")
// revert undoes a commit of the publishing branch on a new draft branch: a
// git revert, made as the person, pushed with their token, reviewed and
// published like any other change. A merge (as publishing makes) is undone
// against its first parent.
func (ws *workspaces) revert(sess *Session, kind forge.Kind, sc *SiteConfig, repo, sha, target string) (string, error) {
dir := ws.dir(sess, sc, sc.Branch)
defer ws.lock(dir)()
if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil {
return "", err
}
auth := gitx.Auth{User: kind.TokenUser(), Token: sess.Token}
tip, err := gitx.Checkout(auth, repo, sc.Branch, dir)
if err != nil {
return "", err
}
if _, err := gitx.Run(auth, dir, "fetch", "--quiet", "--depth", "300", "--end-of-options", "origin", "+refs/heads/"+sc.Branch); err != nil {
return "", err
}
if _, err := gitx.Run(auth, dir, "cat-file", "-e", sha+"^{commit}"); err != nil {
return "", fmt.Errorf("no commit %s on %s (only the last 300 can be undone here)", shortSHA(sha), sc.Branch)
}
args := []string{"-c", "user.name=" + sess.Name, "-c", "user.email=" + sess.Email, "-c", "commit.gpgsign=false", "revert", "--no-edit"}
if parents, _ := gitx.Run(auth, dir, "rev-list", "--parents", "-n", "1", sha); len(strings.Fields(parents)) > 2 {
args = append(args, "-m", "1")
}
defer func() { _, _ = gitx.Run(auth, dir, "reset", "--quiet", "--hard", tip) }()
if _, err := gitx.Run(auth, dir, append(args, sha)...); err != nil {
_, _ = gitx.Run(auth, dir, "revert", "--abort")
if strings.Contains(err.Error(), "conflict") || strings.Contains(err.Error(), "could not revert") {
return "", errRevertConflict
}
return "", err
}
commit, err := gitx.Run(auth, dir, "rev-parse", "HEAD")
if err != nil {
return "", err
}
if _, err := gitx.Run(auth, dir, "push", "--quiet", "origin", "HEAD:refs/heads/"+target); err != nil {
return "", err
}
return commit, nil
}