12use InvalidArgumentException;
13use Shellbox\Command\BoxedCommand;
33use Wikimedia\Timestamp\ConvertibleTimestamp;
34use Wikimedia\Timestamp\TimestampFormat as TS;
83 protected const RES_ABSENT =
false;
85 protected const RES_ERROR =
null;
88 protected const ABSENT_NORMAL =
'FNE-N';
90 protected const ABSENT_LATEST =
'FNE-L';
106 parent::__construct( $config );
107 $this->mimeCallback = $config[
'mimeCallback'] ??
null;
109 $this->wanCache = $config[
'wanCache'] ?? WANObjectCache::newEmpty();
110 $this->wanStatCache = WANObjectCache::newEmpty();
112 $this->procFileStatCache =
new MapCacheLRU( self::CACHE_CHEAP_SIZE );
114 $this->procFileDataCache =
new MapCacheLRU( self::CACHE_EXPENSIVE_SIZE );
126 return min( $this->maxFileSize, PHP_INT_MAX );
162 $status = $this->
newStatus(
'backend-fail-maxsize',
167 if ( $params[
'dstExists'] ??
true ) {
202 $status = $this->
newStatus(
'backend-fail-maxsize',
207 if ( $params[
'dstExists'] ??
true ) {
244 if ( $params[
'dstExists'] ??
true ) {
307 $this->
clearCache( [ $params[
'src'], $params[
'dst'] ] );
309 if ( $params[
'dstExists'] ??
true ) {
338 if ( count( $params[
'headers'] ) ) {
375 $scopeLockS = $this->
getScopedFileLocks( $params[
'srcs'], LockManager::LOCK_UW, $status );
376 if ( $status->isOK() ) {
378 $hrStart = hrtime(
true );
380 $sec = ( hrtime(
true ) - $hrStart ) / 1e9;
381 if ( !$status->isOK() ) {
382 $this->logger->error( static::class .
"-{$this->name}" .
383 " failed to concatenate " . count( $params[
'srcs'] ) .
" file(s) [$sec sec]" );
398 $tmpPath = $params[
'dst'];
399 unset( $params[
'latest'] );
403 $ok = ( @is_file( $tmpPath ) && @filesize( $tmpPath ) == 0 );
405 $status->fatal(
'backend-fail-opentemp', $tmpPath );
412 foreach ( $fsFiles as
$path => &$fsFile ) {
417 $fsFile === self::RES_ERROR ?
'backend-fail-read' :
'backend-fail-notexists',
428 $tmpHandle = fopen( $tmpPath,
'ab' );
429 if ( $tmpHandle ===
false ) {
430 $status->fatal(
'backend-fail-opentemp', $tmpPath );
436 foreach ( $fsFiles as $virtualSource => $fsFile ) {
438 $sourceHandle = fopen( $fsFile->getPath(),
'rb' );
439 if ( $sourceHandle ===
false ) {
440 fclose( $tmpHandle );
441 $status->fatal(
'backend-fail-read', $virtualSource );
446 if ( !stream_copy_to_stream( $sourceHandle, $tmpHandle ) ) {
447 fclose( $sourceHandle );
448 fclose( $tmpHandle );
449 $status->fatal(
'backend-fail-writetemp', $tmpPath );
453 fclose( $sourceHandle );
455 if ( !fclose( $tmpHandle ) ) {
456 $status->fatal(
'backend-fail-closetemp', $tmpPath );
473 if ( $dir ===
null ) {
474 $status->fatal(
'backend-fail-invalidpath', $params[
'dir'] );
479 if ( $shard !==
null ) {
482 $this->logger->debug( __METHOD__ .
": iterating over all container shards." );
485 $status->merge( $this->
doPrepareInternal(
"{$fullCont}{$suffix}", $dir, $params ) );
505 final protected function doSecure( array $params ) {
509 if ( $dir ===
null ) {
510 $status->fatal(
'backend-fail-invalidpath', $params[
'dir'] );
515 if ( $shard !==
null ) {
518 $this->logger->debug( __METHOD__ .
": iterating over all container shards." );
521 $status->merge( $this->
doSecureInternal(
"{$fullCont}{$suffix}", $dir, $params ) );
545 if ( $dir ===
null ) {
546 $status->fatal(
'backend-fail-invalidpath', $params[
'dir'] );
551 if ( $shard !==
null ) {
554 $this->logger->debug( __METHOD__ .
": iterating over all container shards." );
557 $status->merge( $this->
doPublishInternal(
"{$fullCont}{$suffix}", $dir, $params ) );
577 final protected function doClean( array $params ) {
583 if ( $subDirsRel !==
null ) {
584 foreach ( $subDirsRel as $subDirRel ) {
585 $subDir = $params[
'dir'] .
"/{$subDirRel}";
586 $status->merge( $this->
doClean( [
'dir' => $subDir ] + $params ) );
588 unset( $subDirsRel );
593 if ( $dir ===
null ) {
594 $status->fatal(
'backend-fail-invalidpath', $params[
'dir'] );
600 $filesLockEx = [ $params[
'dir'] ];
602 $scopedLockE = $this->
getScopedFileLocks( $filesLockEx, LockManager::LOCK_EX, $status );
603 if ( !$status->isOK() ) {
607 if ( $shard !==
null ) {
611 $this->logger->debug( __METHOD__ .
": iterating over all container shards." );
614 $status->merge( $this->
doCleanInternal(
"{$fullCont}{$suffix}", $dir, $params ) );
637 if ( is_array( $stat ) ) {
641 return $stat === self::RES_ABSENT ? false : self::EXISTENCE_ERROR;
647 if ( is_array( $stat ) ) {
648 return $stat[
'mtime'];
651 return self::TIMESTAMP_FAIL;
657 if ( is_array( $stat ) ) {
658 return $stat[
'size'];
661 return self::SIZE_FAIL;
667 if (
$path ===
null ) {
668 return self::STAT_ERROR;
673 $latest = !empty( $params[
'latest'] );
675 $requireSHA1 = !empty( $params[
'requireSHA1'] );
677 $stat = $this->procFileStatCache->getField(
$path,
'stat', self::CACHE_TTL );
686 ( $requireSHA1 && is_array( $stat ) && !isset( $stat[
'sha1'] ) )
690 $stat = $this->procFileStatCache->getField(
$path,
'stat', self::CACHE_TTL );
694 if ( is_array( $stat ) ) {
696 ( !$latest || !empty( $stat[
'latest'] ) ) &&
697 ( !$requireSHA1 || isset( $stat[
'sha1'] ) )
701 } elseif ( $stat === self::ABSENT_LATEST ) {
702 return self::STAT_ABSENT;
703 } elseif ( $stat === self::ABSENT_NORMAL ) {
705 return self::STAT_ABSENT;
713 if ( is_array( $stat ) ) {
717 return $stat === self::RES_ERROR ? self::STAT_ERROR : self::STAT_ABSENT;
730 foreach ( $stats as
$path => $stat ) {
731 if ( is_array( $stat ) ) {
733 $stat[
'latest'] ??= $latest;
735 $this->procFileStatCache->setField(
$path,
'stat', $stat );
736 if ( isset( $stat[
'sha1'] ) ) {
738 $this->procFileStatCache->setField(
741 [
'hash' => $stat[
'sha1'],
'latest' => $latest ]
744 if ( isset( $stat[
'xattr'] ) ) {
747 $this->procFileStatCache->setField(
750 [
'map' => $stat[
'xattr'],
'latest' => $latest ]
755 } elseif ( $stat === self::RES_ABSENT ) {
756 $this->procFileStatCache->setField(
759 $latest ? self::ABSENT_LATEST : self::ABSENT_NORMAL
761 $this->procFileStatCache->setField(
764 [
'map' => self::XATTRS_FAIL,
'latest' => $latest ]
766 $this->procFileStatCache->setField(
769 [
'hash' => self::SHA1_FAIL,
'latest' => $latest ]
771 $this->logger->debug(
772 __METHOD__ .
': File {path} does not exist',
777 $this->logger->error(
778 __METHOD__ .
': Could not stat file {path}',
798 foreach ( $contents as
$path => $content ) {
799 if ( !is_string( $content ) ) {
800 $contents[
$path] = self::CONTENT_FAIL;
816 if ( $fsFile instanceof
FSFile ) {
818 $content = @file_get_contents( $fsFile->getPath() );
819 $contents[
$path] = is_string( $content ) ? $content : self::RES_ERROR;
822 $contents[
$path] = $fsFile;
832 if (
$path ===
null ) {
833 return self::XATTRS_FAIL;
835 $latest = !empty( $params[
'latest'] );
836 if ( $this->procFileStatCache->hasField(
$path,
'xattr', self::CACHE_TTL ) ) {
837 $stat = $this->procFileStatCache->getField(
$path,
'xattr' );
840 if ( !$latest || $stat[
'latest'] ) {
845 if ( is_array( $fields ) ) {
847 $this->procFileStatCache->setField(
850 [
'map' => $fields,
'latest' => $latest ]
852 } elseif ( $fields === self::RES_ABSENT ) {
853 $this->procFileStatCache->setField(
856 [
'map' => self::XATTRS_FAIL,
'latest' => $latest ]
859 $fields = self::XATTRS_FAIL;
872 return [
'headers' => [],
'metadata' => [] ];
878 if (
$path ===
null ) {
879 return self::SHA1_FAIL;
881 $latest = !empty( $params[
'latest'] );
882 if ( $this->procFileStatCache->hasField(
$path,
'sha1', self::CACHE_TTL ) ) {
883 $stat = $this->procFileStatCache->getField(
$path,
'sha1' );
886 if ( !$latest || $stat[
'latest'] ) {
887 return $stat[
'hash'];
891 if ( is_string( $sha1 ) ) {
892 $this->procFileStatCache->setField(
895 [
'hash' => $sha1,
'latest' => $latest ]
897 } elseif ( $sha1 === self::RES_ABSENT ) {
898 $this->procFileStatCache->setField(
901 [
'hash' => self::SHA1_FAIL,
'latest' => $latest ]
904 $sha1 = self::SHA1_FAIL;
918 if ( $fsFile instanceof
FSFile ) {
919 $sha1 = $fsFile->getSha1Base36();
921 return is_string( $sha1 ) ? $sha1 : self::RES_ERROR;
924 return $fsFile === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
931 return $fsFile ? $fsFile->getProps() : FSFile::placeholderProps();
939 $latest = !empty( $params[
'latest'] );
941 foreach ( $params[
'srcs'] as $src ) {
943 if (
$path ===
null ) {
944 $fsFiles[$src] = self::RES_ERROR;
945 } elseif ( $this->procFileDataCache->hasField(
$path,
'localRef' ) ) {
946 $val = $this->procFileDataCache->getField(
$path,
'localRef' );
949 if ( !$latest || $val[
'latest'] ) {
950 $fsFiles[$src] = $val[
'object'];
955 $params[
'srcs'] = array_diff( $params[
'srcs'], array_keys( $fsFiles ) );
957 $fsFiles[
$path] = $fsFile;
958 if ( $fsFile instanceof
FSFile ) {
959 $this->procFileDataCache->setField(
962 [
'object' => $fsFile,
'latest' => $latest ]
1001 return self::TEMPURL_ERROR;
1009 if ( $ref ===
false ) {
1010 return $this->
newStatus(
'backend-fail-notexists', $params[
'src'] );
1011 } elseif ( $ref ===
null ) {
1012 return $this->
newStatus(
'backend-fail-read', $params[
'src'] );
1014 $file = $command->newInputFileFromFile( $ref->getPath() )
1015 ->userData( __CLASS__, $ref );
1016 $command->inputFile( $boxedName, $file );
1026 $params[
'options'] ??= [];
1027 $params[
'headers'] ??= [];
1030 if ( ( empty( $params[
'headless'] ) || $params[
'headers'] ) && headers_sent() ) {
1031 print
"Headers already sent, terminating.\n";
1032 $status->fatal(
'backend-fail-stream', $params[
'src'] );
1058 $this->getStreamerOptions()
1060 $res = $streamer->stream( $params[
'headers'],
true, $params[
'options'], $flags );
1067 $status->fatal(
'backend-fail-stream', $params[
'src'] );
1076 if ( $dir ===
null ) {
1077 return self::EXISTENCE_ERROR;
1079 if ( $shard !==
null ) {
1082 $this->logger->debug( __METHOD__ .
": iterating over all container shards." );
1087 if ( $exists ===
true ) {
1090 } elseif ( $exists === self::RES_ERROR ) {
1091 $res = self::EXISTENCE_ERROR;
1112 if ( $dir ===
null ) {
1113 return self::EXISTENCE_ERROR;
1115 if ( $shard !==
null ) {
1119 $this->logger->debug( __METHOD__ .
": iterating over all container shards." );
1143 if ( $dir ===
null ) {
1144 return self::LIST_ERROR;
1146 if ( $shard !==
null ) {
1150 $this->logger->debug( __METHOD__ .
": iterating over all container shards." );
1184 'store' => StoreFileOp::class,
1185 'copy' => CopyFileOp::class,
1186 'move' => MoveFileOp::class,
1187 'delete' => DeleteFileOp::class,
1188 'create' => CreateFileOp::class,
1189 'describe' => DescribeFileOp::class,
1190 'null' => NullFileOp::class
1195 foreach ( $ops as $operation ) {
1196 $opName = $operation[
'op'];
1197 if ( isset( $supportedOps[$opName] ) ) {
1198 $class = $supportedOps[$opName];
1200 $params = $operation;
1202 $performOps[] =
new $class( $this, $params, $this->logger );
1223 $paths = [
'sh' => [],
'ex' => [] ];
1224 foreach ( $performOps as $fileOp ) {
1225 $paths[
'sh'] = array_merge( $paths[
'sh'], $fileOp->storagePathsRead() );
1226 $paths[
'ex'] = array_merge( $paths[
'ex'], $fileOp->storagePathsChanged() );
1229 $paths[
'sh'] = array_diff( $paths[
'sh'], $paths[
'ex'] );
1231 $paths[
'sh'] = array_merge( $paths[
'sh'], array_map(
'dirname', $paths[
'ex'] ) );
1234 LockManager::LOCK_UW => $paths[
'sh'],
1235 LockManager::LOCK_EX => $paths[
'ex']
1255 foreach ( $fileOps as $fileOp ) {
1256 $pathsUsed = array_merge( $pathsUsed, $fileOp->storagePathsReadOrChanged() );
1260 if ( empty( $opts[
'nonLocking'] ) ) {
1264 if ( !$status->isOK() ) {
1270 if ( empty( $opts[
'preserveCache'] ) ) {
1275 $this->procFileStatCache->setMaxSize(
1276 max( 2 * count( $pathsUsed ), self::CACHE_CHEAP_SIZE )
1282 $ok = $this->
preloadFileStat( [
'srcs' => $pathsUsed,
'latest' =>
true ] );
1290 $subStatus = $this->
newStatus(
'backend-fail-internal', $this->name );
1291 foreach ( $ops as $i => $op ) {
1292 $subStatus->success[$i] =
false;
1293 ++$subStatus->failCount;
1295 $this->logger->error( static::class .
"-{$this->name} stat failure",
1296 [
'aborted_operations' => $ops ]
1301 $status->merge( $subStatus );
1302 $status->success = $subStatus->success;
1305 $this->procFileStatCache->setMaxSize( self::CACHE_CHEAP_SIZE );
1319 foreach ( $fileOps as $fileOp ) {
1320 $pathsUsed = array_merge( $pathsUsed, $fileOp->storagePathsReadOrChanged() );
1327 $async = ( $this->parallelize ===
'implicit' && count( $ops ) > 1 );
1333 foreach ( $fileOps as $index => $fileOp ) {
1335 ? $fileOp->attemptAsyncQuick()
1336 : $fileOp->attemptQuick();
1338 if ( count( $batch ) >= $maxConcurrency ) {
1344 $batch[$index] = $subStatus->value;
1346 $statuses[$index] = $subStatus;
1349 if ( count( $batch ) ) {
1353 foreach ( $statuses as $index => $subStatus ) {
1354 $status->merge( $subStatus );
1355 if ( $subStatus->isOK() ) {
1356 $status->success[$index] =
true;
1357 ++$status->successCount;
1359 $status->success[$index] =
false;
1360 ++$status->failCount;
1379 foreach ( $fileOpHandles as $fileOpHandle ) {
1381 throw new InvalidArgumentException(
"Expected FileBackendStoreOpHandle object." );
1382 } elseif ( $fileOpHandle->backend->getName() !== $this->getName() ) {
1383 throw new InvalidArgumentException(
"Expected handle for this file backend." );
1388 foreach ( $fileOpHandles as $fileOpHandle ) {
1389 $fileOpHandle->closeResources();
1405 if ( count( $fileOpHandles ) ) {
1406 throw new FileBackendError(
"Backend does not support asynchronous operations." );
1424 static $longs = [
'content-disposition' ];
1426 if ( isset( $op[
'headers'] ) ) {
1428 foreach ( $op[
'headers'] as
$name => $value ) {
1430 $maxHVLen = in_array(
$name, $longs ) ? INF : 255;
1431 if ( strlen(
$name ) > 255 || strlen( $value ) > $maxHVLen ) {
1432 $this->logger->error(
"Header '{header}' is too long.", [
1433 'filebackend' => $this->name,
1434 'header' =>
"$name: $value",
1437 $newHeaders[
$name] = strlen( $value ) ? $value :
'';
1440 $op[
'headers'] = $newHeaders;
1448 foreach ( $paths as
$path ) {
1450 $fullConts[] = $fullCont;
1458 if ( is_array( $paths ) ) {
1460 $paths = array_filter( $paths,
'strlen' );
1462 if ( $paths ===
null ) {
1463 $this->procFileStatCache->clear();
1464 $this->procFileDataCache->clear();
1466 foreach ( $paths as
$path ) {
1467 $this->procFileStatCache->clear(
$path );
1468 $this->procFileDataCache->clear(
$path );
1487 $params[
'concurrency'] = ( $this->parallelize !==
'off' ) ? $this->concurrency : 1;
1489 if ( $stats ===
null ) {
1494 $latest = !empty( $params[
'latest'] );
1558 return (
bool)preg_match(
'/^[a-z0-9][a-z0-9-_.]{0,199}$/i', $container );
1576 if ( $backend === $this->name && $relPath !==
null ) {
1578 if ( $relPath !==
null && self::isValidShortContainerName( $shortCont ) ) {
1583 if ( $relPath !==
null ) {
1586 if ( self::isValidContainerName( $container ) ) {
1589 if ( $container !==
null ) {
1590 return [ $container, $relPath, $cShard ];
1597 return [
null,
null, null ];
1617 if ( $cShard !==
null && !str_ends_with( $relPath,
'/' ) ) {
1618 return [ $container, $relPath ];
1621 return [
null, null ];
1634 if ( $levels == 1 || $levels == 2 ) {
1636 $char = ( $base == 36 ) ?
'[0-9a-z]' :
'[0-9a-f]';
1639 if ( $levels === 1 ) {
1640 $hashDirRegex =
'(' . $char .
')';
1643 $hashDirRegex = $char .
'/(' . $char .
'{2})';
1645 $hashDirRegex =
'(' . $char .
')/(' . $char .
')';
1652 if ( preg_match(
"!^(?:[^/]{2,}/)*$hashDirRegex(?:/|$)!", $relPath, $m ) ) {
1653 return '.' . implode(
'', array_slice( $m, 1 ) );
1673 return ( $shard !==
null );
1685 if ( isset( $this->shardViaHashLevels[$container] ) ) {
1686 $config = $this->shardViaHashLevels[$container];
1687 $hashLevels = (int)$config[
'levels'];
1688 if ( $hashLevels == 1 || $hashLevels == 2 ) {
1689 $hashBase = (int)$config[
'base'];
1690 if ( $hashBase == 16 || $hashBase == 36 ) {
1691 return [ $hashLevels, $hashBase, $config[
'repeat'] ];
1696 return [ 0, 0, false ];
1708 if ( $digits > 0 ) {
1709 $numShards = $base ** $digits;
1710 for ( $index = 0; $index < $numShards; $index++ ) {
1711 $shards[] =
'.' . \Wikimedia\base_convert( (
string)$index, 10, $base, $digits );
1725 if ( $this->domainId !=
'' ) {
1726 return "{$this->domainId}-$container";
1757 return $relStoragePath;
1766 private function containerCacheKey( $container ) {
1767 return "filebackend:{$this->name}:{$this->domainId}:container:{$container}";
1777 if ( !$this->wanStatCache->set(
1778 $this->containerCacheKey( $container ),
1782 $this->logger->warning(
"Unable to set stat cache for container {container}.",
1783 [
'filebackend' => $this->name,
'container' => $container ]
1795 if ( !$this->wanStatCache->delete( $this->containerCacheKey( $container ), 300 ) ) {
1796 $this->logger->warning(
"Unable to delete stat cache for container {container}.",
1797 [
'filebackend' => $this->name,
'container' => $container ]
1811 foreach ( $items as $item ) {
1812 if ( self::isStoragePath( $item ) ) {
1814 } elseif ( is_string( $item ) ) {
1815 $contNames[$this->containerCacheKey( $item )] = $item;
1819 foreach ( $paths as
$path ) {
1821 if ( $fullCont !==
null ) {
1822 $contNames[$this->containerCacheKey( $fullCont )] = $fullCont;
1828 $values = $this->wanStatCache->getMulti( array_keys( $contNames ) );
1829 foreach ( $values as $cacheKey => $val ) {
1830 $contInfo[$contNames[$cacheKey]] = $val;
1854 private function fileCacheKey(
$path ) {
1855 return "filebackend:{$this->name}:{$this->domainId}:file:" . sha1(
$path );
1868 if (
$path ===
null ) {
1871 $mtime = (int)ConvertibleTimestamp::convert( TS::UNIX, $val[
'mtime'] );
1872 $ttl = $this->wanStatCache->adaptiveTTL( $mtime, 7 * 86400, 300, 0.1 );
1874 if ( !$this->wanStatCache->set( $this->fileCacheKey(
$path ), $val, $ttl ) ) {
1875 $this->logger->warning(
"Unable to set stat cache for file {path}.",
1876 [
'filebackend' => $this->name,
'path' =>
$path ]
1891 if (
$path ===
null ) {
1894 if ( !$this->wanStatCache->delete( $this->fileCacheKey(
$path ), 300 ) ) {
1895 $this->logger->warning(
"Unable to delete stat cache for file {path}.",
1896 [
'filebackend' => $this->name,
'path' =>
$path ]
1912 foreach ( $items as $item ) {
1913 if ( self::isStoragePath( $item ) ) {
1915 if (
$path !==
null ) {
1921 foreach ( $paths as
$path ) {
1923 if ( $rel !==
null ) {
1924 $pathNames[$this->fileCacheKey(
$path )] =
$path;
1929 $values = $this->wanStatCache->getMulti( array_keys( $pathNames ) );
1931 foreach ( array_filter( $values,
'is_array' ) as $cacheKey => $stat ) {
1932 $path = $pathNames[$cacheKey];
1935 unset( $stat[
'latest'] );
1937 $this->procFileStatCache->setField(
$path,
'stat', $stat );
1938 if ( isset( $stat[
'sha1'] ) && strlen( $stat[
'sha1'] ) == 31 ) {
1940 $this->procFileStatCache->setField(
1943 [
'hash' => $stat[
'sha1'],
'latest' =>
false ]
1946 if ( isset( $stat[
'xattr'] ) && is_array( $stat[
'xattr'] ) ) {
1949 $this->procFileStatCache->setField(
1952 [
'map' => $stat[
'xattr'],
'latest' =>
false ]
1966 $newXAttr = [
'headers' => [],
'metadata' => [] ];
1968 foreach ( $xattr[
'headers'] as
$name => $value ) {
1969 $newXAttr[
'headers'][strtolower(
$name )] = $value;
1972 foreach ( $xattr[
'metadata'] as
$name => $value ) {
1973 $newXAttr[
'metadata'][strtolower(
$name )] = $value;
1986 $opts[
'concurrency'] = 1;
1987 if ( $this->parallelize ===
'implicit' ) {
1988 if ( $opts[
'parallelize'] ??
true ) {
1991 } elseif ( $this->parallelize ===
'explicit' ) {
1992 if ( !empty( $opts[
'parallelize'] ) ) {
2010 if ( $this->mimeCallback ) {
2011 return ( $this->mimeCallback )( $storagePath, $content, $fsPath );
2014 $mime = ( $fsPath !== null ) ? mime_content_type( $fsPath ) :
false;
2015 return $mime ?:
'unknown/unknown';
2020class_alias( FileBackendStore::class,
'FileBackendStore' );
Generic operation result class Has warning/error list, boolean status and arbitrary value.