Replaces the super-admin-only /.admin/ surface with a public-by-default /.profile/ page that layers admin tools server-side based on the caller's effective access: - Universal (everyone, anonymous included): identity card, effective access summary, theme picker, localStorage utilities (export / import / clear, landing-presets viewer). - Subtree admins additionally see: editable .zddc files list (linking to the existing form-based editor) and a "Create new project folder" form. - Super-admins additionally see: server config, log viewer, whoami headers (the old /.admin/ JSON endpoints, repointed under /.profile/). Project creation is gated on CanEditZddc(newDir) — the same strict- ancestor rule that already governs .zddc writes — so no new authority concept is introduced. ValidateProjectName mirrors the existing reserved-prefix policy (no leading '.' or '_', no path separators). /.admin/* is hard-cut: no redirect shim. Old URLs fall through to the existing dot-prefix guard and 404. Custom CSS file rename: prefer <root>/.profile.css, fall back to legacy <root>/.admin.css. Per-resource 404 leakage gates preserved on whoami / config / logs / zddc / projects so non-admin callers cannot detect the existence of admin-only sub-resources. Tree-wide gofmt -w applied as a side-effect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
2.7 KiB
Go
131 lines
2.7 KiB
Go
package archive
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
"github.com/fsnotify/fsnotify"
|
|
)
|
|
|
|
// Watcher watches fsRoot for filesystem changes and updates the archive index
|
|
// and the zddc ACL policy cache.
|
|
type Watcher struct {
|
|
fsRoot string
|
|
idx *Index
|
|
watcher *fsnotify.Watcher
|
|
|
|
// Debounce: pending dir → timer
|
|
mu sync.Mutex
|
|
pending map[string]*time.Timer
|
|
}
|
|
|
|
// NewWatcher creates a new Watcher. Call Start to begin watching.
|
|
func NewWatcher(fsRoot string, idx *Index) (*Watcher, error) {
|
|
w, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Watcher{
|
|
fsRoot: fsRoot,
|
|
idx: idx,
|
|
watcher: w,
|
|
pending: make(map[string]*time.Timer),
|
|
}, nil
|
|
}
|
|
|
|
// Start begins watching and blocks until ctx is cancelled.
|
|
// It registers the entire directory tree under fsRoot.
|
|
func (w *Watcher) Start(ctx context.Context) error {
|
|
// Walk and register all directories
|
|
if err := filepath.WalkDir(w.fsRoot, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil || !d.IsDir() {
|
|
return nil
|
|
}
|
|
return w.watcher.Add(path)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
go func() {
|
|
defer w.watcher.Close()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case event, ok := <-w.watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
w.handleEvent(event)
|
|
|
|
case err, ok := <-w.watcher.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
slog.Warn("fsnotify error", "err", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
return nil
|
|
}
|
|
|
|
func (w *Watcher) handleEvent(event fsnotify.Event) {
|
|
path := event.Name
|
|
base := filepath.Base(path)
|
|
|
|
// New directory created — register it
|
|
if event.Has(fsnotify.Create) {
|
|
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
|
_ = w.watcher.Add(path)
|
|
}
|
|
}
|
|
|
|
// .zddc file changed — invalidate ACL policy cache
|
|
if base == ".zddc" {
|
|
dir := filepath.Dir(path)
|
|
zddc.InvalidateCache(dir)
|
|
return
|
|
}
|
|
|
|
// Skip dot-files
|
|
if strings.HasPrefix(base, ".") {
|
|
return
|
|
}
|
|
|
|
// For transmittal folder events, schedule a debounced index update
|
|
dirPath := filepath.Dir(path)
|
|
dirName := filepath.Base(dirPath)
|
|
if transmittalFolderRE.MatchString(dirName) {
|
|
w.scheduleIndexUpdate(dirPath)
|
|
}
|
|
}
|
|
|
|
// scheduleIndexUpdate debounces an index update for dirPath (2-second delay).
|
|
func (w *Watcher) scheduleIndexUpdate(dirPath string) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
if t, ok := w.pending[dirPath]; ok {
|
|
t.Reset(2 * time.Second)
|
|
return
|
|
}
|
|
|
|
w.pending[dirPath] = time.AfterFunc(2*time.Second, func() {
|
|
w.mu.Lock()
|
|
delete(w.pending, dirPath)
|
|
w.mu.Unlock()
|
|
|
|
if err := w.idx.UpdateFromDir(w.fsRoot, dirPath); err != nil {
|
|
slog.Warn("archive index update failed", "dir", dirPath, "err", err)
|
|
}
|
|
})
|
|
}
|