Unable to read a local file with Laravel storage: readStream returns null.

·

3 min read

While working with Laravel Storage and Flysystem, I faced a puzzling situation where attempting to read a file using absolute paths returned null, despite the file's existence. The process seemed straightforward, yet it didn't yield the expected result.

$media = Media::where('uuid', $uuid)->first();
Storage::disk($media->disk)->readStream(storage_path($media->path));
// output null

Exploration

So out of curiosity, I dive down to the rabbit hole and try to figure out what happened. I began by verifying the file's existence.

$path = storage_path($media->path); 
# /Users/chaiwei/code/laravel/storage/app/docs/file.txt

dd(file_exists($path)); // true

Then I delved deeper to the Illuminate\Filesystem\FilesystemAdapter, and it brings me to the League\Flysystem\Filesystem class.

// Illuminate\Filesystem\FilesystemAdapter

public function readStream($path)
{
    try {
        return $this->driver->readStream($path);
    } catch (UnableToReadFile $e) {
        throw_if($this->throwsExceptions(), $e);
    }
}
// League\Flysystem\Filesystem

public function readStream(string $location)
{
    return $this->adapter->readStream($this->pathNormalizer->normalizePath($location));
}

Venturing deeper, I uncovered that Flysystem's WhitespacePathNormalizer treated absolute paths by removing the leading slash, inadvertently converting them into relative paths.

// League\Flysystem\WhitespacePathNormalizer

public function normalizePath(string $path): string
{
    $path = str_replace('\\', '/', $path);
    $this->rejectFunkyWhiteSpace($path);

    return $this->normalizeRelativePath($path);
}

private function normalizeRelativePath(string $path): string
{
    $parts = [];

    foreach (explode('/', $path) as $part) {
        switch ($part) {
            ...
        }
    }

    return implode('/', $parts); // Users/chaiwei/code/laravel/storage/app/docs/file.txt
}

Without the initial leading slash, the path Users/chaiwei/code/laravel/storage/app/docs/file.txt was now passed into the LocalFilesystemAdapter's readStream method. Yet, during processing, this path underwent an unexpected alteration when combined again with the prefixPath.

// League\Flysystem\Local\LocalFilesystemAdapter

public function readStream(string $path)
{
    // $path = "Users/chaiwei/code/laravel/storage/app/docs/file.txt";
    $location = $this->prefixer->prefixPath($path);
    error_clear_last();
    $contents = @fopen($location, 'rb');

    if ($contents === false) {
        throw UnableToReadFile::fromLocation($path, error_get_last()['message'] ?? '');
    }

    return $contents;
}
// League\Flysystem\PathPrefixer

public function prefixPath(string $path): string
{
    return $this->prefix . ltrim($path, '\\/');
}

The prefix is actually referring to the root directory specified within our config/filesystems.php file. The $this->prefix within the codebase utilises this setting during path concatenation.

// config/filesystems.php

'disks' => [
    'local' => [
        'driver' => 'local',
        'root' => storage_path('app'),
        'throw' => false,
    ],

In this specific example, the root directory was set as storage_path('app'). Consequently, this configuration resulted in an additional absolute URL: '/Users/chaiwei/code/laravel/storage/app' serving as the prefix.

This situation led to an unexpected concatenation and duplication. When the prefixPath() method combined the root directory prefix with the earlier relative path, it resulted in /Users/chaiwei/code/laravel/storage/app/Users/chaiwei/code/laravel/storage/app/docs/file.txt

At first, I believed it was a bug. However, after careful consideration and reviewing the comments on GitHub Issues, I came to realize it's a deliberate security measure by the maintainer, it's intended to prevent potential security loopholes resulting from oversight by developers."

Github issues link:

https://github.com/thephpleague/flysystem/issues/1354 https://github.com/thephpleague/flysystem/issues/1017 https://github.com/thephpleague/flysystem/issues/965

Side note:

Initially, I didn't understand the implication of this comment from the maintainer:

if you use a root path of / then this will work as expected

Upon revisiting Flysystem's documentation, I realised it referred to the root directory provided to the constructor:

// The internal adapter
$adapter = new League\Flysystem\Local\LocalFilesystemAdapter(
    // Determine root directory
    __DIR__.'/root/directory/'
);

In the context of Laravel's configuration in config/filesystems.php, adjusting the 'root' as shown below could make it function as intended. However, I disagree with this change as it heightens the risk of potential security breaches for our application.

// config/filesystems.php

'disks' => [
    'local' => [
        'driver' => 'local',
        'root' => '/', // NOT recommended
        'throw' => false,
    ],

Solution

  1. Leveraging Object Storage: Consider utilizing object storage solutions like S3 to manage file uploads. Object storage systems offer scalable, secure, and efficient handling of data, providing a reliable alternative for file management.

  2. Path Considerations: When employing a local file system, prioritise the usage of relative paths. Using relative paths enhances portability and mitigates potential issues arising from absolute path complexities.

Did you find this article valuable?

Support Chaiwei's Coding Journey by becoming a sponsor. Any amount is appreciated!