Load Images Stored Outside Public Directory and Use Browser Cache to Send 304 Laravel Response

2015-05-28 Laravel

Recently I did a handy feature to allow my users upload their own files into my Laravel application. I decided to use built-in Filesystem storage. Everything worked like a charm until I reached to a significant problem – how to display a resource uploaded by some user when the storage directory is obviously located outside public directory?

Displaying an image or a path to any other file that is uploaded inside public is a trivial thing – in the easiest case you can just link a resource and Laravel will generate direct URL to a file but what with any “non-public” locations?

I decided to put all my uploads into

/storage/app

Below is just an excerpt of how does Storage facade upload files (but that’s not what we’re going to cover in this article).

$result = Storage::put($dest_dir . $dest_filename, file_get_contents($original_file));

Solution – 304 Response

OK, let’s focus on the main problem. First, we have to create a route, under which we’ll access the resource. Open

routes.php

and add something similar to

Route::get('files/{file}/preview', ['as' => 'file_preview', 'uses' => 'FilesController@preview']);

Obviously change controller name, alias and path structure to something that suits your needs. I always show just a basic example that is out of context.

Now it’s time for the Preview method. Inside your controller (FilesController in my case) create new public method

public function preview(File $file)

I use route model binding so I inject File object instead of it’s ID but that also really doesn’t matter. Method will take the File object as an argument and will return Response. Please keep in mind that File object in this case is my custom model called File.

use App\File;

Response is

use Illuminate\Support\Facades\Response;

First we have to grab some important data about our file and pass it inside headers.

$path = storage_path('app/') . $file->path . $file->name_thumbnail;
$handler = new \Symfony\Component\HttpFoundation\File\File($path);

By using Symfony’s File class I can easily get things like file’s type, size and modification date.

$handler->getMTime();
$handler->getMimeType();
$handler->getSize();

We also have to set the “life time” value for our resource.

$lifetime = 31556926; //One year in seconds

At the final stage this part should be similar to below code

/**
* Prepare some header variables
*/
$file_time = $handler->getMTime(); // Get the last modified time for the file (Unix timestamp)

$header_content_type = $handler->getMimeType();
$header_content_length = $handler->getSize();
$header_etag = md5($file_time . $path);
$header_last_modified = gmdate('r', $file_time);
$header_expires = gmdate('r', $file_time + $lifetime);

$headers = array(
    'Content-Disposition' => 'inline; filename="' . $file->name_thumbnail . '"',
    'Last-Modified' => $header_last_modified,
    'Cache-Control' => 'must-revalidate',
    'Expires' => $header_expires,
    'Pragma' => 'public',
    'Etag' => $header_etag
);

Now the magic.

/**
 * Is the resource cached?
 */
$h1 = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $_SERVER['HTTP_IF_MODIFIED_SINCE'] == $header_last_modified;
$h2 = isset($_SERVER['HTTP_IF_NONE_MATCH']) && str_replace('"', '', stripslashes($_SERVER['HTTP_IF_NONE_MATCH'])) == $header_etag;

if ($h1 || $h2) {
    return Response::make('', 304, $headers); // File (image) is cached by the browser, so we don't have to send it again
}

If above condition won’t be fulfilled then we should serve normal reponse

$headers = array_merge($headers, array(
    'Content-Type' => $header_content_type,
    'Content-Length' => $header_content_length
));

return Response::make(file_get_contents($path), 200, $headers);

Usage

Now it’s time for the view layer. Create an image tag and inside src attribute add

{{ URL::route('file_preview', [$file->id]) }}

Yes, injecting our route URL into the src attribute will display the image! :)

First time you will open the page, browser will respond with HTTP 200 but after you refresh the page it should say 304 Not Modified.

Full method

/**
* Previews an image if possible.
*
* @param File $file
* @return mixed
* @internal param $id
*/
public function preview(File $file)
{
    $path = storage_path('app/') . $file->path . $file->name_thumbnail;
    $handler = new \Symfony\Component\HttpFoundation\File\File($path);

    $lifetime = 31556926; // One year in seconds

    /**
    * Prepare some header variables
    */
    $file_time = $handler->getMTime(); // Get the last modified time for the file (Unix timestamp)

    $header_content_type = $handler->getMimeType();
    $header_content_length = $handler->getSize();
    $header_etag = md5($file_time . $path);
    $header_last_modified = gmdate('r', $file_time);
    $header_expires = gmdate('r', $file_time + $lifetime);

    $headers = array(
        'Content-Disposition' => 'inline; filename="' . $file->name_thumbnail . '"',
        'Last-Modified' => $header_last_modified,
        'Cache-Control' => 'must-revalidate',
        'Expires' => $header_expires,
        'Pragma' => 'public',
        'Etag' => $header_etag
    );

    /**
    * Is the resource cached?
    */
    $h1 = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $_SERVER['HTTP_IF_MODIFIED_SINCE'] == $header_last_modified;
    $h2 = isset($_SERVER['HTTP_IF_NONE_MATCH']) && str_replace('"', '', stripslashes($_SERVER['HTTP_IF_NONE_MATCH'])) == $header_etag;

    if ($h1 || $h2) {
        return Response::make('', 304, $headers); // File (image) is cached by the browser, so we don't have to send it again
    }

    $headers = array_merge($headers, array(
        'Content-Type' => $header_content_type,
        'Content-Length' => $header_content_length
    ));

    return Response::make(file_get_contents($path), 200, $headers);
}

Credits

To write above solution I was inspired by this gist and this article.