Files

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