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.
This commit is contained in:
Catherine
2025-12-04 18:23:42 +00:00
parent 93565e4e04
commit 464c40db9c
4 changed files with 39 additions and 12 deletions

View File

@@ -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`.

View File

@@ -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)
}

View File

@@ -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], "/")

View File

@@ -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