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>
115 lines
3.5 KiB
Go
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
|
|
}
|