2017 Jan 09

Drupal 8 has private file capabilities built into core just like it's always had, but with some tweaks in the latest version. By default with the core implementation, it is meant to be used with file upload fields. Sometimes a use case comes up though where certain files within editor content need to be made private as well. It's not enough in this case to just have all the files that are private outside of the body text and listed at the bottom of the page in a list for example. There are benefits of maintaining files in separate fields from a content type setup point of view, but not always, especially when there needs to exists tight coupling of surrounding content and the need to mix public and private file systems. 

Typical setup with fields

The normal workflow when setting up private files using fields is to first enable private files in settings.php, then confirm the private files path at admin/config/media/file-system. Then you can start to configure file upload fields to be private in their settings within each content type.

Once that is done, files then start to appear with their paths pointing at Drupal's internal file route (/system/files) to confirm access. So instead of /sites/default/files/private/myfile.pdf it would be /system/files/myfile.pdf. Note here that the folder called "private" has been set as the root of the private file system. When you call /system/files, it'll start looking in private. Any sub-folder structure is maintained. If I had /sites/default/files/private/publications/myfile.pdf, then it would just be /system/files/publications/myfile.pdf. Please note that a true private file setup recommends putting the private folder outside of the document root. That is outside the scope of this setup and doesn't speak to the need for mixing public and private files.

Setup with IMCE

Usually when you upload files using IMCE, the file paths used and inserted into the editor are just the literal paths. There is no facility in place for handling files coming from private folders. Ideally IMCE is made aware of the private setup and would then use /system/files path when necessary instead of always using /sites/default/files.

But since it doesn't, it presents the first problem we need to solve. The way we've handled this is to create a custom output filter to convert known private paths into Drupals internal file access route. This is easy since it's just converting the first part of the path. Any nested folders maintain their structure as mentioned above. As a small aside on output filters, they are basically there as a way to filter content/manipulate it before output. Examples of built in filters are shown below as seen at admin/config/content/formats/manage/full_html:

output filters

The other common type of filtering you might have seen is with "shortcodes". These are a popular concept in Wordpress whereby a content editor can use custom tags to effect the output. An example would be for the jquery ui filter in Drupal, where a user can implement [accordion] [/accordion] tags to generate foldable sections of content.

So in this case, we just need to do a find and replace for /sites/default/files/private and convert it into /system/files. Now that this is in place, the files that should be considered private will go through Drupal to check if the user has access or not. Here is some more information about how to implement custom filters in Drupal 8.

Below is the contents of the custom module. This file lives in [module_name]/src/Plugin/Filter/FilterPrivateFiles.php

<?php
/**
 * @file
 * Contains Drupal\private_files_output_filter\Plugin\Filter\FilterPrivateFiles
 */

namespace Drupal\private_files_output_filter\Plugin\Filter;

use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Provides a filter to convert /sites/default/files/private to /system/files
 * https://www.lullabot.com/articles/creating-a-custom-filter-in-drupal-8
 *
 * @Filter(
 *   id = "filter_private_files_output_filter",
 *   title = @Translation("Private Files Output Filter"),
 *   description = @Translation("Provides a filter to convert /sites/default/files/private to /system/files"),
 *   type = Drupal\filter\Plugin\FilterInterface::TYPE_MARKUP_LANGUAGE,
 * )
 */
class FilterPrivateFiles extends FilterBase {

  /**
   * {@inheritdoc}
   */
  public function process($text, $langcode) {
    $new_text = str_replace('/sites/default/files/private', '/system/files', $text);
    $result = new FilterProcessResult($new_text);
    return $result;
  }

}

Blocking direct access to the files

Now that we are using the private path within the content, you don't want anyone to be able to guess or otherwise access the files directly. An easy way to do this with NGINX is to setup a new location block for that private folder that looks like this:

location ^~ /sites/default/files/private {
  internal;
}

Here's some more information on setting up private files in NGINX. Typically placing files within the sites document root isn't the recommended approach. Having it within the normal files directory though does make managing the files easier through the CMS.

Managing access using Drupals path

The last step is setting permissions on files being accessed using the /system/files route. Now that we are referencing files that aren't stored properly in Drupal as private files, it'll prevent access 100% of the time. Typically if you were to look at the file_managed table in the database, you'd see the difference between public and private files under the uri column. Public ones would look like public://2015-10/Contact.png and the private version would have a different prefix such as private://2015-10/Contact.png. That's basically how the system knows to check for permissions. Again, ideally new files uploaded through IMCE would be stored properly, but right now it just deals with all public files.

In Drupal 7, there was a handy module that stepped in here to provide custom access permissions as well as mixing public and private uploads. It's called Private files download permission but unfortunately a version for Drupal 8 doesn't exist. We'll have to instead implement a custom check using basically the same hook.

Custom access checking

The basic yet effective way to implement this into a module is to use the hook_file_download hook. The below example just checks whether the user is logged in at all. You could expand on this though to start checking for roles and other criteria, which is partly what the above mentioned module does in Drupal 7.

<?php
function file_download_perms_file_download($uri) {
  // Check to see if this user is logged in so we can serve private files
  if (!\Drupal::currentUser()->isAnonymous()) {
    return 1;
  }
}

Conclusion

Having said all of the above, the hope is that IMCE will either be updated to work with private files, or better yet, Drupal core gets new media handling capabilities. There has been an ongoing discussion regarding media management that looks promising. 

We've broken down the steps into 3 parts:

  1. Setup the output filter to convert /sites/default/files/private into /system/files
  2. Configure a NGINX location block for the private files directory to block direct incoming requests for files there
  3. Setup an access hook within a custom module to act as a gate for file requests going through Drupal's system/files route.