[mirror] Scalable static site server for Git forges (like GitHub Pages)

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.

Changed files
+39 -12
src
+2 -1
README.md
··· 79 79 - 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. 80 80 - A directory entry replaces any existing file or directory with the same name (if any), recursively removing the old contents. 81 81 - A file or symlink entry replaces any existing file or directory with the same name (if any). 82 - - In any case, the parent of an entry must exist and be a directory. 82 + - 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. 83 + - 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. 83 84 - 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. 84 85 - 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. 85 86 - If the site has no contents after the update is applied, performs the same action as `DELETE`.
+16 -5
src/pages.go
··· 452 452 return err 453 453 } 454 454 455 - updateCtx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 455 + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 456 456 defer cancel() 457 457 458 458 contentType := getMediaType(r.Header.Get("Content-Type")) ··· 486 486 return nil 487 487 } 488 488 489 - result = UpdateFromRepository(updateCtx, webRoot, repoURL, branch) 489 + result = UpdateFromRepository(ctx, webRoot, repoURL, branch) 490 490 491 491 default: 492 492 _, err := AuthorizeUpdateFromArchive(r) ··· 500 500 501 501 // request body contains archive 502 502 reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) 503 - result = UpdateFromArchive(updateCtx, webRoot, contentType, reader) 503 + result = UpdateFromArchive(ctx, webRoot, contentType, reader) 504 504 } 505 505 506 506 return reportUpdateResult(w, result) ··· 554 554 return nil 555 555 } 556 556 557 - updateCtx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 557 + var parents CreateParentsMode 558 + switch r.Header.Get("Create-Parents") { 559 + case "", "no": 560 + parents = RequireParents 561 + case "yes": 562 + parents = CreateParents 563 + default: 564 + http.Error(w, "malformed Create-Parents: header", http.StatusBadRequest) 565 + return nil 566 + } 567 + 568 + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 558 569 defer cancel() 559 570 560 571 contentType := getMediaType(r.Header.Get("Content-Type")) 561 572 reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) 562 - result := PartialUpdateFromArchive(updateCtx, webRoot, contentType, reader) 573 + result := PartialUpdateFromArchive(ctx, webRoot, contentType, reader, parents) 563 574 return reportUpdateResult(w, result) 564 575 } 565 576
+19 -5
src/patch.go
··· 12 12 13 13 var ErrMalformedPatch = errors.New("malformed patch") 14 14 15 + type CreateParentsMode int 16 + 17 + const ( 18 + RequireParents CreateParentsMode = iota 19 + CreateParents 20 + ) 21 + 15 22 // Mutates `manifest` according to a tar stream and the following rules: 16 23 // - A character device with major 0 and minor 0 is a "whiteout marker". When placed 17 24 // at a given path, this path and its entire subtree (if any) are removed from the manifest. 18 25 // - When a directory is placed at a given path, this path and its entire subtree (if any) are 19 26 // removed from the manifest and replaced with the contents of the directory. 20 - func ApplyTarPatch(manifest *Manifest, reader io.Reader) error { 27 + func ApplyTarPatch(manifest *Manifest, reader io.Reader, parents CreateParentsMode) error { 21 28 type Node struct { 22 29 entry *Entry 23 30 children map[string]*Node ··· 72 79 return fmt.Errorf("%w: %s: not a directory", ErrMalformedPatch, dirName) 73 80 } 74 81 if _, exists := node.children[segment]; !exists { 75 - nodeName := strings.Join(segments[:index+1], "/") 76 - return fmt.Errorf("%w: %s: path not found", ErrMalformedPatch, nodeName) 77 - } else { 78 - node = node.children[segment] 82 + switch parents { 83 + case RequireParents: 84 + nodeName := strings.Join(segments[:index+1], "/") 85 + return fmt.Errorf("%w: %s: path not found", ErrMalformedPatch, nodeName) 86 + case CreateParents: 87 + node.children[segment] = &Node{ 88 + entry: NewManifestEntry(Type_Directory, nil), 89 + children: map[string]*Node{}, 90 + } 91 + } 79 92 } 93 + node = node.children[segment] 80 94 } 81 95 if node.children == nil { 82 96 dirName := strings.Join(segments[:len(segments)-1], "/")
+2 -1
src/update.go
··· 159 159 webRoot string, 160 160 contentType string, 161 161 reader io.Reader, 162 + parents CreateParentsMode, 162 163 ) (result UpdateResult) { 163 164 var err error 164 165 ··· 177 178 // `*Manifest` objects, which should never be mutated. 178 179 newManifest := &Manifest{} 179 180 proto.Merge(newManifest, oldManifest) 180 - if err := ApplyTarPatch(newManifest, reader); err != nil { 181 + if err := ApplyTarPatch(newManifest, reader, parents); err != nil { 181 182 return nil, err 182 183 } else { 183 184 return newManifest, nil