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