When developing a system, there are instances where it becomes necessary to safeguard uploaded files. By default, these files are usually stored in the public directory, making them accessible to anyone with the corresponding link. However, in real-world applications, certain files should only be accessible to specific authorized users. Consider scenarios such as storing personal information like passport copies or other sensitive documents, where it is imperative to prevent unauthorized access.

In this tutorial, we will delve into the process of achieving this level of file security using the powerful Spatie media library within the Laravel framework. We will explore the steps required to ensure that files are stored privately and can only be retrieved by authorized users. By the end of this tutorial, you will possess a comprehensive understanding of how to implement robust file protection and access controls in your Laravel applications, enhancing data confidentiality and user security.

Scenario

We are going to create a Document model and then associate each document with a user. Each document will have a file associated with it.

Migration:

Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
});

Models:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

class Document extends Model implements HasMedia
{
    use HasFactory;
    use InteractsWithMedia;

    protected $fillable = ['name'];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('file')
            ->singleFile();
    }
}

Here, a file is associated with each Document record.

Routes

Route::middleware('auth')->group(function () {
    Route::resource('documents', DocumentController::class);
});

Controller

<?php

namespace App\Http\Controllers;

use App\Models\Document;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;

class DocumentController extends Controller
{

    public function index(Request $request)
    {
        $documents = Document::all();
       return view('web.documents.index', compact('documents'));
    }

    public function create(Request $request)
    {
        return view('web.documents.create', [
            'document' => new Document(),
        ]);
    }

    public function store(Request $request)
    {
        $request->validate([
            'name' => ['required', 'string'],
            'document' => ['required', 'file'],
        ]);

        $document = new Document();
        $document->name = $request->name;
        $document->user()->associate($request->user());
        $document->save();

        $document->addMedia($request->file('document'))->toMediaCollection('file');

        return redirect()->route('documents.show', $document);
    }

    public function show(Request $request, Document $document)
    {
        return view('web.documents.show', compact('document'));
    }
}

Here, an authenticated user may submit a file with a name to the store method, and it will create a Document record with the name. The user will be associated with the document. Additionally, the submitted file will also be associated with the document.

The Problem

However, if you want to download the file, you will need to provide a link to the given file. One such way to do this would be by adding a link to the show method as shown below:

<a href="{{ $document->getFirstMediaUrl('file') }}">
    {{ $document->name }}
</a>

Using the above method, a user can click on the link and the file will be downloaded. Unfortunately, this is not very secure, as anyone with the link can download this file, even if they are not authenticated, because the URL is a direct link to the file with no authorization in between.

The Solution

Step 1

First, let's move the file to a location where you cannot make a direct link to the file. You can do this by defining a new disk under disks in the config/filesystems.php file.

  'private_uploads' => [
      'driver' => 'local',
      'root' => storage_path('private_uploads'),
  ],

Now, any file saved under this disk will not be accessible to the public directly, as it is stored in the storage/private_uploads directory, which is not symlinked to the public directory. Since the storage directory sits at the same level as the public directory, no direct link can be established to anything inside the storage directory.

Step 2

Update the media collection in the Model to use the private_uploads disk.

public function registerMediaCollections(): void
{
    $this->addMediaCollection('file')
        ->useDisk('private_uploads')
        ->singleFile();
}

This ensures that all the files associated with the file collection will be saved in the private_uploads disk.

Step 3

Now that there is no direct link to the file, we need a way for users to download the file when it is required. So, let's add a route for that.

Route::get('documents/{document}/download', [DocumentController::class, 'download'])->name('documents.download');

And then let's implement that method in the controller.

public function download(Document $document)
{
  // check if the user is authorized to download it
  if (auth()->user()->id != $document->user_id) {
	  abort(403);
  }
  
	$media = $document->getFirstMedia('file');
	return response()->download($media->getPath(), $media->file_name);
}

Now we have added an authorization layer before the file is served to the user.

Step 4 Instead of using the getFirstMediaUrl method to retrieve the link, update it to direct to the implemented route.

<a href="{{ route('documents.download', $document) }}">
    {{ $document->name }},
</a>

This would do the trick, but we can clean things up a bit more.

Cleaning things up

First, let's create a policy method.

<?php

namespace App\Policies;

use App\Models\Document;
use App\Models\User;

class DocumentPolicy
{

    public function download(User $user, Document $document): bool
    {
        return $user->id == $document->user_id;
    }
}

Now, use the policy in the controller.

<?php

namespace App\Http\Controllers;

use App\Models\Document;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;

class DocumentController extends Controller
{
    use AuthorizesRequests;
    
    //...
    
		public function download(Document $document)
    {
        $this->authorize('download', $document); // makes things more Laravel, don't you think?

        $media = $document->getFirstMedia('file');
        return response()->download($media->getPath(), $media->file_name);
    }
}

Next, let's retrieve that route we implemented using the getFirstMediaUrl. To do this, we need to override the config/media-library file.

/*
* When urls to files get generated, this class will be called. Use the default
* if your files are stored locally above the site root or on s3.
*/
'url_generator' => App\Support\CustomUrlGenerator::class,

We do not have a CustomUrlGenerator class yet, so let's implement it.

<?php

namespace App\Support;

use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator;
use Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator;

class CustomUrlGenerator extends DefaultUrlGenerator
{

    public function getUrl(): string
    {
		    // Get the normal URL
        $url = parent::getUrl();

				// Find the disk the media is using
        $disk = $this->media->disk;
        
        if ($disk === 'private_uploads') { // if it is using the private_uploads disk
        
		        // Then return our custom implemented route
            return route('documents.download', $this->media->model);
        }

        return $url;
    }
}

Now, you can directly call getFirstMediaUrl on the document, and it will still return our custom route.

<a href="{{ $document->getFirstMediaUrl('file') }}">
    {{ $document->name }},
</a>

What else can you do?

Well, you can create a custom route to handle all the media downloads.

Route::get('documents/{media}', [MediaController::class, 'show'])->name('media.show');

Then, you can implement an interface for all model policies of models that use private_uploads, defining who is authorized to download files from that model. After that, on the show method, get the model via $media->model and then call the download policy of that model before streaming it to the user. Plus, update the CustomUrlGenerator to match the implemented route.

This ensures you have one single route to download all private files, and all requests are checked for authorization.

Hope you like it!