Adds internal/apps/ package serving the five tool HTMLs at virtual paths based on the surrounding folder name convention: archive every directory (multi-project, project, archive, vendor) classifier any Incoming/Working/Staging directory and subtree mdedit any Working directory and subtree transmittal any Staging directory and subtree landing only at deployment root The current-stable build of every tool is //go:embed'd into the binary at compile time — that's the default with zero config. Operators override per-directory via .zddc apps: entries; closer-to-leaf wins. Spec syntax (in any apps: value): stable / beta / alpha / :stable channel v0.0.4 / v0.0 / v0 / :v0.0.4 version https://my-mirror/releases URL prefix only https://my-mirror/releases:beta URL prefix + channel https://my-fork/archive.html terminal full URL ./local.html / /abs/path.html terminal local path The special apps.default key provides a baseline URL prefix and channel inherited by any app not overridden per-name. Per-axis cascade: a deeper .zddc can override the URL, the channel, or both. Cascade walks root→leaf; default applies first at each level, then the per-app entry. Terminal sources (paths and full .html URLs) short-circuit composition; deeper non-terminal entries override parent terminals. URL sources fetch once on first request and cache forever in <ZDDC_ROOT>/_app/<host>/<path> — different upstreams with the same filename stay distinct. No background refresh, no SHA-256 verification: operators delete the cache file to force a refetch. Concurrent misses for the same source dedupe via a 30-line hand-rolled singleflight. Per-request override: any user can append ?v=<spec> to a tool URL (e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha, ?v=https://mirror/releases:beta) to ask for a different build for one request. Security: ?v= serves ONLY versions already in the cache (cache miss returns 404; path sources are rejected outright with 400). Users cannot trigger arbitrary upstream fetches via crafted URLs. Failed URL fetches (network down, 5xx) fall back to embedded with a one-time WARN log. The X-ZDDC-Source response header reports what served: fetch:URL / cache:URL / path:/abs / embedded:<app>@<build>. Wire-in (cmd/zddc-server/main.go): dispatch routes <dir>/<app>.html through apps.MatchAppHTML + AppAvailableAt + apps.Server.Serve when no real file exists. Direct URL access to /_app/... is blocked at the dispatch layer — cached files must go through the apps resolver so they get correct Content-Type and ACL gating. Schema (internal/zddc/file.go): ZddcFile gains Apps map[string]string for cascade overrides. Validator (internal/zddc/validate.go) accepts the special "default" key alongside the five canonical app names and all spec forms. Removes ZDDC_APPS_* env vars (no admin UI, no refresh interval, no upstream allow-list — the simpler model has fewer knobs). 40+ unit tests across the new package: parser shapes, cascade resolution with default+per-app interactions, terminal short-circuit semantics, ?v= cache-only enforcement, embedded fallback, atomic cache writes, singleflight dedup. Plus end-to-end dispatch tests in cmd/zddc-server/main_test.go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
71 lines
2.4 KiB
Go
71 lines
2.4 KiB
Go
package apps
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Folder name conventions that gate which tools are virtually available
|
|
// at a given path. The names are case-sensitive; ZDDC convention uses
|
|
// the capitalized forms.
|
|
var (
|
|
folderNamesIncomingWorkingStaging = []string{"Incoming", "Working", "Staging"}
|
|
folderNamesWorking = []string{"Working"}
|
|
folderNamesStaging = []string{"Staging"}
|
|
)
|
|
|
|
// AppAvailableAt reports whether app's virtual HTML can be served at
|
|
// requestDir. Rules:
|
|
//
|
|
// - archive: every directory (multi-project, project, archive, vendor)
|
|
// - classifier: requestDir is, or descends from, a folder named
|
|
// "Incoming", "Working", or "Staging" (the directories where
|
|
// incoming/outgoing files get classified)
|
|
// - mdedit: requestDir is, or descends from, a "Working" folder
|
|
// (where markdown drafts are written and edited)
|
|
// - transmittal: requestDir is, or descends from, a "Staging" folder
|
|
// (where outgoing transmittals are prepared)
|
|
// - landing: only at the deployment root (the project picker)
|
|
//
|
|
// Operators can always drop a real <name>.html file at any path to override
|
|
// — that path is served by the static handler regardless of this function's
|
|
// result. AppAvailableAt is consulted only when no real file exists.
|
|
func AppAvailableAt(root, requestDir, app string) bool {
|
|
root = filepath.Clean(root)
|
|
requestDir = filepath.Clean(requestDir)
|
|
|
|
switch app {
|
|
case "archive":
|
|
return true
|
|
case "landing":
|
|
return requestDir == root
|
|
case "classifier":
|
|
return inAncestorWithName(root, requestDir, folderNamesIncomingWorkingStaging)
|
|
case "mdedit":
|
|
return inAncestorWithName(root, requestDir, folderNamesWorking)
|
|
case "transmittal":
|
|
return inAncestorWithName(root, requestDir, folderNamesStaging)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// inAncestorWithName reports whether requestDir is, or has an ancestor
|
|
// (not including root itself), named one of names. The match is on the
|
|
// last segment of each directory in the chain root → requestDir.
|
|
func inAncestorWithName(root, requestDir string, names []string) bool {
|
|
if requestDir == root {
|
|
return false
|
|
}
|
|
rel, err := filepath.Rel(root, requestDir)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
return false
|
|
}
|
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
|
for _, n := range names {
|
|
if part == n {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|