Files

225 lines
7.6 KiB
Go

package editor
import (
"fmt"
"net/http"
"path"
"path/filepath"
"strings"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/preview"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// Redirects, and moving a page. When a page moves, its old address keeps
// working: a redirect is added in the same commit, and redirects that
// pointed at the old address are pointed at the new one, so there are no
// chains. Redirects live in site.yaml, and only that block is rewritten.
func (s *Server) apiRedirects(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id 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
}
branch := r.URL.Query().Get("branch")
if branch == "" {
branch = st.Branch
}
loaded, _, commit, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), branch)
if loaded == nil {
apiError(w, http.StatusBadGateway, "Couldn't load the site: "+trimErr(err))
return
}
list := loaded.Config.Redirects
if list == nil {
list = []site.Redirect{}
}
writeJSON(w, http.StatusOK, map[string]any{"redirects": list, "commit": commit, "branch": branch, "canWrite": acc.Write})
}
// checkRedirects says what's wrong with a list of redirects, or nothing.
func checkRedirects(list []site.Redirect, pages map[string]bool) error {
seen := map[string]bool{}
for i, rd := range list {
n := i + 1
switch {
case !strings.HasPrefix(rd.From, "/") || strings.Contains(rd.From, ".."):
return fmt.Errorf("redirect %d: %q should be an address on the site, starting with /", n, rd.From)
case seen[rd.From]:
return fmt.Errorf("redirect %d: %s is redirected twice", n, rd.From)
case !strings.HasPrefix(rd.To, "/") && !strings.HasPrefix(rd.To, "https://") && !strings.HasPrefix(rd.To, "http://"):
return fmt.Errorf("redirect %d: %q should start with / or https://", n, rd.To)
case rd.From == rd.To:
return fmt.Errorf("redirect %d: %s redirects to itself", n, rd.From)
case pages[rd.From] || pages[strings.TrimSuffix(rd.From, "/")+"/"]:
return fmt.Errorf("redirect %d: %s is a page; a redirect there would hide it", n, rd.From)
}
seen[rd.From] = true
}
return nil
}
func redirectsValue(list []site.Redirect) []map[string]string {
out := []map[string]string{}
for _, rd := range list {
out = append(out, map[string]string{"from": rd.From, "to": rd.To})
}
return out
}
type redirectsRequest struct {
Branch string `json:"branch"`
BaseCommit string `json:"baseCommit"`
Redirects []site.Redirect `json:"redirects"`
}
func (s *Server) redirectsSave(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
var req redirectsRequest
if !decode(w, r, &req) {
return
}
if req.Branch == "" {
req.Branch = st.Branch
}
loaded, siteDir, _, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), req.Branch)
if loaded == nil {
s.writeError(w, r, err)
return
}
for i := range req.Redirects {
req.Redirects[i].From = strings.TrimSpace(req.Redirects[i].From)
req.Redirects[i].To = strings.TrimSpace(req.Redirects[i].To)
}
if err := checkRedirects(req.Redirects, pagePaths(loaded)); err != nil {
apiError(w, http.StatusUnprocessableEntity, err.Error())
return
}
cfg, err := readIn(siteDir, "site.yaml")
if err != nil {
apiError(w, http.StatusBadGateway, "Couldn't read site.yaml.")
return
}
out, err := setTopLevel(string(cfg), "redirects", redirectsValue(req.Redirects))
if err != nil {
apiError(w, http.StatusUnprocessableEntity, err.Error())
return
}
target, create := req.Branch, false
if req.Branch == st.Branch {
target, create = s.draftName(r, sess, f, st, "redirects.md"), true
}
commit, err := s.ws.save(sess, f.Kind, st, s.cloneURL(st), req.Branch, target, create, req.BaseCommit, map[string][]byte{repoFile(st, "site.yaml"): []byte(out)}, "Update redirects")
if err != nil {
s.writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"branch": target, "commit": commit, "created": create})
}
func pagePaths(s *site.Site) map[string]bool {
out := map[string]bool{}
for _, p := range s.Pages {
if p.RedirectTo == "" {
out[p.Path] = true
}
}
return out
}
type moveRequest struct {
Branch string `json:"branch"`
BaseCommit string `json:"baseCommit"`
Source string `json:"source"`
To string `json:"to"` // the new source path, content/…md
}
// movePage renames a page's file, and redirects its old address to its new
// one, in one commit.
func (s *Server) movePage(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
var req moveRequest
if !decode(w, r, &req) {
return
}
if req.Branch == "" {
req.Branch = st.Branch
}
loaded, siteDir, _, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), req.Branch)
if loaded == nil {
s.writeError(w, r, err)
return
}
content, err := readSource(siteDir, req.Source)
if err != nil {
apiError(w, http.StatusNotFound, "No such page on this branch.")
return
}
to := path.Clean(strings.TrimSpace(req.To))
if _, err := readSource(siteDir, to); err == nil {
apiError(w, http.StatusConflict, "There's already a page at "+to+".")
return
}
if c := filepath.ToSlash(filepath.Clean("/" + to))[1:]; c != to || !strings.HasPrefix(to, "content/") || !strings.HasSuffix(to, ".md") || to == req.Source {
apiError(w, http.StatusBadRequest, "The new place should be a different file under content/, ending in .md.")
return
}
oldPath, newPath := "", ""
for _, p := range loaded.Pages {
if "content/"+p.Source == req.Source {
oldPath = p.Path
}
}
// Where the page will be, worked out by the build's own rules.
moved, err := site.Load(siteDir, site.Options{Drafts: true, Skip: map[string]bool{strings.TrimPrefix(req.Source, "content/"): true}, Overrides: map[string][]byte{strings.TrimPrefix(to, "content/"): content}})
if err != nil {
apiError(w, http.StatusUnprocessableEntity, "The page wouldn't load there: "+trimErr(err))
return
}
for _, p := range moved.Pages {
if "content/"+p.Source == to {
newPath = p.Path
}
}
files := map[string][]byte{repoFile(st, req.Source): nil, repoFile(st, to): content}
redirected := false
if oldPath != "" && newPath != "" && oldPath != newPath {
list := []site.Redirect{}
for _, rd := range loaded.Config.Redirects {
if rd.From == newPath {
continue // the new address is a page now
}
if rd.To == oldPath {
rd.To = newPath // no chains
}
list = append(list, rd)
}
list = append(list, site.Redirect{From: oldPath, To: newPath})
cfg, err := readIn(siteDir, "site.yaml")
if err != nil {
apiError(w, http.StatusBadGateway, "Couldn't read site.yaml.")
return
}
out, err := setTopLevel(string(cfg), "redirects", redirectsValue(list))
if err != nil {
apiError(w, http.StatusUnprocessableEntity, err.Error())
return
}
files[repoFile(st, "site.yaml")] = []byte(out)
redirected = true
}
target, create := req.Branch, false
if req.Branch == st.Branch {
target, create = s.draftName(r, sess, f, st, to), true
}
commit, err := s.ws.save(sess, f.Kind, st, s.cloneURL(st), req.Branch, target, create, req.BaseCommit, files, fmt.Sprintf("Move %s to %s", req.Source, to))
if err != nil {
s.writeError(w, r, err)
return
}
s.log.Printf("%s moved %s to %s on %s@%s (%s)", sess.Login, req.Source, to, st.ID, target, shortSHA(commit))
writeJSON(w, http.StatusOK, map[string]any{"branch": target, "commit": commit, "created": create, "source": to, "from": oldPath, "to": newPath, "redirected": redirected, "preview": st.Preview.url(preview.Slug(target))})
}