The private filesystem is used whenever we want to control access to the files being downloaded. Using the default public storage, users can get to the files simply by pointing to them in the browser, thereby bypassing Drupal completely. However, .htaccess rules prevent users from directly accessing any files in the private storage, making it necessary to create a route that delivers the requested file. It goes without saying that the latter is a hell of a lot less performant, as Drupal needs to be loaded for each file. Therefore, it's important to only use it when files should be restricted based on certain criteria.
Drupal already comes with a route and Controller ready to download private files, but we can create one as well if we really need to. For example, the image module does so in order to control the creation and download of image styles—ImageStyleDownloadController.
The route definition for the default Drupal path looks like this:
system.files: path: '/system/files/{scheme}' defaults: _controller: 'Drupal\system\FileDownloadController::download' scheme: private requirements: _access: 'TRUE'
This is a bit of an odd route definition. We have a {scheme} parameter but which will be the actual file path requested for download. The URI scheme itself defaults to private, as illustrated by the signature of FileDownloadController::download(). Moreover, access is allowed at all times as Drupal delegates this check to other modules—as we will see in a minute.
If we look inside FileDownloadController::download(), we can see that it isn't actually much that it is doing itself. However, we also note that in the first line, it looks for the query parameter called file in order to get the URI of the requested file:
$target = $request->query->get('file');
But based on the route definition, we don't even have this parameter. This is where Path Processors come into play, more specifically, implementations of InboundPathProcessorInterface. These are tagged services that get invoked by the routing system when building up the routes by the requested path. And essentially, they allow the alteration of a given path as it comes in. For Drupal 7 veterans, these can be likened to implementations of hook_url_inbound_alter().
The core System module implements its own path processor for the purpose of handling the download of private files:
path_processor.files: class: Drupal\system\PathProcessor\PathProcessorFiles tags: - { name: path_processor_inbound, priority: 200 }
It's a simple tagged service definition whose class needs to implement the correct interface that has one method. In the case of PathProcessorFiles, it looks like this:
/** * {@inheritdoc} */ public function processInbound($path, Request $request) { if (strpos($path, '/system/files/') === 0 && !$request->query->has('file')) { $file_path = preg_replace('|^\/system\/files\/|', '', $path); $request->query->set('file', $file_path); return '/system/files'; } return $path; }
The goal of this method is to return a path that can be the same as the one requested or changed for whatever reason. And what Drupal does here is checks whether the path is the one defined earlier (starts with /system/files/) and extracts the requested file path that comes as the first argument after that. It takes that and adds it to the current request parameter keyed by file. Finally, it returns a cleaner path called simply /system/files. So this is why the FileDownloadController::download() method looks there for the file path.
Turning back to the Controller, we see that it essentially checks for the file and, if it is not found, throws a 404 (NotFoundHttpException). Otherwise, it invokes hook_file_download() which allows all modules to control access to the file. And these can do so in two ways: either by returning -1, which denies access, or by returning an array of headers to control the download for that specific file. By default, files in the private filesystem cannot be downloaded unless a specific module allows this to happen.
So what does this mean? If we have a file in the private filesystem, we need to implement hook_file_download() and control access to it. Let's see an example of how this might work by assuming we have a folder called /pdfs whose files we want to make accessible to users that have the administer site configuration permission:
/** * Implements hook_file_download(). */ function module_name_file_download($uri) { $file_system = \Drupal::service('file_system'); $dir = $file_system->dirname($uri); if ($dir !== 'private://pdfs') { return NULL; } if (!\Drupal::currentUser()->hasPermission('administer site configuration')) { return -1; } return [ 'Content-type' => 'application/pdf', ]; }
This hook receives as an argument the URI of the file being requested. And based on that, we try to get the folder name it's in. To do this, we use the file_system service again.
If the file is not in the private filesystem inside the /pdfs folder, we simply return NULL to signify that we don't control the access to this file. Other modules may do so (and if none do, access is denied). If it is our file, we check for the permission we want and return -1 if the user doesn't have it. This will deny access. Finally, if access is allowed, we return an array of headers we want to use in the file delivery. In our case, we simply use the PDF-specific headers that facilitate the display of the PDF file in the browser. If we wanted to trigger a file download, we could do something like this instead:
$name = $file_system->basename($uri); return [ 'Content-Disposition' => "attachment;filename='$name'" ];
We use the filesystem service to determine the file name being requested and adjust our headers accordingly to treat it like an attachment that has to be downloaded.
And that is all there is to it. If we want more control (or a different path to download the files), we can implement our own route and follow the same approach. Without, of course, the need to invoke a hook, but simply handling the download inside the controller method. For example, this is what FileDownloadController::download() does to handle the actual response:
return new BinaryFileResponse($uri, 200, $headers, $scheme !== 'private');
This type of response is used when we want to deliver files to the browser and it comes straight from Symfony.