572 lines
18 KiB
Go
572 lines
18 KiB
Go
package editor
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/about"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/preview"
|
|
)
|
|
|
|
//go:embed all:ui/dist
|
|
var uiFiles embed.FS
|
|
|
|
// Server is the editor.
|
|
type Server struct {
|
|
cfg *Config
|
|
seal *sealer
|
|
ws *workspaces
|
|
http *http.Client
|
|
log *log.Logger
|
|
ui fs.FS
|
|
csp string
|
|
// cloneURL is where a site is cloned from; the site's repo, except in tests.
|
|
cloneURL func(*SiteConfig) string
|
|
live *liveServer
|
|
|
|
accessMu sync.Mutex
|
|
accessSeen map[string]accessHit
|
|
found discoverCache
|
|
|
|
renewMu sync.Mutex
|
|
renewed map[string]renewal // recent token renewals, by the refresh token used
|
|
}
|
|
|
|
// New builds the editor from editor.yaml. HOTDOG_EDITOR_SECRET seals sessions.
|
|
func New(configFile string, logw io.Writer) (*Server, error) {
|
|
cfg, err := LoadConfig(configFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seal, err := newSealer(os.Getenv("HOTDOG_EDITOR_SECRET"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, f := range cfg.Forges {
|
|
if os.Getenv(f.ClientSecretEnv) == "" {
|
|
return nil, fmt.Errorf("%s isn't set: the client secret for %s", f.ClientSecretEnv, f.Host)
|
|
}
|
|
}
|
|
for _, st := range cfg.Sites {
|
|
if env := st.Submissions.TokenEnv; env != "" && os.Getenv(env) == "" {
|
|
return nil, fmt.Errorf("%s isn't set: the token for %s's submissions viewer", env, st.Name)
|
|
}
|
|
}
|
|
ui, _ := fs.Sub(uiFiles, "ui/dist")
|
|
seal.secure = strings.HasPrefix(cfg.PublicURL, "https://")
|
|
s := &Server{cfg: cfg, seal: seal, ws: newWorkspaces(cfg.Cache), http: &http.Client{Timeout: 20 * time.Second}, log: log.New(logw, "", log.LstdFlags), ui: ui,
|
|
cloneURL: func(sc *SiteConfig) string { return sc.Repo }}
|
|
// The editor's own pages get the strictest policy that still lets them
|
|
// show previews: nothing inline, nothing from anywhere else, and frames
|
|
// only from the configured preview servers.
|
|
frames := []string{}
|
|
for _, st := range cfg.Sites {
|
|
if st.Preview.Domain != "" {
|
|
u, _ := url.Parse(st.Preview.url("x"))
|
|
frames = append(frames, u.Scheme+"://*."+st.Preview.Domain+portSuffix(st.Preview.Port))
|
|
}
|
|
}
|
|
s.live = newLiveServer(cfg.Live, cfg.PublicURL)
|
|
frames = append(frames, s.live.origin())
|
|
frameSrc := strings.Join(frames, " ")
|
|
s.csp = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-src " + frameSrc + "; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
|
|
return s, nil
|
|
}
|
|
|
|
func portSuffix(p string) string {
|
|
if p == "" {
|
|
return ""
|
|
}
|
|
return ":" + p
|
|
}
|
|
|
|
// Listen is the configured address.
|
|
func (s *Server) Listen() string { return s.cfg.Listen }
|
|
|
|
// Live is the live preview server and where it listens. It must be served
|
|
// on its own address: its origin keeps sites' scripts away from the editor.
|
|
func (s *Server) Live() (http.Handler, string) { return s.live, s.live.cfg.Listen }
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("Content-Security-Policy", s.csp)
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Referrer-Policy", "same-origin")
|
|
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
switch {
|
|
case r.URL.Path == "/auth/login" && r.Method == http.MethodGet:
|
|
s.login(w, r)
|
|
case r.URL.Path == "/auth/callback" && r.Method == http.MethodGet:
|
|
s.callback(w, r)
|
|
case r.URL.Path == "/auth/logout" && r.Method == http.MethodPost:
|
|
if !s.sameOrigin(r) {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
// One account (?account=platform/login), or all of them.
|
|
which := r.URL.Query().Get("account")
|
|
for _, a := range s.seal.accounts(r) {
|
|
if which == "" || a.ID() == which {
|
|
s.seal.clearCookie(w, accountCookie(a.slot))
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case strings.HasPrefix(r.URL.Path, "/api/"):
|
|
h.Set("Cache-Control", "no-store")
|
|
s.apiRoute(w, r)
|
|
default:
|
|
s.serveUI(w, r)
|
|
}
|
|
}
|
|
|
|
// sameOrigin guards anything that changes state: the request must come from
|
|
// the editor's own pages, and carry the header only its own script sends.
|
|
func (s *Server) sameOrigin(r *http.Request) bool {
|
|
return r.Header.Get("Origin") == s.cfg.PublicURL && r.Header.Get("X-HotDog") == "1"
|
|
}
|
|
|
|
func (s *Server) serveUI(w http.ResponseWriter, r *http.Request) {
|
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
if p != "" {
|
|
if f, err := s.ui.Open(p); err == nil {
|
|
st, _ := f.Stat()
|
|
f.Close()
|
|
if st != nil && !st.IsDir() {
|
|
if strings.HasPrefix(p, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
http.ServeFileFS(w, r, s.ui, p)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
index, err := fs.ReadFile(s.ui, "index.html")
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
fmt.Fprintln(w, "The editor's interface isn't built into this binary. Run `make ui` (or npm run build in editor-ui/) and build again.")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
_, _ = w.Write(index)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func apiError(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
}
|
|
|
|
func (s *Server) apiRoute(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/")
|
|
if path == "session" {
|
|
s.apiSession(w, r)
|
|
return
|
|
}
|
|
if path == "about" { // before sign-in too: the source link is for everyone
|
|
writeJSON(w, http.StatusOK, about.Get(s.cfg.Source, s.cfg.Links))
|
|
return
|
|
}
|
|
accts := s.freshen(w, r, s.seal.accounts(r))
|
|
if len(accts) == 0 {
|
|
apiError(w, http.StatusUnauthorized, "Sign in to continue.")
|
|
return
|
|
}
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 1 && parts[0] == "sites" && r.Method == http.MethodGet {
|
|
s.apiSites(w, r, accts)
|
|
return
|
|
}
|
|
// Everything else is about one site, worked on as one account.
|
|
if len(parts) < 2 || parts[0] != "sites" {
|
|
apiError(w, http.StatusNotFound, "no such thing")
|
|
return
|
|
}
|
|
st := s.findSite(r.Context(), accts, parts[1])
|
|
sess := s.accountFor(r, accts, st)
|
|
if sess == nil {
|
|
apiError(w, http.StatusNotFound, "No such site, or you can't open it.")
|
|
return
|
|
}
|
|
f := s.cfg.forge(sess.Forge)
|
|
if f == nil {
|
|
apiError(w, http.StatusNotFound, "No such site, or you can't open it.")
|
|
return
|
|
}
|
|
r = r.WithContext(context.WithValue(r.Context(), reqKey{}, &reqInfo{sess: sess, accts: accts, site: st}))
|
|
if r.Method == http.MethodPost {
|
|
if !s.sameOrigin(r) {
|
|
apiError(w, http.StatusForbidden, "That request didn't come from the editor.")
|
|
return
|
|
}
|
|
if len(parts) == 3 && parts[0] == "sites" {
|
|
s.apiWrite(w, r, sess, f, parts[1], parts[2])
|
|
return
|
|
}
|
|
apiError(w, http.StatusNotFound, "no such thing")
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
apiError(w, http.StatusMethodNotAllowed, "not here")
|
|
return
|
|
}
|
|
switch {
|
|
case len(parts) == 2 && parts[0] == "sites":
|
|
s.apiSite(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "page":
|
|
s.apiPage(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "quick":
|
|
s.apiQuick(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "people":
|
|
s.apiPeople(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "forms":
|
|
s.apiForms(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "submissions":
|
|
s.apiSubmissions(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "search":
|
|
s.apiSearch(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "privacy":
|
|
s.apiPrivacy(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "redirects":
|
|
s.apiRedirects(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "files":
|
|
s.apiFiles(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "file":
|
|
s.apiFile(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "compare":
|
|
s.apiCompare(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "log":
|
|
s.apiLog(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "look":
|
|
s.apiLook(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "sections":
|
|
s.apiSections(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "media":
|
|
s.apiMedia(w, r, sess, f, parts[1])
|
|
case len(parts) == 3 && parts[0] == "sites" && parts[2] == "media-file":
|
|
s.apiMediaFile(w, r, sess, f, parts[1])
|
|
default:
|
|
apiError(w, http.StatusNotFound, "no such thing")
|
|
}
|
|
}
|
|
|
|
func (s *Server) apiSession(w http.ResponseWriter, r *http.Request) {
|
|
type forgeInfo struct {
|
|
Host string `json:"host"`
|
|
Kind forge.Kind `json:"kind"`
|
|
}
|
|
var forges []forgeInfo
|
|
for _, f := range s.cfg.Forges {
|
|
forges = append(forges, forgeInfo{f.Host, f.Kind})
|
|
}
|
|
accounts := []map[string]string{}
|
|
for _, a := range s.seal.accounts(r) {
|
|
accounts = append(accounts, map[string]string{"id": a.ID(), "login": a.Login, "name": a.Name, "forge": a.Forge})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"signedIn": len(accounts) > 0, "accounts": accounts, "forges": forges})
|
|
}
|
|
|
|
// reqInfo is the account a request works as, and every account signed in.
|
|
type reqKey struct{}
|
|
|
|
type reqInfo struct {
|
|
sess *Session
|
|
accts []*Session
|
|
site *SiteConfig // the site the request is about
|
|
}
|
|
|
|
func infoFrom(r *http.Request) *reqInfo {
|
|
i, _ := r.Context().Value(reqKey{}).(*reqInfo)
|
|
return i
|
|
}
|
|
|
|
// candidates are the signed-in accounts on a site's platform.
|
|
func candidates(accts []*Session, st *SiteConfig) []*Session {
|
|
u, err := url.Parse(st.Repo)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []*Session
|
|
for _, a := range accts {
|
|
if strings.EqualFold(a.Forge, u.Hostname()) {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// accountFor picks the account a request about a site works as: the one
|
|
// named by ?as=platform/login if it's signed in, otherwise the first that
|
|
// can write to the site, otherwise the first that can read it.
|
|
func (s *Server) accountFor(r *http.Request, accts []*Session, st *SiteConfig) *Session {
|
|
if st == nil {
|
|
return nil
|
|
}
|
|
cands := candidates(accts, st)
|
|
if len(cands) == 0 {
|
|
return nil
|
|
}
|
|
if want := r.URL.Query().Get("as"); want != "" {
|
|
for _, c := range cands {
|
|
if c.ID() == want {
|
|
return c
|
|
}
|
|
}
|
|
}
|
|
if len(cands) == 1 {
|
|
return cands[0]
|
|
}
|
|
var reader, gone *Session
|
|
for _, c := range cands {
|
|
f := s.cfg.forge(c.Forge)
|
|
if f == nil {
|
|
continue
|
|
}
|
|
acc, err := s.accessCached(r.Context(), f, c.Token, st)
|
|
if errors.Is(err, errSignedOut) && gone == nil {
|
|
gone = c
|
|
}
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if acc.Write {
|
|
return c
|
|
}
|
|
if acc.Read && reader == nil {
|
|
reader = c
|
|
}
|
|
}
|
|
if reader != nil {
|
|
return reader
|
|
}
|
|
if gone != nil {
|
|
return gone // so the person hears that this sign-in ended
|
|
}
|
|
return cands[0]
|
|
}
|
|
|
|
// siteAccount is an account that can open a site, as the interface lists it.
|
|
type siteAccount struct {
|
|
ID string `json:"id"`
|
|
Login string `json:"login"`
|
|
Forge string `json:"forge"`
|
|
CanWrite bool `json:"canWrite"`
|
|
}
|
|
|
|
// accountsFor lists the signed-in accounts that can open a site. An account
|
|
// whose token the platform no longer takes is signed out on the way.
|
|
func (s *Server) accountsFor(w http.ResponseWriter, r *http.Request, accts []*Session, st *SiteConfig) []siteAccount {
|
|
out := []siteAccount{}
|
|
for _, c := range candidates(accts, st) {
|
|
f := s.cfg.forge(c.Forge)
|
|
if f == nil {
|
|
continue
|
|
}
|
|
acc, err := s.accessCached(r.Context(), f, c.Token, st)
|
|
if errors.Is(err, errSignedOut) {
|
|
s.seal.clearCookie(w, accountCookie(c.slot))
|
|
continue
|
|
}
|
|
if err == nil && acc.Read {
|
|
out = append(out, siteAccount{ID: c.ID(), Login: c.Login, Forge: c.Forge, CanWrite: acc.Write})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// apiSites lists the configured sites any signed-in account can open, and
|
|
// which accounts can.
|
|
func (s *Server) apiSites(w http.ResponseWriter, r *http.Request, accts []*Session) {
|
|
out := []map[string]any{}
|
|
for _, st := range s.allSites(r.Context(), accts) {
|
|
who := s.accountsFor(w, r, accts, st)
|
|
if len(who) == 0 {
|
|
continue
|
|
}
|
|
canWrite := false
|
|
for _, a := range who {
|
|
canWrite = canWrite || a.CanWrite
|
|
}
|
|
out = append(out, map[string]any{"id": st.ID, "name": st.Name, "repo": st.Repo, "branch": st.Branch, "canWrite": canWrite, "accounts": who, "discovered": st.discovered})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// signedOut signs out the account in use when the platform has stopped
|
|
// accepting its token. Other accounts stay signed in.
|
|
func (s *Server) signedOut(w http.ResponseWriter, r *http.Request, err error) bool {
|
|
if errors.Is(err, errSignedOut) {
|
|
who := "Your sign-in"
|
|
if i := infoFrom(r); i != nil {
|
|
s.seal.clearCookie(w, accountCookie(i.sess.slot))
|
|
who = "The sign-in for @" + i.sess.Login + " on " + i.sess.Forge
|
|
}
|
|
apiError(w, http.StatusUnauthorized, who+" has ended. Sign in again.")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) siteByID(ctx context.Context, sess *Session, f *ForgeConfig, id string) (*SiteConfig, Access, error) {
|
|
st := s.cfg.site(id)
|
|
if i, _ := ctx.Value(reqKey{}).(*reqInfo); i != nil && i.site != nil && i.site.ID == id {
|
|
st = i.site // a site found in an account's repositories
|
|
}
|
|
if st == nil {
|
|
return nil, Access{}, nil
|
|
}
|
|
u, _ := url.Parse(st.Repo)
|
|
if !strings.EqualFold(u.Hostname(), f.Host) {
|
|
return nil, Access{}, nil
|
|
}
|
|
acc, err := s.accessCached(ctx, f, sess.Token, st)
|
|
if err != nil || !acc.Read {
|
|
return nil, Access{}, err
|
|
}
|
|
return st, acc, nil
|
|
}
|
|
|
|
func (s *Server) apiSite(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, siteDir, commit, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), branch)
|
|
if err != nil {
|
|
s.log.Printf("%s opening %s@%s: %v", sess.Login, st.ID, branch, err)
|
|
apiError(w, http.StatusBadGateway, "Couldn't fetch the site: "+trimErr(err))
|
|
return
|
|
}
|
|
pages, cols := pagesOf(loaded)
|
|
result := s.ws.buildAndCheck(siteDir, commit)
|
|
|
|
type draft struct {
|
|
Branch string `json:"branch"`
|
|
Commit string `json:"commit"`
|
|
Preview string `json:"preview,omitempty"`
|
|
}
|
|
var drafts []draft
|
|
var reviews []map[string]any
|
|
notYet := ""
|
|
if bs, err := s.branches(r.Context(), f, sess.Token, st); err == nil {
|
|
for _, b := range bs {
|
|
if b.Name != st.Branch {
|
|
drafts = append(drafts, draft{Branch: b.Name, Commit: b.Commit, Preview: st.Preview.url(preview.Slug(b.Name))})
|
|
}
|
|
}
|
|
} else if errors.Is(err, errNotYet) {
|
|
notYet = err.Error()
|
|
}
|
|
if rs, err := s.reviews(r.Context(), f, sess.Token, st); err == nil {
|
|
for _, rv := range rs {
|
|
reviews = append(reviews, map[string]any{"number": rv.Number, "title": rv.Title, "branch": rv.Branch, "author": rv.Author, "url": rv.URL, "preview": st.Preview.url(preview.Slug(rv.Branch))})
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"id": st.ID, "name": st.Name, "repo": st.Repo, "branch": branch, "publishBranch": st.Branch,
|
|
"commit": commit, "canWrite": acc.Write, "siteName": loaded.Config.Name, "siteURL": loaded.Config.URL,
|
|
"collections": cols, "pages": pages, "drafts": drafts, "reviews": reviews, "build": result,
|
|
"preview": st.Preview.url(preview.Slug(branch)), "notYet": notYet,
|
|
"as": sess.ID(), "accounts": s.accountsFor(w, r, infoFrom(r).accts, st), "role": role(acc),
|
|
})
|
|
}
|
|
|
|
func (s *Server) apiPage(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
|
|
}
|
|
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 err != nil {
|
|
apiError(w, http.StatusBadGateway, "Couldn't fetch the site: "+trimErr(err))
|
|
return
|
|
}
|
|
raw, err := readSource(siteDir, r.URL.Query().Get("source"))
|
|
if err != nil {
|
|
apiError(w, http.StatusNotFound, "No such page.")
|
|
return
|
|
}
|
|
fmRaw, body := splitSource(raw)
|
|
var fm any = map[string]any{}
|
|
var doc yaml.Node
|
|
if yaml.Unmarshal([]byte(fmRaw), &doc) == nil && len(doc.Content) == 1 && doc.Content[0].Kind == yaml.MappingNode {
|
|
if j, err := orderedJSON(&doc); err == nil {
|
|
fm = j // keys in the file's order
|
|
}
|
|
} else {
|
|
fm, _ = splitFrontMatter(raw)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"source": r.URL.Query().Get("source"), "frontMatter": fm, "frontMatterRaw": fmRaw, "body": body, "branch": branch, "commit": commit})
|
|
}
|
|
|
|
// splitFrontMatter separates the YAML block from the body, for display.
|
|
func splitFrontMatter(raw []byte) (map[string]any, string) {
|
|
s := strings.TrimPrefix(string(raw), "\ufeff")
|
|
fm := map[string]any{}
|
|
if strings.HasPrefix(s, "---\n") || strings.HasPrefix(s, "---\r\n") {
|
|
rest := s[strings.Index(s, "\n")+1:]
|
|
if i := strings.Index(rest, "\n---"); i >= 0 {
|
|
_ = yaml.Unmarshal([]byte(rest[:i]), &fm)
|
|
body := rest[i+4:]
|
|
body = strings.TrimPrefix(strings.TrimPrefix(body, "\r"), "\n")
|
|
return fm, body
|
|
}
|
|
}
|
|
return fm, s
|
|
}
|
|
|
|
func trimErr(err error) string {
|
|
msg := err.Error()
|
|
if len(msg) > 200 {
|
|
msg = msg[:200] + "…"
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// role names what the platform lets someone do with a site.
|
|
func role(a Access) string {
|
|
switch {
|
|
case a.Admin:
|
|
return "maintainer"
|
|
case a.Write:
|
|
return "writer"
|
|
}
|
|
return "reader"
|
|
}
|