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.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.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 /.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 }