MediaWiki master
SwiftFileBackend.php
Go to the documentation of this file.
1<?php
11namespace Wikimedia\FileBackend;
12
13use Exception;
14use Psr\Log\LoggerInterface;
15use Shellbox\Command\BoxedCommand;
16use StatusValue;
17use stdClass;
25use Wikimedia\RequestTimeout\TimeoutException;
26use Wikimedia\Timestamp\ConvertibleTimestamp;
27use Wikimedia\Timestamp\TimestampFormat as TS;
28
39 private const DEFAULT_HTTP_OPTIONS = [ 'httpVersion' => 'v1.1' ];
40
42 protected $http;
44 protected $swiftUser;
46 protected $swiftKey;
54 protected $rgwS3AccessKey;
56 protected $rgwS3SecretKey;
58 protected $readUsers;
60 protected $writeUsers;
65
68 private SwiftAuthProvider $swiftAuthProvider;
69
116 public function __construct( array $config ) {
117 parent::__construct( $config );
118 // Required settings
119 $this->swiftUser = $config['swiftUser'];
120 $this->swiftKey = $config['swiftKey'];
121 // Optional settings
122 $this->swiftTempUrlKey = $config['swiftTempUrlKey'] ?? '';
123 $this->canShellboxGetTempUrl = $config['canShellboxGetTempUrl'] ?? false;
124 $this->shellboxIpRange = $config['shellboxIpRange'] ?? null;
125
126 $this->shardViaHashLevels = $config['shardViaHashLevels'] ?? '';
127 $this->rgwS3AccessKey = $config['rgwS3AccessKey'] ?? '';
128 $this->rgwS3SecretKey = $config['rgwS3SecretKey'] ?? '';
129
130 // HTTP helper client
131 $httpOptions = [];
132 foreach ( [ 'connTimeout', 'reqTimeout' ] as $optionName ) {
133 if ( isset( $config[$optionName] ) ) {
134 $httpOptions[$optionName] = $config[$optionName];
135 }
136 }
137 if ( isset( $config['telemetry'] ) ) {
138 $httpOptions['telemetry'] = $config['telemetry'];
139 }
140 $this->http = new MultiHttpClient( $httpOptions );
141 $this->http->setLogger( $this->logger );
142
143 // Cache container information to mask latency
144 $this->wanStatCache = $this->wanCache;
145 // Process cache for container info
146 $this->containerStatCache = new MapCacheLRU( 300 );
147 $this->readUsers = $config['readUsers'] ?? [];
148 $this->writeUsers = $config['writeUsers'] ?? [];
149 $this->secureReadUsers = $config['secureReadUsers'] ?? [];
150 $this->secureWriteUsers = $config['secureWriteUsers'] ?? [];
151 // Per https://docs.openstack.org/swift/latest/overview_large_objects.html
152 // we need to split objects if they are larger than 5 GB. Support for
153 // splitting objects has not yet been implemented by this class
154 // so limit max file size to 5GiB.
155 $this->maxFileSize = 5 * 1024 * 1024 * 1024;
156
157 $this->swiftAuthProvider = new SwiftAuthProvider( $config, $this->http, $this->logger );
158 }
159
160 public function setLogger( LoggerInterface $logger ): void {
161 parent::setLogger( $logger );
162 $this->http->setLogger( $logger );
163 $this->swiftAuthProvider->setLogger( $logger );
164 }
165
167 public function getFeatures() {
168 return (
169 self::ATTR_UNICODE_PATHS |
170 self::ATTR_HEADERS |
171 self::ATTR_METADATA
172 );
173 }
174
176 protected function resolveContainerPath( $container, $relStoragePath ) {
177 if ( !mb_check_encoding( $relStoragePath, 'UTF-8' ) ) {
178 return null; // not UTF-8, makes it hard to use CF and the swift HTTP API
179 } elseif ( strlen( rawurlencode( $relStoragePath ) ) > 1024 ) {
180 return null; // too long for Swift
181 }
182
183 return $relStoragePath;
184 }
185
187 public function isPathUsableInternal( $storagePath ) {
188 [ $container, $rel ] = $this->resolveStoragePathReal( $storagePath );
189 if ( $rel === null ) {
190 return false; // invalid
191 }
192
193 return is_array( $this->getContainerStat( $container ) );
194 }
195
205 protected function extractMutableContentHeaders( array $headers ) {
206 $contentHeaders = [];
207 // Normalize casing, and strip out illegal headers
208 foreach ( $headers as $name => $value ) {
209 $name = strtolower( $name );
210 if ( $name === 'x-delete-at' && is_numeric( $value ) ) {
211 // Expects a Unix Epoch date
212 $contentHeaders[$name] = $value;
213 } elseif ( $name === 'x-delete-after' && is_numeric( $value ) ) {
214 // Expects number of minutes time to live.
215 $contentHeaders[$name] = $value;
216 } elseif ( preg_match( '/^(x-)?content-(?!length$)/', $name ) ) {
217 // Only allow content-* and x-content-* headers (but not content-length)
218 $contentHeaders[$name] = $value;
219 } elseif ( $name === 'content-type' && $value !== '' ) {
220 // This header can be set to a value but not unset
221 $contentHeaders[$name] = $value;
222 }
223 }
224 // By default, Swift has annoyingly low maximum header value limits
225 if ( isset( $contentHeaders['content-disposition'] ) ) {
226 $maxLength = 255;
227 // @note: assume FileBackend::makeContentDisposition() already used
228 $offset = $maxLength - strlen( $contentHeaders['content-disposition'] );
229 if ( $offset < 0 ) {
230 $pos = strrpos( $contentHeaders['content-disposition'], ';', $offset );
231 $contentHeaders['content-disposition'] = $pos === false
232 ? ''
233 : trim( substr( $contentHeaders['content-disposition'], 0, $pos ) );
234 }
235 }
236
237 return $contentHeaders;
238 }
239
245 protected function extractMetadataHeaders( array $headers ) {
246 $metadataHeaders = [];
247 foreach ( $headers as $name => $value ) {
248 $name = strtolower( $name );
249 if ( str_starts_with( $name, 'x-object-meta-' ) ) {
250 $metadataHeaders[$name] = $value;
251 }
252 }
253
254 return $metadataHeaders;
255 }
256
262 protected function getMetadataFromHeaders( array $headers ) {
263 $prefixLen = strlen( 'x-object-meta-' );
264
265 $metadata = [];
266 foreach ( $this->extractMetadataHeaders( $headers ) as $name => $value ) {
267 $metadata[substr( $name, $prefixLen )] = $value;
268 }
269
270 return $metadata;
271 }
272
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 'container' => $dstCont,
293 'relPath' => $dstRel,
294 'headers' => array_merge(
295 $mutableHeaders,
296 [
297 'etag' => md5( $params['content'] ),
298 'content-length' => strlen( $params['content'] ),
299 'x-object-meta-sha1base36' =>
300 \Wikimedia\base_convert( sha1( $params['content'] ), 16, 36, 31 )
301 ]
302 ),
303 'body' => $params['content']
304 ] ];
305
306 $method = __METHOD__;
307 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
308 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
309 if ( $rcode === 201 || $rcode === 202 ) {
310 // good
311 } elseif ( $rcode === 412 ) {
312 $status->fatal( 'backend-fail-contenttype', $params['dst'] );
313 } else {
314 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
315 }
316
317 return SwiftFileOpHandle::CONTINUE_IF_OK;
318 };
319
320 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
321 if ( !empty( $params['async'] ) ) { // deferred
322 $status->value = $opHandle;
323 } else { // actually write the object in Swift
324 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
325 }
326
327 return $status;
328 }
329
331 protected function doStoreInternal( array $params ) {
332 $status = $this->newStatus();
333
334 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
335 if ( $dstRel === null ) {
336 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
337
338 return $status;
339 }
340
341 // Open a handle to the source file so that it can be streamed. The size and hash
342 // will be computed using the handle. In the off chance that the source file changes
343 // during this operation, the PUT will fail due to an ETag mismatch and be aborted.
344 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
345 $srcHandle = @fopen( $params['src'], 'rb' );
346 if ( $srcHandle === false ) { // source doesn't exist?
347 $status->fatal( 'backend-fail-notexists', $params['src'] );
348
349 return $status;
350 }
351
352 // Compute the MD5 and SHA-1 hashes in one pass
353 $srcSize = fstat( $srcHandle )['size'];
354 $md5Context = hash_init( 'md5' );
355 $sha1Context = hash_init( 'sha1' );
356 $hashDigestSize = 0;
357 while ( !feof( $srcHandle ) ) {
358 $buffer = (string)fread( $srcHandle, 131_072 ); // 128 KiB
359 hash_update( $md5Context, $buffer );
360 hash_update( $sha1Context, $buffer );
361 $hashDigestSize += strlen( $buffer );
362 }
363 // Reset the handle back to the beginning so that it can be streamed
364 rewind( $srcHandle );
365
366 if ( $hashDigestSize !== $srcSize ) {
367 $status->fatal( 'backend-fail-hash', $params['src'] );
368
369 return $status;
370 }
371
372 // Headers that are not strictly a function of the file content
373 $mutableHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
374 // Make sure that the "content-type" header is set to something sensible
375 $mutableHeaders['content-type']
376 ??= $this->getContentType( $params['dst'], null, $params['src'] );
377
378 $reqs = [ [
379 'method' => 'PUT',
380 'container' => $dstCont,
381 'relPath' => $dstRel,
382 'headers' => array_merge(
383 $mutableHeaders,
384 [
385 'content-length' => $srcSize,
386 'etag' => hash_final( $md5Context ),
387 'x-object-meta-sha1base36' =>
388 \Wikimedia\base_convert( hash_final( $sha1Context ), 16, 36, 31 )
389 ]
390 ),
391 'body' => $srcHandle // resource
392 ] ];
393
394 $method = __METHOD__;
395 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
396 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
397 if ( $rcode === 201 || $rcode === 202 ) {
398 // good
399 } elseif ( $rcode === 412 ) {
400 $status->fatal( 'backend-fail-contenttype', $params['dst'] );
401 } else {
402 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
403 }
404
405 return SwiftFileOpHandle::CONTINUE_IF_OK;
406 };
407
408 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
409 $opHandle->resourcesToClose[] = $srcHandle;
410
411 if ( !empty( $params['async'] ) ) { // deferred
412 $status->value = $opHandle;
413 } else { // actually write the object in Swift
414 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
415 }
416
417 return $status;
418 }
419
421 protected function doCopyInternal( array $params ) {
422 $status = $this->newStatus();
423
424 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
425 if ( $srcRel === null ) {
426 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
427
428 return $status;
429 }
430
431 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
432 if ( $dstRel === null ) {
433 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
434
435 return $status;
436 }
437
438 $reqs = [ [
439 'method' => 'PUT',
440 'container' => $dstCont,
441 'relPath' => $dstRel,
442 'headers' => array_merge(
443 $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
444 [
445 'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
446 str_replace( "%2F", "/", rawurlencode( $srcRel ) )
447 ]
448 )
449 ] ];
450
451 $method = __METHOD__;
452 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
453 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
454 if ( $rcode === 201 ) {
455 // good
456 } elseif ( $rcode === 404 ) {
457 if ( empty( $params['ignoreMissingSource'] ) ) {
458 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
459 }
460 } else {
461 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
462 }
463
464 return SwiftFileOpHandle::CONTINUE_IF_OK;
465 };
466
467 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
468 if ( !empty( $params['async'] ) ) { // deferred
469 $status->value = $opHandle;
470 } else { // actually write the object in Swift
471 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
472 }
473
474 return $status;
475 }
476
478 protected function doMoveInternal( array $params ) {
479 $status = $this->newStatus();
480
481 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
482 if ( $srcRel === null ) {
483 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
484
485 return $status;
486 }
487
488 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
489 if ( $dstRel === null ) {
490 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
491
492 return $status;
493 }
494
495 $reqs = [ [
496 'method' => 'PUT',
497 'container' => $dstCont,
498 'relPath' => $dstRel,
499 'headers' => array_merge(
500 $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
501 [
502 'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
503 str_replace( "%2F", "/", rawurlencode( $srcRel ) )
504 ]
505 )
506 ] ];
507 if ( "{$srcCont}/{$srcRel}" !== "{$dstCont}/{$dstRel}" ) {
508 $reqs[] = [
509 'method' => 'DELETE',
510 'container' => $srcCont,
511 'relPath' => $srcRel,
512 'headers' => []
513 ];
514 }
515
516 $method = __METHOD__;
517 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
518 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
519 if ( $request['method'] === 'PUT' && $rcode === 201 ) {
520 // good
521 } elseif ( $request['method'] === 'DELETE' && $rcode === 204 ) {
522 // good
523 } elseif ( $rcode === 404 ) {
524 if ( empty( $params['ignoreMissingSource'] ) ) {
525 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
526 } else {
527 // Leave Status as OK but skip the DELETE request
528 return SwiftFileOpHandle::CONTINUE_NO;
529 }
530 } else {
531 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
532 }
533
534 return SwiftFileOpHandle::CONTINUE_IF_OK;
535 };
536
537 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
538 if ( !empty( $params['async'] ) ) { // deferred
539 $status->value = $opHandle;
540 } else { // actually move the object in Swift
541 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
542 }
543
544 return $status;
545 }
546
548 protected function doDeleteInternal( array $params ) {
549 $status = $this->newStatus();
550
551 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
552 if ( $srcRel === null ) {
553 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
554
555 return $status;
556 }
557
558 $reqs = [ [
559 'method' => 'DELETE',
560 'container' => $srcCont,
561 'relPath' => $srcRel,
562 'headers' => []
563 ] ];
564
565 $method = __METHOD__;
566 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
567 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
568 if ( $rcode === 204 ) {
569 // good
570 } elseif ( $rcode === 404 ) {
571 if ( empty( $params['ignoreMissingSource'] ) ) {
572 $status->fatal( 'backend-fail-delete', $params['src'] );
573 }
574 } else {
575 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
576 }
577
578 return SwiftFileOpHandle::CONTINUE_IF_OK;
579 };
580
581 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
582 if ( !empty( $params['async'] ) ) { // deferred
583 $status->value = $opHandle;
584 } else { // actually delete the object in Swift
585 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
586 }
587
588 return $status;
589 }
590
592 protected function doDescribeInternal( array $params ) {
593 $status = $this->newStatus();
594
595 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
596 if ( $srcRel === null ) {
597 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
598
599 return $status;
600 }
601
602 // Fetch the old object headers/metadata...this should be in stat cache by now
603 $stat = $this->getFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
604 if ( $stat && !isset( $stat['xattr'] ) ) { // older cache entry
605 $stat = $this->doGetFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
606 }
607 if ( !$stat ) {
608 $status->fatal( 'backend-fail-describe', $params['src'] );
609
610 return $status;
611 }
612
613 // Swift object POST clears any prior headers, so merge the new and old headers here.
614 // Also, during, POST, libcurl adds "Content-Type: application/x-www-form-urlencoded"
615 // if "Content-Type" is not set, which would clobber the header value for the object.
616 $oldMetadataHeaders = [];
617 foreach ( $stat['xattr']['metadata'] as $name => $value ) {
618 $oldMetadataHeaders["x-object-meta-$name"] = $value;
619 }
620 $newContentHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
621 $oldContentHeaders = $stat['xattr']['headers'];
622
623 $reqs = [ [
624 'method' => 'POST',
625 'container' => $srcCont,
626 'relPath' => $srcRel,
627 'headers' => $oldMetadataHeaders + $newContentHeaders + $oldContentHeaders
628 ] ];
629
630 $method = __METHOD__;
631 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
632 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
633 if ( $rcode === 202 ) {
634 // good
635 } elseif ( $rcode === 404 ) {
636 $status->fatal( 'backend-fail-describe', $params['src'] );
637 } else {
638 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
639 }
640 };
641
642 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
643 if ( !empty( $params['async'] ) ) { // deferred
644 $status->value = $opHandle;
645 } else { // actually change the object in Swift
646 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
647 }
648
649 return $status;
650 }
651
655 protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
656 $status = $this->newStatus();
657
658 // (a) Check if container already exists
659 $stat = $this->getContainerStat( $fullCont );
660 if ( is_array( $stat ) ) {
661 return $status; // already there
662 } elseif ( $stat === self::RES_ERROR ) {
663 $status->fatal( 'backend-fail-internal', $this->name );
664 $this->logger->error( __METHOD__ . ': cannot get container stat' );
665 } else {
666 // (b) Create container as needed with proper ACLs
667 $params['op'] = 'prepare';
668 $status->merge( $this->createContainer( $fullCont, $params ) );
669 }
670
671 return $status;
672 }
673
675 protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
676 $status = $this->newStatus();
677 if ( empty( $params['noAccess'] ) ) {
678 return $status; // nothing to do
679 }
680
681 $stat = $this->getContainerStat( $fullCont );
682 if ( is_array( $stat ) ) {
683 $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
684 $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
685 // Make container private to end-users...
686 $status->merge( $this->setContainerAccess(
687 $fullCont,
688 $readUsers,
689 $writeUsers
690 ) );
691 } elseif ( $stat === self::RES_ABSENT ) {
692 $status->fatal( 'backend-fail-usable', $params['dir'] );
693 } else {
694 $status->fatal( 'backend-fail-internal', $this->name );
695 $this->logger->error( __METHOD__ . ': cannot get container stat' );
696 }
697
698 return $status;
699 }
700
702 protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
703 $status = $this->newStatus();
704 if ( empty( $params['access'] ) ) {
705 return $status; // nothing to do
706 }
707
708 $stat = $this->getContainerStat( $fullCont );
709 if ( is_array( $stat ) ) {
710 $readUsers = array_merge( $this->readUsers, [ $this->swiftUser, '.r:*' ] );
711 if ( !empty( $params['listing'] ) ) {
712 $readUsers[] = '.rlistings';
713 }
714 $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
715
716 // Make container public to end-users...
717 $status->merge( $this->setContainerAccess(
718 $fullCont,
719 $readUsers,
720 $writeUsers
721 ) );
722 } elseif ( $stat === self::RES_ABSENT ) {
723 $status->fatal( 'backend-fail-usable', $params['dir'] );
724 } else {
725 $status->fatal( 'backend-fail-internal', $this->name );
726 $this->logger->error( __METHOD__ . ': cannot get container stat' );
727 }
728
729 return $status;
730 }
731
733 protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
734 $status = $this->newStatus();
735
736 // Only containers themselves can be removed, all else is virtual
737 if ( $dirRel != '' ) {
738 return $status; // nothing to do
739 }
740
741 // (a) Check the container
742 $stat = $this->getContainerStat( $fullCont, true );
743 if ( $stat === self::RES_ABSENT ) {
744 return $status; // ok, nothing to do
745 } elseif ( $stat === self::RES_ERROR ) {
746 $status->fatal( 'backend-fail-internal', $this->name );
747 $this->logger->error( __METHOD__ . ': cannot get container stat' );
748 } elseif ( is_array( $stat ) && $stat['count'] == 0 ) {
749 // (b) Delete the container if empty
750 $params['op'] = 'clean';
751 $status->merge( $this->deleteContainer( $fullCont, $params ) );
752 }
753
754 return $status;
755 }
756
758 protected function doGetFileStat( array $params ) {
759 $params = [ 'srcs' => [ $params['src'] ], 'concurrency' => 1 ] + $params;
760 unset( $params['src'] );
761 $stats = $this->doGetFileStatMulti( $params );
762
763 return reset( $stats );
764 }
765
776 protected function convertSwiftDate( $ts, $format = TS::MW ) {
777 try {
778 $timestamp = new ConvertibleTimestamp( $ts );
779
780 return $timestamp->getTimestamp( $format );
781 } catch ( TimeoutException $e ) {
782 throw $e;
783 } catch ( Exception $e ) {
784 throw new FileBackendError( $e->getMessage() );
785 }
786 }
787
795 protected function addMissingHashMetadata( array $objHdrs, $path ) {
796 if ( isset( $objHdrs['x-object-meta-sha1base36'] ) ) {
797 return $objHdrs; // nothing to do
798 }
799
800 $this->logger->error( __METHOD__ . ": {path} was not stored with SHA-1 metadata.",
801 [ 'path' => $path ] );
802
803 $objHdrs['x-object-meta-sha1base36'] = false;
804
805 // Find prior custom HTTP headers
806 $postHeaders = $this->extractMutableContentHeaders( $objHdrs );
807 // Find prior metadata headers
808 $postHeaders += $this->extractMetadataHeaders( $objHdrs );
809
810 $status = $this->newStatus();
812 $scopeLockS = $this->getScopedFileLocks( [ $path ], LockManager::LOCK_UW, $status );
813 if ( $status->isOK() ) {
814 $tmpFile = $this->getLocalCopy( [ 'src' => $path, 'latest' => 1 ] );
815 if ( $tmpFile ) {
816 $hash = $tmpFile->getSha1Base36();
817 if ( $hash !== false ) {
818 $objHdrs['x-object-meta-sha1base36'] = $hash;
819 // Merge new SHA1 header into the old ones
820 $postHeaders['x-object-meta-sha1base36'] = $hash;
821 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
822 [ $rcode ] = $this->requestWithAuth( [
823 'method' => 'POST',
824 'container' => $srcCont,
825 'relPath' => $srcRel,
826 'headers' => $postHeaders
827 ] );
828 if ( $rcode >= 200 && $rcode <= 299 ) {
829 $this->deleteFileCache( $path );
830
831 return $objHdrs; // success
832 }
833 }
834 }
835 }
836
837 $this->logger->error( __METHOD__ . ': unable to set SHA-1 metadata for {path}',
838 [ 'path' => $path ] );
839
840 return $objHdrs; // failed
841 }
842
844 protected function doGetFileContentsMulti( array $params ) {
845 $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
846 // Blindly create tmp files and stream to them, catching any exception
847 // if the file does not exist. Do not waste time doing file stats here.
848 $reqs = []; // (path => op)
849
850 // Initial dummy values to preserve path order
851 $contents = array_fill_keys( $params['srcs'], self::RES_ERROR );
852 foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
853 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
854 if ( $srcRel === null ) {
855 continue; // invalid storage path
856 }
857 // Create a new temporary memory file...
858 $handle = fopen( 'php://temp', 'wb' );
859 if ( $handle ) {
860 $reqs[$path] = [
861 'method' => 'GET',
862 'container' => $srcCont,
863 'relPath' => $srcRel,
864 'headers' => $this->headersFromParams( $params ),
865 'stream' => $handle,
866 ];
867 }
868 }
869
870 $reqs = $this->requestMultiWithAuth(
871 $reqs,
872 [ 'maxConnsPerHost' => $params['concurrency'] ]
873 );
874 foreach ( $reqs as $path => $op ) {
875 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
876 if ( $rcode >= 200 && $rcode <= 299 ) {
877 rewind( $op['stream'] ); // start from the beginning
878 $content = (string)stream_get_contents( $op['stream'] );
879 $size = strlen( $content );
880 // Make sure that stream finished
881 if ( $size === (int)$rhdrs['content-length'] ) {
882 $contents[$path] = $content;
883 } else {
884 $contents[$path] = self::RES_ERROR;
885 $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
886 $this->onError( null, __METHOD__,
887 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
888 }
889 } elseif ( $rcode === 404 ) {
890 $contents[$path] = self::RES_ABSENT;
891 } else {
892 $contents[$path] = self::RES_ERROR;
893 $this->onError( null, __METHOD__,
894 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
895 }
896 fclose( $op['stream'] ); // close open handle
897 }
898
899 return $contents;
900 }
901
903 protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
904 $prefix = ( $dirRel == '' ) ? null : "{$dirRel}/";
905 $status = $this->objectListing( $fullCont, 'names', 1, null, $prefix );
906 if ( $status->isOK() ) {
907 return ( count( $status->value ) ) > 0;
908 }
909
910 return self::RES_ERROR;
911 }
912
920 public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
921 return new SwiftFileBackendDirList( $this, $fullCont, $dirRel, $params );
922 }
923
931 public function getFileListInternal( $fullCont, $dirRel, array $params ) {
932 return new SwiftFileBackendFileList( $this, $fullCont, $dirRel, $params );
933 }
934
946 public function getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
947 $dirs = [];
948 if ( $after === INF ) {
949 return $dirs; // nothing more
950 }
951
952 $prefix = ( $dir == '' ) ? null : "{$dir}/";
953 // Non-recursive: only list dirs right under $dir
954 if ( !empty( $params['topOnly'] ) ) {
955 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
956 if ( !$status->isOK() ) {
957 throw new FileBackendError( "Iterator page I/O error." );
958 }
959 $objects = $status->value;
960 foreach ( $objects as $object ) { // files and directories
961 if ( str_ends_with( $object, '/' ) ) {
962 $dirs[] = $object; // directories end in '/'
963 }
964 }
965 } else {
966 // Recursive: list all dirs under $dir and its subdirs
967 $getParentDir = static function ( $path ) {
968 return ( $path !== null && str_contains( $path, '/' ) ) ? dirname( $path ) : false;
969 };
970
971 // Get directory from last item of prior page
972 $lastDir = $getParentDir( $after ); // must be first page
973 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
974
975 if ( !$status->isOK() ) {
976 throw new FileBackendError( "Iterator page I/O error." );
977 }
978
979 $objects = $status->value;
980
981 foreach ( $objects as $object ) { // files
982 $objectDir = $getParentDir( $object ); // directory of object
983
984 if ( $objectDir !== false && $objectDir !== $dir ) {
985 // Swift stores paths in UTF-8, using binary sorting.
986 // See function "create_container_table" in common/db.py.
987 // If a directory is not "greater" than the last one,
988 // then it was already listed by the calling iterator.
989 if ( strcmp( $objectDir, $lastDir ) > 0 ) {
990 $pDir = $objectDir;
991 do { // add dir and all its parent dirs
992 $dirs[] = "{$pDir}/";
993 $pDir = $getParentDir( $pDir );
994 } while ( $pDir !== false
995 && strcmp( $pDir, $lastDir ) > 0 // not done already
996 && strlen( $pDir ) > strlen( $dir ) // within $dir
997 );
998 }
999 $lastDir = $objectDir;
1000 }
1001 }
1002 }
1003 // Page on the unfiltered directory listing (what is returned may be filtered)
1004 if ( count( $objects ) < $limit ) {
1005 $after = INF; // avoid a second RTT
1006 } else {
1007 $after = end( $objects ); // update last item
1008 }
1009
1010 return $dirs;
1011 }
1012
1024 public function getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
1025 $files = []; // list of (path, stat map or null) entries
1026 if ( $after === INF ) {
1027 return $files; // nothing more
1028 }
1029
1030 $prefix = ( $dir == '' ) ? null : "{$dir}/";
1031 // $objects will contain a list of unfiltered names or stdClass items
1032 // Non-recursive: only list files right under $dir
1033 if ( !empty( $params['topOnly'] ) ) {
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 } else {
1040 // Recursive: list all files under $dir and its subdirs
1041 if ( !empty( $params['adviseStat'] ) ) {
1042 $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix );
1043 } else {
1044 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
1045 }
1046 }
1047
1048 // Reformat this list into a list of (name, stat map or null) entries
1049 if ( !$status->isOK() ) {
1050 throw new FileBackendError( "Iterator page I/O error." );
1051 }
1052
1053 $objects = $status->value;
1054 $files = $this->buildFileObjectListing( $objects );
1055
1056 // Page on the unfiltered object listing (what is returned may be filtered)
1057 if ( count( $objects ) < $limit ) {
1058 $after = INF; // avoid a second RTT
1059 } else {
1060 $after = end( $objects ); // update last item
1061 $after = is_object( $after ) ? $after->name : $after;
1062 }
1063
1064 return $files;
1065 }
1066
1074 private function buildFileObjectListing( array $objects ) {
1075 $names = [];
1076 foreach ( $objects as $object ) {
1077 if ( is_object( $object ) ) {
1078 if ( isset( $object->subdir ) || !isset( $object->name ) ) {
1079 continue; // virtual directory entry; ignore
1080 }
1081 $stat = [
1082 // Convert various random Swift dates to TS::MW
1083 'mtime' => $this->convertSwiftDate( $object->last_modified, TS::MW ),
1084 'size' => (int)$object->bytes,
1085 'sha1' => null,
1086 // Note: manifest ETags are not an MD5 of the file
1087 'md5' => ctype_xdigit( $object->hash ) ? $object->hash : null,
1088 'latest' => false // eventually consistent
1089 ];
1090 $names[] = [ $object->name, $stat ];
1091 } elseif ( !str_ends_with( $object, '/' ) ) {
1092 // Omit directories, which end in '/' in listings
1093 $names[] = [ $object, null ];
1094 }
1095 }
1096
1097 return $names;
1098 }
1099
1106 public function loadListingStatInternal( $path, array $val ) {
1107 $this->procFileStatCache->setField( $path, 'stat', $val );
1108 }
1109
1111 protected function doGetFileXAttributes( array $params ) {
1112 $stat = $this->getFileStat( $params );
1113 // Stat entries filled by file listings don't include metadata/headers
1114 if ( is_array( $stat ) && !isset( $stat['xattr'] ) ) {
1115 $this->clearCache( [ $params['src'] ] );
1116 $stat = $this->getFileStat( $params );
1117 }
1118
1119 if ( is_array( $stat ) ) {
1120 return $stat['xattr'];
1121 }
1122
1123 return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1124 }
1125
1127 protected function doGetFileSha1base36( array $params ) {
1128 // Avoid using stat entries from file listings, which never include the SHA-1 hash.
1129 // Also, recompute the hash if it's not part of the metadata headers for some reason.
1130 $params['requireSHA1'] = true;
1131
1132 $stat = $this->getFileStat( $params );
1133 if ( is_array( $stat ) ) {
1134 return $stat['sha1'];
1135 }
1136
1137 return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1138 }
1139
1141 protected function doStreamFile( array $params ) {
1142 $status = $this->newStatus();
1143
1144 $flags = !empty( $params['headless'] ) ? HTTPFileStreamer::STREAM_HEADLESS : 0;
1145
1146 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1147 if ( $srcRel === null ) {
1148 HTTPFileStreamer::send404Message( $params['src'], $flags );
1149 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
1150
1151 return $status;
1152 }
1153
1154 if ( !is_array( $this->getContainerStat( $srcCont ) ) ) {
1155 HTTPFileStreamer::send404Message( $params['src'], $flags );
1156 $status->fatal( 'backend-fail-stream', $params['src'] );
1157
1158 return $status;
1159 }
1160
1161 // If "headers" is set, we only want to send them if the file is there.
1162 // Do not bother checking if the file exists if headers are not set though.
1163 if ( $params['headers'] && !$this->fileExists( $params ) ) {
1164 HTTPFileStreamer::send404Message( $params['src'], $flags );
1165 $status->fatal( 'backend-fail-stream', $params['src'] );
1166
1167 return $status;
1168 }
1169
1170 // Send the requested additional headers
1171 if ( empty( $params['headless'] ) ) {
1172 foreach ( $params['headers'] as $header ) {
1173 $this->header( $header );
1174 }
1175 }
1176
1177 if ( empty( $params['allowOB'] ) ) {
1178 // Cancel output buffering and gzipping if set
1179 $this->resetOutputBuffer();
1180 }
1181
1182 $handle = fopen( 'php://output', 'wb' );
1183 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1184 'method' => 'GET',
1185 'container' => $srcCont,
1186 'relPath' => $srcRel,
1187 'headers' => $this->headersFromParams( $params ) + $params['options'],
1188 'stream' => $handle,
1189 'flags' => [ 'relayResponseHeaders' => empty( $params['headless'] ) ]
1190 ] );
1191
1192 if ( $rcode >= 200 && $rcode <= 299 ) {
1193 // good
1194 } elseif ( $rcode === 404 ) {
1195 $status->fatal( 'backend-fail-stream', $params['src'] );
1196 // Per T43113, nasty things can happen if bad cache entries get
1197 // stuck in cache. It's also possible that this error can come up
1198 // with simple race conditions. Clear out the stat cache to be safe.
1199 $this->clearCache( [ $params['src'] ] );
1200 $this->deleteFileCache( $params['src'] );
1201 } else {
1202 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1203 }
1204
1205 return $status;
1206 }
1207
1209 protected function doGetLocalCopyMulti( array $params ) {
1210 $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
1211 // Blindly create tmp files and stream to them, catching any exception
1212 // if the file does not exist. Do not waste time doing file stats here.
1213 $reqs = []; // (path => op)
1214
1215 // Initial dummy values to preserve path order
1216 $tmpFiles = array_fill_keys( $params['srcs'], self::RES_ERROR );
1217 foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
1218 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1219 if ( $srcRel === null ) {
1220 continue; // invalid storage path
1221 }
1222 // Get source file extension
1224 // Create a new temporary file...
1225 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
1226 $handle = $tmpFile ? fopen( $tmpFile->getPath(), 'wb' ) : false;
1227 if ( $handle ) {
1228 $reqs[$path] = [
1229 'method' => 'GET',
1230 'container' => $srcCont,
1231 'relPath' => $srcRel,
1232 'headers' => $this->headersFromParams( $params ),
1233 'stream' => $handle,
1234 ];
1235 $tmpFiles[$path] = $tmpFile;
1236 }
1237 }
1238
1239 // X-Newest will be used
1240 $latest = !empty( $params['latest'] );
1241
1242 $reqs = $this->requestMultiWithAuth(
1243 $reqs,
1244 [ 'maxConnsPerHost' => $params['concurrency'] ]
1245 );
1246 foreach ( $reqs as $path => $op ) {
1247 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1248 fclose( $op['stream'] ); // close open handle
1249 if ( $rcode >= 200 && $rcode <= 299 ) {
1251 $tmpFile = $tmpFiles[$path];
1252 // Make sure that the stream finished and fully wrote to disk
1253 $size = $tmpFile->getSize();
1254 if ( $size !== (int)$rhdrs['content-length'] ) {
1255 $tmpFiles[$path] = self::RES_ERROR;
1256 $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
1257 $this->onError( null, __METHOD__,
1258 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
1259 }
1260 // Set the file stat process cache in passing
1261 $stat = $this->getStatFromHeaders( $rhdrs );
1262 $stat['latest'] = $latest;
1263 $this->procFileStatCache->setField( $path, 'stat', $stat );
1264 } elseif ( $rcode === 404 ) {
1265 $tmpFiles[$path] = self::RES_ABSENT;
1266 $this->procFileStatCache->setField(
1267 $path,
1268 'stat',
1269 $latest ? self::ABSENT_LATEST : self::ABSENT_NORMAL
1270 );
1271 } else {
1272 $tmpFiles[$path] = self::RES_ERROR;
1273 $this->onError( null, __METHOD__,
1274 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
1275 }
1276 }
1277
1278 return $tmpFiles;
1279 }
1280
1282 public function addShellboxInputFile( BoxedCommand $command, string $boxedName,
1283 array $params
1284 ) {
1285 if ( $this->canShellboxGetTempUrl ) {
1286 $urlParams = [ 'src' => $params['src'] ];
1287 if ( $this->shellboxIpRange !== null ) {
1288 $urlParams['ipRange'] = $this->shellboxIpRange;
1289 }
1290 $url = $this->getFileHttpUrl( $urlParams );
1291 if ( $url ) {
1292 $command->inputFileFromUrl( $boxedName, $url );
1293 return $this->newStatus();
1294 }
1295 }
1296 return parent::addShellboxInputFile( $command, $boxedName, $params );
1297 }
1298
1300 public function getFileHttpUrl( array $params ) {
1301 if ( $this->swiftTempUrlKey == '' &&
1302 ( $this->rgwS3AccessKey == '' || $this->rgwS3SecretKey != '' )
1303 ) {
1304 $this->logger->debug( "Can't get Swift file URL: no key available" );
1305 return self::TEMPURL_ERROR;
1306 }
1307
1308 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1309 if ( $srcRel === null ) {
1310 $this->logger->debug( "Can't get Swift file URL: can't resolve path" );
1311 return self::TEMPURL_ERROR; // invalid path
1312 }
1313
1314 $auth = $this->swiftAuthProvider->getAuthentication();
1315 if ( !$auth ) {
1316 $this->logger->debug( "Can't get Swift file URL: authentication failed" );
1317 return self::TEMPURL_ERROR;
1318 }
1319
1320 $method = $params['method'] ?? 'GET';
1321 $ttl = $params['ttl'] ?? 86400;
1322 $expires = time() + $ttl;
1323
1324 if ( $this->swiftTempUrlKey != '' ) {
1325 $url = $this->storageUrl( $auth, $srcCont, $srcRel );
1326 // Swift wants the signature based on the unencoded object name
1327 $contPath = parse_url( $this->storageUrl( $auth, $srcCont ), PHP_URL_PATH );
1328 $messageParts = [
1329 $method,
1330 $expires,
1331 "{$contPath}/{$srcRel}"
1332 ];
1333 $query = [
1334 'temp_url_expires' => $expires,
1335 ];
1336 if ( isset( $params['ipRange'] ) ) {
1337 array_unshift( $messageParts, "ip={$params['ipRange']}" );
1338 $query['temp_url_ip_range'] = $params['ipRange'];
1339 }
1340
1341 $signature = hash_hmac( 'sha1',
1342 implode( "\n", $messageParts ),
1343 $this->swiftTempUrlKey
1344 );
1345 $query = [ 'temp_url_sig' => $signature ] + $query;
1346
1347 return $url . '?' . http_build_query( $query );
1348 } else { // give S3 API URL for rgw
1349 // Path for signature starts with the bucket
1350 $spath = '/' . rawurlencode( $srcCont ) . '/' .
1351 str_replace( '%2F', '/', rawurlencode( $srcRel ) );
1352 // Calculate the hash
1353 $signature = base64_encode( hash_hmac(
1354 'sha1',
1355 "{$method}\n\n\n{$expires}\n{$spath}",
1356 $this->rgwS3SecretKey,
1357 true // raw
1358 ) );
1359 // See https://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html.
1360 // Note: adding a newline for empty CanonicalizedAmzHeaders does not work.
1361 // Note: S3 API is the rgw default; remove the /swift/ URL bit.
1362 return str_replace( '/swift/v1', '', $this->storageUrl( $auth ) . $spath ) .
1363 '?' .
1364 http_build_query( [
1365 'Signature' => $signature,
1366 'Expires' => $expires,
1367 'AWSAccessKeyId' => $this->rgwS3AccessKey
1368 ] );
1369 }
1370 }
1371
1373 protected function directoriesAreVirtual() {
1374 return true;
1375 }
1376
1385 protected function headersFromParams( array $params ) {
1386 $hdrs = [];
1387 if ( !empty( $params['latest'] ) ) {
1388 $hdrs['x-newest'] = 'true';
1389 }
1390
1391 return $hdrs;
1392 }
1393
1395 protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
1397 '@phan-var SwiftFileOpHandle[] $fileOpHandles';
1398
1400 $statuses = [];
1401
1402 // Split the HTTP requests into stages that can be done concurrently
1403 $httpReqsByStage = []; // map of (stage => index => HTTP request)
1404 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
1405 $reqs = $fileOpHandle->httpOp;
1406 foreach ( $reqs as $stage => $req ) {
1407 $httpReqsByStage[$stage][$index] = $req;
1408 }
1409 $statuses[$index] = $this->newStatus();
1410 }
1411
1412 // Run all requests for the first stage, then the next, and so on
1413 $reqCount = count( $httpReqsByStage );
1414 for ( $stage = 0; $stage < $reqCount; ++$stage ) {
1415 $httpReqs = $this->requestMultiWithAuth( $httpReqsByStage[$stage] );
1416 foreach ( $httpReqs as $index => $httpReq ) {
1418 $fileOpHandle = $fileOpHandles[$index];
1419 // Run the callback for each request of this operation
1420 $status = $statuses[$index];
1421 ( $fileOpHandle->callback )( $httpReq, $status );
1422 // On failure, abort all remaining requests for this operation. This is used
1423 // in "move" operations to abort the DELETE request if the PUT request fails.
1424 if (
1425 !$status->isOK() ||
1426 $fileOpHandle->state === $fileOpHandle::CONTINUE_NO
1427 ) {
1428 $stages = count( $fileOpHandle->httpOp );
1429 for ( $s = ( $stage + 1 ); $s < $stages; ++$s ) {
1430 unset( $httpReqsByStage[$s][$index] );
1431 }
1432 }
1433 }
1434 }
1435
1436 return $statuses;
1437 }
1438
1461 protected function setContainerAccess( $container, array $readUsers, array $writeUsers ) {
1462 $status = $this->newStatus();
1463
1464 [ $rcode, , , , ] = $this->requestWithAuth( [
1465 'method' => 'POST',
1466 'container' => $container,
1467 'headers' => [
1468 'x-container-read' => implode( ',', $readUsers ),
1469 'x-container-write' => implode( ',', $writeUsers )
1470 ]
1471 ] );
1472
1473 if ( $rcode != 204 && $rcode !== 202 ) {
1474 $status->fatal( 'backend-fail-internal', $this->name );
1475 $this->logger->error( __METHOD__ . ': unexpected rcode value ({rcode})',
1476 [ 'rcode' => $rcode ] );
1477 }
1478
1479 return $status;
1480 }
1481
1490 protected function getContainerStat( $container, $bypassCache = false ) {
1491 if ( $bypassCache ) { // purge cache
1492 $this->containerStatCache->clear( $container );
1493 } elseif ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1494 $this->primeContainerCache( [ $container ] ); // check persistent cache
1495 }
1496 if ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1497 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $this->requestWithAuth( [
1498 'method' => 'HEAD',
1499 'container' => $container
1500 ] );
1501
1502 if ( $rcode === 204 ) {
1503 $stat = [
1504 'count' => $rhdrs['x-container-object-count'],
1505 'bytes' => $rhdrs['x-container-bytes-used']
1506 ];
1507 if ( $bypassCache ) {
1508 return $stat;
1509 } else {
1510 $this->containerStatCache->setField( $container, 'stat', $stat ); // cache it
1511 $this->setContainerCache( $container, $stat ); // update persistent cache
1512 }
1513 } elseif ( $rcode === 404 ) {
1514 return self::RES_ABSENT;
1515 } else {
1516 $this->onError( null, __METHOD__,
1517 [ 'cont' => $container ], $rerr, $rcode, $rdesc, $rbody );
1518
1519 return self::RES_ERROR;
1520 }
1521 }
1522
1523 return $this->containerStatCache->getField( $container, 'stat' );
1524 }
1525
1533 protected function createContainer( $container, array $params ) {
1534 $status = $this->newStatus();
1535
1536 // @see SwiftFileBackend::setContainerAccess()
1537 if ( empty( $params['noAccess'] ) ) {
1538 // public
1539 $readUsers = array_merge( $this->readUsers, [ '.r:*', $this->swiftUser ] );
1540 if ( empty( $params['noListing'] ) ) {
1541 $readUsers[] = '.rlistings';
1542 }
1543 $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
1544 } else {
1545 // private
1546 $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
1547 $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
1548 }
1549
1550 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1551 'method' => 'PUT',
1552 'container' => $container,
1553 'headers' => [
1554 'x-container-read' => implode( ',', $readUsers ),
1555 'x-container-write' => implode( ',', $writeUsers )
1556 ]
1557 ] );
1558
1559 if ( $rcode === 201 ) { // new
1560 // good
1561 } elseif ( $rcode === 202 ) { // already there
1562 // this shouldn't really happen, but is OK
1563 } else {
1564 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1565 }
1566
1567 return $status;
1568 }
1569
1577 protected function deleteContainer( $container, array $params ) {
1578 $status = $this->newStatus();
1579
1580 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1581 'method' => 'DELETE',
1582 'container' => $container
1583 ] );
1584
1585 if ( $rcode >= 200 && $rcode <= 299 ) { // deleted
1586 $this->containerStatCache->clear( $container ); // purge
1587 } elseif ( $rcode === 404 ) { // not there
1588 // this shouldn't really happen, but is OK
1589 } elseif ( $rcode === 409 ) { // not empty
1590 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); // race?
1591 } else {
1592 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1593 }
1594
1595 return $status;
1596 }
1597
1610 private function objectListing(
1611 $fullCont, $type, $limit, $after = null, $prefix = null, $delim = null
1612 ) {
1613 $status = $this->newStatus();
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->requestWithAuth( [
1630 'method' => 'GET',
1631 'container' => $fullCont,
1632 'query' => $query,
1633 ] );
1634
1635 $params = [ 'cont' => $fullCont, 'prefix' => $prefix, 'delim' => $delim ];
1636 if ( $rcode === 200 ) { // good
1637 if ( $type === 'info' ) {
1638 $status->value = json_decode( trim( $rbody ) );
1639 } else {
1640 $status->value = explode( "\n", trim( $rbody ) );
1641 }
1642 } elseif ( $rcode === 204 ) {
1643 $status->value = []; // empty container
1644 } elseif ( $rcode === 404 ) {
1645 $status->value = []; // no container
1646 } else {
1647 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1648 }
1649
1650 return $status;
1651 }
1652
1654 protected function doPrimeContainerCache( array $containerInfo ) {
1655 foreach ( $containerInfo as $container => $info ) {
1656 $this->containerStatCache->setField( $container, 'stat', $info );
1657 }
1658 }
1659
1661 protected function doGetFileStatMulti( array $params ) {
1662 $stats = [];
1663
1664 $reqs = []; // (path => op)
1665 // (a) Check the containers of the paths...
1666 foreach ( $params['srcs'] as $path ) {
1667 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1668 if ( $srcRel === null ) {
1669 // invalid storage path
1670 $stats[$path] = self::RES_ERROR;
1671 continue;
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 'container' => $srcCont,
1686 'relPath' => $srcRel,
1687 'headers' => $this->headersFromParams( $params )
1688 ];
1689 }
1690
1691 // (b) Check the files themselves...
1692 $reqs = $this->requestMultiWithAuth(
1693 $reqs,
1694 [ 'maxConnsPerHost' => $params['concurrency'] ]
1695 );
1696 foreach ( $reqs as $path => $op ) {
1697 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1698 if ( $rcode === 200 || $rcode === 204 ) {
1699 // Update the object if it is missing some headers
1700 if ( !empty( $params['requireSHA1'] ) ) {
1701 $rhdrs = $this->addMissingHashMetadata( $rhdrs, $path );
1702 }
1703 // Load the stat map from the headers
1704 $stat = $this->getStatFromHeaders( $rhdrs );
1705 } elseif ( $rcode === 404 ) {
1706 $stat = self::RES_ABSENT;
1707 } else {
1708 $stat = self::RES_ERROR;
1709 $this->onError( null, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1710 }
1711 $stats[$path] = $stat;
1712 }
1713
1714 return $stats;
1715 }
1716
1721 protected function getStatFromHeaders( array $rhdrs ) {
1722 // Fetch all of the custom metadata headers
1723 $metadata = $this->getMetadataFromHeaders( $rhdrs );
1724 // Fetch all of the custom raw HTTP headers
1725 $headers = $this->extractMutableContentHeaders( $rhdrs );
1726
1727 return [
1728 // Convert various random Swift dates to TS::MW
1729 'mtime' => $this->convertSwiftDate( $rhdrs['last-modified'], TS::MW ),
1730 // Empty objects actually return no content-length header in Ceph
1731 'size' => isset( $rhdrs['content-length'] ) ? (int)$rhdrs['content-length'] : 0,
1732 'sha1' => $metadata['sha1base36'] ?? null,
1733 // Note: manifest ETags are not an MD5 of the file
1734 'md5' => ctype_xdigit( $rhdrs['etag'] ) ? $rhdrs['etag'] : null,
1735 'xattr' => [ 'metadata' => $metadata, 'headers' => $headers ]
1736 ];
1737 }
1738
1745 protected function storageUrl( array $creds, $container = null, $object = null ) {
1746 $parts = [ $creds['storage_url'] ];
1747 if ( ( $container ?? '' ) !== '' ) {
1748 $parts[] = rawurlencode( $container );
1749 }
1750 if ( ( $object ?? '' ) !== '' ) {
1751 $parts[] = str_replace( "%2F", "/", rawurlencode( $object ) );
1752 }
1753
1754 return implode( '/', $parts );
1755 }
1756
1769 private function requestWithAuth( array $req ) {
1770 return $this->requestMultiWithAuth( [ $req ] )[0]['response'];
1771 }
1772
1782 private function requestMultiWithAuth( array $reqs, $options = [] ) {
1783 $remainingTries = 2;
1784 $auth = $this->swiftAuthProvider->getAuthentication();
1785 while ( true ) {
1786 if ( !$auth ) {
1787 foreach ( $reqs as &$req ) {
1788 if ( !isset( $req['response'] ) ) {
1789 $req['response'] = $this->swiftAuthProvider->getAuthFailureResponse();
1790 }
1791 }
1792 break;
1793 }
1794 foreach ( $reqs as &$req ) {
1795 '@phan-var array $req'; // Not array[]
1796 if ( isset( $req['response'] ) ) {
1797 // Request was attempted before
1798 // Retry only if it gave a 401 response code
1799 if ( $req['response']['code'] !== 401 ) {
1800 continue;
1801 }
1802 }
1803 $req['headers'] = $this->swiftAuthProvider->authTokenHeaders( $auth ) + ( $req['headers'] ?? [] );
1804 $req['url'] = $this->storageUrl( $auth, $req['container'], $req['relPath'] ?? null );
1805 }
1806 unset( $req );
1807 $reqs = $this->http->runMulti( $reqs, $options + self::DEFAULT_HTTP_OPTIONS );
1808 if ( --$remainingTries > 0 ) {
1809 // Retry if any request failed with 401 "not authorized"
1810 foreach ( $reqs as $req ) {
1811 if ( $req['response']['code'] === 401 ) {
1812 $auth = $this->swiftAuthProvider->refreshAuthentication();
1813 continue 2;
1814 }
1815 }
1816 }
1817 break;
1818 }
1819 return $reqs;
1820 }
1821
1834 public function onError( $status, $func, array $params, $err = '', $code = 0, $desc = '', $body = '' ) {
1835 if ( $code === 0 && $err === SwiftAuthProvider::AUTH_FAILURE_ERROR ) {
1836 if ( $status instanceof StatusValue ) {
1837 $status->fatal( 'backend-fail-connect', $this->name );
1838 }
1839 // Already logged
1840 return;
1841 }
1842 if ( $status instanceof StatusValue ) {
1843 $status->fatal( 'backend-fail-internal', $this->name );
1844 }
1845 $msg = "HTTP {code} ({desc}) in '{func}'";
1846 $msgParams = [
1847 'code' => $code,
1848 'desc' => $desc,
1849 'func' => $func,
1850 'req_params' => $params,
1851 ];
1852 if ( $err ) {
1853 $msg .= ': {err}';
1854 $msgParams['err'] = $err;
1855 }
1856 if ( $code == 502 ) {
1857 $msg .= ' ({truncatedBody})';
1858 $msgParams['truncatedBody'] = substr( strip_tags( $body ), 0, 100 );
1859 }
1860 $this->logger->error( $msg, $msgParams );
1861 }
1862}
1863
1865class_alias( SwiftFileBackend::class, 'SwiftFileBackend' );
Generic operation result class Has warning/error list, boolean status and arbitrary value.
This class is used to hold the location and do limited manipulation of files stored temporarily (this...
File backend exception for checked exceptions (e.g.
Base class for all backends using particular storage medium.
WANObjectCache $wanCache
Persistent cache accessible to all relevant datacenters.
static extensionFromPath( $path, $case='lowercase')
Get the final extension from a storage or FS path.
static send404Message( $fname, $flags=0)
Send out a standard 404 message for a file.
Provides authentication to swift file backend.
Class for an OpenStack Swift (or Ceph RGW) based file backend.
doGetFileContentsMulti(array $params)
FileBackendStore::getFileContentsMulti() to override string[]|bool[]|null[] Map of (path => string,...
MapCacheLRU $containerStatCache
Container stat cache.
doCreateInternal(array $params)
FileBackendStore::createInternal() StatusValue
headersFromParams(array $params)
Get headers to send to Swift when reading a file based on a FileBackend params array,...
doDeleteInternal(array $params)
FileBackendStore::deleteInternal() StatusValue
doMoveInternal(array $params)
FileBackendStore::moveInternal() StatusValue
resolveContainerPath( $container, $relStoragePath)
Resolve a relative storage path, checking if it's allowed by the backend.This is intended for interna...
convertSwiftDate( $ts, $format=TS::MW)
Convert dates like "Tue, 03 Jan 2012 22:01:04 GMT"/"2013-05-11T07:37:27.678360Z".
string $swiftTempUrlKey
Shared secret value for making temp URLs.
addMissingHashMetadata(array $objHdrs, $path)
Fill in any missing object metadata and save it to Swift.
doSecureInternal( $fullCont, $dirRel, array $params)
FileBackendStore::doSecure() to override StatusValue Good status without value for success,...
doDirectoryExists( $fullCont, $dirRel, array $params)
FileBackendStore::directoryExists()bool|null
doPrepareInternal( $fullCont, $dirRel, array $params)
FileBackendStore::doPrepare() to override StatusValue Good status without value for success,...
array $writeUsers
Additional users (account:user) with write permissions on public containers.
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.
doGetFileXAttributes(array $params)
FileBackendStore::getFileXAttributes() to override array[][]|false|null Attributes,...
doCopyInternal(array $params)
FileBackendStore::copyInternal() StatusValue
doStoreInternal(array $params)
FileBackendStore::storeInternal() StatusValue
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.
getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params)
Do not call this function outside of SwiftFileBackendFileList.
getFileListInternal( $fullCont, $dirRel, array $params)
deleteContainer( $container, array $params)
Delete a Swift container.
directoriesAreVirtual()
Whether this a key/value store where directories are merely virtual.Virtual directories exists in so ...
string $rgwS3AccessKey
S3 access key (RADOS Gateway)
doPublishInternal( $fullCont, $dirRel, array $params)
FileBackendStore::doPublish() to override StatusValue
getFileHttpUrl(array $params)
FileBackend::getFileHttpUrl() to override string|null
addShellboxInputFile(BoxedCommand $command, string $boxedName, array $params)
Add a file to a Shellbox command as an input file.StatusValue 1.43
doDescribeInternal(array $params)
FileBackendStore::describeInternal() to override StatusValue
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.to overrideint Bitfield of FileB...
getDirectoryListInternal( $fullCont, $dirRel, array $params)
doStreamFile(array $params)
FileBackendStore::streamFile() to override StatusValue
doGetFileStatMulti(array $params)
Get file stat information (concurrently if possible) for several files.to overrideFileBackend::getFil...
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.This quickly checks ...
setContainerAccess( $container, array $readUsers, array $writeUsers)
Set read/write permissions for a Swift container.
doGetLocalCopyMulti(array $params)
FileBackendStore::getLocalCopyMulti() string[]|bool[]|null[] Map of (path => TempFSFile,...
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.
doCleanInternal( $fullCont, $dirRel, array $params)
FileBackendStore::doClean() to override StatusValue
doExecuteOpHandlesInternal(array $fileOpHandles)
FileBackendStore::executeOpHandlesInternal() to overrideStatusValue[] List of corresponding StatusVal...
doGetFileStat(array $params)
FileBackendStore::getFileStat() array|false|null
getContainerStat( $container, $bypassCache=false)
Get a Swift container stat map, possibly from process cache.
Class to handle multiple HTTP requests.
Resource locking handling.
Store key-value entries in a size-limited in-memory LRU cache.
array $params
The job parameters.