490 lines
16 KiB
Go
490 lines
16 KiB
Go
package editor
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"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/gitx"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/isolate"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/media"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/preview"
|
|
)
|
|
|
|
// Writing. Every change is a commit made as the signed-in person and pushed
|
|
// with their token. Nothing is ever written to the branch a site publishes
|
|
// from: the first save of a change starts a draft branch, a draft goes to
|
|
// review as a pull request, and publishing merges it, but only when the
|
|
// draft passes the same checks every other route to the live site requires.
|
|
|
|
func (s *Server) apiWrite(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id, action string) {
|
|
st, acc, err := s.siteByID(r.Context(), sess, f, id)
|
|
if s.signedOut(w, r, err) {
|
|
return
|
|
}
|
|
if err != nil || st == nil {
|
|
apiError(w, http.StatusNotFound, "No such site, or you can't open it.")
|
|
return
|
|
}
|
|
if !acc.Write && action != "live" && action != "look-live" && action != "file-diff" && action != "form-live" {
|
|
apiError(w, http.StatusForbidden, "The platform gives you read-only access to this site.")
|
|
return
|
|
}
|
|
limit := int64(2 << 20)
|
|
if action == "media" {
|
|
limit = media.MaxBytes + 1<<20
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
|
switch action {
|
|
case "media":
|
|
s.upload(w, r, sess, f, st)
|
|
case "save":
|
|
s.save(w, r, sess, f, st)
|
|
case "new":
|
|
s.newPage(w, r, sess, f, st)
|
|
case "live":
|
|
s.liveRender(w, r, sess, f, st)
|
|
case "revert":
|
|
s.revertCommit(w, r, sess, f, st)
|
|
case "form-live":
|
|
s.formLive(w, r, sess, f, st)
|
|
case "form-save":
|
|
s.formSave(w, r, sess, f, st)
|
|
case "search-save":
|
|
s.searchSave(w, r, sess, f, st)
|
|
case "privacy-save":
|
|
s.privacySave(w, r, sess, f, st)
|
|
case "redirects-save":
|
|
s.redirectsSave(w, r, sess, f, st)
|
|
case "move":
|
|
s.movePage(w, r, sess, f, st)
|
|
case "file-save":
|
|
s.fileSave(w, r, sess, f, st, acc)
|
|
case "file-diff":
|
|
s.fileDiff(w, r, sess, f, st)
|
|
case "look-live":
|
|
s.lookLive(w, r, sess, f, st)
|
|
case "look-save":
|
|
s.lookSave(w, r, sess, f, st)
|
|
case "review":
|
|
s.review(w, r, sess, f, st)
|
|
case "publish":
|
|
s.publish(w, r, sess, f, st, acc)
|
|
case "quick":
|
|
s.quickPost(w, r, sess, f, st)
|
|
default:
|
|
apiError(w, http.StatusNotFound, "no such thing")
|
|
}
|
|
}
|
|
|
|
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
apiError(w, http.StatusBadRequest, "That request couldn't be read.")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func repoFile(st *SiteConfig, source string) string {
|
|
return path.Join(filepath.ToSlash(st.Subdir), source)
|
|
}
|
|
|
|
// draftName picks an unused draft branch name for a page.
|
|
func (s *Server) draftName(r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig, source string) string {
|
|
base := "draft/" + slug(strings.TrimSuffix(path.Base(source), path.Ext(source)))
|
|
if strings.HasSuffix(source, "/index.md") || strings.HasSuffix(source, "/_index.md") {
|
|
base = "draft/" + slug(path.Base(path.Dir(source)))
|
|
if path.Dir(source) == "content" { // the site's front page
|
|
base = "draft/home"
|
|
}
|
|
}
|
|
if base == "draft/" || base == "draft/index" {
|
|
base = "draft/home"
|
|
}
|
|
taken := map[string]bool{}
|
|
if bs, err := s.branches(r.Context(), f, sess.Token, st); err == nil {
|
|
for _, b := range bs {
|
|
taken[b.Name] = true
|
|
}
|
|
}
|
|
name := base
|
|
for i := 2; taken[name] && i < 100; i++ {
|
|
name = fmt.Sprintf("%s-%d", base, i)
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
|
switch {
|
|
case s.signedOut(w, r, err):
|
|
case errors.Is(err, errConflict):
|
|
apiError(w, http.StatusConflict, "Someone changed this branch since you opened it. Your text is still here: copy it, reload to see their change, then save again.")
|
|
default:
|
|
var pe *platformError
|
|
if errors.As(err, &pe) {
|
|
apiError(w, http.StatusBadGateway, pe.Error())
|
|
return
|
|
}
|
|
apiError(w, http.StatusBadGateway, trimErr(err))
|
|
}
|
|
}
|
|
|
|
type saveRequest struct {
|
|
Source string `json:"source"`
|
|
Branch string `json:"branch"`
|
|
BaseCommit string `json:"baseCommit"`
|
|
Edit Edit `json:"edit"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (s *Server) save(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
|
|
var req saveRequest
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Branch == "" {
|
|
req.Branch = st.Branch
|
|
}
|
|
_, siteDir, _, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), req.Branch)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
original, err := readSource(siteDir, req.Source)
|
|
if err != nil {
|
|
apiError(w, http.StatusNotFound, "No such page on this branch.")
|
|
return
|
|
}
|
|
content, err := compose(original, req.Edit)
|
|
if err != nil {
|
|
apiError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
msg := strings.TrimSpace(req.Message)
|
|
if msg == "" {
|
|
msg = "Edit " + req.Source
|
|
}
|
|
target, create := req.Branch, false
|
|
if req.Branch == st.Branch {
|
|
target, create = s.draftName(r, sess, f, st, req.Source), true
|
|
}
|
|
commit, err := s.ws.save(sess, f.Kind, st, s.cloneURL(st), req.Branch, target, create, req.BaseCommit, map[string][]byte{repoFile(st, req.Source): content}, msg)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
s.log.Printf("%s saved %s to %s@%s (%s)", sess.Login, req.Source, 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))})
|
|
}
|
|
|
|
func shortSHA(c string) string {
|
|
if len(c) > 10 {
|
|
return c[:10]
|
|
}
|
|
return c
|
|
}
|
|
|
|
type newRequest struct {
|
|
Collection string `json:"collection"`
|
|
Title string `json:"title"`
|
|
// Branch: a draft to add the page to; empty (or the publishing branch)
|
|
// starts a new draft.
|
|
Branch string `json:"branch"`
|
|
}
|
|
|
|
func (s *Server) newPage(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
|
|
var req newRequest
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
req.Title = strings.TrimSpace(req.Title)
|
|
name := slug(req.Title)
|
|
if req.Title == "" || name == "" {
|
|
apiError(w, http.StatusBadRequest, "Give the page a title with some letters or numbers in it.")
|
|
return
|
|
}
|
|
if req.Collection != "" && (slug(req.Collection) != req.Collection) {
|
|
apiError(w, http.StatusBadRequest, "No such collection.")
|
|
return
|
|
}
|
|
source := path.Join("content", req.Collection, name+".md")
|
|
from := st.Branch
|
|
if req.Branch != "" && req.Branch != st.Branch {
|
|
if !gitx.ValidBranch(req.Branch) {
|
|
apiError(w, http.StatusBadRequest, "That isn't a branch name the editor uses.")
|
|
return
|
|
}
|
|
from = req.Branch
|
|
}
|
|
_, siteDir, base, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), from)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
if _, err := readSource(siteDir, source); err == nil {
|
|
apiError(w, http.StatusConflict, "A page with that address already exists: "+source)
|
|
return
|
|
}
|
|
set := map[string]any{"title": req.Title}
|
|
if req.Collection != "" {
|
|
set["date"] = time.Now().Format("2006-01-02")
|
|
}
|
|
content, err := compose(nil, Edit{Set: rawSet(set)})
|
|
if err != nil {
|
|
apiError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
target, create := from, false
|
|
if from == st.Branch {
|
|
target, create = s.draftName(r, sess, f, st, source), true
|
|
}
|
|
commit, err := s.ws.save(sess, f.Kind, st, s.cloneURL(st), from, target, create, base, map[string][]byte{repoFile(st, source): content}, "New page: "+req.Title)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
s.log.Printf("%s started %s on %s@%s", sess.Login, source, st.ID, target)
|
|
writeJSON(w, http.StatusOK, map[string]any{"branch": target, "commit": commit, "source": source})
|
|
}
|
|
|
|
type liveRequest struct {
|
|
Source string `json:"source"`
|
|
Branch string `json:"branch"`
|
|
Edit *Edit `json:"edit"` // nil: show the page as saved
|
|
}
|
|
|
|
// liveRender renders the page as edited, onto the branch's last build.
|
|
func (s *Server) liveRender(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
|
|
var req liveRequest
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Branch == "" {
|
|
req.Branch = st.Branch
|
|
}
|
|
loaded, siteDir, commit, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), req.Branch)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
s.ws.buildAndCheck(siteDir, commit)
|
|
original, err := readSource(siteDir, req.Source)
|
|
if err != nil {
|
|
apiError(w, http.StatusNotFound, "No such page on this branch.")
|
|
return
|
|
}
|
|
content := original
|
|
if req.Edit != nil {
|
|
if content, err = compose(original, *req.Edit); err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
html, pagePath, err := isolate.RenderPage(siteDir, req.Source, content, build.Options{URL: loaded.Config.URL})
|
|
if err != nil {
|
|
// A page that doesn't render yet (a typo in front matter, mid-edit)
|
|
// is reported, not an error: the preview keeps its last good version.
|
|
writeJSON(w, http.StatusOK, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
owner := sess.Forge + "|" + sess.Login + "|" + st.ID + "|" + req.Branch
|
|
url := s.live.show(owner, siteDir+".editor-build", pagePath, html, nil)
|
|
// What search results and link previews will show, read from the page.
|
|
writeJSON(w, http.StatusOK, map[string]any{"url": url, "path": pagePath, "seo": check.PageMeta(html), "siteURL": loaded.Config.URL, "siteName": loaded.Config.Name})
|
|
}
|
|
|
|
type reviewRequest struct {
|
|
Branch string `json:"branch"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Reviewers []string `json:"reviewers"`
|
|
}
|
|
|
|
func (s *Server) review(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
|
|
var req reviewRequest
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Branch == "" || req.Branch == st.Branch {
|
|
apiError(w, http.StatusBadRequest, "Only a draft can go to review.")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Title) == "" {
|
|
apiError(w, http.StatusBadRequest, "Give the review a title.")
|
|
return
|
|
}
|
|
_, siteDir, commit, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), req.Branch)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
res := s.ws.buildAndCheck(siteDir, commit)
|
|
body := reviewBody(st, req.Branch, res, req.Description, sess.Login)
|
|
rv, err := s.openReview(r.Context(), f, sess.Token, st, req.Branch, strings.TrimSpace(req.Title), body)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
s.log.Printf("%s opened review #%d for %s@%s", sess.Login, rv.Number, st.ID, req.Branch)
|
|
out := map[string]any{"number": rv.Number, "title": rv.Title, "branch": rv.Branch, "url": rv.URL, "preview": st.Preview.url(preview.Slug(req.Branch))}
|
|
if len(req.Reviewers) > 0 {
|
|
if err := s.requestReviewers(r.Context(), f, sess.Token, st, rv.Number, req.Reviewers); err != nil {
|
|
out["reviewersError"] = "The review is open, but asking for reviewers didn't work: " + trimErr(err)
|
|
} else {
|
|
out["reviewers"] = req.Reviewers
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// reviewBody is what a review says: the person's description, the
|
|
// preview, and how the checks went.
|
|
func reviewBody(st *SiteConfig, branch string, res *BuildResult, desc, login string) string {
|
|
body := strings.TrimSpace(desc)
|
|
if u := st.Preview.url(preview.Slug(branch)); u != "" {
|
|
body += "\n\nPreview: " + u
|
|
}
|
|
switch {
|
|
case res.Failed != "":
|
|
body += "\n\nThis branch doesn't build yet: " + res.Failed
|
|
case res.Errors > 0:
|
|
body += fmt.Sprintf("\n\nChecks: %d error(s), %d warning(s). It can't be published until the errors are fixed.", res.Errors, res.Warnings)
|
|
default:
|
|
body += fmt.Sprintf("\n\nChecks: passing, %d warning(s).", res.Warnings)
|
|
}
|
|
body += "\n\nOpened from the HotDog CMS editor by @" + login + "."
|
|
return strings.TrimSpace(body)
|
|
}
|
|
|
|
type publishRequest struct {
|
|
Branch string `json:"branch"`
|
|
}
|
|
|
|
// publish merges a draft's review, after running the checks on the draft
|
|
// exactly as the publishing pipeline will.
|
|
func (s *Server) publish(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig, acc Access) {
|
|
var req publishRequest
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Branch == "" || req.Branch == st.Branch {
|
|
apiError(w, http.StatusBadRequest, "Only a draft can be published.")
|
|
return
|
|
}
|
|
reviews, err := s.reviews(r.Context(), f, sess.Token, st)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
number := 0
|
|
for _, rv := range reviews {
|
|
if rv.Branch == req.Branch {
|
|
number = rv.Number
|
|
}
|
|
}
|
|
if number == 0 {
|
|
apiError(w, http.StatusConflict, "Submit this draft for review first; publishing merges the review.")
|
|
return
|
|
}
|
|
_, siteDir, commit, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), req.Branch)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
// A draft can be pushed with plain git, so the rule the Files view keeps
|
|
// (only maintainers change what runs at publish time) is kept here too.
|
|
if !acc.Admin {
|
|
changed, err := s.draftChanges(sess, f, st, req.Branch)
|
|
if err != nil {
|
|
apiError(w, http.StatusBadGateway, "Couldn't work out what the draft changes: "+trimErr(err))
|
|
return
|
|
}
|
|
for _, p := range changed {
|
|
if pipelineFile(p) {
|
|
apiError(w, http.StatusForbidden, p+" decides what runs when the site is published, so only a maintainer of the repository can publish a draft that changes it.")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
res := s.ws.buildAndCheck(siteDir, commit)
|
|
if res.Failed != "" || res.Errors > 0 {
|
|
msg := fmt.Sprintf("Not published: %d check error(s). Fix them on the draft, or switch the rule off in site.yaml.", res.Errors)
|
|
if res.Failed != "" {
|
|
msg = "Not published: the draft doesn't build. " + res.Failed
|
|
}
|
|
writeJSON(w, http.StatusConflict, map[string]any{"error": msg, "problems": res.Problems})
|
|
return
|
|
}
|
|
if err := s.merge(r.Context(), f, sess.Token, st, number); err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
s.log.Printf("%s published %s@%s (review #%d, %s)", sess.Login, st.ID, req.Branch, number, shortSHA(commit))
|
|
writeJSON(w, http.StatusOK, map[string]any{"published": true, "number": number})
|
|
}
|
|
|
|
type revertRequest struct {
|
|
SHA string `json:"sha"`
|
|
}
|
|
|
|
// revertCommit undoes a published change on a new draft.
|
|
func (s *Server) revertCommit(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
|
|
var req revertRequest
|
|
if !decode(w, r, &req) {
|
|
return
|
|
}
|
|
if !regexp.MustCompile(`^[0-9a-f]{7,40}$`).MatchString(req.SHA) {
|
|
apiError(w, http.StatusBadRequest, "That isn't a commit.")
|
|
return
|
|
}
|
|
target := s.draftName(r, sess, f, st, "undo-"+req.SHA[:7]+".md")
|
|
commit, err := s.ws.revert(sess, f.Kind, st, s.cloneURL(st), req.SHA, target)
|
|
if errors.Is(err, errRevertConflict) {
|
|
apiError(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
s.log.Printf("%s undid %s on %s@%s (%s)", sess.Login, shortSHA(req.SHA), st.ID, target, shortSHA(commit))
|
|
writeJSON(w, http.StatusOK, map[string]any{"branch": target, "commit": commit, "created": true, "preview": st.Preview.url(preview.Slug(target))})
|
|
}
|
|
|
|
// apiPeople lists who can be asked to review.
|
|
func (s *Server) apiPeople(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) {
|
|
st, _, err := s.siteByID(r.Context(), sess, f, id)
|
|
if s.signedOut(w, r, err) {
|
|
return
|
|
}
|
|
if err != nil || st == nil {
|
|
apiError(w, http.StatusNotFound, "No such site, or you can't open it.")
|
|
return
|
|
}
|
|
people, err := s.reviewers(r.Context(), f, sess.Token, st)
|
|
if s.signedOut(w, r, err) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"people": []Person{}, "note": trimErr(err)})
|
|
return
|
|
}
|
|
others := []Person{}
|
|
for _, p := range people {
|
|
if p.Login != sess.Login {
|
|
others = append(others, p)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"people": others})
|
|
}
|