Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.
Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
instead of allow/deny lists.
Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
own their own .zddc; the policy decider's IsActiveAdmin short-circuit
is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
true after the retirement). Profile page renders AdminSubtrees
directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
IsAdminForChain — no production caller passed true.
Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).
ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
/.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
"deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
is the parent-deny-is-absolute variant. The in-process Go evaluator
implements only the commercial cascade.
Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
.zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.
.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
.zddc out of the dot-prefix guard so PUT/DELETE/POST reach
ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
(matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
body is designed to materialize on PUT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
462 lines
18 KiB
Go
462 lines
18 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// Plan Review — the doc-controller's "establish the canonical record"
|
|
// step. Right-click on archive/<party>/received/<tracking>/ in the
|
|
// browse app; the client POSTs X-ZDDC-Op: plan-review with the body
|
|
// below.
|
|
//
|
|
// Authorisation model — no ACL exception, only existing grants:
|
|
//
|
|
// - Create authority on received/<tracking>/. The doc_controller
|
|
// gets this from `worm: [document_controller]` on received/ in the
|
|
// cascade defaults; the same `c` (write-once-create) verb that
|
|
// lets them file canonical submittals lets them establish this
|
|
// .zddc once.
|
|
// - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The
|
|
// invoker must already administer those subtrees per the cascade
|
|
// defaults.
|
|
//
|
|
// Operation:
|
|
//
|
|
// 1. Workflow folders converge first (idempotent — match by
|
|
// .zddc.received_path; mkdir if missing; rewrite workflow .zddc
|
|
// with received_path + ACL).
|
|
// 2. Write received/<tracking>/.zddc — but only if it doesn't exist.
|
|
// The .zddc schema is server-constrained to {planned_review_date,
|
|
// planned_response_date, created_by} — no ACL, admins, or other
|
|
// fields, so this write cannot escalate the invoker's authority.
|
|
// If the file already exists, the canonical record is sealed; the
|
|
// dates in the request are ignored and the workflow folders are
|
|
// converged on top.
|
|
//
|
|
// So Plan Review's first run establishes the canonical commitment;
|
|
// subsequent runs can only re-converge the workflow ACLs (e.g. swap
|
|
// review lead). The planned dates are write-once — to change them, an
|
|
// admin must edit received/<tracking>/.zddc directly via their admin
|
|
// authority (which under the cascade defaults is nobody beneath the
|
|
// root admin; deliberate).
|
|
|
|
const opPlanReview = "plan-review"
|
|
|
|
// planReviewRequest is the YAML body the browse client POSTs.
|
|
type planReviewRequest struct {
|
|
ReviewLead string `yaml:"review_lead"`
|
|
Approver string `yaml:"approver"`
|
|
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
|
|
PlanResponseDate string `yaml:"plan_response_date"`
|
|
}
|
|
|
|
// planReviewResponse is the JSON returned to the client.
|
|
type planReviewResponse struct {
|
|
Tracking string `json:"tracking"`
|
|
Title string `json:"title"`
|
|
Reviewing planReviewFolderOK `json:"reviewing"`
|
|
Staging planReviewFolderOK `json:"staging"`
|
|
Received planReviewFolderOK `json:"received"`
|
|
}
|
|
|
|
type planReviewFolderOK struct {
|
|
Path string `json:"path"`
|
|
Created bool `json:"created"`
|
|
ZddcWritten bool `json:"zddc_written"`
|
|
}
|
|
|
|
// receivedURLPattern matches /<project>/archive/<party>/received/<tracking>/
|
|
// — Plan Review is only valid at that depth. Trailing slash required.
|
|
var receivedURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/received/([^/]+)/?$`)
|
|
|
|
func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
// 1. URL must be a received-tracking folder.
|
|
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
|
|
m := receivedURLPattern.FindStringSubmatch(cleanURL)
|
|
if m == nil {
|
|
http.Error(w, "Bad Request — plan-review must POST to /<project>/archive/<party>/received/<tracking>/", http.StatusBadRequest)
|
|
return
|
|
}
|
|
project, party, tracking := m[1], m[2], m[3]
|
|
|
|
// 2. Body parse.
|
|
body, ok := readBodyCapped(cfg, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req planReviewRequest
|
|
if err := yaml.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.ReviewLead == "" || req.Approver == "" ||
|
|
req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
|
|
http.Error(w, "Bad Request — body must include review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
resp, status, msg := executePlanReview(cfg, r, project, party, tracking, req)
|
|
if status != http.StatusOK {
|
|
auditFile(r, "plan-review", cleanURL, status, 0, nil)
|
|
http.Error(w, msg, status)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-ZDDC-Source", "fileapi:plan-review")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
auditFile(r, "plan-review", cleanURL, http.StatusOK, 0, nil)
|
|
}
|
|
|
|
// executePlanReview runs the Plan Review three-stage flow against an
|
|
// already-resolved received/<tracking>/ path. URL and body parsing
|
|
// happen in the caller. Returns the response struct on success;
|
|
// non-200 (status, message) on auth or execution failure. The caller
|
|
// is responsible for writing the HTTP response.
|
|
//
|
|
// Exposed so accept-transmittal can chain Plan Review in the same
|
|
// request without round-tripping through HTTP.
|
|
func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) {
|
|
receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking))
|
|
receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
|
|
cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
|
|
|
|
prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs)
|
|
if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" {
|
|
return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree"
|
|
}
|
|
reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/")))
|
|
stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/")))
|
|
|
|
// Pre-flight authorisation. No ACL exception — we use existing
|
|
// cascade grants:
|
|
// (a) ActionAdmin on reviewing_root and staging_root proves the
|
|
// invoker is subtree-admin of the workflow roots and can
|
|
// write the workflow .zddc files.
|
|
// (b) The invoker has `c` (write-once-create) authority on
|
|
// received/<tracking>/. For the doc_controller this comes
|
|
// from `worm: [document_controller]` on received/ in the
|
|
// cascade defaults — the same authority that lets them file
|
|
// canonical submittals lets them establish this .zddc once.
|
|
p := PrincipalFromContext(r)
|
|
email := EmailFromContext(r)
|
|
if email == "" {
|
|
return nil, http.StatusForbidden, "Forbidden — no authenticated principal"
|
|
}
|
|
// All three pre-flight checks go through the consolidated decider.
|
|
// AllowActionFromChainP routes ActionAdmin .zddc edits and the
|
|
// single admin-bypass branch for elevated admins. No manual
|
|
// IsAdmin / IsSubtreeAdmin branching here.
|
|
decider := DeciderFromContext(r)
|
|
for _, root := range []string{reviewingRoot, stagingRoot} {
|
|
chain, perr := zddc.EffectivePolicy(cfg.Root, root)
|
|
if perr != nil {
|
|
return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
|
|
}
|
|
rel, _ := filepath.Rel(cfg.Root, root)
|
|
rootURL := "/" + filepath.ToSlash(rel) + "/.zddc"
|
|
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, rootURL, policy.ActionAdmin)
|
|
if !allowed {
|
|
return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks subtree-admin authority for %s",
|
|
email, strings.TrimPrefix(root, cfg.Root+string(filepath.Separator)))
|
|
}
|
|
}
|
|
// Verify `c` (create) authority on received/<tracking>/. Elevated
|
|
// admins short-circuit inside the decider; non-admin doc_controllers
|
|
// come through the WORM-list grant. One code path either way.
|
|
{
|
|
chain, perr := zddc.EffectivePolicy(cfg.Root, receivedAbs)
|
|
if perr != nil {
|
|
return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
|
|
}
|
|
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, cleanURL, policy.ActionCreate)
|
|
if !allowed {
|
|
return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks create authority on %s (filing this submittal requires the doc_controller WORM grant)",
|
|
email, strings.TrimPrefix(receivedAbs, cfg.Root+string(filepath.Separator)))
|
|
}
|
|
}
|
|
|
|
// Derive a title from received/<tracking>/'s contents — first
|
|
// ZDDC-parseable filename's title field wins. Fallback to the
|
|
// tracking number itself so the folder name always has a tail.
|
|
title := deriveTitleFromReceived(receivedAbs)
|
|
if title == "" {
|
|
title = tracking
|
|
}
|
|
|
|
// Materialise roots + received/<tracking>/ ancestors (the received
|
|
// folder itself was created when the doc controller moved the
|
|
// submittal in; defensive ensure here for tests).
|
|
for _, root := range []string{reviewingRoot, stagingRoot, receivedAbs} {
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return nil, http.StatusInternalServerError, "Internal Server Error — ensure dirs: " + err.Error()
|
|
}
|
|
}
|
|
|
|
// received/<tracking>/.zddc is WRITE-ONCE — the canonical commitment.
|
|
// First-run creates it under the invoker's WORM-`c` authority
|
|
// (verified above); subsequent runs leave it alone and the request's
|
|
// date fields are ignored. The schema is server-constrained: only
|
|
// planned_review_date + planned_response_date + created_by are written.
|
|
// No ACL, admins, or other content — so this write cannot escalate
|
|
// the invoker's authority.
|
|
receivedResult, err := establishReceivedPlanDates(receivedAbs, req.PlanReviewCompleteDate, req.PlanResponseDate, email, cfg.Root)
|
|
if err != nil {
|
|
return nil, http.StatusInternalServerError, "Internal Server Error — received .zddc: " + err.Error()
|
|
}
|
|
|
|
// Converge the workflow folders.
|
|
reviewingResult, err := convergeWorkflowFolder(workflowConverge{
|
|
fsRoot: cfg.Root,
|
|
root: reviewingRoot,
|
|
forecast: req.PlanReviewCompleteDate,
|
|
tracking: tracking,
|
|
title: title,
|
|
receivedRel: receivedRel,
|
|
acl: map[string]string{req.ReviewLead: "rwcda"},
|
|
creatorEmail: email,
|
|
})
|
|
if err != nil {
|
|
return nil, http.StatusInternalServerError, "Internal Server Error — reviewing convergence: " + err.Error()
|
|
}
|
|
|
|
stagingResult, err := convergeWorkflowFolder(workflowConverge{
|
|
fsRoot: cfg.Root,
|
|
root: stagingRoot,
|
|
forecast: req.PlanResponseDate,
|
|
tracking: tracking,
|
|
title: title,
|
|
receivedRel: receivedRel,
|
|
acl: map[string]string{req.Approver: "rwcda"},
|
|
creatorEmail: email,
|
|
})
|
|
if err != nil {
|
|
return nil, http.StatusInternalServerError, "Internal Server Error — staging convergence: " + err.Error()
|
|
}
|
|
|
|
return &planReviewResponse{
|
|
Tracking: tracking,
|
|
Title: title,
|
|
Reviewing: planReviewFolderOK{
|
|
Path: "/" + filepath.ToSlash(reviewingResult.relPath) + "/",
|
|
Created: reviewingResult.created,
|
|
ZddcWritten: reviewingResult.zddcWritten,
|
|
},
|
|
Staging: planReviewFolderOK{
|
|
Path: "/" + filepath.ToSlash(stagingResult.relPath) + "/",
|
|
Created: stagingResult.created,
|
|
ZddcWritten: stagingResult.zddcWritten,
|
|
},
|
|
Received: planReviewFolderOK{
|
|
Path: "/" + filepath.ToSlash(receivedResult.relPath) + "/",
|
|
Created: receivedResult.created,
|
|
ZddcWritten: receivedResult.zddcWritten,
|
|
},
|
|
}, http.StatusOK, ""
|
|
}
|
|
|
|
// establishReceivedPlanDates writes received/<tracking>/.zddc with the
|
|
// committed planned dates iff the file doesn't yet exist. If it does,
|
|
// the canonical record is already sealed and the call is a no-op
|
|
// (zddcWritten=false in the result); the request's date fields are
|
|
// silently ignored on subsequent runs. The schema is server-constrained
|
|
// to just the two date fields + created_by — no ACL or admin grants.
|
|
func establishReceivedPlanDates(receivedAbs, planReview, planResponse, creatorEmail, fsRoot string) (workflowResult, error) {
|
|
var res workflowResult
|
|
res.absPath = receivedAbs
|
|
if rel, err := filepath.Rel(fsRoot, receivedAbs); err == nil {
|
|
res.relPath = filepath.ToSlash(rel)
|
|
} else {
|
|
res.relPath = receivedAbs
|
|
}
|
|
zddcPath := filepath.Join(receivedAbs, ".zddc")
|
|
if _, err := os.Stat(zddcPath); err == nil {
|
|
// Sealed — leave alone. zddcWritten stays false.
|
|
return res, nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return res, err
|
|
}
|
|
zf := zddc.ZddcFile{
|
|
PlannedReviewDate: planReview,
|
|
PlannedResponseDate: planResponse,
|
|
CreatedBy: creatorEmail,
|
|
}
|
|
if err := zddc.WriteFile(receivedAbs, zf); err != nil {
|
|
return res, err
|
|
}
|
|
res.zddcWritten = true
|
|
res.created = true // first-time establishment
|
|
return res, nil
|
|
}
|
|
|
|
// deriveTitleFromReceived scans received/<tracking>/ for ZDDC-parseable
|
|
// filenames and returns the first one's title field. Empty if no
|
|
// parseable file is found.
|
|
func deriveTitleFromReceived(receivedAbs string) string {
|
|
entries, err := os.ReadDir(receivedAbs)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
// Sort for deterministic title selection (first alphabetical wins).
|
|
names := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
names = append(names, e.Name())
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
parsed := zddc.ParseFilename(name)
|
|
if parsed.Valid && parsed.Title != "" {
|
|
return parsed.Title
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// workflowConverge captures the parameters for converging a single
|
|
// reviewing/ or staging/ workflow folder.
|
|
type workflowConverge struct {
|
|
fsRoot string // master root (cfg.Root) — used to compute response paths
|
|
root string // absolute path of reviewing_root or staging_root
|
|
forecast string // initial forecast date for the folder name (YYYY-MM-DD)
|
|
tracking string // tracking number
|
|
title string // derived title
|
|
receivedRel string // relative path to canonical submittal, e.g. archive/Acme/received/Acme-0042
|
|
acl map[string]string // per-folder ACL grants (principal → verb-set)
|
|
creatorEmail string // creator/audit email
|
|
}
|
|
|
|
// workflowResult is the post-convergence summary for one folder.
|
|
type workflowResult struct {
|
|
relPath string // server-relative path (no leading slash, no trailing slash)
|
|
absPath string
|
|
created bool // true iff this convergence run mkdir'd the folder
|
|
zddcWritten bool // true iff a .zddc was written (always true on success)
|
|
}
|
|
|
|
// convergeWorkflowFolder converges one of the workflow folders (reviewing
|
|
// or staging) toward the desired state. Idempotent on re-run.
|
|
func convergeWorkflowFolder(c workflowConverge) (workflowResult, error) {
|
|
var res workflowResult
|
|
|
|
// Search the root for an existing folder whose .zddc.received_path
|
|
// matches. If found, use it — the user controls the folder name via
|
|
// direct rename, so we don't fight their date.
|
|
existing, err := findWorkflowFolderByReceivedPath(c.root, c.receivedRel)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
target := existing
|
|
if target == "" {
|
|
// No match — mkdir at <root>/<forecast>_<tracking> (TBD) - <title>/.
|
|
// Append _2, _3 to disambiguate exact-name collisions with a
|
|
// folder belonging to a DIFFERENT submittal.
|
|
baseName := sanitiseFolderName(fmt.Sprintf("%s_%s (TBD) - %s", c.forecast, c.tracking, c.title))
|
|
candidate := filepath.Join(c.root, baseName)
|
|
for n := 2; n <= 100; n++ {
|
|
if _, statErr := os.Stat(candidate); errors.Is(statErr, os.ErrNotExist) {
|
|
break
|
|
} else if statErr != nil {
|
|
return res, statErr
|
|
}
|
|
candidate = filepath.Join(c.root, fmt.Sprintf("%s_%d", baseName, n))
|
|
if n == 100 {
|
|
return res, fmt.Errorf("convergence: exhausted suffix attempts for %s", baseName)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(candidate, 0o755); err != nil {
|
|
return res, fmt.Errorf("mkdir workflow folder: %w", err)
|
|
}
|
|
target = candidate
|
|
res.created = true
|
|
}
|
|
|
|
// Write .zddc with desired content. Overwrites if present. Workflow
|
|
// .zddc carries received_path + acl ONLY — no planned dates (those
|
|
// live in the canonical received/.zddc, which the sub-admins
|
|
// cannot modify).
|
|
zf := zddc.ZddcFile{
|
|
ReceivedPath: c.receivedRel,
|
|
CreatedBy: c.creatorEmail,
|
|
}
|
|
if len(c.acl) > 0 {
|
|
zf.ACL = zddc.ACLRules{Permissions: c.acl}
|
|
}
|
|
if err := zddc.WriteFile(target, zf); err != nil {
|
|
return res, fmt.Errorf("write workflow .zddc: %w", err)
|
|
}
|
|
res.zddcWritten = true
|
|
|
|
res.absPath = target
|
|
if rel, err := filepath.Rel(c.fsRoot, target); err == nil {
|
|
res.relPath = filepath.ToSlash(rel)
|
|
} else {
|
|
res.relPath = target
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// findWorkflowFolderByReceivedPath scans root for direct children
|
|
// whose .zddc has received_path matching the given relative path.
|
|
// Returns the matching absolute path, or "" if none.
|
|
func findWorkflowFolderByReceivedPath(root, receivedRel string) (string, error) {
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
want := filepath.ToSlash(filepath.Clean(receivedRel))
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
zddcPath := filepath.Join(root, e.Name(), ".zddc")
|
|
zf, perr := zddc.ParseFile(zddcPath)
|
|
if perr != nil {
|
|
slog.Warn("plan-review: parse workflow .zddc", "path", zddcPath, "err", perr)
|
|
continue
|
|
}
|
|
if zf.ReceivedPath == "" {
|
|
continue
|
|
}
|
|
got := filepath.ToSlash(filepath.Clean(zf.ReceivedPath))
|
|
if got == want {
|
|
return filepath.Join(root, e.Name()), nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// sanitiseFolderName replaces filesystem-troublesome characters in a
|
|
// title with safe substitutes. Conservative — keeps the ZDDC folder
|
|
// grammar (the parens and the " - " separator) intact while taming
|
|
// arbitrary user input in the title segment.
|
|
func sanitiseFolderName(name string) string {
|
|
repl := strings.NewReplacer(
|
|
"/", "-",
|
|
"\\", "-",
|
|
":", "-",
|
|
"\x00", "",
|
|
)
|
|
return strings.TrimSpace(repl.Replace(name))
|
|
}
|