429 lines
14 KiB
Go
429 lines
14 KiB
Go
package editor
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/gitx"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/preview"
|
|
)
|
|
|
|
// The power interface's file access: every file of a site, any text file
|
|
// edited as raw text with a diff before saving, files added and deleted, a
|
|
// draft compared with the published branch, and history. All of it is git:
|
|
// a save is a commit on a draft, like every other change, and nothing here
|
|
// runs anything but git.
|
|
|
|
const maxTextFile = 1 << 20
|
|
|
|
// FileInfo is one file of a site.
|
|
type FileInfo struct {
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
Text bool `json:"text"`
|
|
}
|
|
|
|
// cleanSitePath accepts a path inside the site, never in .git or the build
|
|
// output.
|
|
func cleanSitePath(p string) (string, bool) {
|
|
c := path.Clean("/" + strings.ReplaceAll(p, "\\", "/"))[1:]
|
|
if c == "" || c != p || c == ".git" || strings.HasPrefix(c, ".git/") || c == "public" || strings.HasPrefix(c, "public/") || strings.Contains(c, "\x00") {
|
|
return "", false
|
|
}
|
|
return c, true
|
|
}
|
|
|
|
// pipelineFile says whether a repository path is one that decides what runs
|
|
// when the site is built or published: the operator's publish targets (and
|
|
// their before and after steps) and CI pipelines. Changing one is changing
|
|
// what runs with the operator's credentials, so only maintainers may.
|
|
func pipelineFile(repoPath string) bool {
|
|
base := path.Base(repoPath)
|
|
top, _, _ := strings.Cut(repoPath, "/")
|
|
switch {
|
|
case base == "publish.yaml" || base == "publish.yml":
|
|
return true
|
|
case top == ".github" || top == ".gitea" || top == ".forgejo" || top == ".circleci":
|
|
return true
|
|
case repoPath == ".gitlab-ci.yml" || repoPath == "Jenkinsfile" || repoPath == ".drone.yml" || repoPath == "bitbucket-pipelines.yml":
|
|
return true
|
|
case top == ".woodpecker" || repoPath == ".woodpecker.yml" || repoPath == ".woodpecker.yaml":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isText(data []byte) bool {
|
|
head := data
|
|
if len(head) > 8192 {
|
|
head = head[:8192]
|
|
}
|
|
return !bytes.ContainsRune(head, 0) && utf8.Valid(head)
|
|
}
|
|
|
|
func (s *Server) siteFor(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) (*SiteConfig, Access, string, string, bool) {
|
|
st, acc, err := s.siteByID(r.Context(), sess, f, id)
|
|
if s.signedOut(w, r, err) {
|
|
return nil, acc, "", "", false
|
|
}
|
|
if err != nil || st == nil {
|
|
apiError(w, http.StatusNotFound, "No such site, or you can't open it.")
|
|
return nil, acc, "", "", false
|
|
}
|
|
branch := r.URL.Query().Get("branch")
|
|
if branch == "" {
|
|
branch = st.Branch
|
|
}
|
|
_, siteDir, commit, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), branch)
|
|
if siteDir == "" {
|
|
apiError(w, http.StatusBadGateway, "Couldn't fetch the site: "+trimErr(err))
|
|
return nil, acc, "", "", false
|
|
}
|
|
return st, acc, siteDir, commit, true
|
|
}
|
|
|
|
func (s *Server) apiFiles(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) {
|
|
_, _, siteDir, commit, ok := s.siteFor(w, r, sess, f, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
files := []FileInfo{}
|
|
_ = filepath.WalkDir(siteDir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(siteDir, p)
|
|
rel = filepath.ToSlash(rel)
|
|
if d.IsDir() {
|
|
switch rel {
|
|
case ".git", "public", "node_modules":
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(rel, ".editor-build") || d.Type()&fs.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
fi := FileInfo{Path: rel, Size: info.Size()}
|
|
if info.Size() <= maxTextFile {
|
|
if data, err := readIn(siteDir, rel); err == nil {
|
|
fi.Text = isText(data)
|
|
}
|
|
}
|
|
files = append(files, fi)
|
|
return nil
|
|
})
|
|
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
|
|
writeJSON(w, http.StatusOK, map[string]any{"files": files, "commit": commit})
|
|
}
|
|
|
|
func (s *Server) apiFile(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) {
|
|
_, _, siteDir, commit, ok := s.siteFor(w, r, sess, f, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
rel, valid := cleanSitePath(r.URL.Query().Get("path"))
|
|
if !valid {
|
|
apiError(w, http.StatusNotFound, "No such file.")
|
|
return
|
|
}
|
|
data, err := readIn(siteDir, rel)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": rel, "exists": false, "content": "", "commit": commit})
|
|
return
|
|
}
|
|
if err != nil {
|
|
apiError(w, http.StatusNotFound, "No such file.")
|
|
return
|
|
}
|
|
if len(data) > maxTextFile || !isText(data) {
|
|
apiError(w, http.StatusUnprocessableEntity, "That file isn't text the editor can show (or is over 1 MB).")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": rel, "exists": true, "content": string(data), "commit": commit})
|
|
}
|
|
|
|
type fileSaveRequest struct {
|
|
Branch string `json:"branch"`
|
|
BaseCommit string `json:"baseCommit"`
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
Delete bool `json:"delete"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (s *Server) fileSave(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig, acc Access) {
|
|
var req fileSaveRequest
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
rel, ok := cleanSitePath(req.Path)
|
|
if !ok {
|
|
apiError(w, http.StatusBadRequest, "That isn't a path in the site (and .git and public/ can't be written).")
|
|
return
|
|
}
|
|
if pipelineFile(repoFile(st, rel)) && !acc.Admin {
|
|
apiError(w, http.StatusForbidden, rel+" decides what runs when the site is published, so only a maintainer of the repository can change it.")
|
|
return
|
|
}
|
|
if len(req.Content) > maxTextFile {
|
|
apiError(w, http.StatusRequestEntityTooLarge, "The file is over 1 MB.")
|
|
return
|
|
}
|
|
if req.Branch == "" {
|
|
req.Branch = st.Branch
|
|
}
|
|
_, siteDir, _, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), req.Branch)
|
|
if siteDir == "" {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
_, statErr := os.Stat(filepath.Join(siteDir, filepath.FromSlash(rel)))
|
|
exists := statErr == nil
|
|
msg := strings.TrimSpace(req.Message)
|
|
var data []byte
|
|
switch {
|
|
case req.Delete:
|
|
if !exists {
|
|
apiError(w, http.StatusNotFound, "There's no such file to delete.")
|
|
return
|
|
}
|
|
if msg == "" {
|
|
msg = "Delete " + rel
|
|
}
|
|
default:
|
|
data = []byte(req.Content)
|
|
if msg == "" {
|
|
msg = map[bool]string{true: "Edit ", false: "Add "}[exists] + rel
|
|
}
|
|
}
|
|
target, create := req.Branch, false
|
|
if req.Branch == st.Branch {
|
|
target, create = s.draftName(r, sess, f, st, rel), true
|
|
}
|
|
commit, err := s.ws.save(sess, f.Kind, st, s.cloneURL(st), req.Branch, target, create, req.BaseCommit, map[string][]byte{repoFile(st, rel): data}, msg)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
s.log.Printf("%s: %s on %s@%s (%s)", sess.Login, msg, st.ID, target, shortSHA(commit))
|
|
writeJSON(w, http.StatusOK, map[string]any{"branch": target, "commit": commit, "created": create, "preview": st.Preview.url(preview.Slug(target))})
|
|
}
|
|
|
|
// fileDiff shows what saving would change: the branch's file against the
|
|
// text being edited, as a unified diff.
|
|
func (s *Server) fileDiff(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
|
|
var req fileSaveRequest
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
rel, ok := cleanSitePath(req.Path)
|
|
if !ok {
|
|
apiError(w, http.StatusBadRequest, "That isn't a path in the site.")
|
|
return
|
|
}
|
|
if req.Branch == "" {
|
|
req.Branch = st.Branch
|
|
}
|
|
_, siteDir, _, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), req.Branch)
|
|
if siteDir == "" {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
old, _ := readIn(siteDir, rel)
|
|
var now []byte
|
|
if !req.Delete {
|
|
now = []byte(req.Content)
|
|
}
|
|
d, err := unifiedDiff(rel, old, now)
|
|
if err != nil {
|
|
apiError(w, http.StatusBadGateway, trimErr(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"diff": d})
|
|
}
|
|
|
|
// unifiedDiff is git's diff of two versions of one file.
|
|
func unifiedDiff(rel string, old, now []byte) (string, error) {
|
|
dir, err := os.MkdirTemp("", "hotdog-cms-diff-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
a, b := filepath.Join(dir, "a"), filepath.Join(dir, "b")
|
|
if err := os.WriteFile(a, old, 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(b, now, 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
var out bytes.Buffer
|
|
cmd := exec.Command("git", "diff", "--no-index", "--no-color", "-U3", "--src-prefix=a/", "--dst-prefix=b/", "a", "b")
|
|
cmd.Dir, cmd.Stdout = dir, &out
|
|
err = cmd.Run()
|
|
var ee *exec.ExitError
|
|
if err != nil && !(errors.As(err, &ee) && ee.ExitCode() == 1) { // 1: they differ
|
|
return "", err
|
|
}
|
|
// Name the file as it is in the site.
|
|
d := strings.Replace(out.String(), "diff --git a/a b/b", "diff --git a/"+rel+" b/"+rel, 1)
|
|
d = strings.Replace(d, "--- a/a", "--- a/"+rel, 1)
|
|
d = strings.Replace(d, "+++ b/b", "+++ b/"+rel, 1)
|
|
return d, nil
|
|
}
|
|
|
|
// apiCompare is a draft against the branch the site publishes from: what
|
|
// publishing it would change.
|
|
func (s *Server) apiCompare(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) {
|
|
st, _, _, _, ok := s.siteFor(w, r, sess, f, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
branch := r.URL.Query().Get("branch")
|
|
if branch == "" || branch == st.Branch {
|
|
writeJSON(w, http.StatusOK, map[string]any{"diff": "", "stat": "", "base": st.Branch, "branch": st.Branch})
|
|
return
|
|
}
|
|
if !gitx.ValidBranch(branch) {
|
|
apiError(w, http.StatusBadRequest, "That isn't a branch name the editor uses.")
|
|
return
|
|
}
|
|
dir := s.ws.dir(sess, st, branch)
|
|
unlock := s.ws.lock(dir)
|
|
defer unlock()
|
|
auth := gitx.Auth{User: f.Kind.TokenUser(), Token: sess.Token}
|
|
if _, err := gitx.Run(auth, dir, "fetch", "--quiet", "--depth", "200", "--end-of-options", "origin",
|
|
"+refs/heads/"+st.Branch+":refs/hotdog/base", "+refs/heads/"+branch+":refs/hotdog/head"); err != nil {
|
|
apiError(w, http.StatusBadGateway, "Couldn't fetch the branches: "+trimErr(err))
|
|
return
|
|
}
|
|
scope := []string{}
|
|
if st.Subdir != "" {
|
|
scope = []string{"--", st.Subdir}
|
|
}
|
|
// Changes since the draft started; if their common start is deeper than
|
|
// fetched, the two tips directly.
|
|
rng := "refs/hotdog/base...refs/hotdog/head"
|
|
if _, err := gitx.Run(auth, dir, "merge-base", "refs/hotdog/base", "refs/hotdog/head"); err != nil {
|
|
rng = "refs/hotdog/base..refs/hotdog/head"
|
|
}
|
|
stat, _ := gitx.Run(auth, dir, append([]string{"diff", "--no-color", "--stat", rng}, scope...)...)
|
|
d, err := gitx.Run(auth, dir, append([]string{"diff", "--no-color", "-U3", rng}, scope...)...)
|
|
if err != nil {
|
|
apiError(w, http.StatusBadGateway, trimErr(err))
|
|
return
|
|
}
|
|
if len(d) > 2<<20 {
|
|
d = d[:2<<20] + "\n… (the rest of the diff is too long to show)"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"diff": d, "stat": stat, "base": st.Branch, "branch": branch})
|
|
}
|
|
|
|
// draftChanges lists the repository paths a draft changes since it left the
|
|
// publishing branch.
|
|
func (s *Server) draftChanges(sess *Session, f *ForgeConfig, st *SiteConfig, branch string) ([]string, error) {
|
|
if !gitx.ValidBranch(branch) || !gitx.ValidBranch(st.Branch) {
|
|
return nil, fmt.Errorf("not a branch name the editor uses")
|
|
}
|
|
dir := s.ws.dir(sess, st, branch)
|
|
unlock := s.ws.lock(dir)
|
|
defer unlock()
|
|
auth := gitx.Auth{User: f.Kind.TokenUser(), Token: sess.Token}
|
|
if _, err := gitx.Run(auth, dir, "fetch", "--quiet", "--depth", "200", "--end-of-options", "origin",
|
|
"+refs/heads/"+st.Branch+":refs/hotdog/base", "+refs/heads/"+branch+":refs/hotdog/head"); err != nil {
|
|
return nil, err
|
|
}
|
|
rng := "refs/hotdog/base...refs/hotdog/head"
|
|
if _, err := gitx.Run(auth, dir, "merge-base", "refs/hotdog/base", "refs/hotdog/head"); err != nil {
|
|
rng = "refs/hotdog/base..refs/hotdog/head"
|
|
}
|
|
out, err := gitx.Run(auth, dir, "diff", "--name-only", "--no-renames", "-z", rng)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var paths []string
|
|
for _, p := range strings.Split(out, "\x00") {
|
|
if p != "" {
|
|
paths = append(paths, p)
|
|
}
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
// Commit is one entry of a branch's history.
|
|
type Commit struct {
|
|
SHA string `json:"sha"`
|
|
Author string `json:"author"`
|
|
When time.Time `json:"when"`
|
|
Subject string `json:"subject"`
|
|
}
|
|
|
|
func (s *Server) apiLog(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) {
|
|
st, _, _, _, ok := s.siteFor(w, r, sess, f, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
branch := r.URL.Query().Get("branch")
|
|
if branch == "" {
|
|
branch = st.Branch
|
|
}
|
|
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
|
|
if n <= 0 || n > 200 {
|
|
n = 30
|
|
}
|
|
args := []string{"log", "-n", strconv.Itoa(n), "--format=%H%x1f%an%x1f%aI%x1f%s", "refs/hotdog/log"}
|
|
if p := r.URL.Query().Get("path"); p != "" {
|
|
rel, ok := cleanSitePath(p)
|
|
if !ok {
|
|
apiError(w, http.StatusBadRequest, "That isn't a path in the site.")
|
|
return
|
|
}
|
|
args = append(args, "--", repoFile(st, rel))
|
|
} else if st.Subdir != "" {
|
|
args = append(args, "--", st.Subdir)
|
|
}
|
|
if !gitx.ValidBranch(branch) {
|
|
apiError(w, http.StatusBadRequest, "That isn't a branch name the editor uses.")
|
|
return
|
|
}
|
|
dir := s.ws.dir(sess, st, branch)
|
|
unlock := s.ws.lock(dir)
|
|
defer unlock()
|
|
auth := gitx.Auth{User: f.Kind.TokenUser(), Token: sess.Token}
|
|
if _, err := gitx.Run(auth, dir, "fetch", "--quiet", "--depth", strconv.Itoa(n+1), "--end-of-options", "origin", "+refs/heads/"+branch+":refs/hotdog/log"); err != nil {
|
|
apiError(w, http.StatusBadGateway, "Couldn't fetch the history: "+trimErr(err))
|
|
return
|
|
}
|
|
out, err := gitx.Run(auth, dir, args...)
|
|
if err != nil {
|
|
apiError(w, http.StatusBadGateway, trimErr(err))
|
|
return
|
|
}
|
|
commits := []Commit{}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
p := strings.Split(line, "\x1f")
|
|
if len(p) != 4 {
|
|
continue
|
|
}
|
|
when, _ := time.Parse(time.RFC3339, p[2])
|
|
commits = append(commits, Commit{SHA: p[0], Author: p[1], When: when, Subject: p[3]})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"branch": branch, "commits": commits})
|
|
}
|