diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 3af56a7..46fdc03 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -529,6 +529,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // MDL convenience redirect: GET archive//mdl/ → mdl.table.html. + // The table app is the canonical view for the per-party Master + // Deliverables List. Direct navigation to the data folder lands on + // the grid editor; clients that want the raw row listing can still + // hit archive//mdl/ via the file API or with an explicit + // trailing-segment beyond mdl/. + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && len(segments) == 4 { + if strings.EqualFold(segments[1], "archive") && strings.EqualFold(segments[3], "mdl") { + target := "/" + segments[0] + "/" + segments[1] + "/" + segments[2] + "/mdl.table.html" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) + return + } + } + // Tables-system intercept: *.table.html is a virtual URL that the // table handler renders inline, reading rows from a directory of // *.yaml files declared in the directory's .zddc tables: map. @@ -536,6 +553,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // so RecognizeTableRequest returns nil whenever there's no matching // declaration and the URL falls through to the static-file path // (or to the form intercept below for *.form.html / *.yaml.html). + // + // One exception: archive//mdl.table.html falls back to the + // embedded default MDL spec when no operator declaration exists. + // RecognizeTableRequest implements that fallback internally. if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil { handler.ServeTable(cfg, tableReq, w, r) return @@ -596,11 +617,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps info, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { + // Default MDL spec fallback: archive//mdl.table.yaml + // and archive//mdl.form.yaml are served from embedded + // bytes when no operator file exists on disk. The table app + // fetches these client-side; the fallback lets a fresh + // project work out of the box. + if r.Method == http.MethodGet || r.Method == http.MethodHead { + if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok { + chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath)) + if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-ZDDC-Source", "default-mdl-spec") + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(bytes) + return + } + } // File doesn't exist at this path. If the URL matches one of - // the five canonical app HTML names AND the request directory - // is one where that app is available (Incoming/Working/Staging - // for classifier/mdedit/transmittal, anywhere for archive, - // root only for landing), resolve via the apps subsystem. + // the canonical app HTML names AND the request directory is + // one where that app is available (working/staging/incoming + // for classifier, working for mdedit, staging for + // transmittal, anywhere for archive, root only for landing), + // resolve via the apps subsystem. if appsSrv != nil { if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" { requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel)) diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 1998032..7512a6e 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -373,6 +373,79 @@ func TestDispatchArchiveRedirect(t *testing.T) { } } +func TestDispatchMdlRedirect(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"*@example.com\": rwcda\n") + mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme")) + + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + cfg := config.Config{ + Root: root, + IndexPath: ".archive", + EmailHeader: "X-Auth-Request-Email", + } + ring := handler.NewLogRing(10) + + cases := []struct { + name string + path string + wantStatus int + wantLoc string + }{ + { + "mdl trailing slash → table", + "/ProjectA/archive/Acme/mdl/", + http.StatusFound, + "/ProjectA/archive/Acme/mdl.table.html", + }, + { + "case-fold MDL trailing slash", + "/ProjectA/archive/Acme/MDL/", + http.StatusFound, + "/ProjectA/archive/Acme/mdl.table.html", + }, + { + "case-fold ARCHIVE", + "/ProjectA/Archive/Acme/mdl/", + http.StatusFound, + "/ProjectA/Archive/Acme/mdl.table.html", + }, + { + "deeper than party-level mdl is NOT redirected", + "/ProjectA/archive/Acme/incoming/mdl/", + // Falls through to static-file pipeline; no folder exists there → 404. + http.StatusNotFound, + "", + }, + { + "working/mdl is NOT redirected (not under archive)", + "/ProjectA/working/mdl/", + http.StatusNotFound, + "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, rec, req) + if rec.Code != tc.wantStatus { + t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantLoc != "" { + if got := rec.Header().Get("Location"); got != tc.wantLoc { + t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc) + } + } + }) + } +} + // TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on // any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file // API's write path, so a write to an archive URL never silently mutates diff --git a/zddc/internal/handler/default-mdl.form.yaml b/zddc/internal/handler/default-mdl.form.yaml new file mode 100644 index 0000000..4bd855e --- /dev/null +++ b/zddc/internal/handler/default-mdl.form.yaml @@ -0,0 +1,48 @@ +# Default row schema for a Master Deliverables List entry. Served by +# zddc-server when no operator-supplied mdl.form.yaml exists at +# archive//. Operators can override per-party. + +title: Deliverable +description: One planned or in-flight deliverable for this party. + +schema: + type: object + required: [tracking, title] + additionalProperties: false + properties: + tracking: + type: string + title: Tracking number + description: ZDDC tracking identifier (e.g. proj-EM-SPC-0001). + minLength: 1 + title: + type: string + title: Deliverable title + minLength: 1 + discipline: + type: string + title: Discipline + description: Engineering discipline code (EM, EL, MC, ST, ...). + type: + type: string + title: Document type + description: Code for document class (SPC, DWG, RPT, ...). + plannedRevision: + type: string + title: Planned revision + description: Issue revision label, e.g. A, B, IFR, IFC. + plannedDate: + type: string + title: Planned date + format: date + status: + type: string + title: Current status + enum: [DFT, IFR, IFA, IFC, AFC, AB] + owner: + type: string + title: Owner + description: Email or party name responsible for producing this row. + notes: + type: string + title: Notes diff --git a/zddc/internal/handler/default-mdl.table.yaml b/zddc/internal/handler/default-mdl.table.yaml new file mode 100644 index 0000000..2bbec18 --- /dev/null +++ b/zddc/internal/handler/default-mdl.table.yaml @@ -0,0 +1,41 @@ +# Default Master Deliverables List spec, served by zddc-server when no +# operator-supplied mdl.table.yaml exists at archive//. Operators +# can override per-party by writing their own file at +# archive//mdl.table.yaml plus a tables: { mdl: ./mdl.table.yaml } +# entry in the party's .zddc. + +title: Master Deliverables List +description: Planned and actual deliverables for this party. + +rowSchema: ./mdl.form.yaml +rows: ./mdl + +columns: + - field: tracking + title: Tracking + width: 11em + sort: asc + - field: title + title: Deliverable + - field: discipline + title: Disc. + width: 5em + - field: type + title: Type + width: 6em + - field: plannedRevision + title: Rev. + width: 5em + - field: plannedDate + title: Planned + format: date + - field: status + title: Status + width: 6em + enum: [DFT, IFR, IFA, IFC, AFC, AB] + - field: owner + title: Owner + +defaults: + sort: + - { field: plannedDate, dir: asc } diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index 9845336..0463387 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -41,6 +41,66 @@ import ( //go:embed tables.html var embeddedTablesHTML []byte +//go:embed default-mdl.table.yaml +var embeddedDefaultMdlTable []byte + +//go:embed default-mdl.form.yaml +var embeddedDefaultMdlForm []byte + +// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes. +// Used by the static-file handler to serve the default spec at +// archive//mdl.table.yaml when no operator file exists on disk. +func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable } + +// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes. +func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm } + +// IsDefaultMdlSpec reports whether (urlPath, dirAbs) describes a request +// for the default mdl.table.yaml or mdl.form.yaml under archive// +// where no operator file exists. Caller is the static-file handler. +// +// Returns the embedded bytes + true when the fallback should fire. +// Returns nil + false when an operator-supplied file exists or the path +// is not eligible for the fallback. +func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) { + base := strings.ToLower(filepath.Base(urlPath)) + var bytes []byte + switch base { + case "mdl.table.yaml": + bytes = embeddedDefaultMdlTable + case "mdl.form.yaml": + bytes = embeddedDefaultMdlForm + default: + return nil, false + } + if !isAtArchivePartyLevel(fsRoot, urlPath) { + return nil, false + } + // Operator file wins if it exists on disk. + rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/") + abs := filepath.Join(fsRoot, filepath.FromSlash(rel)) + if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot { + return nil, false + } + if fileExists(abs) { + return nil, false + } + return bytes, true +} + +// isAtArchivePartyLevel reports whether urlPath refers to a file +// directly under /archive// (depth-3 directory). The +// canonical-folder names are case-folded. +func isAtArchivePartyLevel(fsRoot, urlPath string) bool { + rel := strings.Trim(filepath.ToSlash(urlPath), "/") + parts := strings.Split(rel, "/") + // /archive// = 4 segments + if len(parts) != 4 { + return false + } + return strings.EqualFold(parts[1], "archive") +} + // TableRequest describes a recognized table-system request. type TableRequest struct { // Name is the table's URL stem (the key declared in .zddc tables). @@ -86,28 +146,65 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest { } zddcPath := filepath.Join(dirAbs, ".zddc") zf, err := zddc.ParseFile(zddcPath) - if err != nil { + if err != nil && !isNotExistError(err) { // Malformed .zddc — log and pass through; static handler will 500 // if it cares. Recognition just says "not a declared table here." slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err) return nil } - specRel, ok := zf.Tables[name] - if !ok { - return nil + if specRel, ok := zf.Tables[name]; ok { + // Operator explicitly declared this table — honour it strictly. + // If the declared spec file is missing, return nil so the URL + // 404s rather than silently falling back to the default. This + // keeps a typo in the operator's .zddc visible. + specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel)) + if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot { + return nil + } + if !fileExists(specAbs) { + return nil + } + return &TableRequest{ + Name: name, + SpecPath: specAbs, + Dir: dirAbs, + } } - specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel)) - if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot { - return nil + // No operator declaration — apply the default MDL spec fallback at + // archive//. The spec bytes are served by IsDefaultMdlSpec via + // the static-file dispatcher. + if strings.EqualFold(name, "mdl") && isArchivePartyDir(fsRoot, dirAbs) { + return &TableRequest{ + Name: "mdl", + SpecPath: filepath.Join(dirAbs, "mdl.table.yaml"), // virtual; static handler may serve embedded bytes + Dir: dirAbs, + } } - if !fileExists(specAbs) { - return nil + return nil +} + +// isNotExistError reports whether err indicates a missing file. Local +// helper to avoid pulling errors.Is into the handler package. +func isNotExistError(err error) bool { + return err != nil && strings.Contains(err.Error(), "no such file or directory") +} + +// isArchivePartyDir reports whether dirAbs is a /archive// +// directory under fsRoot, with archive case-folded. +func isArchivePartyDir(fsRoot, dirAbs string) bool { + rel, err := filepath.Rel(fsRoot, dirAbs) + if err != nil { + return false } - return &TableRequest{ - Name: name, - SpecPath: specAbs, - Dir: dirAbs, + rel = filepath.ToSlash(rel) + if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." { + return false } + parts := strings.Split(rel, "/") + if len(parts) != 3 { + return false + } + return strings.EqualFold(parts[1], "archive") } // ServeTable serves the static tables.html bytes for a recognized diff --git a/zddc/internal/handler/tablehandler_test.go b/zddc/internal/handler/tablehandler_test.go index ccebca0..7b1442e 100644 --- a/zddc/internal/handler/tablehandler_test.go +++ b/zddc/internal/handler/tablehandler_test.go @@ -227,3 +227,144 @@ tables: t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) } } + +// --- default MDL spec fallback --------------------------------------------- + +// archivePartyTestSetup builds a minimal Project/archive// tree +// with no operator-supplied tables: declaration. RecognizeTableRequest +// should still fire for "mdl" thanks to the default-spec fallback. +func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(method, target, email string) *httptest.ResponseRecorder) { + t.Helper() + root := t.TempDir() + + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil { + t.Fatal(err) + } + partyDir := filepath.Join(root, "Project", "archive", "Acme") + if err := os.MkdirAll(partyDir, 0o755); err != nil { + t.Fatal(err) + } + if partyZddcExtras != "" { + if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), []byte(partyZddcExtras), 0o644); err != nil { + t.Fatal(err) + } + } + zddc.InvalidateCache(root) + + cfg := config.Config{ + Root: root, + EmailHeader: "X-Auth-Request-Email", + } + do := func(method, target, email string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, bytes.NewReader(nil)) + ctx := context.WithValue(req.Context(), EmailKey, email) + req = req.WithContext(ctx) + + tr := RecognizeTableRequest(cfg.Root, method, target) + rec := httptest.NewRecorder() + if tr == nil { + rec.WriteHeader(http.StatusNotFound) + return rec + } + ServeTable(cfg, tr, rec, req) + return rec + } + return root, do +} + +func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) { + _, do := archivePartyTestSetup(t, "") + + rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com") + if rec.Code != http.StatusOK { + t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "/archive//. A + // request at a deeper path (e.g. archive/Acme/mdl/sub/) or a + // non-archive path should return nil (no recognition). + _, do := archivePartyTestSetup(t, "") + + rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl.table.html", "alice@example.com") + if rec.Code != http.StatusNotFound { + t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code) + } + rec = do(http.MethodGet, "/Project/working/mdl.table.html", "alice@example.com") + if rec.Code != http.StatusNotFound { + t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code) + } +} + +func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) { + root := t.TempDir() + // archive/Acme/ exists but no mdl.table.yaml on disk. + if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil { + t.Fatal(err) + } + + bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml") + if !ok { + t.Fatalf("expected fallback to fire") + } + if !strings.Contains(string(bts), "Master Deliverables List") { + t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))]) + } + + bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.form.yaml") + if !ok { + t.Fatalf("expected form fallback to fire") + } + if !strings.Contains(string(bts), "Deliverable") { + t.Errorf("default form spec missing expected title") + } +} + +func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "Project", "archive", "Acme") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "mdl.table.yaml"), []byte("custom: yes\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml"); ok { + t.Errorf("operator file should win over embedded fallback") + } +} + +func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) { + root := t.TempDir() + cases := []string{ + "/Project/working/mdl.table.yaml", + "/Project/archive/mdl.table.yaml", // depth 3 — no party segment + "/Project/archive/Acme/sub/mdl.table.yaml", + } + for _, p := range cases { + if _, ok := IsDefaultMdlSpec(root, p); ok { + t.Errorf("path %q should NOT trigger default fallback", p) + } + } +} +