Foundation for the generalized view model: `.zddc` declares, per URL shape,
which tool renders and where its supporting config lives.
- ZddcFile.Views map[string]ViewSpec{Tool, Config}; shapes "dir" / "dir_slash"
/ "file". config is a filename resolved under <dir>/.zddc.d/. Pure data — no
behaviour; presentation/routing only (ACL/WORM/admin stay server-enforced).
- lookups.ViewAt(root, dir, shape): cascade leaf→root first-match, with
default_tool / dir_tool honored as sugar for dir / dir_slash (semantics
unchanged). No merged map — resolved per-shape like DefaultToolAt.
- cascade summary, isZero/is-empty checks, and validation (tool ∈ AppNames;
config a path-bounded plain filename). Client .zddc validator (preview-yaml.js)
gains a `views` key + `viewmap` case.
Additive only — nothing consumes Views yet (the generic resolver + dispatch
wiring + recognizer retirement follow). go build + zddc/handler tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
175 lines
5.8 KiB
Go
175 lines
5.8 KiB
Go
package zddc
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// AppNames is the canonical set of app HTML files the server can serve
|
|
// (from disk, the site .zddc.zip bundle, or the embedded default). Order
|
|
// is stable for reproducible rendering.
|
|
//
|
|
// All seven HTML tools belong here — including browse, form, and tables.
|
|
//
|
|
// Markdown editing used to be a dedicated tool ("mdedit"); it now
|
|
// lives as a plugin inside browse (browse/js/preview-markdown.js).
|
|
var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"}
|
|
|
|
// IsKnownApp reports whether name is one of the canonical apps.
|
|
func IsKnownApp(name string) bool {
|
|
for _, n := range AppNames {
|
|
if n == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidatePattern returns an error if pattern is not a syntactically
|
|
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
|
|
// will silently fail to match malformed patterns (e.g., "alice@@x" or
|
|
// patterns with spaces); we want explicit rejection at write time so a
|
|
// human typo shows up as a 400 instead of "your rule mysteriously does
|
|
// nothing".
|
|
//
|
|
// Rules:
|
|
// - non-empty after trimming surrounding whitespace
|
|
// - no internal whitespace
|
|
// - at most one '@'
|
|
// - if an '@' is present, both local and domain parts are non-empty
|
|
// - the bare token "@" is rejected (covered by the non-empty parts rule)
|
|
func ValidatePattern(pattern string) error {
|
|
if pattern == "" {
|
|
return fmt.Errorf("pattern is empty")
|
|
}
|
|
if strings.TrimSpace(pattern) != pattern {
|
|
return fmt.Errorf("pattern has leading or trailing whitespace")
|
|
}
|
|
if strings.ContainsAny(pattern, " \t\n\r") {
|
|
return fmt.Errorf("pattern contains whitespace")
|
|
}
|
|
at := strings.Count(pattern, "@")
|
|
if at > 1 {
|
|
return fmt.Errorf("pattern contains more than one '@'")
|
|
}
|
|
if at == 1 {
|
|
parts := strings.SplitN(pattern, "@", 2)
|
|
if parts[0] == "" {
|
|
return fmt.Errorf("pattern has empty local part before '@'")
|
|
}
|
|
if parts[1] == "" {
|
|
return fmt.Errorf("pattern has empty domain part after '@'")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateFile returns a slice of (field, message) errors for any
|
|
// malformed entry in zf. An empty slice means the file is acceptable to
|
|
// write. The returned errors include enough field context that the editor
|
|
// can render them next to the offending row.
|
|
type FieldError struct {
|
|
Field string `json:"field"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ValidateProjectName returns an error if name is not acceptable as a new
|
|
// directory name created under cfg.Root. The rules mirror the reserved-prefix
|
|
// policy enforced elsewhere (resolvePath, ScanZddcFiles, ServeProjectList) so
|
|
// a project created here is enumerable by the same listing code.
|
|
//
|
|
// Rules:
|
|
// - length 1..64
|
|
// - first char alphanumeric (rejects leading '.' and '_', matching the
|
|
// hidden-segment convention)
|
|
// - subsequent chars alphanumeric, '-', or '_'
|
|
// - rejects path separators, whitespace, and any '.' anywhere (so "..",
|
|
// ".hidden", "foo.bar" all fail — directory names stay flat)
|
|
func ValidateProjectName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("name is empty")
|
|
}
|
|
if len(name) > 64 {
|
|
return fmt.Errorf("name exceeds 64 characters")
|
|
}
|
|
for i, r := range name {
|
|
switch {
|
|
case r >= 'A' && r <= 'Z':
|
|
case r >= 'a' && r <= 'z':
|
|
case r >= '0' && r <= '9':
|
|
case (r == '-' || r == '_') && i > 0:
|
|
// allowed in the body, not as the leading character
|
|
default:
|
|
return fmt.Errorf("name contains invalid character %q at position %d", r, i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateFile(zf ZddcFile) []FieldError {
|
|
var errs []FieldError
|
|
check := func(field string, vals []string) {
|
|
for i, v := range vals {
|
|
if err := ValidatePattern(v); err != nil {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("%s[%d]", field, i),
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
for principal := range zf.ACL.Permissions {
|
|
if err := ValidatePattern(principal); err != nil {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("acl.permissions[%q]", principal),
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
check("admins", zf.Admins)
|
|
if len(zf.Title) > 200 {
|
|
errs = append(errs, FieldError{
|
|
Field: "title",
|
|
Message: "title exceeds 200 characters",
|
|
})
|
|
}
|
|
// views: each entry names a known tool and (optionally) a config file
|
|
// resolved under <dir>/.zddc.d/ — so it must be a safe relative filename
|
|
// (no slashes, no traversal, no leading dot).
|
|
for shape, v := range zf.Views {
|
|
if v.Tool == "" || !IsKnownApp(v.Tool) {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("views.%s.tool", shape),
|
|
Message: fmt.Sprintf("unknown tool %q (known: %s)", v.Tool, strings.Join(AppNames, ", ")),
|
|
})
|
|
}
|
|
if v.Config != "" {
|
|
if strings.ContainsAny(v.Config, "/\\") || v.Config == "." || v.Config == ".." || strings.HasPrefix(v.Config, ".") {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("views.%s.config", shape),
|
|
Message: "config must be a plain filename (resolved under .zddc.d/); no slashes, traversal, or leading dot",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
// worm: is a list of principal patterns (email-globs, @role:name,
|
|
// or bare role names) that get write-once-create inside the WORM
|
|
// zone. Validate each as an email-glob unless it's a role
|
|
// reference (role names are validated by the role machinery).
|
|
for i, principal := range zf.Worm {
|
|
if strings.HasPrefix(principal, "@role:") {
|
|
continue // role refs validated elsewhere
|
|
}
|
|
// A bare name with no "@" could be a role name; ValidatePattern
|
|
// accepts it (no @, no whitespace), and MatchesPrincipal
|
|
// resolves it as a role if one is defined. So this only
|
|
// rejects genuinely malformed entries (whitespace, double @).
|
|
if err := ValidatePattern(principal); err != nil {
|
|
errs = append(errs, FieldError{
|
|
Field: fmt.Sprintf("worm[%d]", i),
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
return errs
|
|
}
|