From 464c40db9ce01bd1ad45482f7bda647f7aac679c Mon Sep 17 00:00:00 2001 From: Catherine Date: Thu, 4 Dec 2025 18:23:42 +0000 Subject: [PATCH] Add `Create-Parents:` mode to PATCH method. This acts like `mkdir -p`, making it much less annoying to deploy e.g. documentation preview generators that use deep paths. Like before, the site must already exist: we cannot do a CAS on a non-existent manifest at the moment. --- README.md | 3 ++- src/pages.go | 21 ++++++++++++++++----- src/patch.go | 24 +++++++++++++++++++----- src/update.go | 3 ++- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0dadb03..ea636e7 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ Features - A character device entry with major 0 and minor 0 is treated as a "whiteout marker" (following [unionfs][whiteout]): it causes any existing file or directory with the same name to be deleted. - A directory entry replaces any existing file or directory with the same name (if any), recursively removing the old contents. - A file or symlink entry replaces any existing file or directory with the same name (if any). - - In any case, the parent of an entry must exist and be a directory. + - If there is no `Create-Parents:` header or a `Create-Parents: no` header is present, the parent path of an entry must exist and refer to a directory. + - If a `Create-Parents: yes` header is present, any missing segments in the parent path of an entry will be created (like `mkdir -p`). Any existing segments refer to directories. - The request must have a `Atomic: yes` or `Atomic: no` header. Not every backend configuration makes it possible to perform atomic compare-and-swap operations; on backends without atomic CAS support, `Atomic: yes` requests will fail, while `Atomic: no` requests will provide a best-effort approximation. - If a `PATCH` request loses a race against another content update request, it may return `409 Conflict`. This is true regardless of the `Atomic:` header value. Whenever this happens, resubmit the request as-is. - If the site has no contents after the update is applied, performs the same action as `DELETE`. diff --git a/src/pages.go b/src/pages.go index 2599830..735c08f 100644 --- a/src/pages.go +++ b/src/pages.go @@ -452,7 +452,7 @@ func putPage(w http.ResponseWriter, r *http.Request) error { return err } - updateCtx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) defer cancel() contentType := getMediaType(r.Header.Get("Content-Type")) @@ -486,7 +486,7 @@ func putPage(w http.ResponseWriter, r *http.Request) error { return nil } - result = UpdateFromRepository(updateCtx, webRoot, repoURL, branch) + result = UpdateFromRepository(ctx, webRoot, repoURL, branch) default: _, err := AuthorizeUpdateFromArchive(r) @@ -500,7 +500,7 @@ func putPage(w http.ResponseWriter, r *http.Request) error { // request body contains archive reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) - result = UpdateFromArchive(updateCtx, webRoot, contentType, reader) + result = UpdateFromArchive(ctx, webRoot, contentType, reader) } return reportUpdateResult(w, result) @@ -554,12 +554,23 @@ func patchPage(w http.ResponseWriter, r *http.Request) error { return nil } - updateCtx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) + var parents CreateParentsMode + switch r.Header.Get("Create-Parents") { + case "", "no": + parents = RequireParents + case "yes": + parents = CreateParents + default: + http.Error(w, "malformed Create-Parents: header", http.StatusBadRequest) + return nil + } + + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) defer cancel() contentType := getMediaType(r.Header.Get("Content-Type")) reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) - result := PartialUpdateFromArchive(updateCtx, webRoot, contentType, reader) + result := PartialUpdateFromArchive(ctx, webRoot, contentType, reader, parents) return reportUpdateResult(w, result) } diff --git a/src/patch.go b/src/patch.go index ad5d3b3..78d06c9 100644 --- a/src/patch.go +++ b/src/patch.go @@ -12,12 +12,19 @@ import ( var ErrMalformedPatch = errors.New("malformed patch") +type CreateParentsMode int + +const ( + RequireParents CreateParentsMode = iota + CreateParents +) + // Mutates `manifest` according to a tar stream and the following rules: // - A character device with major 0 and minor 0 is a "whiteout marker". When placed // at a given path, this path and its entire subtree (if any) are removed from the manifest. // - When a directory is placed at a given path, this path and its entire subtree (if any) are // removed from the manifest and replaced with the contents of the directory. -func ApplyTarPatch(manifest *Manifest, reader io.Reader) error { +func ApplyTarPatch(manifest *Manifest, reader io.Reader, parents CreateParentsMode) error { type Node struct { entry *Entry children map[string]*Node @@ -72,11 +79,18 @@ func ApplyTarPatch(manifest *Manifest, reader io.Reader) error { return fmt.Errorf("%w: %s: not a directory", ErrMalformedPatch, dirName) } if _, exists := node.children[segment]; !exists { - nodeName := strings.Join(segments[:index+1], "/") - return fmt.Errorf("%w: %s: path not found", ErrMalformedPatch, nodeName) - } else { - node = node.children[segment] + switch parents { + case RequireParents: + nodeName := strings.Join(segments[:index+1], "/") + return fmt.Errorf("%w: %s: path not found", ErrMalformedPatch, nodeName) + case CreateParents: + node.children[segment] = &Node{ + entry: NewManifestEntry(Type_Directory, nil), + children: map[string]*Node{}, + } + } } + node = node.children[segment] } if node.children == nil { dirName := strings.Join(segments[:len(segments)-1], "/") diff --git a/src/update.go b/src/update.go index 4aa8276..e0a6af5 100644 --- a/src/update.go +++ b/src/update.go @@ -159,6 +159,7 @@ func PartialUpdateFromArchive( webRoot string, contentType string, reader io.Reader, + parents CreateParentsMode, ) (result UpdateResult) { var err error @@ -177,7 +178,7 @@ func PartialUpdateFromArchive( // `*Manifest` objects, which should never be mutated. newManifest := &Manifest{} proto.Merge(newManifest, oldManifest) - if err := ApplyTarPatch(newManifest, reader); err != nil { + if err := ApplyTarPatch(newManifest, reader, parents); err != nil { return nil, err } else { return newManifest, nil