github.com/kovansky/hugo@v0.92.3-0.20220224232819-63076e4ff19f/deploy/deploy.go (about) 1 // Copyright 2019 The Hugo Authors. All rights reserved. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // http://www.apache.org/licenses/LICENSE-2.0 7 // 8 // Unless required by applicable law or agreed to in writing, software 9 // distributed under the License is distributed on an "AS IS" BASIS, 10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 // See the License for the specific language governing permissions and 12 // limitations under the License. 13 14 // +build !nodeploy 15 16 package deploy 17 18 import ( 19 "bytes" 20 "compress/gzip" 21 "context" 22 "crypto/md5" 23 "fmt" 24 "io" 25 "io/ioutil" 26 "mime" 27 "os" 28 "path/filepath" 29 "regexp" 30 "runtime" 31 "sort" 32 "strings" 33 "sync" 34 35 "github.com/dustin/go-humanize" 36 "github.com/gobwas/glob" 37 "github.com/gohugoio/hugo/config" 38 "github.com/gohugoio/hugo/media" 39 "github.com/pkg/errors" 40 "github.com/spf13/afero" 41 jww "github.com/spf13/jwalterweatherman" 42 "golang.org/x/text/unicode/norm" 43 44 "gocloud.dev/blob" 45 _ "gocloud.dev/blob/fileblob" // import 46 _ "gocloud.dev/blob/gcsblob" // import 47 _ "gocloud.dev/blob/s3blob" // import 48 "gocloud.dev/gcerrors" 49 ) 50 51 // Deployer supports deploying the site to target cloud providers. 52 type Deployer struct { 53 localFs afero.Fs 54 bucket *blob.Bucket 55 56 target *target // the target to deploy to 57 matchers []*matcher // matchers to apply to uploaded files 58 mediaTypes media.Types // Hugo's MediaType to guess ContentType 59 ordering []*regexp.Regexp // orders uploads 60 quiet bool // true reduces STDOUT 61 confirm bool // true enables confirmation before making changes 62 dryRun bool // true skips conformations and prints changes instead of applying them 63 force bool // true forces upload of all files 64 invalidateCDN bool // true enables invalidate CDN cache (if possible) 65 maxDeletes int // caps the # of files to delete; -1 to disable 66 67 // For tests... 68 summary deploySummary // summary of latest Deploy results 69 } 70 71 type deploySummary struct { 72 NumLocal, NumRemote, NumUploads, NumDeletes int 73 } 74 75 // New constructs a new *Deployer. 76 func New(cfg config.Provider, localFs afero.Fs) (*Deployer, error) { 77 targetName := cfg.GetString("target") 78 79 // Load the [deployment] section of the config. 80 dcfg, err := decodeConfig(cfg) 81 if err != nil { 82 return nil, err 83 } 84 85 if len(dcfg.Targets) == 0 { 86 return nil, errors.New("no deployment targets found") 87 } 88 89 // Find the target to deploy to. 90 var tgt *target 91 if targetName == "" { 92 // Default to the first target. 93 tgt = dcfg.Targets[0] 94 } else { 95 for _, t := range dcfg.Targets { 96 if t.Name == targetName { 97 tgt = t 98 } 99 } 100 if tgt == nil { 101 return nil, fmt.Errorf("deployment target %q not found", targetName) 102 } 103 } 104 105 return &Deployer{ 106 localFs: localFs, 107 target: tgt, 108 matchers: dcfg.Matchers, 109 ordering: dcfg.ordering, 110 mediaTypes: dcfg.mediaTypes, 111 quiet: cfg.GetBool("quiet"), 112 confirm: cfg.GetBool("confirm"), 113 dryRun: cfg.GetBool("dryRun"), 114 force: cfg.GetBool("force"), 115 invalidateCDN: cfg.GetBool("invalidateCDN"), 116 maxDeletes: cfg.GetInt("maxDeletes"), 117 }, nil 118 } 119 120 func (d *Deployer) openBucket(ctx context.Context) (*blob.Bucket, error) { 121 if d.bucket != nil { 122 return d.bucket, nil 123 } 124 jww.FEEDBACK.Printf("Deploying to target %q (%s)\n", d.target.Name, d.target.URL) 125 return blob.OpenBucket(ctx, d.target.URL) 126 } 127 128 // Deploy deploys the site to a target. 129 func (d *Deployer) Deploy(ctx context.Context) error { 130 bucket, err := d.openBucket(ctx) 131 if err != nil { 132 return err 133 } 134 135 // Load local files from the source directory. 136 var include, exclude glob.Glob 137 if d.target != nil { 138 include, exclude = d.target.includeGlob, d.target.excludeGlob 139 } 140 local, err := walkLocal(d.localFs, d.matchers, include, exclude, d.mediaTypes) 141 if err != nil { 142 return err 143 } 144 jww.INFO.Printf("Found %d local files.\n", len(local)) 145 d.summary.NumLocal = len(local) 146 147 // Load remote files from the target. 148 remote, err := walkRemote(ctx, bucket, include, exclude) 149 if err != nil { 150 return err 151 } 152 jww.INFO.Printf("Found %d remote files.\n", len(remote)) 153 d.summary.NumRemote = len(remote) 154 155 // Diff local vs remote to see what changes need to be applied. 156 uploads, deletes := findDiffs(local, remote, d.force) 157 d.summary.NumUploads = len(uploads) 158 d.summary.NumDeletes = len(deletes) 159 if len(uploads)+len(deletes) == 0 { 160 if !d.quiet { 161 jww.FEEDBACK.Println("No changes required.") 162 } 163 return nil 164 } 165 if !d.quiet { 166 jww.FEEDBACK.Println(summarizeChanges(uploads, deletes)) 167 } 168 169 // Ask for confirmation before proceeding. 170 if d.confirm && !d.dryRun { 171 fmt.Printf("Continue? (Y/n) ") 172 var confirm string 173 if _, err := fmt.Scanln(&confirm); err != nil { 174 return err 175 } 176 if confirm != "" && confirm[0] != 'y' && confirm[0] != 'Y' { 177 return errors.New("aborted") 178 } 179 } 180 181 // Order the uploads. They are organized in groups; all uploads in a group 182 // must be complete before moving on to the next group. 183 uploadGroups := applyOrdering(d.ordering, uploads) 184 185 // Apply the changes in parallel, using an inverted worker 186 // pool (https://www.youtube.com/watch?v=5zXAHh5tJqQ&t=26m58s). 187 // sem prevents more than nParallel concurrent goroutines. 188 const nParallel = 10 189 var errs []error 190 var errMu sync.Mutex // protects errs 191 192 for _, uploads := range uploadGroups { 193 // Short-circuit for an empty group. 194 if len(uploads) == 0 { 195 continue 196 } 197 198 // Within the group, apply uploads in parallel. 199 sem := make(chan struct{}, nParallel) 200 for _, upload := range uploads { 201 if d.dryRun { 202 if !d.quiet { 203 jww.FEEDBACK.Printf("[DRY RUN] Would upload: %v\n", upload) 204 } 205 continue 206 } 207 208 sem <- struct{}{} 209 go func(upload *fileToUpload) { 210 if err := doSingleUpload(ctx, bucket, upload); err != nil { 211 errMu.Lock() 212 defer errMu.Unlock() 213 errs = append(errs, err) 214 } 215 <-sem 216 }(upload) 217 } 218 // Wait for all uploads in the group to finish. 219 for n := nParallel; n > 0; n-- { 220 sem <- struct{}{} 221 } 222 } 223 224 if d.maxDeletes != -1 && len(deletes) > d.maxDeletes { 225 jww.WARN.Printf("Skipping %d deletes because it is more than --maxDeletes (%d). If this is expected, set --maxDeletes to a larger number, or -1 to disable this check.\n", len(deletes), d.maxDeletes) 226 d.summary.NumDeletes = 0 227 } else { 228 // Apply deletes in parallel. 229 sort.Slice(deletes, func(i, j int) bool { return deletes[i] < deletes[j] }) 230 sem := make(chan struct{}, nParallel) 231 for _, del := range deletes { 232 if d.dryRun { 233 if !d.quiet { 234 jww.FEEDBACK.Printf("[DRY RUN] Would delete %s\n", del) 235 } 236 continue 237 } 238 sem <- struct{}{} 239 go func(del string) { 240 jww.INFO.Printf("Deleting %s...\n", del) 241 if err := bucket.Delete(ctx, del); err != nil { 242 if gcerrors.Code(err) == gcerrors.NotFound { 243 jww.WARN.Printf("Failed to delete %q because it wasn't found: %v", del, err) 244 } else { 245 errMu.Lock() 246 defer errMu.Unlock() 247 errs = append(errs, err) 248 } 249 } 250 <-sem 251 }(del) 252 } 253 // Wait for all deletes to finish. 254 for n := nParallel; n > 0; n-- { 255 sem <- struct{}{} 256 } 257 } 258 if len(errs) > 0 { 259 if !d.quiet { 260 jww.FEEDBACK.Printf("Encountered %d errors.\n", len(errs)) 261 } 262 return errs[0] 263 } 264 if !d.quiet { 265 jww.FEEDBACK.Println("Success!") 266 } 267 268 if d.invalidateCDN { 269 if d.target.CloudFrontDistributionID != "" { 270 if d.dryRun { 271 if !d.quiet { 272 jww.FEEDBACK.Printf("[DRY RUN] Would invalidate CloudFront CDN with ID %s\n", d.target.CloudFrontDistributionID) 273 } 274 } else { 275 jww.FEEDBACK.Println("Invalidating CloudFront CDN...") 276 if err := InvalidateCloudFront(ctx, d.target.CloudFrontDistributionID); err != nil { 277 jww.FEEDBACK.Printf("Failed to invalidate CloudFront CDN: %v\n", err) 278 return err 279 } 280 } 281 } 282 if d.target.GoogleCloudCDNOrigin != "" { 283 if d.dryRun { 284 if !d.quiet { 285 jww.FEEDBACK.Printf("[DRY RUN] Would invalidate Google Cloud CDN with origin %s\n", d.target.GoogleCloudCDNOrigin) 286 } 287 } else { 288 jww.FEEDBACK.Println("Invalidating Google Cloud CDN...") 289 if err := InvalidateGoogleCloudCDN(ctx, d.target.GoogleCloudCDNOrigin); err != nil { 290 jww.FEEDBACK.Printf("Failed to invalidate Google Cloud CDN: %v\n", err) 291 return err 292 } 293 } 294 } 295 jww.FEEDBACK.Println("Success!") 296 } 297 return nil 298 } 299 300 // summarizeChanges creates a text description of the proposed changes. 301 func summarizeChanges(uploads []*fileToUpload, deletes []string) string { 302 uploadSize := int64(0) 303 for _, u := range uploads { 304 uploadSize += u.Local.UploadSize 305 } 306 return fmt.Sprintf("Identified %d file(s) to upload, totaling %s, and %d file(s) to delete.", len(uploads), humanize.Bytes(uint64(uploadSize)), len(deletes)) 307 } 308 309 // doSingleUpload executes a single file upload. 310 func doSingleUpload(ctx context.Context, bucket *blob.Bucket, upload *fileToUpload) error { 311 jww.INFO.Printf("Uploading %v...\n", upload) 312 opts := &blob.WriterOptions{ 313 CacheControl: upload.Local.CacheControl(), 314 ContentEncoding: upload.Local.ContentEncoding(), 315 ContentType: upload.Local.ContentType(), 316 } 317 w, err := bucket.NewWriter(ctx, upload.Local.SlashPath, opts) 318 if err != nil { 319 return err 320 } 321 r, err := upload.Local.Reader() 322 if err != nil { 323 return err 324 } 325 defer r.Close() 326 _, err = io.Copy(w, r) 327 if err != nil { 328 return err 329 } 330 if err := w.Close(); err != nil { 331 return err 332 } 333 return nil 334 } 335 336 // localFile represents a local file from the source. Use newLocalFile to 337 // construct one. 338 type localFile struct { 339 // NativePath is the native path to the file (using file.Separator). 340 NativePath string 341 // SlashPath is NativePath converted to use /. 342 SlashPath string 343 // UploadSize is the size of the content to be uploaded. It may not 344 // be the same as the local file size if the content will be 345 // gzipped before upload. 346 UploadSize int64 347 348 fs afero.Fs 349 matcher *matcher 350 md5 []byte // cache 351 gzipped bytes.Buffer // cached of gzipped contents if gzipping 352 mediaTypes media.Types 353 } 354 355 // newLocalFile initializes a *localFile. 356 func newLocalFile(fs afero.Fs, nativePath, slashpath string, m *matcher, mt media.Types) (*localFile, error) { 357 f, err := fs.Open(nativePath) 358 if err != nil { 359 return nil, err 360 } 361 defer f.Close() 362 lf := &localFile{ 363 NativePath: nativePath, 364 SlashPath: slashpath, 365 fs: fs, 366 matcher: m, 367 mediaTypes: mt, 368 } 369 if m != nil && m.Gzip { 370 // We're going to gzip the content. Do it once now, and cache the result 371 // in gzipped. The UploadSize is the size of the gzipped content. 372 gz := gzip.NewWriter(&lf.gzipped) 373 if _, err := io.Copy(gz, f); err != nil { 374 return nil, err 375 } 376 if err := gz.Close(); err != nil { 377 return nil, err 378 } 379 lf.UploadSize = int64(lf.gzipped.Len()) 380 } else { 381 // Raw content. Just get the UploadSize. 382 info, err := f.Stat() 383 if err != nil { 384 return nil, err 385 } 386 lf.UploadSize = info.Size() 387 } 388 return lf, nil 389 } 390 391 // Reader returns an io.ReadCloser for reading the content to be uploaded. 392 // The caller must call Close on the returned ReaderCloser. 393 // The reader content may not be the same as the local file content due to 394 // gzipping. 395 func (lf *localFile) Reader() (io.ReadCloser, error) { 396 if lf.matcher != nil && lf.matcher.Gzip { 397 // We've got the gzipped contents cached in gzipped. 398 // Note: we can't use lf.gzipped directly as a Reader, since we it discards 399 // data after it is read, and we may read it more than once. 400 return ioutil.NopCloser(bytes.NewReader(lf.gzipped.Bytes())), nil 401 } 402 // Not expected to fail since we did it successfully earlier in newLocalFile, 403 // but could happen due to changes in the underlying filesystem. 404 return lf.fs.Open(lf.NativePath) 405 } 406 407 // CacheControl returns the Cache-Control header to use for lf, based on the 408 // first matching matcher (if any). 409 func (lf *localFile) CacheControl() string { 410 if lf.matcher == nil { 411 return "" 412 } 413 return lf.matcher.CacheControl 414 } 415 416 // ContentEncoding returns the Content-Encoding header to use for lf, based 417 // on the matcher's Content-Encoding and Gzip fields. 418 func (lf *localFile) ContentEncoding() string { 419 if lf.matcher == nil { 420 return "" 421 } 422 if lf.matcher.Gzip { 423 return "gzip" 424 } 425 return lf.matcher.ContentEncoding 426 } 427 428 // ContentType returns the Content-Type header to use for lf. 429 // It first checks if there's a Content-Type header configured via a matching 430 // matcher; if not, it tries to generate one based on the filename extension. 431 // If this fails, the Content-Type will be the empty string. In this case, Go 432 // Cloud will automatically try to infer a Content-Type based on the file 433 // content. 434 func (lf *localFile) ContentType() string { 435 if lf.matcher != nil && lf.matcher.ContentType != "" { 436 return lf.matcher.ContentType 437 } 438 439 ext := filepath.Ext(lf.NativePath) 440 if mimeType, _, found := lf.mediaTypes.GetFirstBySuffix(strings.TrimPrefix(ext, ".")); found { 441 return mimeType.Type() 442 } 443 444 return mime.TypeByExtension(ext) 445 } 446 447 // Force returns true if the file should be forced to re-upload based on the 448 // matching matcher. 449 func (lf *localFile) Force() bool { 450 return lf.matcher != nil && lf.matcher.Force 451 } 452 453 // MD5 returns an MD5 hash of the content to be uploaded. 454 func (lf *localFile) MD5() []byte { 455 if len(lf.md5) > 0 { 456 return lf.md5 457 } 458 h := md5.New() 459 r, err := lf.Reader() 460 if err != nil { 461 return nil 462 } 463 defer r.Close() 464 if _, err := io.Copy(h, r); err != nil { 465 return nil 466 } 467 lf.md5 = h.Sum(nil) 468 return lf.md5 469 } 470 471 // knownHiddenDirectory checks if the specified name is a well known 472 // hidden directory. 473 func knownHiddenDirectory(name string) bool { 474 knownDirectories := []string{ 475 ".well-known", 476 } 477 478 for _, dir := range knownDirectories { 479 if name == dir { 480 return true 481 } 482 } 483 return false 484 } 485 486 // walkLocal walks the source directory and returns a flat list of files, 487 // using localFile.SlashPath as the map keys. 488 func walkLocal(fs afero.Fs, matchers []*matcher, include, exclude glob.Glob, mediaTypes media.Types) (map[string]*localFile, error) { 489 retval := map[string]*localFile{} 490 err := afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error { 491 if err != nil { 492 return err 493 } 494 if info.IsDir() { 495 // Skip hidden directories. 496 if path != "" && strings.HasPrefix(info.Name(), ".") { 497 // Except for specific hidden directories 498 if !knownHiddenDirectory(info.Name()) { 499 return filepath.SkipDir 500 } 501 } 502 return nil 503 } 504 505 // .DS_Store is an internal MacOS attribute file; skip it. 506 if info.Name() == ".DS_Store" { 507 return nil 508 } 509 510 // When a file system is HFS+, its filepath is in NFD form. 511 if runtime.GOOS == "darwin" { 512 path = norm.NFC.String(path) 513 } 514 515 // Check include/exclude matchers. 516 slashpath := filepath.ToSlash(path) 517 if include != nil && !include.Match(slashpath) { 518 jww.INFO.Printf(" dropping %q due to include\n", slashpath) 519 return nil 520 } 521 if exclude != nil && exclude.Match(slashpath) { 522 jww.INFO.Printf(" dropping %q due to exclude\n", slashpath) 523 return nil 524 } 525 526 // Find the first matching matcher (if any). 527 var m *matcher 528 for _, cur := range matchers { 529 if cur.Matches(slashpath) { 530 m = cur 531 break 532 } 533 } 534 lf, err := newLocalFile(fs, path, slashpath, m, mediaTypes) 535 if err != nil { 536 return err 537 } 538 retval[lf.SlashPath] = lf 539 return nil 540 }) 541 if err != nil { 542 return nil, err 543 } 544 return retval, nil 545 } 546 547 // walkRemote walks the target bucket and returns a flat list. 548 func walkRemote(ctx context.Context, bucket *blob.Bucket, include, exclude glob.Glob) (map[string]*blob.ListObject, error) { 549 retval := map[string]*blob.ListObject{} 550 iter := bucket.List(nil) 551 for { 552 obj, err := iter.Next(ctx) 553 if err == io.EOF { 554 break 555 } 556 if err != nil { 557 return nil, err 558 } 559 // Check include/exclude matchers. 560 if include != nil && !include.Match(obj.Key) { 561 jww.INFO.Printf(" remote dropping %q due to include\n", obj.Key) 562 continue 563 } 564 if exclude != nil && exclude.Match(obj.Key) { 565 jww.INFO.Printf(" remote dropping %q due to exclude\n", obj.Key) 566 continue 567 } 568 // If the remote didn't give us an MD5, compute one. 569 // This can happen for some providers (e.g., fileblob, which uses the 570 // local filesystem), but not for the most common Cloud providers 571 // (S3, GCS, Azure). Although, it can happen for S3 if the blob was uploaded 572 // via a multi-part upload. 573 // Although it's unfortunate to have to read the file, it's likely better 574 // than assuming a delta and re-uploading it. 575 if len(obj.MD5) == 0 { 576 r, err := bucket.NewReader(ctx, obj.Key, nil) 577 if err == nil { 578 h := md5.New() 579 if _, err := io.Copy(h, r); err == nil { 580 obj.MD5 = h.Sum(nil) 581 } 582 r.Close() 583 } 584 } 585 retval[obj.Key] = obj 586 } 587 return retval, nil 588 } 589 590 // uploadReason is an enum of reasons why a file must be uploaded. 591 type uploadReason string 592 593 const ( 594 reasonUnknown uploadReason = "unknown" 595 reasonNotFound uploadReason = "not found at target" 596 reasonForce uploadReason = "--force" 597 reasonSize uploadReason = "size differs" 598 reasonMD5Differs uploadReason = "md5 differs" 599 reasonMD5Missing uploadReason = "remote md5 missing" 600 ) 601 602 // fileToUpload represents a single local file that should be uploaded to 603 // the target. 604 type fileToUpload struct { 605 Local *localFile 606 Reason uploadReason 607 } 608 609 func (u *fileToUpload) String() string { 610 details := []string{humanize.Bytes(uint64(u.Local.UploadSize))} 611 if s := u.Local.CacheControl(); s != "" { 612 details = append(details, fmt.Sprintf("Cache-Control: %q", s)) 613 } 614 if s := u.Local.ContentEncoding(); s != "" { 615 details = append(details, fmt.Sprintf("Content-Encoding: %q", s)) 616 } 617 if s := u.Local.ContentType(); s != "" { 618 details = append(details, fmt.Sprintf("Content-Type: %q", s)) 619 } 620 return fmt.Sprintf("%s (%s): %v", u.Local.SlashPath, strings.Join(details, ", "), u.Reason) 621 } 622 623 // findDiffs diffs localFiles vs remoteFiles to see what changes should be 624 // applied to the remote target. It returns a slice of *fileToUpload and a 625 // slice of paths for files to delete. 626 func findDiffs(localFiles map[string]*localFile, remoteFiles map[string]*blob.ListObject, force bool) ([]*fileToUpload, []string) { 627 var uploads []*fileToUpload 628 var deletes []string 629 630 found := map[string]bool{} 631 for path, lf := range localFiles { 632 upload := false 633 reason := reasonUnknown 634 635 if remoteFile, ok := remoteFiles[path]; ok { 636 // The file exists in remote. Let's see if we need to upload it anyway. 637 638 // TODO: We don't register a diff if the metadata (e.g., Content-Type 639 // header) has changed. This would be difficult/expensive to detect; some 640 // providers return metadata along with their "List" result, but others 641 // (notably AWS S3) do not, so gocloud.dev's blob.Bucket doesn't expose 642 // it in the list result. It would require a separate request per blob 643 // to fetch. At least for now, we work around this by documenting it and 644 // providing a "force" flag (to re-upload everything) and a "force" bool 645 // per matcher (to re-upload all files in a matcher whose headers may have 646 // changed). 647 // Idea: extract a sample set of 1 file per extension + 1 file per matcher 648 // and check those files? 649 if force { 650 upload = true 651 reason = reasonForce 652 } else if lf.Force() { 653 upload = true 654 reason = reasonForce 655 } else if lf.UploadSize != remoteFile.Size { 656 upload = true 657 reason = reasonSize 658 } else if len(remoteFile.MD5) == 0 { 659 // This shouldn't happen unless the remote didn't give us an MD5 hash 660 // from List, AND we failed to compute one by reading the remote file. 661 // Default to considering the files different. 662 upload = true 663 reason = reasonMD5Missing 664 } else if !bytes.Equal(lf.MD5(), remoteFile.MD5) { 665 upload = true 666 reason = reasonMD5Differs 667 } else { 668 // Nope! Leave uploaded = false. 669 } 670 found[path] = true 671 } else { 672 // The file doesn't exist in remote. 673 upload = true 674 reason = reasonNotFound 675 } 676 if upload { 677 jww.DEBUG.Printf("%s needs to be uploaded: %v\n", path, reason) 678 uploads = append(uploads, &fileToUpload{lf, reason}) 679 } else { 680 jww.DEBUG.Printf("%s exists at target and does not need to be uploaded", path) 681 } 682 } 683 684 // Remote files that weren't found locally should be deleted. 685 for path := range remoteFiles { 686 if !found[path] { 687 deletes = append(deletes, path) 688 } 689 } 690 return uploads, deletes 691 } 692 693 // applyOrdering returns an ordered slice of slices of uploads. 694 // 695 // The returned slice will have length len(ordering)+1. 696 // 697 // The subslice at index i, for i = 0 ... len(ordering)-1, will have all of the 698 // uploads whose Local.SlashPath matched the regex at ordering[i] (but not any 699 // previous ordering regex). 700 // The subslice at index len(ordering) will have the remaining uploads that 701 // didn't match any ordering regex. 702 // 703 // The subslices are sorted by Local.SlashPath. 704 func applyOrdering(ordering []*regexp.Regexp, uploads []*fileToUpload) [][]*fileToUpload { 705 // Sort the whole slice by Local.SlashPath first. 706 sort.Slice(uploads, func(i, j int) bool { return uploads[i].Local.SlashPath < uploads[j].Local.SlashPath }) 707 708 retval := make([][]*fileToUpload, len(ordering)+1) 709 for _, u := range uploads { 710 matched := false 711 for i, re := range ordering { 712 if re.MatchString(u.Local.SlashPath) { 713 retval[i] = append(retval[i], u) 714 matched = true 715 break 716 } 717 } 718 if !matched { 719 retval[len(ordering)] = append(retval[len(ordering)], u) 720 } 721 } 722 return retval 723 }