Files

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