Require all denied Deny from all HTACCESS; /** * IIS guard file contents. * * @var string */ private const WEB_CONFIG_CONTENTS = <<<'WEBCONFIG' WEBCONFIG; /** * Listing guard contents. The guard is an empty index.html, so no PHP file goes into uploads. * * @var string */ private const INDEX_HTML_CONTENTS = ''; /** * Guard-file names blocked at the packages root. * * @var array */ private const RESERVED_GUARD_FILES = array( '.htaccess', 'web.config', 'index.html' ); /** * Cached packages root. * * @var string|null */ private ?string $root = null; /** * Unique provider identifier stored alongside file references. * * @since 1.0.0 * * @return string Always 'local'. */ public function id(): string { return 'local'; } /** * Whether a stored object exists for the given key. * * @since 1.0.0 * * @param string $key Provider-scoped object key. * @return bool True when the key resolves to a readable file under the packages root. */ public function exists( string $key ): bool { $path = $this->root() . '/' . $this->normalizeKey( $key ); return is_file( $path ) && is_readable( $path ); } /** * Copy an uploaded file into the packages root and return its storage reference. * * @since 1.0.0 * * @param string $sourcePath Absolute path of the uploaded temp file. * @param string $key Desired provider-scoped key. * @return StoredFile Reference DTO: provider id, final key, filesize, sha256. * * @throws StorageException When the source is unreadable, the key is invalid, WordPress * cannot write files directly, or the write fails. */ public function store( string $sourcePath, string $key ): StoredFile { if ( ! is_file( $sourcePath ) || ! is_readable( $sourcePath ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw StorageException::unreadableSource( $sourcePath ); } $normalizedKey = $this->normalizeKey( $key ); $filesystem = $this->directFilesystem(); // Checked before any folder or file is written. if ( null === $filesystem ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw StorageException::filesystemUnavailable(); } $this->ensureRoot( $filesystem ); $targetPath = $this->root() . '/' . $normalizedKey; if ( ! wp_mkdir_p( dirname( $targetPath ) ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw StorageException::writeFailed( $normalizedKey, 'could not create the destination directory' ); } if ( ! $filesystem->copy( $sourcePath, $targetPath, true ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw StorageException::writeFailed( $normalizedKey, 'copy to the packages directory failed' ); } $this->guardFolders( $filesystem, dirname( $normalizedKey ) ); $filesize = filesize( $targetPath ); $sha256 = hash_file( 'sha256', $targetPath ); if ( false === $filesize || false === $sha256 ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw StorageException::writeFailed( $normalizedKey, 'could not read back the stored file to checksum it' ); } return new StoredFile( $this->id(), $normalizedKey, $filesize, $sha256 ); } /** * Delete a stored object. Deleting a missing key is a no-op. * * @since 1.0.0 * * @param string $key Provider-scoped object key. * @return void */ public function delete( string $key ): void { $path = $this->root() . '/' . $this->normalizeKey( $key ); if ( is_file( $path ) ) { wp_delete_file( $path ); } } /** * Stream the object to the current HTTP response. * * @since 1.0.0 * * @param string $key Provider-scoped object key. * @return bool True once the file has been streamed; false when the key does not exist. */ public function stream( string $key ): bool { $path = $this->root() . '/' . $this->normalizeKey( $key ); if ( ! is_file( $path ) || ! is_readable( $path ) ) { return false; } if ( ! headers_sent() ) { header( 'Content-Type: application/octet-stream' ); header( 'Content-Length: ' . (string) filesize( $path ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streams the package to the response; WP_Filesystem has no streaming read. readfile( $path ); return true; } /** * A short-lived URL the client can be redirected to. * * @since 1.0.0 * * @param string $key Provider-scoped object key. * @param int $ttlSeconds Requested URL lifetime. * @return string|null Always null: local storage always streams, it never issues a retrieval URL. */ public function retrievalUrl( string $key, int $ttlSeconds ): ?string { return null; } /** * Resolve and cache the packages root. * * @since 1.0.0 * * @return string Absolute path of the packages root, no trailing slash. */ private function root(): string { if ( null === $this->root ) { $uploadDir = wp_upload_dir(); $this->root = rtrim( (string) $uploadDir['basedir'], '/\\' ) . '/releasecart/packages'; } return $this->root; } /** * Create the packages root and its guard files. * * @since 1.0.0 * * @param \WP_Filesystem_Base $filesystem The direct filesystem. * @return void */ private function ensureRoot( \WP_Filesystem_Base $filesystem ): void { $root = $this->root(); wp_mkdir_p( $root ); $this->writeGuardFileIfMissing( $filesystem, $root . '/.htaccess', self::HTACCESS_CONTENTS ); $this->writeGuardFileIfMissing( $filesystem, $root . '/web.config', self::WEB_CONFIG_CONTENTS ); $this->writeGuardFileIfMissing( $filesystem, $root . '/index.html', self::INDEX_HTML_CONTENTS ); } /** * Put an index.html in each folder of a key's path below the root. * * The root's .htaccess and web.config cover these folders on Apache and IIS. * The index.html stops a folder listing on servers that ignore both. * * @since 1.0.0 * * @param \WP_Filesystem_Base $filesystem The direct filesystem. * @param string $folder Normalized folder part of the key, or "." for none. * @return void */ private function guardFolders( \WP_Filesystem_Base $filesystem, string $folder ): void { while ( '.' !== $folder && '' !== $folder ) { $this->writeGuardFileIfMissing( $filesystem, $this->root() . '/' . $folder . '/index.html', self::INDEX_HTML_CONTENTS ); $folder = dirname( $folder ); } } /** * Write a protected-directory guard file, but only if it is not already there. * * @since 1.0.0 * * @param \WP_Filesystem_Base $filesystem The direct filesystem. * @param string $path Absolute path of the guard file. * @param string $contents Contents to write when the file is missing. * @return void */ private function writeGuardFileIfMissing( \WP_Filesystem_Base $filesystem, string $path, string $contents ): void { if ( file_exists( $path ) ) { return; } $filesystem->put_contents( $path, $contents ); } /** * Get the direct WP_Filesystem, or null when WordPress would use another method. * * An instance WordPress already set up is reused only when it is direct, so an * FTP instance in use elsewhere in the request is never replaced. Another method * needs credentials, and a REST upload cannot ask for them. * * @since 1.0.0 * * @return \WP_Filesystem_Base|null */ private function directFilesystem(): ?\WP_Filesystem_Base { global $wp_filesystem; if ( $wp_filesystem instanceof \WP_Filesystem_Base ) { return 'direct' === $wp_filesystem->method ? $wp_filesystem : null; } if ( ! function_exists( 'get_filesystem_method' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } // WordPress picks the method by writing a test file in this folder, so it // must exist. The packages root does not, before the first upload. $context = $this->root(); while ( ! is_dir( $context ) && dirname( $context ) !== $context ) { $context = dirname( $context ); } // PHP writes the uploads folder itself, so file ownership is not checked. if ( 'direct' !== get_filesystem_method( array(), $context, true ) || ! WP_Filesystem( false, $context, true ) ) { return null; } return $wp_filesystem instanceof \WP_Filesystem_Base ? $wp_filesystem : null; } /** * Normalize a key and keep it below the packages root. * * @since 1.0.0 * * @param string $key Provider-scoped object key as supplied by the caller. * @return string The normalized key, safe to append to the packages root. * * @throws StorageException When the key is empty, absolute, contains a null * byte, attempts to traverse above the root, or targets * one of the packages root's guard files. */ private function normalizeKey( string $key ): string { $normalized = ltrim( str_replace( '\\', '/', $key ), '/' ); if ( '' === $normalized || str_contains( $normalized, "\0" ) || str_contains( $normalized, '..' ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw StorageException::invalidKey( $key ); } // A root-level key matching a guard file would overwrite the deny-all // protection when stored. Guard basenames deeper in the tree are harmless, so // only reject them when they resolve directly to the packages root. if ( '.' === dirname( $normalized ) && in_array( basename( $normalized ), self::RESERVED_GUARD_FILES, true ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output here; escaped where printed. throw StorageException::invalidKey( $key ); } return $normalized; } }