// Package webroot maintains the $WEBROOT/~project symlinks. // // The server serves its own content, so these links are not on any request // path. They exist for everything else: an nginx that would rather serve the // files itself, a backup job, an operator running ls. That makes every // operation here best-effort — a failure is logged and reconciled later, never // a reason to fail a deployment that the database already committed. package webroot import ( "crypto/rand" "encoding/hex" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "github.com/iceBear67/simplepages/internal/config" ) // prefix is what marks an entry as ours. Project names cannot contain a slash // or start with a dot — config.ProjectNamePattern sees to that — so "~" + name // is always a single entry directly inside the webroot and can never escape it. const prefix = "~" // tmpMarker separates a half-built link from a live one. Point never renames a // name containing it into place, and Reconcile sweeps any that a crash left. const tmpMarker = ".tmp." // Webroot is a directory of symlinks pointing into the deployments directory. type Webroot struct { dir string deployDir string } // Open prepares the webroot directory. deployDir bounds what Reconcile is // willing to delete: an entry that does not point inside it belongs to the // operator, not to us. func Open(dir, deployDir string) (*Webroot, error) { if dir == "" { return nil, errors.New("webroot: no directory configured") } abs, err := filepath.Abs(dir) if err != nil { return nil, err } if err := os.MkdirAll(abs, 0o755); err != nil { return nil, err } deployAbs, err := filepath.Abs(deployDir) if err != nil { return nil, err } return &Webroot{dir: abs, deployDir: deployAbs}, nil } // Dir is the directory being maintained. func (w *Webroot) Dir() string { return w.dir } // Point makes ~project refer to target. // // The link is created under a temporary name and renamed into place. rename(2) // within one directory replaces an existing symlink atomically, so an external // reader never observes the link missing, dangling or half-written — which is // the same guarantee, at a coarser grain, that the in-process pointer swap // gives HTTP clients. func (w *Webroot) Point(project, target string) error { link, err := w.linkPath(project) if err != nil { return err } if cur, err := os.Readlink(link); err == nil && cur == target { return nil } tmp, err := w.tempLink(project, target) if err != nil { return err } if err := os.Rename(tmp, link); err != nil { os.Remove(tmp) return fmt.Errorf("webroot: point %s: %w", project, err) } return nil } // Unpoint removes ~project, if it is one of ours. func (w *Webroot) Unpoint(project string) error { link, err := w.linkPath(project) if err != nil { return err } if !w.ours(link) { return nil } if err := os.Remove(link); err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("webroot: unpoint %s: %w", project, err) } return nil } // Reconcile makes the directory match want, a project name to target directory // map, and reports the first thing that went wrong after attempting all of it. // // It only ever deletes an entry that is itself a symlink pointing inside the // deployments directory. An operator's unrelated file, directory or link that // happens to share the webroot is left strictly alone: this program owns the // names it created, not the directory. func (w *Webroot) Reconcile(want map[string]string) error { entries, err := os.ReadDir(w.dir) if err != nil { return err } var errs []error for _, e := range entries { name := e.Name() if !strings.HasPrefix(name, prefix) || e.Type()&fs.ModeSymlink == 0 { continue } full := filepath.Join(w.dir, name) if !w.pointsIntoDeployments(full) { continue } project := name[len(prefix):] if strings.Contains(project, tmpMarker) { // A Point that died between symlink and rename. if err := os.Remove(full); err != nil { errs = append(errs, err) } continue } if _, keep := want[project]; keep { continue } if err := os.Remove(full); err != nil && !errors.Is(err, fs.ErrNotExist) { errs = append(errs, err) } } for project, target := range want { if err := w.Point(project, target); err != nil { errs = append(errs, err) } } return errors.Join(errs...) } // linkPath validates the project name and returns the path of its link. func (w *Webroot) linkPath(project string) (string, error) { if !config.ProjectNamePattern.MatchString(project) { return "", fmt.Errorf("webroot: refusing to touch %q: not a project name", project) } return filepath.Join(w.dir, prefix+project), nil } // tempLink creates a symlink under a name that Reconcile will recognise as // abandoned if this process dies before the rename. func (w *Webroot) tempLink(project, target string) (string, error) { base, err := w.linkPath(project) if err != nil { return "", err } var buf [6]byte for range 8 { if _, err := rand.Read(buf[:]); err != nil { return "", err } tmp := base + tmpMarker + hex.EncodeToString(buf[:]) err := os.Symlink(target, tmp) if err == nil { return tmp, nil } if !errors.Is(err, fs.ErrExist) { return "", fmt.Errorf("webroot: link %s: %w", project, err) } } return "", fmt.Errorf("webroot: could not create a temporary link for %s", project) } // ours reports whether path is a symlink this package would have created. func (w *Webroot) ours(path string) bool { fi, err := os.Lstat(path) if err != nil || fi.Mode()&fs.ModeSymlink == 0 { return false } return w.pointsIntoDeployments(path) } // pointsIntoDeployments reports whether a symlink's target is inside the // deployments directory. The target is compared lexically after cleaning: it is // the string this program wrote, and resolving it would answer a different // question about a link that may well dangle. func (w *Webroot) pointsIntoDeployments(path string) bool { target, err := os.Readlink(path) if err != nil { return false } if !filepath.IsAbs(target) { target = filepath.Join(w.dir, target) } target = filepath.Clean(target) if target == w.deployDir { return false } return strings.HasPrefix(target, w.deployDir+string(filepath.Separator)) }