package main
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
// liveJS is the one script a page may run. It holds an EventSource while
// the tab is visible and reloads the page on its one event, or when the
// stream stays lost for three seconds, since the server is gone; a doc
// instead takes its new version in place and marks the blocks that
// changed. A hidden tab lets go, since a browser opens no more than six
// connections to a host, and asks again when it shows. It also walks a
// listing by arrow and loads a commit's diff into a history when its fold
// opens.
//
//go:embed assets/live.js
var liveJS string
// scriptSrc allows liveJS and no other script.
func scriptSrc(js string) string {
sum := sha256.Sum256([]byte(js))
return "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"
}
// settle lets an editor finish a save, which takes several steps.
const settle = 100 * time.Millisecond
// goneWait is how long a page waits for its tree or file to come back
// before it reloads to say it is gone. A tree leaves for a moment on
// --restart, and a file while an editor renames a new one over it. Tests
// shorten it.
var goneWait = 2 * time.Second
// live tells open pages when what they show has changed. A page names its
// version: the server instance that rendered it and what the page showed.
// It keeps a stream open on /_www/live, which wakes on any change in
// the directories the page reads from and sends one event, reload, when
// the version is no longer current.
type live struct {
id string // this server instance; a page from an earlier one reloads
key []byte // keys versions, which pages show to visitors
w *watcher
mu sync.Mutex
subs map[chan struct{}]string // each open page's stream, to the page's path
wds map[int]int // open pages per watch descriptor
done chan struct{}
once sync.Once
}
func newLive() *live {
l := &live{id: rand.Text()[:12], key: []byte(rand.Text()), w: newWatcher(pageMask), subs: map[chan struct{}]string{},
wds: map[int]int{}, done: make(chan struct{})}
go l.loop()
return l
}
// subscribe watches dirs for the page at path and returns the channel that
// wakes it. release undoes both.
func (l *live) subscribe(path string, dirs []string) (wake chan struct{}, release func()) {
wake = make(chan struct{}, 1)
var wds []int
l.mu.Lock()
l.subs[wake] = path
for _, dir := range dirs {
// A directory reached twice, through a link, has one descriptor.
if wd := l.w.add(dir); wd >= 0 && !slices.Contains(wds, wd) {
wds = append(wds, wd)
l.wds[wd]++
}
}
l.mu.Unlock()
return wake, func() {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.subs, wake)
for _, wd := range wds {
if l.wds[wd]--; l.wds[wd] == 0 {
delete(l.wds, wd)
l.w.remove(wd)
}
}
}
}
// loop wakes every page once a change has settled, and every second when
// inotify is unavailable. Each page checks its own version.
func (l *live) loop() {
tick := time.NewTicker(time.Second)
defer tick.Stop()
for {
select {
case <-l.done:
return
case <-l.w.wake:
select {
case <-time.After(settle):
case <-l.done:
return
}
select {
case <-l.w.wake:
default:
}
case <-tick.C:
if !l.w.poll.Load() {
continue
}
}
l.mu.Lock()
for wake := range l.subs {
select {
case wake <- struct{}{}:
default:
}
}
l.mu.Unlock()
}
}
// pages returns the path of each page that holds a stream, one entry per
// page, so two tabs on one path count twice.
func (l *live) pages() []string {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
paths := make([]string, 0, len(l.subs))
for _, p := range l.subs {
paths = append(paths, p)
}
return paths
}
// kick wakes every page, as when a tree joins or leaves.
func (l *live) kick() {
if l != nil {
l.w.signal()
}
}
// close ends every stream without a reload: the server is going, and a
// page reloaded now would find nothing. The browser asks again, and the
// next server's pages differ in instance.
func (l *live) close() {
if l != nil {
l.once.Do(func() {
close(l.done)
l.w.close()
})
}
}
type liveKey struct{}
// withLive gives the request the URL of its page's stream. The version is
// taken before the page is read, so a change while the page renders still
// reloads the page.
func (s *server) withLive(r *http.Request) *http.Request {
if s.live == nil {
return r
}
q := url.Values{"path": {r.URL.Path}, "v": {s.live.id + "." + s.version(r.URL.Path)}}
return r.WithContext(context.WithValue(r.Context(), liveKey{}, ownPath+"live?"+q.Encode()))
}
func liveURL(r *http.Request) string {
u, _ := r.Context().Value(liveKey{}).(string)
return u
}
// handleLive holds a page's stream open until the page is out of date,
// then sends reload. A page whose tree or file has gone waits goneWait
// for it to come back.
func (s *server) handleLive(w http.ResponseWriter, r *http.Request) {
l := s.live
fl, ok := w.(http.Flusher)
if l == nil || !ok {
http.NotFound(w, r)
return
}
p, seen := r.URL.Query().Get("path"), r.URL.Query().Get("v")
wake, release := l.subscribe(p, s.watchDirs(p))
defer release()
w.Header().Set("Content-Type", "text/event-stream")
// A dropped stream comes back after a second, as on --restart. One lost
// for three seconds reloads its page, so a page goes down with its
// server.
fmt.Fprint(w, "retry: 1000\n\n")
fl.Flush()
var grace <-chan time.Time
expired := false
for {
now := l.id + "." + s.version(p)
switch {
case now == seen:
grace, expired = nil, false
case absent(now) && !absent(seen) && !expired:
if grace == nil {
grace = time.After(goneWait)
}
default:
select {
case <-l.done:
default:
fmt.Fprint(w, "data: reload\n\n")
fl.Flush()
}
return
}
select {
case <-wake:
case <-grace:
expired = true
case <-l.done:
return
case <-r.Context().Done():
return
}
}
}
// absent reports whether a version is of a page that says its tree or file
// is not there.
func absent(v string) bool {
_, v, _ = strings.Cut(v, ".")
return v == "" || v[0] == '-' || v[0] == '!'
}
// version names what the page at urlPath shows: a hash of the stats it
// reads, marked f for a file, d for a directory, ! for a page within a
// tree that shows an error and - for the index, which lists the trees.
// The hash is keyed, since it covers the tree's path, which a shared
// server must not let a visitor guess.
func (s *server) version(urlPath string) string {
h := hmac.New(sha256.New, s.live.key)
kind := byte('-')
if t := s.lookup(urlPath); t != nil {
kind = t.version(h, t.name(urlPath))
} else if s.web != nil {
s.web.version(h, urlPath)
} else {
for _, t := range s.list() {
// A secret tree is not listed, so it must not show by a reload.
if !(s.shared && t.secret) {
io.WriteString(h, t.prefix+"\n")
}
}
}
return string(kind) + hex.EncodeToString(h.Sum(nil)[:8])
}
// version writes what the page at name reads to h and returns its kind.
func (t *tree) version(h io.Writer, name string) byte {
// Another tree at the same prefix is another page.
io.WriteString(h, t.dir+"\n")
info, err := fs.Stat(t.fsys, name)
if err != nil {
io.WriteString(h, err.Error())
return '!'
}
// Pages in a repo show its history, which a commit changes.
_, head := t.gitHead(name)
io.WriteString(h, head+"\n")
if !info.IsDir() {
fmt.Fprintf(h, "%d %d", info.ModTime().UnixNano(), info.Size())
return 'f'
}
// A listing shows names. A change to a file in the directory leaves the
// page as it was.
entries, err := fs.ReadDir(t.fsys, name)
if err != nil {
io.WriteString(h, err.Error())
return '!'
}
for _, e := range entries {
n := e.Name()
if t.unlisted(name, n) {
continue
}
fmt.Fprintf(h, "%s %v\n", n, e.Type())
}
return 'd'
}
// watchDirs are the directories whose changes may change the page at
// urlPath: the directory a listing shows, or the one holding the file, and
// the same for the target when the path leads through a link, and the
// repo's git directory, which a commit writes. The
// index needs none, since trees that join or leave kick every page.
func (s *server) watchDirs(urlPath string) []string {
t := s.lookup(urlPath)
if t == nil {
return nil
}
target := filepath.Join(t.dir, filepath.FromSlash(t.name(urlPath)))
paths := []string{target}
if real, err := filepath.EvalSymlinks(target); err == nil && real != target {
paths = append(paths, real)
}
var dirs []string
for _, p := range paths {
if info, err := os.Stat(p); err == nil && info.IsDir() {
dirs = append(dirs, p)
} else {
dirs = append(dirs, filepath.Dir(p))
}
}
if gitDir, _ := t.gitHead(t.name(urlPath)); gitDir != "" {
dirs = append(dirs, gitDir)
}
return dirs
}