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