MediaWiki master
SwiftFileBackend.php
Go to the documentation of this file.
1<?php
25namespace Wikimedia\FileBackend;
26
27use Exception;
28use LockManager;
29use MapCacheLRU;
32use Psr\Log\LoggerInterface;
33use StatusValue;
34use stdClass;
35use Wikimedia\AtEase\AtEase;
43use Wikimedia\RequestTimeout\TimeoutException;
44
55 private const DEFAULT_HTTP_OPTIONS = [ 'httpVersion' => 'v1.1' ];
56 private const AUTH_FAILURE_ERROR = 'Could not connect due to prior authentication failure';
57
59 protected $http;
61 protected $authTTL;
63 protected $swiftAuthUrl;
67 protected $swiftUser;
69 protected $swiftKey;
73 protected $rgwS3AccessKey;
75 protected $rgwS3SecretKey;
77 protected $readUsers;
79 protected $writeUsers;
84
86 protected $srvCache;
87
90
92 protected $authCreds;
94 protected $authErrorTimestamp = null;
95
97 protected $isRGW = false;
98
137 public function __construct( array $config ) {
138 parent::__construct( $config );
139 // Required settings
140 $this->swiftAuthUrl = $config['swiftAuthUrl'];
141 $this->swiftUser = $config['swiftUser'];
142 $this->swiftKey = $config['swiftKey'];
143 // Optional settings
144 $this->authTTL = $config['swiftAuthTTL'] ?? 15 * 60; // some sensible number
145 $this->swiftTempUrlKey = $config['swiftTempUrlKey'] ?? '';
146 $this->swiftStorageUrl = $config['swiftStorageUrl'] ?? null;
147 $this->shardViaHashLevels = $config['shardViaHashLevels'] ?? '';
148 $this->rgwS3AccessKey = $config['rgwS3AccessKey'] ?? '';
149 $this->rgwS3SecretKey = $config['rgwS3SecretKey'] ?? '';
150
151 // HTTP helper client
152 $httpOptions = [];
153 foreach ( [ 'connTimeout', 'reqTimeout' ] as $optionName ) {
154 if ( isset( $config[$optionName] ) ) {
155 $httpOptions[$optionName] = $config[$optionName];
156 }
157 }
158 $this->http = new MultiHttpClient( $httpOptions );
159 $this->http->setLogger( $this->logger );
160
161 // Cache container information to mask latency
162 if ( isset( $config['wanCache'] ) && $config['wanCache'] instanceof WANObjectCache ) {
163 $this->memCache = $config['wanCache'];
164 }
165 // Process cache for container info
166 $this->containerStatCache = new MapCacheLRU( 300 );
167 // Cache auth token information to avoid RTTs
168 if ( !empty( $config['cacheAuthInfo'] ) && isset( $config['srvCache'] ) ) {
169 $this->srvCache = $config['srvCache'];
170 } else {
171 $this->srvCache = new EmptyBagOStuff();
172 }
173 $this->readUsers = $config['readUsers'] ?? [];
174 $this->writeUsers = $config['writeUsers'] ?? [];
175 $this->secureReadUsers = $config['secureReadUsers'] ?? [];
176 $this->secureWriteUsers = $config['secureWriteUsers'] ?? [];
177 // Per https://docs.openstack.org/swift/latest/overview_large_objects.html
178 // we need to split objects if they are larger than 5 GB. Support for
179 // splitting objects has not yet been implemented by this class
180 // so limit max file size to 5GiB.
181 $this->maxFileSize = 5 * 1024 * 1024 * 1024;
182 }
183
184 public function setLogger( LoggerInterface $logger ) {
185 parent::setLogger( $logger );
186 $this->http->setLogger( $logger );
187 }
188
189 public function getFeatures() {
190 return (
191 self::ATTR_UNICODE_PATHS |
192 self::ATTR_HEADERS |
193 self::ATTR_METADATA
194 );
195 }
196
197 protected function resolveContainerPath( $container, $relStoragePath ) {
198 if ( !mb_check_encoding( $relStoragePath, 'UTF-8' ) ) {
199 return null; // not UTF-8, makes it hard to use CF and the swift HTTP API
200 } elseif ( strlen( rawurlencode( $relStoragePath ) ) > 1024 ) {
201 return null; // too long for Swift
202 }
203
204 return $relStoragePath;
205 }
206
207 public function isPathUsableInternal( $storagePath ) {
208 [ $container, $rel ] = $this->resolveStoragePathReal( $storagePath );
209 if ( $rel === null ) {
210 return false; // invalid
211 }
212
213 return is_array( $this->getContainerStat( $container ) );
214 }
215
225 protected function extractMutableContentHeaders( array $headers ) {
226 $contentHeaders = [];
227 // Normalize casing, and strip out illegal headers
228 foreach ( $headers as $name => $value ) {
229 $name = strtolower( $name );
230 if ( $name === 'x-delete-at' && is_numeric( $value ) ) {
231 // Expects a Unix Epoch date
232 $contentHeaders[$name] = $value;
233 } elseif ( $name === 'x-delete-after' && is_numeric( $value ) ) {
234 // Expects number of minutes time to live.
235 $contentHeaders[$name] = $value;
236 } elseif ( preg_match( '/^(x-)?content-(?!length$)/', $name ) ) {
237 // Only allow content-* and x-content-* headers (but not content-length)
238 $contentHeaders[$name] = $value;
239 } elseif ( $name === 'content-type' && strlen( $value ) ) {
240 // This header can be set to a value but not unset
241 $contentHeaders[$name] = $value;
242 }
243 }
244 // By default, Swift has annoyingly low maximum header value limits
245 if ( isset( $contentHeaders['content-disposition'] ) ) {
246 $maxLength = 255;
247 // @note: assume FileBackend::makeContentDisposition() already used
248 $offset = $maxLength - strlen( $contentHeaders['content-disposition'] );
249 if ( $offset < 0 ) {
250 $pos = strrpos( $contentHeaders['content-disposition'], ';', $offset );
251 $contentHeaders['content-disposition'] = $pos === false
252 ? ''
253 : trim( substr( $contentHeaders['content-disposition'], 0, $pos ) );
254 }
255 }
256
257 return $contentHeaders;
258 }
259
265 protected function extractMetadataHeaders( array $headers ) {
266 $metadataHeaders = [];
267 foreach ( $headers as $name => $value ) {
268 $name = strtolower( $name );
269 if ( strpos( $name, 'x-object-meta-' ) === 0 ) {
270 $metadataHeaders[$name] = $value;
271 }
272 }
273
274 return $metadataHeaders;
275 }
276
282 protected function getMetadataFromHeaders( array $headers ) {
283 $prefixLen = strlen( 'x-object-meta-' );
284
285 $metadata = [];
286 foreach ( $this->extractMetadataHeaders( $headers ) as $name => $value ) {
287 $metadata[substr( $name, $prefixLen )] = $value;
288 }
289
290 return $metadata;
291 }
292
293 protected function doCreateInternal( array $params ) {
294 $status = $this->newStatus();
295
296 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
297 if ( $dstRel === null ) {
298 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
299
300 return $status;
301 }
302
303 // Headers that are not strictly a function of the file content
304 $mutableHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
305 // Make sure that the "content-type" header is set to something sensible
306 $mutableHeaders['content-type']
307 ??= $this->getContentType( $params['dst'], $params['content'], null );
308
309 $reqs = [ [
310 'method' => 'PUT',
311 'container' => $dstCont,
312 'relPath' => $dstRel,
313 'headers' => array_merge(
314 $mutableHeaders,
315 [
316 'etag' => md5( $params['content'] ),
317 'content-length' => strlen( $params['content'] ),
318 'x-object-meta-sha1base36' =>
319 \Wikimedia\base_convert( sha1( $params['content'] ), 16, 36, 31 )
320 ]
321 ),
322 'body' => $params['content']
323 ] ];
324
325 $method = __METHOD__;
326 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
327 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
328 if ( $rcode === 201 || $rcode === 202 ) {
329 // good
330 } elseif ( $rcode === 412 ) {
331 $status->fatal( 'backend-fail-contenttype', $params['dst'] );
332 } else {
333 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
334 }
335
336 return SwiftFileOpHandle::CONTINUE_IF_OK;
337 };
338
339 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
340 if ( !empty( $params['async'] ) ) { // deferred
341 $status->value = $opHandle;
342 } else { // actually write the object in Swift
343 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
344 }
345
346 return $status;
347 }
348
349 protected function doStoreInternal( array $params ) {
350 $status = $this->newStatus();
351
352 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
353 if ( $dstRel === null ) {
354 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
355
356 return $status;
357 }
358
359 // Open a handle to the source file so that it can be streamed. The size and hash
360 // will be computed using the handle. In the off chance that the source file changes
361 // during this operation, the PUT will fail due to an ETag mismatch and be aborted.
362 AtEase::suppressWarnings();
363 $srcHandle = fopen( $params['src'], 'rb' );
364 AtEase::restoreWarnings();
365 if ( $srcHandle === false ) { // source doesn't exist?
366 $status->fatal( 'backend-fail-notexists', $params['src'] );
367
368 return $status;
369 }
370
371 // Compute the MD5 and SHA-1 hashes in one pass
372 $srcSize = fstat( $srcHandle )['size'];
373 $md5Context = hash_init( 'md5' );
374 $sha1Context = hash_init( 'sha1' );
375 $hashDigestSize = 0;
376 while ( !feof( $srcHandle ) ) {
377 $buffer = (string)fread( $srcHandle, 131_072 ); // 128 KiB
378 hash_update( $md5Context, $buffer );
379 hash_update( $sha1Context, $buffer );
380 $hashDigestSize += strlen( $buffer );
381 }
382 // Reset the handle back to the beginning so that it can be streamed
383 rewind( $srcHandle );
384
385 if ( $hashDigestSize !== $srcSize ) {
386 $status->fatal( 'backend-fail-hash', $params['src'] );
387
388 return $status;
389 }
390
391 // Headers that are not strictly a function of the file content
392 $mutableHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
393 // Make sure that the "content-type" header is set to something sensible
394 $mutableHeaders['content-type']
395 ??= $this->getContentType( $params['dst'], null, $params['src'] );
396
397 $reqs = [ [
398 'method' => 'PUT',
399 'container' => $dstCont,
400 'relPath' => $dstRel,
401 'headers' => array_merge(
402 $mutableHeaders,
403 [
404 'content-length' => $srcSize,
405 'etag' => hash_final( $md5Context ),
406 'x-object-meta-sha1base36' =>
407 \Wikimedia\base_convert( hash_final( $sha1Context ), 16, 36, 31 )
408 ]
409 ),
410 'body' => $srcHandle // resource
411 ] ];
412
413 $method = __METHOD__;
414 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
415 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
416 if ( $rcode === 201 || $rcode === 202 ) {
417 // good
418 } elseif ( $rcode === 412 ) {
419 $status->fatal( 'backend-fail-contenttype', $params['dst'] );
420 } else {
421 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
422 }
423
424 return SwiftFileOpHandle::CONTINUE_IF_OK;
425 };
426
427 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
428 $opHandle->resourcesToClose[] = $srcHandle;
429
430 if ( !empty( $params['async'] ) ) { // deferred
431 $status->value = $opHandle;
432 } else { // actually write the object in Swift
433 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
434 }
435
436 return $status;
437 }
438
439 protected function doCopyInternal( array $params ) {
440 $status = $this->newStatus();
441
442 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
443 if ( $srcRel === null ) {
444 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
445
446 return $status;
447 }
448
449 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
450 if ( $dstRel === null ) {
451 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
452
453 return $status;
454 }
455
456 $reqs = [ [
457 'method' => 'PUT',
458 'container' => $dstCont,
459 'relPath' => $dstRel,
460 'headers' => array_merge(
461 $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
462 [
463 'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
464 str_replace( "%2F", "/", rawurlencode( $srcRel ) )
465 ]
466 )
467 ] ];
468
469 $method = __METHOD__;
470 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
471 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
472 if ( $rcode === 201 ) {
473 // good
474 } elseif ( $rcode === 404 ) {
475 if ( empty( $params['ignoreMissingSource'] ) ) {
476 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
477 }
478 } else {
479 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
480 }
481
482 return SwiftFileOpHandle::CONTINUE_IF_OK;
483 };
484
485 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
486 if ( !empty( $params['async'] ) ) { // deferred
487 $status->value = $opHandle;
488 } else { // actually write the object in Swift
489 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
490 }
491
492 return $status;
493 }
494
495 protected function doMoveInternal( array $params ) {
496 $status = $this->newStatus();
497
498 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
499 if ( $srcRel === null ) {
500 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
501
502 return $status;
503 }
504
505 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
506 if ( $dstRel === null ) {
507 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
508
509 return $status;
510 }
511
512 $reqs = [ [
513 'method' => 'PUT',
514 'container' => $dstCont,
515 'relPath' => $dstRel,
516 'headers' => array_merge(
517 $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
518 [
519 'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
520 str_replace( "%2F", "/", rawurlencode( $srcRel ) )
521 ]
522 )
523 ] ];
524 if ( "{$srcCont}/{$srcRel}" !== "{$dstCont}/{$dstRel}" ) {
525 $reqs[] = [
526 'method' => 'DELETE',
527 'container' => $srcCont,
528 'relPath' => $srcRel,
529 'headers' => []
530 ];
531 }
532
533 $method = __METHOD__;
534 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
535 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
536 if ( $request['method'] === 'PUT' && $rcode === 201 ) {
537 // good
538 } elseif ( $request['method'] === 'DELETE' && $rcode === 204 ) {
539 // good
540 } elseif ( $rcode === 404 ) {
541 if ( empty( $params['ignoreMissingSource'] ) ) {
542 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
543 } else {
544 // Leave Status as OK but skip the DELETE request
545 return SwiftFileOpHandle::CONTINUE_NO;
546 }
547 } else {
548 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
549 }
550
551 return SwiftFileOpHandle::CONTINUE_IF_OK;
552 };
553
554 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
555 if ( !empty( $params['async'] ) ) { // deferred
556 $status->value = $opHandle;
557 } else { // actually move the object in Swift
558 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
559 }
560
561 return $status;
562 }
563
564 protected function doDeleteInternal( array $params ) {
565 $status = $this->newStatus();
566
567 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
568 if ( $srcRel === null ) {
569 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
570
571 return $status;
572 }
573
574 $reqs = [ [
575 'method' => 'DELETE',
576 'container' => $srcCont,
577 'relPath' => $srcRel,
578 'headers' => []
579 ] ];
580
581 $method = __METHOD__;
582 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
583 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
584 if ( $rcode === 204 ) {
585 // good
586 } elseif ( $rcode === 404 ) {
587 if ( empty( $params['ignoreMissingSource'] ) ) {
588 $status->fatal( 'backend-fail-delete', $params['src'] );
589 }
590 } else {
591 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
592 }
593
594 return SwiftFileOpHandle::CONTINUE_IF_OK;
595 };
596
597 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
598 if ( !empty( $params['async'] ) ) { // deferred
599 $status->value = $opHandle;
600 } else { // actually delete the object in Swift
601 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
602 }
603
604 return $status;
605 }
606
607 protected function doDescribeInternal( array $params ) {
608 $status = $this->newStatus();
609
610 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
611 if ( $srcRel === null ) {
612 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
613
614 return $status;
615 }
616
617 // Fetch the old object headers/metadata...this should be in stat cache by now
618 $stat = $this->getFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
619 if ( $stat && !isset( $stat['xattr'] ) ) { // older cache entry
620 $stat = $this->doGetFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
621 }
622 if ( !$stat ) {
623 $status->fatal( 'backend-fail-describe', $params['src'] );
624
625 return $status;
626 }
627
628 // Swift object POST clears any prior headers, so merge the new and old headers here.
629 // Also, during, POST, libcurl adds "Content-Type: application/x-www-form-urlencoded"
630 // if "Content-Type" is not set, which would clobber the header value for the object.
631 $oldMetadataHeaders = [];
632 foreach ( $stat['xattr']['metadata'] as $name => $value ) {
633 $oldMetadataHeaders["x-object-meta-$name"] = $value;
634 }
635 $newContentHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
636 $oldContentHeaders = $stat['xattr']['headers'];
637
638 $reqs = [ [
639 'method' => 'POST',
640 'container' => $srcCont,
641 'relPath' => $srcRel,
642 'headers' => $oldMetadataHeaders + $newContentHeaders + $oldContentHeaders
643 ] ];
644
645 $method = __METHOD__;
646 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
647 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
648 if ( $rcode === 202 ) {
649 // good
650 } elseif ( $rcode === 404 ) {
651 $status->fatal( 'backend-fail-describe', $params['src'] );
652 } else {
653 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
654 }
655 };
656
657 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
658 if ( !empty( $params['async'] ) ) { // deferred
659 $status->value = $opHandle;
660 } else { // actually change the object in Swift
661 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
662 }
663
664 return $status;
665 }
666
670 protected function doPrepareInternal( $fullCont, $dir, array $params ) {
671 $status = $this->newStatus();
672
673 // (a) Check if container already exists
674 $stat = $this->getContainerStat( $fullCont );
675 if ( is_array( $stat ) ) {
676 return $status; // already there
677 } elseif ( $stat === self::RES_ERROR ) {
678 $status->fatal( 'backend-fail-internal', $this->name );
679 $this->logger->error( __METHOD__ . ': cannot get container stat' );
680 } else {
681 // (b) Create container as needed with proper ACLs
682 $params['op'] = 'prepare';
683 $status->merge( $this->createContainer( $fullCont, $params ) );
684 }
685
686 return $status;
687 }
688
689 protected function doSecureInternal( $fullCont, $dir, array $params ) {
690 $status = $this->newStatus();
691 if ( empty( $params['noAccess'] ) ) {
692 return $status; // nothing to do
693 }
694
695 $stat = $this->getContainerStat( $fullCont );
696 if ( is_array( $stat ) ) {
697 $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
698 $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
699 // Make container private to end-users...
700 $status->merge( $this->setContainerAccess(
701 $fullCont,
704 ) );
705 } elseif ( $stat === self::RES_ABSENT ) {
706 $status->fatal( 'backend-fail-usable', $params['dir'] );
707 } else {
708 $status->fatal( 'backend-fail-internal', $this->name );
709 $this->logger->error( __METHOD__ . ': cannot get container stat' );
710 }
711
712 return $status;
713 }
714
715 protected function doPublishInternal( $fullCont, $dir, array $params ) {
716 $status = $this->newStatus();
717 if ( empty( $params['access'] ) ) {
718 return $status; // nothing to do
719 }
720
721 $stat = $this->getContainerStat( $fullCont );
722 if ( is_array( $stat ) ) {
723 $readUsers = array_merge( $this->readUsers, [ $this->swiftUser, '.r:*' ] );
724 if ( !empty( $params['listing'] ) ) {
725 array_push( $readUsers, '.rlistings' );
726 }
727 $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
728
729 // Make container public to end-users...
730 $status->merge( $this->setContainerAccess(
731 $fullCont,
734 ) );
735 } elseif ( $stat === self::RES_ABSENT ) {
736 $status->fatal( 'backend-fail-usable', $params['dir'] );
737 } else {
738 $status->fatal( 'backend-fail-internal', $this->name );
739 $this->logger->error( __METHOD__ . ': cannot get container stat' );
740 }
741
742 return $status;
743 }
744
745 protected function doCleanInternal( $fullCont, $dir, array $params ) {
746 $status = $this->newStatus();
747
748 // Only containers themselves can be removed, all else is virtual
749 if ( $dir != '' ) {
750 return $status; // nothing to do
751 }
752
753 // (a) Check the container
754 $stat = $this->getContainerStat( $fullCont, true );
755 if ( $stat === self::RES_ABSENT ) {
756 return $status; // ok, nothing to do
757 } elseif ( $stat === self::RES_ERROR ) {
758 $status->fatal( 'backend-fail-internal', $this->name );
759 $this->logger->error( __METHOD__ . ': cannot get container stat' );
760 } elseif ( is_array( $stat ) && $stat['count'] == 0 ) {
761 // (b) Delete the container if empty
762 $params['op'] = 'clean';
763 $status->merge( $this->deleteContainer( $fullCont, $params ) );
764 }
765
766 return $status;
767 }
768
769 protected function doGetFileStat( array $params ) {
770 $params = [ 'srcs' => [ $params['src'] ], 'concurrency' => 1 ] + $params;
771 unset( $params['src'] );
772 $stats = $this->doGetFileStatMulti( $params );
773
774 return reset( $stats );
775 }
776
787 protected function convertSwiftDate( $ts, $format = TS_MW ) {
788 try {
789 $timestamp = new MWTimestamp( $ts );
790
791 return $timestamp->getTimestamp( $format );
792 } catch ( TimeoutException $e ) {
793 throw $e;
794 } catch ( Exception $e ) {
795 throw new FileBackendError( $e->getMessage() );
796 }
797 }
798
806 protected function addMissingHashMetadata( array $objHdrs, $path ) {
807 if ( isset( $objHdrs['x-object-meta-sha1base36'] ) ) {
808 return $objHdrs; // nothing to do
809 }
810
812 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
813 $this->logger->error( __METHOD__ . ": {path} was not stored with SHA-1 metadata.",
814 [ 'path' => $path ] );
815
816 $objHdrs['x-object-meta-sha1base36'] = false;
817
818 // Find prior custom HTTP headers
819 $postHeaders = $this->extractMutableContentHeaders( $objHdrs );
820 // Find prior metadata headers
821 $postHeaders += $this->extractMetadataHeaders( $objHdrs );
822
823 $status = $this->newStatus();
825 $scopeLockS = $this->getScopedFileLocks( [ $path ], LockManager::LOCK_UW, $status );
826 if ( $status->isOK() ) {
827 $tmpFile = $this->getLocalCopy( [ 'src' => $path, 'latest' => 1 ] );
828 if ( $tmpFile ) {
829 $hash = $tmpFile->getSha1Base36();
830 if ( $hash !== false ) {
831 $objHdrs['x-object-meta-sha1base36'] = $hash;
832 // Merge new SHA1 header into the old ones
833 $postHeaders['x-object-meta-sha1base36'] = $hash;
834 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
835 [ $rcode ] = $this->requestWithAuth( [
836 'method' => 'POST',
837 'container' => $srcCont,
838 'relPath' => $srcRel,
839 'headers' => $postHeaders
840 ] );
841 if ( $rcode >= 200 && $rcode <= 299 ) {
842 $this->deleteFileCache( $path );
843
844 return $objHdrs; // success
845 }
846 }
847 }
848 }
849
850 $this->logger->error( __METHOD__ . ': unable to set SHA-1 metadata for {path}',
851 [ 'path' => $path ] );
852
853 return $objHdrs; // failed
854 }
855
856 protected function doGetFileContentsMulti( array $params ) {
857 $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
858 // Blindly create tmp files and stream to them, catching any exception
859 // if the file does not exist. Do not waste time doing file stats here.
860 $reqs = []; // (path => op)
861
862 // Initial dummy values to preserve path order
863 $contents = array_fill_keys( $params['srcs'], self::RES_ERROR );
864 foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
865 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
866 if ( $srcRel === null ) {
867 continue; // invalid storage path
868 }
869 // Create a new temporary memory file...
870 $handle = fopen( 'php://temp', 'wb' );
871 if ( $handle ) {
872 $reqs[$path] = [
873 'method' => 'GET',
874 'container' => $srcCont,
875 'relPath' => $srcRel,
876 'headers' => $this->headersFromParams( $params ),
877 'stream' => $handle,
878 ];
879 }
880 }
881
882 $reqs = $this->requestMultiWithAuth(
883 $reqs,
884 [ 'maxConnsPerHost' => $params['concurrency'] ]
885 );
886 foreach ( $reqs as $path => $op ) {
887 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
888 if ( $rcode >= 200 && $rcode <= 299 ) {
889 rewind( $op['stream'] ); // start from the beginning
890 $content = (string)stream_get_contents( $op['stream'] );
891 $size = strlen( $content );
892 // Make sure that stream finished
893 if ( $size === (int)$rhdrs['content-length'] ) {
894 $contents[$path] = $content;
895 } else {
896 $contents[$path] = self::RES_ERROR;
897 $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
898 $this->onError( null, __METHOD__,
899 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
900 }
901 } elseif ( $rcode === 404 ) {
902 $contents[$path] = self::RES_ABSENT;
903 } else {
904 $contents[$path] = self::RES_ERROR;
905 $this->onError( null, __METHOD__,
906 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
907 }
908 fclose( $op['stream'] ); // close open handle
909 }
910
911 return $contents;
912 }
913
914 protected function doDirectoryExists( $fullCont, $dir, array $params ) {
915 $prefix = ( $dir == '' ) ? null : "{$dir}/";
916 $status = $this->objectListing( $fullCont, 'names', 1, null, $prefix );
917 if ( $status->isOK() ) {
918 return ( count( $status->value ) ) > 0;
919 }
920
921 return self::RES_ERROR;
922 }
923
931 public function getDirectoryListInternal( $fullCont, $dir, array $params ) {
932 return new SwiftFileBackendDirList( $this, $fullCont, $dir, $params );
933 }
934
942 public function getFileListInternal( $fullCont, $dir, array $params ) {
943 return new SwiftFileBackendFileList( $this, $fullCont, $dir, $params );
944 }
945
957 public function getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
958 $dirs = [];
959 if ( $after === INF ) {
960 return $dirs; // nothing more
961 }
962
964 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
965
966 $prefix = ( $dir == '' ) ? null : "{$dir}/";
967 // Non-recursive: only list dirs right under $dir
968 if ( !empty( $params['topOnly'] ) ) {
969 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
970 if ( !$status->isOK() ) {
971 throw new FileBackendError( "Iterator page I/O error." );
972 }
973 $objects = $status->value;
974 // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach
975 foreach ( $objects as $object ) { // files and directories
976 if ( substr( $object, -1 ) === '/' ) {
977 $dirs[] = $object; // directories end in '/'
978 }
979 }
980 } else {
981 // Recursive: list all dirs under $dir and its subdirs
982 $getParentDir = static function ( $path ) {
983 return ( $path !== null && strpos( $path, '/' ) !== false ) ? dirname( $path ) : false;
984 };
985
986 // Get directory from last item of prior page
987 $lastDir = $getParentDir( $after ); // must be first page
988 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
989
990 if ( !$status->isOK() ) {
991 throw new FileBackendError( "Iterator page I/O error." );
992 }
993
994 $objects = $status->value;
995
996 // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach
997 foreach ( $objects as $object ) { // files
998 $objectDir = $getParentDir( $object ); // directory of object
999
1000 if ( $objectDir !== false && $objectDir !== $dir ) {
1001 // Swift stores paths in UTF-8, using binary sorting.
1002 // See function "create_container_table" in common/db.py.
1003 // If a directory is not "greater" than the last one,
1004 // then it was already listed by the calling iterator.
1005 if ( strcmp( $objectDir, $lastDir ) > 0 ) {
1006 $pDir = $objectDir;
1007 do { // add dir and all its parent dirs
1008 $dirs[] = "{$pDir}/";
1009 $pDir = $getParentDir( $pDir );
1010 } while ( $pDir !== false
1011 && strcmp( $pDir, $lastDir ) > 0 // not done already
1012 && strlen( $pDir ) > strlen( $dir ) // within $dir
1013 );
1014 }
1015 $lastDir = $objectDir;
1016 }
1017 }
1018 }
1019 // Page on the unfiltered directory listing (what is returned may be filtered)
1020 if ( count( $objects ) < $limit ) {
1021 $after = INF; // avoid a second RTT
1022 } else {
1023 $after = end( $objects ); // update last item
1024 }
1025
1026 return $dirs;
1027 }
1028
1040 public function getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
1041 $files = []; // list of (path, stat map or null) entries
1042 if ( $after === INF ) {
1043 return $files; // nothing more
1044 }
1045
1047 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
1048
1049 $prefix = ( $dir == '' ) ? null : "{$dir}/";
1050 // $objects will contain a list of unfiltered names or stdClass items
1051 // Non-recursive: only list files right under $dir
1052 if ( !empty( $params['topOnly'] ) ) {
1053 if ( !empty( $params['adviseStat'] ) ) {
1054 $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix, '/' );
1055 } else {
1056 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
1057 }
1058 } else {
1059 // Recursive: list all files under $dir and its subdirs
1060 if ( !empty( $params['adviseStat'] ) ) {
1061 $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix );
1062 } else {
1063 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
1064 }
1065 }
1066
1067 // Reformat this list into a list of (name, stat map or null) entries
1068 if ( !$status->isOK() ) {
1069 throw new FileBackendError( "Iterator page I/O error." );
1070 }
1071
1072 $objects = $status->value;
1073 $files = $this->buildFileObjectListing( $objects );
1074
1075 // Page on the unfiltered object listing (what is returned may be filtered)
1076 if ( count( $objects ) < $limit ) {
1077 $after = INF; // avoid a second RTT
1078 } else {
1079 $after = end( $objects ); // update last item
1080 $after = is_object( $after ) ? $after->name : $after;
1081 }
1082
1083 return $files;
1084 }
1085
1093 private function buildFileObjectListing( array $objects ) {
1094 $names = [];
1095 foreach ( $objects as $object ) {
1096 if ( is_object( $object ) ) {
1097 if ( isset( $object->subdir ) || !isset( $object->name ) ) {
1098 continue; // virtual directory entry; ignore
1099 }
1100 $stat = [
1101 // Convert various random Swift dates to TS_MW
1102 'mtime' => $this->convertSwiftDate( $object->last_modified, TS_MW ),
1103 'size' => (int)$object->bytes,
1104 'sha1' => null,
1105 // Note: manifest ETags are not an MD5 of the file
1106 'md5' => ctype_xdigit( $object->hash ) ? $object->hash : null,
1107 'latest' => false // eventually consistent
1108 ];
1109 $names[] = [ $object->name, $stat ];
1110 } elseif ( substr( $object, -1 ) !== '/' ) {
1111 // Omit directories, which end in '/' in listings
1112 $names[] = [ $object, null ];
1113 }
1114 }
1115
1116 return $names;
1117 }
1118
1125 public function loadListingStatInternal( $path, array $val ) {
1126 $this->cheapCache->setField( $path, 'stat', $val );
1127 }
1128
1129 protected function doGetFileXAttributes( array $params ) {
1130 $stat = $this->getFileStat( $params );
1131 // Stat entries filled by file listings don't include metadata/headers
1132 if ( is_array( $stat ) && !isset( $stat['xattr'] ) ) {
1133 $this->clearCache( [ $params['src'] ] );
1134 $stat = $this->getFileStat( $params );
1135 }
1136
1137 if ( is_array( $stat ) ) {
1138 return $stat['xattr'];
1139 }
1140
1141 return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1142 }
1143
1144 protected function doGetFileSha1base36( array $params ) {
1145 // Avoid using stat entries from file listings, which never include the SHA-1 hash.
1146 // Also, recompute the hash if it's not part of the metadata headers for some reason.
1147 $params['requireSHA1'] = true;
1148
1149 $stat = $this->getFileStat( $params );
1150 if ( is_array( $stat ) ) {
1151 return $stat['sha1'];
1152 }
1153
1154 return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1155 }
1156
1157 protected function doStreamFile( array $params ) {
1158 $status = $this->newStatus();
1159
1160 $flags = !empty( $params['headless'] ) ? HTTPFileStreamer::STREAM_HEADLESS : 0;
1161
1162 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1163 if ( $srcRel === null ) {
1164 HTTPFileStreamer::send404Message( $params['src'], $flags );
1165 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
1166
1167 return $status;
1168 }
1169
1170 if ( !is_array( $this->getContainerStat( $srcCont ) ) ) {
1171 HTTPFileStreamer::send404Message( $params['src'], $flags );
1172 $status->fatal( 'backend-fail-stream', $params['src'] );
1173
1174 return $status;
1175 }
1176
1177 // If "headers" is set, we only want to send them if the file is there.
1178 // Do not bother checking if the file exists if headers are not set though.
1179 if ( $params['headers'] && !$this->fileExists( $params ) ) {
1180 HTTPFileStreamer::send404Message( $params['src'], $flags );
1181 $status->fatal( 'backend-fail-stream', $params['src'] );
1182
1183 return $status;
1184 }
1185
1186 // Send the requested additional headers
1187 if ( empty( $params['headless'] ) ) {
1188 foreach ( $params['headers'] as $header ) {
1189 $this->header( $header );
1190 }
1191 }
1192
1193 if ( empty( $params['allowOB'] ) ) {
1194 // Cancel output buffering and gzipping if set
1195 $this->resetOutputBuffer();
1196 }
1197
1198 $handle = fopen( 'php://output', 'wb' );
1199 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1200 'method' => 'GET',
1201 'container' => $srcCont,
1202 'relPath' => $srcRel,
1203 'headers' => $this->headersFromParams( $params ) + $params['options'],
1204 'stream' => $handle,
1205 'flags' => [ 'relayResponseHeaders' => empty( $params['headless'] ) ]
1206 ] );
1207
1208 if ( $rcode >= 200 && $rcode <= 299 ) {
1209 // good
1210 } elseif ( $rcode === 404 ) {
1211 $status->fatal( 'backend-fail-stream', $params['src'] );
1212 // Per T43113, nasty things can happen if bad cache entries get
1213 // stuck in cache. It's also possible that this error can come up
1214 // with simple race conditions. Clear out the stat cache to be safe.
1215 $this->clearCache( [ $params['src'] ] );
1216 $this->deleteFileCache( $params['src'] );
1217 } else {
1218 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1219 }
1220
1221 return $status;
1222 }
1223
1224 protected function doGetLocalCopyMulti( array $params ) {
1225 $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
1226 // Blindly create tmp files and stream to them, catching any exception
1227 // if the file does not exist. Do not waste time doing file stats here.
1228 $reqs = []; // (path => op)
1229
1230 // Initial dummy values to preserve path order
1231 $tmpFiles = array_fill_keys( $params['srcs'], self::RES_ERROR );
1232 foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
1233 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1234 if ( $srcRel === null ) {
1235 continue; // invalid storage path
1236 }
1237 // Get source file extension
1239 // Create a new temporary file...
1240 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
1241 $handle = $tmpFile ? fopen( $tmpFile->getPath(), 'wb' ) : false;
1242 if ( $handle ) {
1243 $reqs[$path] = [
1244 'method' => 'GET',
1245 'container' => $srcCont,
1246 'relPath' => $srcRel,
1247 'headers' => $this->headersFromParams( $params ),
1248 'stream' => $handle,
1249 ];
1250 $tmpFiles[$path] = $tmpFile;
1251 }
1252 }
1253
1254 // Ceph RADOS Gateway is in use (strong consistency) or X-Newest will be used
1255 $latest = ( $this->isRGW || !empty( $params['latest'] ) );
1256
1257 $reqs = $this->requestMultiWithAuth(
1258 $reqs,
1259 [ 'maxConnsPerHost' => $params['concurrency'] ]
1260 );
1261 foreach ( $reqs as $path => $op ) {
1262 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1263 fclose( $op['stream'] ); // close open handle
1264 if ( $rcode >= 200 && $rcode <= 299 ) {
1266 $tmpFile = $tmpFiles[$path];
1267 // Make sure that the stream finished and fully wrote to disk
1268 $size = $tmpFile->getSize();
1269 if ( $size !== (int)$rhdrs['content-length'] ) {
1270 $tmpFiles[$path] = self::RES_ERROR;
1271 $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
1272 $this->onError( null, __METHOD__,
1273 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
1274 }
1275 // Set the file stat process cache in passing
1276 $stat = $this->getStatFromHeaders( $rhdrs );
1277 $stat['latest'] = $latest;
1278 $this->cheapCache->setField( $path, 'stat', $stat );
1279 } elseif ( $rcode === 404 ) {
1280 $tmpFiles[$path] = self::RES_ABSENT;
1281 $this->cheapCache->setField(
1282 $path,
1283 'stat',
1284 $latest ? self::ABSENT_LATEST : self::ABSENT_NORMAL
1285 );
1286 } else {
1287 $tmpFiles[$path] = self::RES_ERROR;
1288 $this->onError( null, __METHOD__,
1289 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
1290 }
1291 }
1292
1293 return $tmpFiles;
1294 }
1295
1296 public function getFileHttpUrl( array $params ) {
1297 if ( $this->swiftTempUrlKey != '' ||
1298 ( $this->rgwS3AccessKey != '' && $this->rgwS3SecretKey != '' )
1299 ) {
1300 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1301 if ( $srcRel === null ) {
1302 return self::TEMPURL_ERROR; // invalid path
1303 }
1304
1305 $auth = $this->getAuthentication();
1306 if ( !$auth ) {
1307 return self::TEMPURL_ERROR;
1308 }
1309
1310 $ttl = $params['ttl'] ?? 86400;
1311 $expires = time() + $ttl;
1312
1313 if ( $this->swiftTempUrlKey != '' ) {
1314 $url = $this->storageUrl( $auth, $srcCont, $srcRel );
1315 // Swift wants the signature based on the unencoded object name
1316 $contPath = parse_url( $this->storageUrl( $auth, $srcCont ), PHP_URL_PATH );
1317 $signature = hash_hmac( 'sha1',
1318 "GET\n{$expires}\n{$contPath}/{$srcRel}",
1319 $this->swiftTempUrlKey
1320 );
1321
1322 return "{$url}?temp_url_sig={$signature}&temp_url_expires={$expires}";
1323 } else { // give S3 API URL for rgw
1324 // Path for signature starts with the bucket
1325 $spath = '/' . rawurlencode( $srcCont ) . '/' .
1326 str_replace( '%2F', '/', rawurlencode( $srcRel ) );
1327 // Calculate the hash
1328 $signature = base64_encode( hash_hmac(
1329 'sha1',
1330 "GET\n\n\n{$expires}\n{$spath}",
1331 $this->rgwS3SecretKey,
1332 true // raw
1333 ) );
1334 // See https://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html.
1335 // Note: adding a newline for empty CanonicalizedAmzHeaders does not work.
1336 // Note: S3 API is the rgw default; remove the /swift/ URL bit.
1337 return str_replace( '/swift/v1', '', $this->storageUrl( $auth ) . $spath ) .
1338 '?' .
1339 http_build_query( [
1340 'Signature' => $signature,
1341 'Expires' => $expires,
1342 'AWSAccessKeyId' => $this->rgwS3AccessKey
1343 ] );
1344 }
1345 }
1346
1347 return self::TEMPURL_ERROR;
1348 }
1349
1350 protected function directoriesAreVirtual() {
1351 return true;
1352 }
1353
1362 protected function headersFromParams( array $params ) {
1363 $hdrs = [];
1364 if ( !empty( $params['latest'] ) ) {
1365 $hdrs['x-newest'] = 'true';
1366 }
1367
1368 return $hdrs;
1369 }
1370
1371 protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
1373 '@phan-var SwiftFileOpHandle[] $fileOpHandles';
1374
1376 $statuses = [];
1377
1378 // Split the HTTP requests into stages that can be done concurrently
1379 $httpReqsByStage = []; // map of (stage => index => HTTP request)
1380 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
1381 $reqs = $fileOpHandle->httpOp;
1382 foreach ( $reqs as $stage => $req ) {
1383 $httpReqsByStage[$stage][$index] = $req;
1384 }
1385 $statuses[$index] = $this->newStatus();
1386 }
1387
1388 // Run all requests for the first stage, then the next, and so on
1389 $reqCount = count( $httpReqsByStage );
1390 for ( $stage = 0; $stage < $reqCount; ++$stage ) {
1391 $httpReqs = $this->requestMultiWithAuth( $httpReqsByStage[$stage] );
1392 foreach ( $httpReqs as $index => $httpReq ) {
1394 $fileOpHandle = $fileOpHandles[$index];
1395 // Run the callback for each request of this operation
1396 $status = $statuses[$index];
1397 ( $fileOpHandle->callback )( $httpReq, $status );
1398 // On failure, abort all remaining requests for this operation. This is used
1399 // in "move" operations to abort the DELETE request if the PUT request fails.
1400 if (
1401 !$status->isOK() ||
1402 $fileOpHandle->state === $fileOpHandle::CONTINUE_NO
1403 ) {
1404 $stages = count( $fileOpHandle->httpOp );
1405 for ( $s = ( $stage + 1 ); $s < $stages; ++$s ) {
1406 unset( $httpReqsByStage[$s][$index] );
1407 }
1408 }
1409 }
1410 }
1411
1412 return $statuses;
1413 }
1414
1437 protected function setContainerAccess( $container, array $readUsers, array $writeUsers ) {
1438 $status = $this->newStatus();
1439
1440 [ $rcode, , , , ] = $this->requestWithAuth( [
1441 'method' => 'POST',
1442 'container' => $container,
1443 'headers' => [
1444 'x-container-read' => implode( ',', $readUsers ),
1445 'x-container-write' => implode( ',', $writeUsers )
1446 ]
1447 ] );
1448
1449 if ( $rcode != 204 && $rcode !== 202 ) {
1450 $status->fatal( 'backend-fail-internal', $this->name );
1451 $this->logger->error( __METHOD__ . ': unexpected rcode value ({rcode})',
1452 [ 'rcode' => $rcode ] );
1453 }
1454
1455 return $status;
1456 }
1457
1466 protected function getContainerStat( $container, $bypassCache = false ) {
1468 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
1469
1470 if ( $bypassCache ) { // purge cache
1471 $this->containerStatCache->clear( $container );
1472 } elseif ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1473 $this->primeContainerCache( [ $container ] ); // check persistent cache
1474 }
1475 if ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1476 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $this->requestWithAuth( [
1477 'method' => 'HEAD',
1478 'container' => $container
1479 ] );
1480
1481 if ( $rcode === 204 ) {
1482 $stat = [
1483 'count' => $rhdrs['x-container-object-count'],
1484 'bytes' => $rhdrs['x-container-bytes-used']
1485 ];
1486 if ( $bypassCache ) {
1487 return $stat;
1488 } else {
1489 $this->containerStatCache->setField( $container, 'stat', $stat ); // cache it
1490 $this->setContainerCache( $container, $stat ); // update persistent cache
1491 }
1492 } elseif ( $rcode === 404 ) {
1493 return self::RES_ABSENT;
1494 } else {
1495 $this->onError( null, __METHOD__,
1496 [ 'cont' => $container ], $rerr, $rcode, $rdesc, $rbody );
1497
1498 return self::RES_ERROR;
1499 }
1500 }
1501
1502 return $this->containerStatCache->getField( $container, 'stat' );
1503 }
1504
1512 protected function createContainer( $container, array $params ) {
1513 $status = $this->newStatus();
1514
1515 // @see SwiftFileBackend::setContainerAccess()
1516 if ( empty( $params['noAccess'] ) ) {
1517 // public
1518 $readUsers = array_merge( $this->readUsers, [ '.r:*', $this->swiftUser ] );
1519 if ( empty( $params['noListing'] ) ) {
1520 array_push( $readUsers, '.rlistings' );
1521 }
1522 $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
1523 } else {
1524 // private
1525 $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
1526 $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
1527 }
1528
1529 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1530 'method' => 'PUT',
1531 'container' => $container,
1532 'headers' => [
1533 'x-container-read' => implode( ',', $readUsers ),
1534 'x-container-write' => implode( ',', $writeUsers )
1535 ]
1536 ] );
1537
1538 if ( $rcode === 201 ) { // new
1539 // good
1540 } elseif ( $rcode === 202 ) { // already there
1541 // this shouldn't really happen, but is OK
1542 } else {
1543 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1544 }
1545
1546 return $status;
1547 }
1548
1556 protected function deleteContainer( $container, array $params ) {
1557 $status = $this->newStatus();
1558
1559 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1560 'method' => 'DELETE',
1561 'container' => $container
1562 ] );
1563
1564 if ( $rcode >= 200 && $rcode <= 299 ) { // deleted
1565 $this->containerStatCache->clear( $container ); // purge
1566 } elseif ( $rcode === 404 ) { // not there
1567 // this shouldn't really happen, but is OK
1568 } elseif ( $rcode === 409 ) { // not empty
1569 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); // race?
1570 } else {
1571 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1572 }
1573
1574 return $status;
1575 }
1576
1589 private function objectListing(
1590 $fullCont, $type, $limit, $after = null, $prefix = null, $delim = null
1591 ) {
1592 $status = $this->newStatus();
1593
1594 $query = [ 'limit' => $limit ];
1595 if ( $type === 'info' ) {
1596 $query['format'] = 'json';
1597 }
1598 if ( $after !== null ) {
1599 $query['marker'] = $after;
1600 }
1601 if ( $prefix !== null ) {
1602 $query['prefix'] = $prefix;
1603 }
1604 if ( $delim !== null ) {
1605 $query['delimiter'] = $delim;
1606 }
1607
1608 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1609 'method' => 'GET',
1610 'container' => $fullCont,
1611 'query' => $query,
1612 ] );
1613
1614 $params = [ 'cont' => $fullCont, 'prefix' => $prefix, 'delim' => $delim ];
1615 if ( $rcode === 200 ) { // good
1616 if ( $type === 'info' ) {
1617 $status->value = FormatJson::decode( trim( $rbody ) );
1618 } else {
1619 $status->value = explode( "\n", trim( $rbody ) );
1620 }
1621 } elseif ( $rcode === 204 ) {
1622 $status->value = []; // empty container
1623 } elseif ( $rcode === 404 ) {
1624 $status->value = []; // no container
1625 } else {
1626 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1627 }
1628
1629 return $status;
1630 }
1631
1632 protected function doPrimeContainerCache( array $containerInfo ) {
1633 foreach ( $containerInfo as $container => $info ) {
1634 $this->containerStatCache->setField( $container, 'stat', $info );
1635 }
1636 }
1637
1638 protected function doGetFileStatMulti( array $params ) {
1639 $stats = [];
1640
1641 $reqs = []; // (path => op)
1642 // (a) Check the containers of the paths...
1643 foreach ( $params['srcs'] as $path ) {
1644 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1645 if ( $srcRel === null ) {
1646 // invalid storage path
1647 $stats[$path] = self::RES_ERROR;
1648 continue;
1649 }
1650
1651 $cstat = $this->getContainerStat( $srcCont );
1652 if ( $cstat === self::RES_ABSENT ) {
1653 $stats[$path] = self::RES_ABSENT;
1654 continue; // ok, nothing to do
1655 } elseif ( $cstat === self::RES_ERROR ) {
1656 $stats[$path] = self::RES_ERROR;
1657 continue;
1658 }
1659
1660 $reqs[$path] = [
1661 'method' => 'HEAD',
1662 'container' => $srcCont,
1663 'relPath' => $srcRel,
1664 'headers' => $this->headersFromParams( $params )
1665 ];
1666 }
1667
1668 // (b) Check the files themselves...
1669 $reqs = $this->requestMultiWithAuth(
1670 $reqs,
1671 [ 'maxConnsPerHost' => $params['concurrency'] ]
1672 );
1673 foreach ( $reqs as $path => $op ) {
1674 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1675 if ( $rcode === 200 || $rcode === 204 ) {
1676 // Update the object if it is missing some headers
1677 if ( !empty( $params['requireSHA1'] ) ) {
1678 $rhdrs = $this->addMissingHashMetadata( $rhdrs, $path );
1679 }
1680 // Load the stat map from the headers
1681 $stat = $this->getStatFromHeaders( $rhdrs );
1682 if ( $this->isRGW ) {
1683 $stat['latest'] = true; // strong consistency
1684 }
1685 } elseif ( $rcode === 404 ) {
1686 $stat = self::RES_ABSENT;
1687 } else {
1688 $stat = self::RES_ERROR;
1689 $this->onError( null, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1690 }
1691 $stats[$path] = $stat;
1692 }
1693
1694 return $stats;
1695 }
1696
1701 protected function getStatFromHeaders( array $rhdrs ) {
1702 // Fetch all of the custom metadata headers
1703 $metadata = $this->getMetadataFromHeaders( $rhdrs );
1704 // Fetch all of the custom raw HTTP headers
1705 $headers = $this->extractMutableContentHeaders( $rhdrs );
1706
1707 return [
1708 // Convert various random Swift dates to TS_MW
1709 'mtime' => $this->convertSwiftDate( $rhdrs['last-modified'], TS_MW ),
1710 // Empty objects actually return no content-length header in Ceph
1711 'size' => isset( $rhdrs['content-length'] ) ? (int)$rhdrs['content-length'] : 0,
1712 'sha1' => $metadata['sha1base36'] ?? null,
1713 // Note: manifest ETags are not an MD5 of the file
1714 'md5' => ctype_xdigit( $rhdrs['etag'] ) ? $rhdrs['etag'] : null,
1715 'xattr' => [ 'metadata' => $metadata, 'headers' => $headers ]
1716 ];
1717 }
1718
1724 protected function getAuthentication() {
1725 if ( $this->authErrorTimestamp !== null ) {
1726 $interval = time() - $this->authErrorTimestamp;
1727 if ( $interval < 60 ) {
1728 $this->logger->debug(
1729 'rejecting request since auth failure occurred {interval} seconds ago',
1730 [ 'interval' => $interval ]
1731 );
1732 return null;
1733 } else { // actually retry this time
1734 $this->authErrorTimestamp = null;
1735 }
1736 }
1737 // Authenticate with proxy and get a session key...
1738 if ( !$this->authCreds ) {
1739 $cacheKey = $this->getCredsCacheKey( $this->swiftUser );
1740 $creds = $this->srvCache->get( $cacheKey ); // credentials
1741 // Try to use the credential cache
1742 if ( isset( $creds['auth_token'] )
1743 && isset( $creds['storage_url'] )
1744 && isset( $creds['expiry_time'] )
1745 && $creds['expiry_time'] > time()
1746 ) {
1747 $this->setAuthCreds( $creds );
1748 } else { // cache miss
1749 $this->refreshAuthentication();
1750 }
1751 }
1752
1753 return $this->authCreds;
1754 }
1755
1761 private function setAuthCreds( ?array $creds ) {
1762 $this->logger->debug( 'Using auth token with expiry_time={expiry_time}',
1763 [
1764 'expiry_time' => isset( $creds['expiry_time'] )
1765 ? gmdate( 'c', $creds['expiry_time'] ) : 'null'
1766 ]
1767 );
1768 $this->authCreds = $creds;
1769 // Ceph RGW does not use <account> in URLs (OpenStack Swift uses "/v1/<account>")
1770 if ( $creds && str_ends_with( $creds['storage_url'], '/v1' ) ) {
1771 $this->isRGW = true; // take advantage of strong consistency in Ceph
1772 }
1773 }
1774
1780 private function refreshAuthentication() {
1781 [ $rcode, , $rhdrs, $rbody, ] = $this->http->run( [
1782 'method' => 'GET',
1783 'url' => "{$this->swiftAuthUrl}/v1.0",
1784 'headers' => [
1785 'x-auth-user' => $this->swiftUser,
1786 'x-auth-key' => $this->swiftKey
1787 ]
1788 ], self::DEFAULT_HTTP_OPTIONS );
1789
1790 if ( $rcode >= 200 && $rcode <= 299 ) { // OK
1791 if ( isset( $rhdrs['x-auth-token-expires'] ) ) {
1792 $ttl = intval( $rhdrs['x-auth-token-expires'] );
1793 } else {
1794 $ttl = $this->authTTL;
1795 }
1796 $expiryTime = time() + $ttl;
1797 $creds = [
1798 'auth_token' => $rhdrs['x-auth-token'],
1799 'storage_url' => $this->swiftStorageUrl ?? $rhdrs['x-storage-url'],
1800 'expiry_time' => $expiryTime,
1801 ];
1802 $this->srvCache->set( $this->getCredsCacheKey( $this->swiftUser ), $creds, $expiryTime );
1803 } elseif ( $rcode === 401 ) {
1804 $this->onError( null, __METHOD__, [], "Authentication failed.", $rcode );
1805 $this->authErrorTimestamp = time();
1806 $creds = null;
1807 } else {
1808 $this->onError( null, __METHOD__, [], "HTTP return code: $rcode", $rcode, $rbody );
1809 $this->authErrorTimestamp = time();
1810 $creds = null;
1811 }
1812 $this->setAuthCreds( $creds );
1813 return $creds;
1814 }
1815
1822 protected function storageUrl( array $creds, $container = null, $object = null ) {
1823 $parts = [ $creds['storage_url'] ];
1824 if ( strlen( $container ?? '' ) ) {
1825 $parts[] = rawurlencode( $container );
1826 }
1827 if ( strlen( $object ?? '' ) ) {
1828 $parts[] = str_replace( "%2F", "/", rawurlencode( $object ) );
1829 }
1830
1831 return implode( '/', $parts );
1832 }
1833
1838 protected function authTokenHeaders( array $creds ) {
1839 return [ 'x-auth-token' => $creds['auth_token'] ];
1840 }
1841
1848 private function getCredsCacheKey( $username ) {
1849 return 'swiftcredentials:' . md5( $username . ':' . $this->swiftAuthUrl );
1850 }
1851
1866 private function requestWithAuth( array $req, array $options = [] ) {
1867 return $this->requestMultiWithAuth( [ $req ], $options )[0]['response'];
1868 }
1869
1879 private function requestMultiWithAuth( array $reqs, $options = [] ) {
1880 $remainingTries = 2;
1881 $auth = $this->getAuthentication();
1882 while ( true ) {
1883 if ( !$auth ) {
1884 foreach ( $reqs as &$req ) {
1885 if ( !isset( $req['response'] ) ) {
1886 $req['response'] = $this->getAuthFailureResponse();
1887 }
1888 }
1889 break;
1890 }
1891 foreach ( $reqs as &$req ) {
1892 '@phan-var array $req'; // Not array[]
1893 if ( isset( $req['response'] ) ) {
1894 // Request was attempted before
1895 // Retry only if it gave a 401 response code
1896 if ( $req['response']['code'] !== 401 ) {
1897 continue;
1898 }
1899 }
1900 $req['headers'] = $this->authTokenHeaders( $auth ) + ( $req['headers'] ?? [] );
1901 $req['url'] = $this->storageUrl( $auth, $req['container'], $req['relPath'] ?? null );
1902 }
1903 unset( $req );
1904 $reqs = $this->http->runMulti( $reqs, $options + self::DEFAULT_HTTP_OPTIONS );
1905 if ( --$remainingTries > 0 ) {
1906 // Retry if any request failed with 401 "not authorized"
1907 foreach ( $reqs as $req ) {
1908 if ( $req['response']['code'] === 401 ) {
1909 $auth = $this->refreshAuthentication();
1910 continue 2;
1911 }
1912 }
1913 }
1914 break;
1915 }
1916 return $reqs;
1917 }
1918
1927 private function getAuthFailureResponse() {
1928 return [
1929 'code' => 0,
1930 0 => 0,
1931 'reason' => '',
1932 1 => '',
1933 'headers' => [],
1934 2 => [],
1935 'body' => '',
1936 3 => '',
1937 'error' => self::AUTH_FAILURE_ERROR,
1938 4 => self::AUTH_FAILURE_ERROR
1939 ];
1940 }
1941
1949 private function isAuthFailureResponse( $code, $error ) {
1950 return $code === 0 && $error === self::AUTH_FAILURE_ERROR;
1951 }
1952
1965 public function onError( $status, $func, array $params, $err = '', $code = 0, $desc = '', $body = '' ) {
1966 if ( $this->isAuthFailureResponse( $code, $err ) ) {
1967 if ( $status instanceof StatusValue ) {
1968 $status->fatal( 'backend-fail-connect', $this->name );
1969 }
1970 // Already logged
1971 return;
1972 }
1973 if ( $status instanceof StatusValue ) {
1974 $status->fatal( 'backend-fail-internal', $this->name );
1975 }
1976 $msg = "HTTP {code} ({desc}) in '{func}' (given '{req_params}')";
1977 $msgParams = [
1978 'code' => $code,
1979 'desc' => $desc,
1980 'func' => $func,
1981 'req_params' => FormatJson::encode( $params ),
1982 ];
1983 if ( $err ) {
1984 $msg .= ': {err}';
1985 $msgParams['err'] = $err;
1986 }
1987 if ( $code == 502 ) {
1988 $msg .= ' ({truncatedBody})';
1989 $msgParams['truncatedBody'] = substr( strip_tags( $body ), 0, 100 );
1990 }
1991 $this->logger->error( $msg, $msgParams );
1992 }
1993}
1994
1996class_alias( SwiftFileBackend::class, 'SwiftFileBackend' );
array $params
The job parameters.
Resource locking handling.
Store key-value entries in a size-limited in-memory LRU cache.
JSON formatter wrapper class.
Library for creating and parsing MW-style timestamps.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
File backend exception for checked exceptions (e.g.
Base class for all backends using particular storage medium.
getContentType( $storagePath, $content, $fsPath)
Get the content type to use in HEAD/GET requests for a file.
resolveStoragePathReal( $storagePath)
Like resolveStoragePath() except null values are returned if the container is sharded and the shard c...
executeOpHandlesInternal(array $fileOpHandles)
Execute a list of FileBackendStoreOpHandle handles in parallel.
clearCache(array $paths=null)
Invalidate any in-process file stat and property cache.
setContainerCache( $container, array $val)
Set the cached info for a container.
getFileStat(array $params)
Get quick information about a file at a storage path in the backend.
fileExists(array $params)
Check if a file exists at a storage path in the backend.
deleteFileCache( $path)
Delete the cached stat info for a file path.
primeContainerCache(array $items)
Do a batch lookup from cache for container stats for all containers used in a list of container names...
string $name
Unique backend name.
getLocalCopy(array $params)
Get a local copy on disk of the file at a storage path in the backend.
static extensionFromPath( $path, $case='lowercase')
Get the final extension from a storage or FS path.
newStatus(... $args)
Yields the result of the status wrapper callback on either:
getScopedFileLocks(array $paths, $type, StatusValue $status, $timeout=0)
Lock the files at the given storage paths in the backend.
static send404Message( $fname, $flags=0)
Send out a standard 404 message for a file.
Class for an OpenStack Swift (or Ceph RGW) based file backend.
getAuthentication()
Get the cached auth token.
MapCacheLRU $containerStatCache
Container stat cache.
headersFromParams(array $params)
Get headers to send to Swift when reading a file based on a FileBackend params array,...
getFileListInternal( $fullCont, $dir, array $params)
resolveContainerPath( $container, $relStoragePath)
Resolve a relative storage path, checking if it's allowed by the backend.
doCleanInternal( $fullCont, $dir, array $params)
string $swiftTempUrlKey
Shared secret value for making temp URLs.
addMissingHashMetadata(array $objHdrs, $path)
Fill in any missing object metadata and save it to Swift.
array $writeUsers
Additional users (account:user) with write permissions on public containers.
string $swiftAuthUrl
Authentication base URL (without version)
doDirectoryExists( $fullCont, $dir, array $params)
storageUrl(array $creds, $container=null, $object=null)
onError( $status, $func, array $params, $err='', $code=0, $desc='', $body='')
Log an unexpected exception for this backend.
createContainer( $container, array $params)
Create a Swift container.
array $secureWriteUsers
Additional users (account:user) with write permissions on private containers.
doPrimeContainerCache(array $containerInfo)
Fill the backend-specific process cache given an array of resolved container names and their correspo...
array $readUsers
Additional users (account:user) with read permissions on public containers.
convertSwiftDate( $ts, $format=TS_MW)
Convert dates like "Tue, 03 Jan 2012 22:01:04 GMT"/"2013-05-11T07:37:27.678360Z".
getDirectoryListInternal( $fullCont, $dir, array $params)
getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params)
Do not call this function outside of SwiftFileBackendFileList.
deleteContainer( $container, array $params)
Delete a Swift container.
directoriesAreVirtual()
Is this a key/value store where directories are just virtual? Virtual directories exists in so much a...
string $rgwS3AccessKey
S3 access key (RADOS Gateway)
doPrepareInternal( $fullCont, $dir, array $params)
FileBackendStore::doPrepare() to override StatusValue Good status without value for success,...
extractMutableContentHeaders(array $headers)
Filter/normalize a header map to only include mutable "content-"/"x-content-" headers.
string $swiftUser
Swift user (account:user) to authenticate as.
getFeatures()
Get the a bitfield of extra features supported by the backend medium.
string $swiftStorageUrl
Override of storage base URL.
doGetFileStatMulti(array $params)
Get file stat information (concurrently if possible) for several files.
loadListingStatInternal( $path, array $val)
Do not call this function outside of SwiftFileBackendFileList.
isPathUsableInternal( $storagePath)
Check if a file can be created or changed at a given storage path in the backend.
setContainerAccess( $container, array $readUsers, array $writeUsers)
Set read/write permissions for a Swift container.
string $rgwS3SecretKey
S3 authentication key (RADOS Gateway)
getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params)
Do not call this function outside of SwiftFileBackendFileList.
array $secureReadUsers
Additional users (account:user) with read permissions on private containers.
bool $isRGW
Whether the server is an Ceph RGW.
int null $authErrorTimestamp
UNIX timestamp.
doSecureInternal( $fullCont, $dir, array $params)
doPublishInternal( $fullCont, $dir, array $params)
getContainerStat( $container, $bypassCache=false)
Get a Swift container stat map, possibly from process cache.
Class to handle multiple HTTP requests.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:89
No-op implementation that stores nothing.
Multi-datacenter aware caching interface.
This program is free software; you can redistribute it and/or modify it under the terms of the GNU Ge...
$header