Listing <project>/{archive,working,staging,reviewing}/ when the folder
doesn't exist on disk now returns an empty 200 listing instead of 404.
The stage-strip nav links into these folders unconditionally; without
this fallback, clicking "Working" against a fresh project (where
working/ hasn't been written to yet) lands on a 404 page rather than
a usable empty view.
Mechanism stays consistent with the existing lazy-folder design:
- GET on missing canonical folder → 200 + empty listing (this commit)
- first WRITE under the same path → EnsureCanonicalAncestors
materialises the on-disk folder + auto-own .zddc
reviewing/ stays virtual-only (in VirtualOnlyCanonicalNames); the
fallback just makes its empty listing always renderable. The future
reviewing/ aggregator (recorded in project memory) will replace the
empty listing with the join-computed virtual entries.
The fallback is gated on IsProjectRootFolder — only depth-2 paths
matching one of the four canonical names. Non-canonical missing paths
still 404 (TestListDirectory_NonCanonicalMissing_StillNotFound).
For working/ specifically the synthetic <viewer-email>/ home entry
still fires from virtualUserHomeEntry, so the user sees their own
placeholder even when working/ doesn't exist yet — first write into
that placeholder triggers the lazy-create chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
251 lines
9.3 KiB
Go
251 lines
9.3 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ProjectRootFolders are the canonical lowercase folder names that may
|
|
// appear directly under a project root. The server resolves them
|
|
// case-insensitively on disk: a manually-created Working/ is reused
|
|
// rather than shadowed by a new working/.
|
|
//
|
|
// - "archive" — formal record of issued/received transmittals,
|
|
// organised by counterparty (and ourselves)
|
|
// - "working" — user-owned drafting workspace
|
|
// - "staging" — outbound-transmittal preparation
|
|
// - "reviewing" — purely virtual cross-reference of in-progress
|
|
// review responses (never written to disk)
|
|
var ProjectRootFolders = []string{"archive", "working", "staging", "reviewing"}
|
|
|
|
// PartyFolders are the canonical lowercase folder names that may appear
|
|
// directly under archive/<party>/, where <party> is a counterparty or
|
|
// the self-folder (we treat ourselves like any other third party).
|
|
//
|
|
// - "mdl" — yaml-per-deliverable metadata, edited via the
|
|
// table-editor app at <party>/mdl.table.html
|
|
// - "incoming" — that party's drop point (we QC then promote)
|
|
// - "received" — immutable record of incoming we've accepted (WORM)
|
|
// - "issued" — immutable record of what we sent (WORM)
|
|
var PartyFolders = []string{"mdl", "incoming", "received", "issued"}
|
|
|
|
// AutoOwnCanonicalNames is the subset of canonical folder names where
|
|
// the file API's first-write hook auto-writes a creator-owned .zddc
|
|
// granting the creator rwcda. Excluded by design:
|
|
//
|
|
// - "archive": container only
|
|
// - "reviewing": purely virtual, never on disk
|
|
// - "mdl": yaml data store; ACL flows from archive/<party>/.zddc
|
|
// - "received" / "issued": WORM — auto-own would defeat the mask
|
|
var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"}
|
|
|
|
// VirtualOnlyCanonicalNames is the subset of canonical folder names
|
|
// that are never materialised on disk by the auto-create hooks. The
|
|
// server treats requests under these prefixes as virtual routes.
|
|
//
|
|
// "reviewing" stays in ProjectRootFolders so case-fold recognition and
|
|
// future tool registration work, but EnsureCanonicalAncestors skips
|
|
// MkdirAll for it.
|
|
var VirtualOnlyCanonicalNames = []string{"reviewing"}
|
|
|
|
// IsProjectRootFolder reports whether dirPath (relative to fsRoot,
|
|
// forward-slash-separated, no leading slash) names one of the canonical
|
|
// project-root folders at exactly depth 2: <project>/<canonical>.
|
|
// Match is case-insensitive against ProjectRootFolders.
|
|
//
|
|
// Used by the directory listing endpoint to materialise an empty
|
|
// listing for canonical folders that don't yet exist on disk, so a
|
|
// fresh project's nav links never land on 404. The first write under
|
|
// such a path triggers EnsureCanonicalAncestors which lazily creates
|
|
// the real on-disk folder + auto-own .zddc.
|
|
//
|
|
// Trailing slashes and accidental "./" segments are tolerated. Paths
|
|
// of any other depth (e.g. project root itself, or deeper subpaths
|
|
// like working/<email>/) return false — the fallback only applies at
|
|
// the canonical-folder boundary.
|
|
func IsProjectRootFolder(dirPath string) bool {
|
|
clean := strings.Trim(filepath.ToSlash(dirPath), "/")
|
|
if clean == "" {
|
|
return false
|
|
}
|
|
parts := strings.Split(clean, "/")
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
for _, name := range ProjectRootFolders {
|
|
if strings.EqualFold(parts[1], name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
|
// principalEmail rwcda and recording it in CreatedBy. Used by the file
|
|
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
|
|
// ownership when a new auto-own folder is materialised.
|
|
//
|
|
// The grant is identical to what an operator would write by hand —
|
|
// direct email pattern, "rwcda" verb set — so the creator can later
|
|
// edit the file normally to add collaborators.
|
|
//
|
|
// Atomic: marshals via the same yaml encoder ParseFile reads
|
|
// (round-trip guaranteed) and writes via zddc.WriteFile (which
|
|
// performs an atomic temp-write + rename via zddc.WriteAtomic).
|
|
func WriteAutoOwnZddc(dir, principalEmail string) error {
|
|
zf := ZddcFile{
|
|
ACL: ACLRules{
|
|
Permissions: map[string]string{principalEmail: "rwcda"},
|
|
},
|
|
CreatedBy: principalEmail,
|
|
}
|
|
return WriteFile(dir, zf)
|
|
}
|
|
|
|
// ResolveCanonical returns the on-disk name of the canonical folder
|
|
// 'logical' (lowercase) inside parentDir, or "" if no case variant
|
|
// exists. Caller decides whether to MkdirAll(parentDir+"/"+logical)
|
|
// when "" is returned.
|
|
//
|
|
// 'logical' is matched case-insensitively against entries returned by
|
|
// os.ReadDir(parentDir). The first matching directory entry wins (if
|
|
// an operator created both Working/ and working/ on a case-sensitive
|
|
// filesystem, the order is filesystem-dependent — that's an unsupported
|
|
// state we don't try to recover from).
|
|
//
|
|
// Returns "" with no error if parentDir doesn't exist or has no match.
|
|
func ResolveCanonical(parentDir, logical string) (string, error) {
|
|
entries, err := os.ReadDir(parentDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
if strings.EqualFold(e.Name(), logical) {
|
|
return e.Name(), nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// IsAutoOwnPath reports whether parentDir is one of the canonical
|
|
// auto-own positions in the ZDDC tree rooted at fsRoot. A child mkdir
|
|
// inside such a directory should receive a creator-owned .zddc.
|
|
//
|
|
// Canonical positions, relative to fsRoot:
|
|
//
|
|
// - <project>/working
|
|
// - <project>/staging
|
|
// - <project>/archive/<party>/incoming
|
|
//
|
|
// Segment matches are case-insensitive on canonical names. The project
|
|
// and party names are unrestricted.
|
|
//
|
|
// parentDir and fsRoot are filesystem paths. parentDir must be inside
|
|
// fsRoot; otherwise the function returns false.
|
|
func IsAutoOwnPath(parentDir, fsRoot string) bool {
|
|
rel, err := filepath.Rel(fsRoot, parentDir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
|
|
return false
|
|
}
|
|
parts := strings.Split(rel, "/")
|
|
switch len(parts) {
|
|
case 2:
|
|
// <project>/working or <project>/staging
|
|
return strings.EqualFold(parts[1], "working") || strings.EqualFold(parts[1], "staging")
|
|
case 4:
|
|
// <project>/archive/<party>/incoming
|
|
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "incoming")
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsWormPath reports whether requestPath crosses an
|
|
// archive/<party>/received/ or archive/<party>/issued/ segment chain.
|
|
// Pure path-segment check; case-fold on canonical names.
|
|
//
|
|
// The party segment is unrestricted — any directory under archive/ is
|
|
// treated as a party, including the self-folder. requestPath may be a
|
|
// URL path ("/Project/archive/ACME/issued/foo.pdf") or a filesystem
|
|
// path; only segment names matter.
|
|
func IsWormPath(requestPath string) bool {
|
|
parts := splitPathSegments(requestPath)
|
|
for i := 0; i+2 < len(parts); i++ {
|
|
if !strings.EqualFold(parts[i], "archive") {
|
|
continue
|
|
}
|
|
// parts[i+1] is the party name (anything).
|
|
if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WormMask reduces a verb set to the subset that survives the WORM
|
|
// constraint: the bitwise AND with VerbsRC. Removes w, d, and a.
|
|
//
|
|
// Callers apply this only when IsWormPath(path) is true AND the
|
|
// principal is NOT an admin (root admin or subtree admin) — admins
|
|
// are the deliberate escape hatch for mis-filed documents.
|
|
//
|
|
// The WORM mask is split-aware via WormFolderLevelIndex: grants
|
|
// inherited from ancestors above the received/issued folder are
|
|
// masked to read only ({r}), while grants at-or-below the WORM
|
|
// folder retain {r, c} so an operator can place a .zddc at the
|
|
// received/issued folder explicitly granting `_doc_controller: cr`.
|
|
func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC }
|
|
|
|
// WormFolderLevelIndex returns the chain index of the deepest
|
|
// archive/<party>/(received|issued) segment in requestPath. The chain
|
|
// corresponds to the directory tree from root (index 0) to the
|
|
// requested directory; level i is the .zddc at path segment depth i.
|
|
//
|
|
// numLevels is len(chain.Levels); used to clamp results to the chain's
|
|
// actual range. URL segment i lives at chain index i+1 (root is chain
|
|
// index 0), so the WORM segment at parts[i+2] corresponds to chain
|
|
// index i+3.
|
|
//
|
|
// Returns -1 if no WORM segment is in the request path or the computed
|
|
// index is out of range. The returned index satisfies
|
|
// 0 <= index < numLevels.
|
|
func WormFolderLevelIndex(requestPath string, numLevels int) int {
|
|
if numLevels <= 0 {
|
|
return -1
|
|
}
|
|
parts := splitPathSegments(requestPath)
|
|
deepest := -1
|
|
for i := 0; i+2 < len(parts); i++ {
|
|
if !strings.EqualFold(parts[i], "archive") {
|
|
continue
|
|
}
|
|
if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
|
|
idx := i + 3
|
|
if idx < numLevels && idx > deepest {
|
|
deepest = idx
|
|
}
|
|
}
|
|
}
|
|
return deepest
|
|
}
|
|
|
|
// splitPathSegments returns the slash-separated segments of p with
|
|
// empty elements removed. Tolerates leading/trailing slashes and
|
|
// mixed separators on Windows (via filepath.ToSlash).
|
|
func splitPathSegments(p string) []string {
|
|
clean := strings.Trim(filepath.ToSlash(p), "/")
|
|
if clean == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(clean, "/")
|
|
}
|