foldershare-8.x-1.2/foldershare.file.inc
foldershare.file.inc
<?php
/**
* @file
* Implements file hooks for the module.
*/
use Drupal\Core\Url;
use Drupal\Component\Utility\Unicode;
use Drupal\foldershare\Constants;
use Drupal\foldershare\ManageFileSystem;
use Drupal\foldershare\Entity\FolderShare;
/**
* Implements hook_file_url_alter().
*
* This hook is called from Drupal core's file_create_url() helper
* function to optionally modify an incoming URI.
*
* This method's goals are:
*
* - Obscure the directory structure this module uses when storing files.
*
* - Create URLs that, when used, lead to this module's file download
* controller, which handles access control.
*
* - Insure that access control checking occurs for private *and* public
* storage of this module's files, *and* derived files (such as those
* for styled images from the Drupal core Image module).
*
* Drupal calls this method for all file URIs (assuming a module is written
* properly and calls file_create_url()), including those for images,
* Javascript, CSS, etc. It is therefore called very frequently and it is
* important that this method be written to very quickly return if the
* incoming URI is not relevant to this method.
*
* This method looks for URIs that include the module's FILE_DIRECTORY
* into which all files managed by the module are stored. If this directory
* name is not found, this method ignores the URI.
*
* When a URI does include this module's file directory name, the URI is
* parsed to see if the subpath sequence that names a module file. This
* sequence has the form "FILE_DIRECTORY/DIGITS/.../DIGITS.EXTENSION", where:
*
* - FILE_DIRECTORY is this module's file directory.
* - DIGITS for the directory and file names form the 20-digit base-10
* zero-padded File entity ID of a file to access.
* - EXTENSION is the filename extension for the file.
*
* URIs like this are created by ManageFileSystem::getFileUri().
*
* If a properly formed subpath is not found, this method ignores the URI.
*
* When the subpath is present, the entire URI is changed to the form
* ROUTE_DOWNLOADFILE/ENTITY_ID?prefix=PREFIX, where:
*
* - ROUTE_DOWNLOADFILE is the route path for the module's file download
* controller.
*
* - ENTITY_ID is the File object entity ID parsed from the DIGITS above.
*
* - PREFIX is the portion of the original path preceding the subpath above.
*
* @see \Drupal\foldershare\Utilities\ManageFileSystem::getFileUri()
* @see \Drupal\foldershare\Utilities\ManageFileSystem::getFileEntityId()
* @see \Drupal\foldershare\Controller\FileDownload::download()
* @see foldershare_file_download()
* @see file_create_url()
*/
function foldershare_file_url_alter(&$uri) {
//
// Check URI for the file directory
// --------------------------------
// If the URI does not include this module's file directory name, then it
// is not a URI we care about. The file directory name may occur anywhere
// within the URI.
$pos = mb_strpos($uri, ManageFileSystem::FILE_DIRECTORY);
if ($pos === FALSE) {
// Fail. The module's file directory name is not in the path. Ignore it.
return;
}
//
// Convert URI to URL
// ------------------
// To be clear:
//
// - The URI passed to this function may use an internal scheme, such as
// "public" or "private". This URI is only usable within Drupal and is
// never shown to a user.
//
// - A URL for the same file maps the URI to an external form that shows up
// on web pages and is sent by a browser back to Drupal to ask for the
// file. This external URL may have a different path, and it certainly
// has a different scheme.
//
// This method's goal is to alter the URI into a form that leads to a URL
// that, when returned to Drupal later, routes to the file download
// controller. We need to pass to that future download controller something
// about the original URI so that it knows what file was requested. Since
// it will be working with URLs, this method here needs to shift to
// working with URLs too.
//
// We therefore need to map the URI to a URL. This is done by the stream
// wrapper manager, which handles streams for public and private files.
$mgr = \Drupal::service('stream_wrapper_manager');
$scheme = \Drupal::service('file_system')->uriScheme($uri);
if ($scheme === FALSE) {
$scheme = 'public';
}
// Get the scheme's stream and map the URI to a URL. The returned URL
// is complete, including http://hostname/path.
//
// This works for almost any scheme, including Drupal core's "public" and
// "private" schemes, as well as any extension schemes that might reference
// files in an external file system (e.g. Amazon).
$stream = $mgr->getViaScheme($scheme);
if ($stream === FALSE) {
// Fail. Scheme is not recognized. The URI is malformed and there is
// nothing more we can do.
return;
}
$streamDirectory = $stream->getDirectoryPath();
$stream->setUri($uri);
try {
$url = $stream->getExternalUrl();
}
catch (\Exception $e) {
// Fail. The stream does not support conversion of URIs to external URLs.
// The Drupal core TranslationsStream class is an example. Such a stream
// is unlikely to ever be used with this method and contain this module's
// FILE_DIRECTORY name, but if it does there's nothing more we can do.
return;
}
// Parse the URL and get the path out of it. The path now includes any
// path prefixes or changes added by the scheme's stream wrapper.
//
// For instance, the Drupal core "private" stream (class PrivateStream)
// adds a prefix from the 'system.private_file_download' route, which
// is always "/system/files".
//
// The Drupal core "public" stream (class PublicStream) does not add a
// prefix. The URI's file path is presumed to be the path to a file directly
// deliverable by the web server.
$parsedUrl = parse_url($url);
if ($parsedUrl === FALSE) {
// Fail. The URL created by the stream is malformed and cannot be parsed.
// Something is wrong with the stream wrapper. Nothing more we can do.
return;
}
if (isset($parsedUrl['path']) === FALSE) {
// Fail. The URL created by the stream does not have a path (i.e. it
// is something like "http://host/"). Since it is the path that we need
// to indicate a specific file, this doesn't make sense. The URL is
// therefore malformed and there is nothing more we can do.
return;
}
if (isset($parsedUrl['user']) === TRUE ||
isset($parsedUrl['pass']) === TRUE) {
// Fail. The stream wrapper has added a user name and password to the URL.
// This module has no mechanism to forward this to its file download
// controller. This is a very unlikely situation, and one not supported
// by Drupal core's "public" and "private" streams, but if it happens
// there is nothing we can do.
return;
}
$path = urldecode($parsedUrl['path']);
// Check again that the returned path still references this module's
// file directory. It certainly should.
$pos = mb_strpos($path, ManageFileSystem::FILE_DIRECTORY);
if ($pos === FALSE) {
// Fail. The module's file directory name is not in the path. Ignore it.
return;
}
//
// Get prefix
// ----------
// The prefix is the part of the URL path that precedes the module's
// file directory name. There may be nothing.
if ($pos === 0) {
// No prefix. The path starts with FILE_DIRECTORY
//
// This only occurs for the "public" file system where external URLs
// indicate site-relative paths to files that can be delivered directly
// by the web server.
$prefix = FALSE;
}
else {
// There is a prefix.
//
// This occurs in two well-known cases (and there may be others):
//
// - The URL is for a file in the "private" file system, so the path has
// a prefix of "/system/files". The web server sends such paths to
// Drupal, which replaces "/system/files" with the full path to the
// local private files directory, then returns that file.
//
// - The URL is for a derived file in the "public" or "private" file
// system. This is the case for the Drupal core Image module, which
// adds the "/styles/STYLENAME/SCHEME" prefix to refer to a styled
// image derived from an image file referred to by the rest of the
// file path. If the styled image doesn't exist yet then the Image
// module's file download controller generates the image. For the
// "public" file system, once the styled image is created it is
// thereafter delivered directly by the web server. For the "private"
// file system, this prefixed URL is always used and needs to go to
// the Image module's file download controller to get the image and
// return it.
//
// The prefix is important, then, because it indicates how the file
// should be retrieved. For this module, all file downloads are routed
// to a custom file download controller in order to do access controls.
// The prefix, then, tells that download controller what to do after
// checking for access - is the file delivered directly or is another
// download controller invoked (e.g. Image's)?
$prefix = mb_substr($path, 0, $pos);
// Trim off possible last slash.
$lastSlashPos = mb_strrpos($prefix, '/');
if ($lastSlashPos !== FALSE && $lastSlashPos === mb_strlen($prefix) - 1) {
$prefix = mb_substr($prefix, 0, $lastSlashPos);
}
if ($scheme === 'public' && $prefix === $streamDirectory) {
// Prefix is redundant.
$prefix = FALSE;
}
elseif ($scheme !== 'public' && $prefix === 'system/files') {
// Prefix is redundant.
$prefix = FALSE;
}
else {
$prefix = '/' . $prefix;
}
}
//
// Get entity ID
// -------------
// The part of the path after the FILE_DIRECTORY contains a sequence of
// subdirectory names, and a final file name, that form a zero-padded
// 20-digit number for the File object entity ID.
$filePath = mb_substr($path, $pos);
$entityId = ManageFileSystem::getFileEntityId($filePath);
if ($entityId === FALSE) {
// Fail. The rest of the file path *did not* contain an entity ID.
// This should not happen and indicates that the original URI was
// malformed. There's nothing more we can do.
return;
}
//
// Return URI for file download controller
// ---------------------------------------
// Return a path that leads to the module's file download controller,
// which handles access control. The path has the form
// "ROUTE_DOWNLOADFILE/ENTITY_ID?prefix=PREFIX", where:
//
// - ROUTE_DOWNLOADFILE is the path for the route to the file download
// controller.
//
// - ENTITY_ID is the File entity ID parsed out of the path above.
//
// - PREFIX (if present) is the portion of the URL preceding this module's
// FILE_DIRECTORY.
//
// It is possible that URL created earlier from the URI also has query
// and fragment components.
//
// Build query components.
$query = '';
if (isset($parsedUrl['query']) === TRUE) {
$query = '?' . urldecode($parsedUrl['query']);
}
if ($prefix !== FALSE) {
// The path prefix is defined by whatever module added the prefix.
// The Drupal core Image module, for instance, adds one of these
// two prefixes:
// - /styles/STYLENAME/SCHEME for public files.
// - /system/files/styles/STYLENAME/SCHEME for private files.
//
// Other modules may have their own prefixes of arbitrary length
// and structure. We need to pass the prefix on to the file download
// controller.
if (empty($query) === TRUE) {
$query = '?';
}
else {
$query .= '&';
}
$query .= Constants::ROUTE_DOWNLOADFILE_PREFIX . '=' . $prefix;
}
// Build fragment component, if any.
$fragment = '';
if (isset($parsedUrl['fragment']) === TRUE) {
$fragment = '#' . urldecode($parsedUrl['fragment']);
}
// Build the URL, without query and fragment.
$url = Url::fromRoute(
Constants::ROUTE_DOWNLOADFILE,
[
'file' => $entityId,
],
[
'absolute' => TRUE,
]);
// The URL created above can be converted to something to return in two
// ways:
// - getInternalPath() returns the path part only.
// - toUriString() returns a URI with "route:ROUTENAME" in front.
//
// The latter URI, however, causes general confusion in later Drupal
// processing and the route never gets turned into a full URL for a page,
// and therefore never causes the browser to invoke the URL. Fail.
//
// We therefore need to use getInternalPath(). Except the path does not
// include any query or fragment parts. This is added here.
$uri = $url->getInternalPath() . $query . $fragment;
}
/**
* Implements hook_file_download().
*
* This hook is supposed to be called by Drupal core or custom download
* controllers when a file is requested from the "private" file system.
* This is known to be true for the following cases:
*
* - Drupal core's FileDownloadController class in the "system" module.
* - Drupal core's ImageStyleDownloadController class in the "image" module.
* - This module's FileDownload class.
*
* Drupal core's FileDownloadController invokes this hook for any valid URI
* scheme (i.e. "private", "public", others). ImageStyleDownloadController
* and this module's FileDownload only invoke this hook for the "private"
* file system.
*
* This hook is expected to respond in one of these ways:
*
* - Return NULL to indicate the file URI is not relevant to this function.
*
* - Return -1 to indicate that the file URI is relevant and that access
* is denied.
*
* - Return an associative array of HTTP header key-value pairs for use if
* the file is returned to a browser.
*
* If none of the installed hooks recognize a file and respond with HTTP
* headers, then the Drupal core FileDownloadController responds with an
* access denied.
*
* @see \Drupal\foldershare\Controller\FileDownload::download()
* @see foldershare_file_url_alter()
*/
function foldershare_file_download($uri) {
//
// Map URI to File
// ---------------
// Find the File object associated with the URI, if there is one. Since
// this hook is called for every file to download (including CSS and JS
// files when using a private file system), it is important that it
// run quickly. We therefore use two steps:
//
// - Step 1 checks if the file URI contains this module's FILE_DIRECTORY.
// If it does not, then it cannot be a file we need to handle.
//
// - Step 2 queries the database for the File entity with this URI. This
// takes longer but yields the File entity we need to handle.
$pos = mb_strpos($uri, ManageFileSystem::FILE_DIRECTORY);
if ($pos === FALSE) {
// Fail. The module's file directory name is not in the path. Ignore it.
return NULL;
}
$files = \Drupal::entityTypeManager()
->getStorage('file')
->loadByProperties(['uri' => $uri]);
if (count($files) === 0) {
// Fail. No file found. This is common and occurs, for instance, when the
// Drupal core Image module invokes this hook on a serived image
// (e.g. one with an image style applied, such as for thumbnails). In
// such cases, the URI is for a stored file, but not for a File entity.
//
// If there is no File entity, then this URI cannot be for a file under
// management by this module. Do nothing.
return NULL;
}
// Some database servers may use a case-insensitive comparison, so the
// query may have found multiple matches. Find the correct one, if any.
foreach ($files as $item) {
if ($item->getFileUri() === $uri) {
$file = $item;
break;
}
}
if (isset($file) === FALSE) {
// Fail. No file found. While the database query found a match, it did so
// on the wrong case. There isn't an exact match for the given URI and
// therefore there is no associated File entity and this URI is not for
// a file under management by this module. Do nothing.
return NULL;
}
//
// Check permission
// ----------------
// Look for the FolderShare entity that wraps this File entity. This issues
// a database query.
$wrapperId = FolderShare::findFileWrapperId($file);
if ($wrapperId === FALSE) {
// Fail. There is none. While the URI is for a File entity, the File
// entity is not one referenced by a FolderShare entity and it is,
// therefore, not a file under management by this module. Do nothing.
return NULL;
}
// Load the wrapper entity. This issues a database query.
$wrapper = FolderShare::load($wrapperId);
if ($wrapper === NULL) {
// Fail. Something has become corrupted! The above query found the ID of a
// FolderShare entity that wraps the file, but now when the entity is
// loaded, the load fails. This can only happen if the entity has been
// deleted between the previous call and this one. Access denied.
return (-1);
}
// Check for view access to the FolderShare entity that manages this file.
if ($wrapper->access('view') === FALSE) {
// Fail. Access denied.
return (-1);
}
//
// Return headers
// --------------
// The URI references a File entity under management by this module,
// and the user has 'view' access. Return headers that include the
// user-visible name for the file, it's MIME type, etc.
//
// Including the user-visible file name in the header is essential.
// The file name in the URI is an internal numeric name (see
// ManageFileSystem::getFileUri()). If the user tries to save the delivered
// file, they'll get that numeric name instead of the user-visible name
// if we didn't include the correct name in the HTTP header.
return [
// Use the File object's MIME type.
'Content-Type' => Unicode::mimeHeaderEncode($file->getMimeType()),
// Use the human-visible file name.
'Content-Disposition' => 'filename="' . $file->getFilename() . '"',
// Use the saved file size, in bytes.
'Content-Length' => $file->getSize(),
// Don't cache the file because permissions and content may change.
'Pragma' => 'no-cache',
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
'Expires' => '0',
'Accept-Ranges' => 'bytes',
];
}
