Files

233 lines
7.2 KiB
Go

package editor
import (
"bytes"
"crypto/rand"
"encoding/hex"
"net"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// The live preview shows a page as it is being edited, before it's saved,
// rendered by the same engine as the build. It runs on its own origin
// (<token>.<domain>), not the editor's: a site's own scripts run in the
// preview, and on a separate origin they can't reach the editor's session.
// Each person, site and branch gets an unguessable host name that expires
// after an idle while; only the editor may frame it.
// LiveConfig is where the live preview listens and how browsers reach it.
type LiveConfig struct {
Listen string `yaml:"listen"` // default 127.0.0.1:8191
Domain string `yaml:"domain"` // previews are <token>.<domain>; default localhost
Port string `yaml:"port"` // the port browsers use; default the listening port on localhost
Scheme string `yaml:"scheme"` // default http
}
type liveEntry struct {
owner string
dir string // the branch's full build, for everything but the page being edited
overlay map[string][]byte // page address -> HTML rendered from the unsaved edit
used time.Time
}
type liveServer struct {
cfg LiveConfig
editor string // the editor's origin, the only one allowed to frame previews
mu sync.Mutex
entries map[string]*liveEntry // by token
byOwner map[string]string
}
const liveIdle = 2 * time.Hour
func newLiveServer(cfg LiveConfig, editorOrigin string) *liveServer {
if cfg.Listen == "" {
cfg.Listen = "127.0.0.1:8191"
}
if cfg.Domain == "" {
cfg.Domain = "localhost"
}
if cfg.Scheme == "" {
cfg.Scheme = "http"
}
if cfg.Port == "" && cfg.Domain == "localhost" {
if _, p, err := net.SplitHostPort(cfg.Listen); err == nil {
cfg.Port = p
}
}
return &liveServer{cfg: cfg, editor: editorOrigin, entries: map[string]*liveEntry{}, byOwner: map[string]string{}}
}
func (l *liveServer) origin() string {
return l.cfg.Scheme + "://*." + l.cfg.Domain + portSuffix(l.cfg.Port)
}
// show points an owner's preview at a build and, if html is given, overlays
// one page with the unsaved version, plus any other files (such as a look
// stylesheet not yet saved). It returns the page's preview address.
func (l *liveServer) show(owner, dir, pagePath string, html []byte, extra map[string][]byte) string {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for tok, e := range l.entries {
if now.Sub(e.used) > liveIdle {
delete(l.entries, tok)
delete(l.byOwner, e.owner)
}
}
tok, ok := l.byOwner[owner]
if !ok {
b := make([]byte, 16)
_, _ = rand.Read(b)
tok = hex.EncodeToString(b)
l.byOwner[owner] = tok
l.entries[tok] = &liveEntry{owner: owner, overlay: map[string][]byte{}}
}
e := l.entries[tok]
if e.dir != dir {
e.dir, e.overlay = dir, map[string][]byte{}
}
if html != nil {
e.overlay[pagePath] = html
}
for p, data := range extra {
e.overlay[p] = data
}
e.used = now
return l.cfg.Scheme + "://" + tok + "." + l.cfg.Domain + portSuffix(l.cfg.Port) + pagePath
}
func (l *liveServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "frame-ancestors "+l.editor)
h.Set("X-Robots-Tag", "noindex, nofollow, noarchive")
h.Set("Cache-Control", "no-store")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
host := r.Host
if hh, _, err := net.SplitHostPort(host); err == nil {
host = hh
}
tok, ok := strings.CutSuffix(strings.ToLower(host), "."+l.cfg.Domain)
l.mu.Lock()
e := l.entries[tok]
if e != nil {
e.used = time.Now()
}
var html []byte
if e != nil {
html = e.overlay[r.URL.Path]
}
dir := ""
if e != nil {
dir = e.dir
}
l.mu.Unlock()
if !ok || e == nil {
http.Error(w, "This preview has expired. Reopen the page in the editor.", http.StatusNotFound)
return
}
if r.URL.Path == jumpScriptPath {
h.Set("Content-Type", "text/javascript; charset=utf-8")
_, _ = w.Write([]byte(strings.Replace(jumpScript, "EDITOR_ORIGIN", strconv.Quote(l.editor), 1)))
return
}
if html != nil {
switch path.Ext(r.URL.Path) {
case ".css":
h.Set("Content-Type", "text/css; charset=utf-8")
_, _ = w.Write(html)
default:
h.Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(withJump(html))
}
return
}
clean := path.Clean("/" + r.URL.Path)
p := filepath.Join(dir, filepath.FromSlash(clean))
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
if !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
p = filepath.Join(p, "index.html")
}
if fi, err := os.Stat(p); err != nil || fi.IsDir() || strings.HasPrefix(filepath.Base(p), ".hotdog-cms") {
if data, err := os.ReadFile(filepath.Join(dir, "404.html")); err == nil {
h.Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
return
}
if strings.HasSuffix(p, ".html") {
if data, err := os.ReadFile(p); err == nil {
h.Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(withJump(data))
return
}
}
http.ServeFile(w, r, p)
}
// Point and edit: preview pages (only these, never a built site) get a small
// script. When the editor turns pointing on, a click on text in the preview
// is sent to the editor, which puts the cursor there. It listens only to the
// editor's window and posts only to the editor's origin; it carries the text
// that was clicked, nothing else.
const jumpScriptPath = "/__hotdog/jump.js"
func withJump(html []byte) []byte {
tag := []byte(`<script src="` + jumpScriptPath + `" defer></script>`)
if i := bytes.LastIndex(html, []byte("</body>")); i >= 0 {
out := make([]byte, 0, len(html)+len(tag))
out = append(out, html[:i]...)
out = append(out, tag...)
return append(out, html[i:]...)
}
return append(append([]byte{}, html...), tag...)
}
const jumpScript = `(() => {
const editor = EDITOR_ORIGIN
if (window.parent === window) return
const pick = 'h1,h2,h3,h4,h5,h6,p,li,blockquote,figcaption,td,th,dt,dd'
let on = false
let lit = null
const unlight = () => { if (lit) { lit.style.outline = ''; lit.style.cursor = ''; lit = null } }
window.addEventListener('message', (e) => {
if (e.source !== window.parent || e.origin !== editor || !e.data || e.data.type !== 'hotdog:point') return
on = !!e.data.on
if (!on) unlight()
})
// Styles are set from script, not a <style> element, so a site's own CSP
// doesn't get in the way.
document.addEventListener('mouseover', (e) => {
if (!on) return
const el = e.target.closest && e.target.closest(pick)
if (el === lit) return
unlight()
if (el) { lit = el; el.style.outline = '2px dashed #f4be2c'; el.style.cursor = 'text' }
})
document.addEventListener('click', (e) => {
if (!on) return
const el = e.target.closest && e.target.closest(pick)
if (!el) return
e.preventDefault()
e.stopPropagation()
const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 200)
if (text) window.parent.postMessage({ type: 'hotdog:jump', tag: el.tagName.toLowerCase(), text }, editor)
}, true)
window.parent.postMessage({ type: 'hotdog:ready' }, editor)
})()
`