// Package apps serves the ZDDC tool HTML files (archive, transmittal, // classifier, landing, browse, form, tables) on virtual paths in the // file tree. Each tool is "available" only at directories whose name // matches a folder convention (Incoming/Working/Staging) — see // availability.go. The markdown editor lives as a plugin inside browse. // // Resolution priority for an enabled /.html request: // // 1. Real file at the request path → static handler (operator override). // 2. Subdir cascade — walk .zddc files root→leaf, accumulating URL prefix // and channel/version components from the special `apps.default` key // and the per-app `apps.` key. Either component can be set, // overridden, or left to inherit at any level. Path or full-`.html`-URL // entries are *terminal* — they short-circuit composition and a deeper // non-terminal entry overrides a parent terminal. // 3. Embedded fallback — bytes baked into the binary at compile time via // //go:embed. Used when no `apps:` entry was found anywhere up the chain. // // Spec forms (each is a string value in `.zddc apps:`): // // :stable / :v0.0.4 — channel-only // stable / v0.0.4 / 0.0.4 — channel-only (no leading colon) // https://host/path — URL-prefix only (combines with cascade channel) // https://host/path:stable — URL-prefix + channel (composes) // https://host/path/file.html — terminal full URL (used as-is) // ./local.html / /abs/local.html — terminal local path // // No background refresh, no SHA-256 verification. To pick up new upstream // bytes, delete the cache file (or the whole .zddc.d/apps/ tree). package apps import ( "fmt" "net/url" "path/filepath" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // DefaultUpstream is where channel and version shorthand specs resolve when // no `apps.default` URL prefix is configured anywhere up the chain. const DefaultUpstream = "https://zddc.varasys.io" // DefaultUpstreamReleases is the prefix appended to DefaultUpstream when // composing the canonical upstream URL. const DefaultUpstreamReleases = DefaultUpstream + "/releases" // DefaultChannel is the channel shorthand used when nothing in the chain // specifies one. const DefaultChannel = "stable" // CacheDirName is the directory under /.zddc.d/ where fetched URL // sources are cached. Living under the reserved .zddc.d/ sidecar means the // cache is hidden from listings and admin-gated for direct URL access like all // other server bookkeeping (see handler.ReservedSidecar); the resolver itself // reads/writes it via the filesystem, not over HTTP. const CacheDirName = "apps" // DefaultAppsKey is the special key in `apps:` that provides the baseline // URL prefix and channel for any app not overridden per-name. Cascades // through .zddc files like everything else. const DefaultAppsKey = "default" // Source is a fully-resolved app source (output of Resolve). type Source struct { App string // canonical app name URL string // upstream URL (mutually exclusive with Path) Path string // resolved local file path } // IsURL reports whether the source is fetched (vs read from disk). func (s Source) IsURL() bool { return s.URL != "" } // SpecComponents is the parsed shape of a single `.zddc apps:` value. // Terminal forms (Path or FullURL) are mutually exclusive with the // composable URLPrefix/Channel forms. Resolve() turns one or more // SpecComponents (one per applicable level in the cascade) into a final // Source. type SpecComponents struct { // Terminal forms — exactly one set means the spec is terminal and // short-circuits composition. Path string // local file path (resolved + bounded to ZDDC_ROOT) FullURL string // full URL ending in `.html` (used as-is) // Composable forms — either or both may be set, both may be empty // (caller should treat empty-everything as a no-op). URLPrefix string // "https://host/path" (no trailing /) Channel string // "stable" (latest), "v0.0.4" (exact version pin) } // IsTerminal reports whether this spec terminates composition. func (c SpecComponents) IsTerminal() bool { return c.Path != "" || c.FullURL != "" } // IsEmpty reports whether the spec contributes nothing to composition. func (c SpecComponents) IsEmpty() bool { return c.Path == "" && c.FullURL == "" && c.URLPrefix == "" && c.Channel == "" } // ParseSpec parses one `.zddc apps:` value into components. // zddcDir anchors relative paths; root bounds path-traversal. func ParseSpec(spec, zddcDir, root string) (SpecComponents, error) { spec = strings.TrimSpace(spec) if spec == "" { return SpecComponents{}, fmt.Errorf("source spec is empty") } // Path forms — terminal. if strings.HasPrefix(spec, "/") || strings.HasPrefix(spec, "./") || strings.HasPrefix(spec, "../") { var abs string if filepath.IsAbs(spec) { abs = filepath.Clean(spec) } else { abs = filepath.Clean(filepath.Join(zddcDir, spec)) } rootClean := filepath.Clean(root) if abs != rootClean && !strings.HasPrefix(abs, rootClean+string(filepath.Separator)) { return SpecComponents{}, fmt.Errorf("path %q escapes ZDDC_ROOT", spec) } return SpecComponents{Path: abs}, nil } // URL forms. if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") { return parseURLSpec(spec) } // Channel-only forms: ":channel" or bare "channel". chanPart := strings.TrimPrefix(spec, ":") if chanPart == "" { return SpecComponents{}, fmt.Errorf("empty channel after ':'") } if !isValidChannelOrVersion(chanPart) { return SpecComponents{}, fmt.Errorf("unrecognized source spec %q (expected channel, version, URL, or path)", spec) } return SpecComponents{Channel: normalizeChannel(chanPart)}, nil } // parseURLSpec splits a URL spec into prefix vs full-URL based on the // last `:` after the last `/`. Examples: // // https://host:8080/path:stable → URLPrefix=https://host:8080/path, Channel=stable // https://host:8080/path → URLPrefix=https://host:8080/path // https://host/path/file.html → FullURL=https://host/path/file.html (terminal) // https://host/path/file.html:stable → error (terminal URL with extra suffix) func parseURLSpec(spec string) (SpecComponents, error) { // Locate the channel separator: last `:` that comes after the last `/`. lastSlash := strings.LastIndex(spec, "/") if lastSlash < 0 { return SpecComponents{}, fmt.Errorf("invalid URL %q: missing path separator", spec) } afterSlash := spec[lastSlash+1:] colonInTail := strings.LastIndex(afterSlash, ":") urlPart, suffixPart := spec, "" if colonInTail >= 0 { urlPart = spec[:lastSlash+1+colonInTail] suffixPart = afterSlash[colonInTail+1:] } // Validate the URL portion. u, err := url.Parse(urlPart) if err != nil { return SpecComponents{}, fmt.Errorf("invalid URL %q: %w", urlPart, err) } if u.Host == "" { return SpecComponents{}, fmt.Errorf("URL %q is missing host", urlPart) } // Terminal full URL: ends in `.html`. A `:suffix` on a `.html` URL is // rejected to prevent silent misinterpretation. if strings.HasSuffix(urlPart, ".html") { if suffixPart != "" { return SpecComponents{}, fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart) } return SpecComponents{FullURL: urlPart}, nil } // URL-prefix form. Strip trailing slash for normalization. prefix := strings.TrimRight(urlPart, "/") out := SpecComponents{URLPrefix: prefix} if suffixPart != "" { if !isValidChannelOrVersion(suffixPart) { return SpecComponents{}, fmt.Errorf("invalid channel/version suffix %q", suffixPart) } out.Channel = normalizeChannel(suffixPart) } return out, nil } // isValidChannelOrVersion reports whether s is `stable` (the canonical // "current stable" alias) or an exact-version pin like `v0.0.4` / `0.0.4`. // Partial pins (`v0.0`, `v0`) and the legacy `beta`/`alpha` channels // are no longer accepted — the upstream publishes only stable + exact. func isValidChannelOrVersion(s string) bool { if s == "stable" { return true } rest := strings.TrimPrefix(s, "v") if rest == "" { return false } parts := strings.Split(rest, ".") if len(parts) != 3 { return false } for _, p := range parts { if p == "" { return false } for _, r := range p { if r < '0' || r > '9' { return false } } } return true } // normalizeChannel ensures versions carry the `v` prefix (so the resulting // filename is `_v.html` per upstream convention). func normalizeChannel(s string) string { if s == "stable" { return s } if !strings.HasPrefix(s, "v") { return "v" + s } return s } // Resolve walks the .zddc chain root→leaf, applying `apps.default` and // `apps.` at each level. Returns the resolved Source and true if any // entry contributed; (Source{}, false, nil) means no override (caller // serves embedded). On malformed spec, returns an error. func Resolve(chain zddc.PolicyChain, app, root, requestDir string) (Source, bool, error) { return ResolveWithOverride(chain, app, root, requestDir, "") } // ResolveWithOverride is Resolve with an additional per-request override // applied as one final cascade level after the .zddc chain. Used to honor // the `?v=` query parameter on tool HTML requests. // // vSpec accepts the same syntax as `.zddc apps:` values (channel/version, // `:channel`, URL prefix, `url:channel`, full `.html` URL). Path sources // are rejected (security: `?v=` must resolve to a URL whose bytes the // caller can fetch from cache only). // // Empty vSpec is equivalent to plain Resolve. func ResolveWithOverride(chain zddc.PolicyChain, app, root, requestDir, vSpec string) (Source, bool, error) { app = strings.ToLower(strings.TrimSpace(app)) if !zddc.IsKnownApp(app) { return Source{}, false, fmt.Errorf("unknown app %q", app) } dirs := walkDirs(root, requestDir) st := newAppsState(app) // Walk root → leaf. for i := 0; i < len(chain.Levels); i++ { lvl := chain.Levels[i] dir := root if i < len(dirs) { dir = dirs[i] } // `default` first, then per-app override at the same level. if spec, ok := lvl.Apps[DefaultAppsKey]; ok && spec != "" { if err := st.apply(spec, dir, root, "apps.default"); err != nil { return Source{}, false, err } } if spec, ok := lvl.Apps[app]; ok && spec != "" { if err := st.apply(spec, dir, root, "apps."+app); err != nil { return Source{}, false, err } } } // Per-request override (`?v=`): one final layer. if vSpec = strings.TrimSpace(vSpec); vSpec != "" { comp, err := ParseSpec(vSpec, requestDir, root) if err != nil { return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err) } // Reject path sources from per-request override — security: we serve // only what the cache (populated by .zddc-controlled fetches) holds. if comp.Path != "" { return Source{}, false, fmt.Errorf("?v= cannot specify a local path source") } if err := st.applyComponents(comp); err != nil { return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err) } } return st.finalize() } // appsState accumulates URL-prefix and channel components across cascade // levels, with terminal-source short-circuit semantics. type appsState struct { app string haveAny bool urlPrefix string channel string terminalSrc *Source } func newAppsState(app string) *appsState { return &appsState{app: app} } func (s *appsState) apply(spec, zddcDir, root, label string) error { comp, err := ParseSpec(spec, zddcDir, root) if err != nil { return fmt.Errorf("%s: %w", label, err) } return s.applyComponents(comp) } func (s *appsState) applyComponents(comp SpecComponents) error { if comp.IsEmpty() { return nil } s.haveAny = true switch { case comp.Path != "": s.terminalSrc = &Source{App: s.app, Path: comp.Path} s.urlPrefix, s.channel = "", "" case comp.FullURL != "": s.terminalSrc = &Source{App: s.app, URL: comp.FullURL} s.urlPrefix, s.channel = "", "" default: // Non-terminal: deeper non-terminal entries override a parent terminal. s.terminalSrc = nil if comp.URLPrefix != "" { s.urlPrefix = comp.URLPrefix } if comp.Channel != "" { s.channel = comp.Channel } } return nil } func (s *appsState) finalize() (Source, bool, error) { if !s.haveAny { return Source{}, false, nil } if s.terminalSrc != nil { return *s.terminalSrc, true, nil } urlPrefix := s.urlPrefix if urlPrefix == "" { urlPrefix = DefaultUpstreamReleases } channel := s.channel if channel == "" { channel = DefaultChannel } // channel == "stable" → canonical URL /.html (a // symlink that always follows the latest stable cut). // channel == "v" → immutable per-version URL. var name string if channel == "stable" { name = s.app + ".html" } else { name = s.app + "_" + channel + ".html" } return Source{ App: s.app, URL: urlPrefix + "/" + name, }, true, nil } // PreviewLine returns a short human-readable description of how an app // resolves at requestDir given the chain. Used by the .zddc editor to // render a "Resolves to: ..." line beside each apps input. func PreviewLine(chain zddc.PolicyChain, app, root, requestDir string) string { src, has, err := Resolve(chain, app, root, requestDir) if err != nil { return "error: " + err.Error() } if !has { return "embedded (build-time default)" } if src.Path != "" { return "local file: " + src.Path } return src.URL } func walkDirs(root, requestDir string) []string { root = filepath.Clean(root) requestDir = filepath.Clean(requestDir) if requestDir == root { return []string{root} } rel, err := filepath.Rel(root, requestDir) if err != nil { return []string{root} } dirs := []string{root} cur := root for _, part := range strings.Split(rel, string(filepath.Separator)) { cur = filepath.Join(cur, part) dirs = append(dirs, cur) } return dirs }