ZDDC/zddc/internal/apps/bundle.go
ZDDC 4eeb25c0ef feat(server): local-only tool-HTML override; remove apps URL/version fetching
Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.

Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
   / a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
   server-side via internal/zipfs (local file, no fetch, no signature;
   re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.

Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
  spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
  SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
  cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
  walker merges, cascade-summary adds, validate.go apps validation
  (ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
  AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
  apps:/apps_pubkey: in an existing .zddc is now silently ignored
  (back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
  drops the apps/apps_pubkey keys + appsmap case.

Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
  stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
  Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
  server reads members from the filesystem internally.

Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:59:28 -05:00

115 lines
3.5 KiB
Go

package apps
import (
"archive/zip"
"bytes"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zipfs"
)
// BundleName is the site-root config bundle that supplies local tool-HTML
// overrides (and, in future, templated config). It lives at
// <ZDDC_ROOT>/.zddc.zip. It is dot-hidden and 404-gated over HTTP (it's
// config, not browsable content); the server reads it from the filesystem
// internally, so members resolve for any user regardless of the HTTP gate.
const BundleName = ".zddc.zip"
// maxBundleBytes caps the whole .zddc.zip read into memory. The bundle is
// small config (a handful of HTML files), so a generous cap is fine.
const maxBundleBytes = 64 << 20 // 64 MiB
// maxBundleMemberBytes caps a single extracted member.
const maxBundleMemberBytes = 32 << 20 // 32 MiB
// Bundle is the cached parsed view of <ZDDC_ROOT>/.zddc.zip. A nil *Bundle
// is valid and behaves as "no bundle present" for all methods. Member()
// re-stats the file each call (cheap, and gives free hot-reload when an
// operator drops in a new bundle), reparsing only when mtime or size change.
type Bundle struct {
path string
logger *slog.Logger
mu sync.Mutex
data []byte
reader *zip.Reader
modTime time.Time
size int64
loaded bool // a valid zip is parsed into reader
}
// NewBundle returns a Bundle bound to <zddcRoot>/.zddc.zip. The file need
// not exist; Member returns (nil,false) until it does.
func NewBundle(zddcRoot string, logger *slog.Logger) *Bundle {
if logger == nil {
logger = slog.Default()
}
return &Bundle{path: filepath.Join(zddcRoot, BundleName), logger: logger}
}
// Member returns the bytes of the named member (e.g. "browse.html") from the
// bundle, or (nil,false) when the bundle is absent, unreadable, corrupt, or
// has no such member. Lookup is case-insensitive (via zipfs), matching the
// rest of the server's URL case-folding.
func (b *Bundle) Member(name string) ([]byte, bool) {
if b == nil {
return nil, false
}
b.mu.Lock()
defer b.mu.Unlock()
info, err := os.Stat(b.path)
if err != nil || info.IsDir() {
// Absent (or replaced by a dir) → no bundle. Drop any stale parse.
b.data, b.reader, b.loaded = nil, nil, false
return nil, false
}
if !b.loaded || info.ModTime() != b.modTime || info.Size() != b.size {
if !b.reparse(info) {
return nil, false
}
}
rc, _, _, _, ok := zipfs.OpenMember(b.reader, name)
if !ok {
return nil, false
}
defer rc.Close()
body, err := io.ReadAll(io.LimitReader(rc, maxBundleMemberBytes+1))
if err != nil || int64(len(body)) > maxBundleMemberBytes {
b.logger.Warn("zddc.zip member unreadable or too large", "member", name)
return nil, false
}
return body, true
}
// reparse re-reads + re-parses the bundle. Caller holds b.mu. On any error
// the bundle is treated as absent (loaded=false) and the server falls back
// to embedded. Returns true when a valid reader is in place.
func (b *Bundle) reparse(info os.FileInfo) bool {
b.data, b.reader, b.loaded = nil, nil, false
if info.Size() > maxBundleBytes {
b.logger.Warn("zddc.zip too large; ignoring", "size", info.Size(), "cap", maxBundleBytes)
return false
}
data, err := os.ReadFile(b.path)
if err != nil {
b.logger.Warn("zddc.zip unreadable; ignoring", "err", err)
return false
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
b.logger.Warn("zddc.zip is not a valid zip; ignoring", "err", err)
return false
}
b.data = data
b.reader = zr
b.modTime = info.ModTime()
b.size = info.Size()
b.loaded = true
return true
}