'Protected file download', 'page callback' => 'protected_download', 'access callback' => TRUE, 'type' => MENU_CALLBACK, ); return $items; } /** * Implements hook_stream_wrappers(). */ function protected_download_stream_wrappers() { // Only register the stream wrapper if a file path has been set. if (variable_get('protected_download_file_path_protected', '')) { return array( 'protected' => array( 'name' => t('Protected files'), 'class' => 'ProtectedDownloadStreamWrapper', 'description' => t('Local files served by Drupal which are protected by a HMAC.'), 'type' => STREAM_WRAPPERS_LOCAL_NORMAL, ), ); } } /** * Generates a HMAC protected URL for the given file. * * @param string $uri * The file uri, e.g., private://ticket-12345.pdf. * @param int $expire * (Optional) The expiry date as a UNIX timestamp. */ function protected_download_create_url($uri, $expire = NULL) { $result = FALSE; $scheme = file_uri_scheme($uri); $target = file_uri_target($uri); if ($scheme !== FALSE && $target !== FALSE) { if (!isset($expire)) { $settings = protected_download_settings($scheme); $expire = protected_download_expire_create_from_settings($settings); } $expire_string = dechex($expire); $hmac = protected_download_hmac_base64($uri, $expire_string); $menu_item_path = variable_get('protected_download_menu_item_path', PROTECTED_DOWNLOAD_DEFAULT_MENU_ITEM_PATH); $path_elements = array($menu_item_path, $scheme, $expire_string, $hmac, $target); $result = url(implode('/', $path_elements), array('absolute' => TRUE)); } return $result; } /** * Verifies an incoming request and delivers a HMAC protected file. * * @param string $scheme * The URI scheme. * @param string $expire * A hex-encoded UNIX timestamp. * @param string $hmac * The message authentication code. * @param string ...$dir * Zero or more directory components. * @param string $basename * The target filename. */ function protected_download() { $path_components = func_get_args(); // Extract scheme, expire and hmac from the beginning. $scheme = array_shift($path_components); $expire = array_shift($path_components); $hmac = array_shift($path_components); // Construct the internal URI and attempt to deliver the file. $uri = $scheme . '://' . implode('/', $path_components); if (protected_download_validate($uri, $expire, $hmac)) { if ($headers = protected_download_headers($uri, hexdec($expire))) { // Send all the headers which are required for both types of responses // handled here (i.e. 200 and 304). drupal_send_headers($headers['cache'], TRUE); if ( isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $headers['cache']['ETag'] && $_SERVER['HTTP_IF_MODIFIED_SINCE'] === $headers['entity']['Last-Modified'] ) { // Send a 304 if cache revalidation succeeded. header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified'); drupal_exit(); } else { // Otherwise send remaining entity headers and deliver the file. // Note, file_transfer() never returns. file_transfer($uri, $headers['entity']); } } elseif ($path_components[0] === 'styles' && ($style = image_style_load($path_components[1]))) { // Attempt to generate and deliver an image style. // Note, image_style_deliver() never returns if a response has been // generated. $parameters = array_merge(array($style), array_slice($path_components, 2)); return call_user_func_array('image_style_deliver', $parameters); } } return MENU_NOT_FOUND; } /** * Validates a HMAC and return TTL. * * @param string $uri * The file uri, e.g., private://ticket-12345.pdf. * @param string $expire * A hex-encoded UNIX timestamp. * @param string $hmac * The message authentication code. * * @return bool * TRUE if this download link is still valid, FALSE otherwise. */ function protected_download_validate($uri, $expire, $hmac) { $result = FALSE; if (strlen($uri) && strlen($expire) && strlen($hmac)) { $calculated_hmac = protected_download_hmac_base64($uri, $expire); $scheme = file_uri_scheme($uri); $result = _protected_download_hash_equals($calculated_hmac, $hmac) && REQUEST_TIME <= hexdec($expire) && file_stream_wrapper_valid_scheme($scheme); } return $result; } /** * Calculates a base-64 encoded, URL-safe sha-256 HMAC. * * @param string $uri * The file uri, e.g., private://ticket-12345.pdf. * @param string $expire * A hex-encoded UNIX timestamp. * * @return string * The message authentication code. */ function protected_download_hmac_base64($uri, $expire) { $key = variable_get('protected_download_private_key', drupal_get_private_key() . drupal_get_hash_salt()); return drupal_hmac_base64($uri . $expire, $key); } /** * Return headers for a file download. * * @param string $uri * The file uri, e.g., private://ticket-12345.pdf. * @param int $expire * The expiry date as a UNIX timestamp. * * @return array|FALSE * Headers for the file, suitable for passing to file_transfer(). */ function protected_download_headers($uri, $expire) { $result = FALSE; $stat = @stat($uri); if (is_array($stat)) { // Determine the cache control directive configured for this URI scheme. // Defaults to 'private' for private files and to 'public' for any other // scheme. $settings = protected_download_settings(file_uri_scheme($uri)); $cache_control = $settings['cache_control']; if (strlen($cache_control)) { $cache_control .= ', '; } $filemtime = $stat['mtime']; $filesize = $stat['size']; // Use the same ETag format as Apache. $etag = '"' . dechex($stat['ino']) . '-' . dechex($filesize) . '-' . dechex($filemtime) . '"'; $result = array( 'cache' => array( 'Cache-Control' => $cache_control . 'max-age=' . (string) ($expire - REQUEST_TIME), 'ETag' => $etag, 'Expires' => gmdate(DATE_RFC7231, $expire), ), 'entity' => array( 'Content-Length' => $filesize, 'Content-Type' => mime_header_encode(file_get_mimetype($uri)), 'Last-Modified' => gmdate(DATE_RFC7231, $filemtime), ), ); } return $result; } /** * Returns the settings for the given URI scheme. * * @param string $scheme * The URI scheme. * * @return array * Associated array of settings for the given scheme. */ function protected_download_settings($scheme) { $default_cache_control = ($scheme === 'private') ? 'private' : 'public'; $cache_control = trim(variable_get('protected_download_cache_control_' . $scheme, $default_cache_control)); $default_ttl_mode = ($scheme === 'private') ? 'exact' : 'aligned'; $ttl_mode = trim(variable_get('protected_download_ttl_mode_' . $scheme, $default_ttl_mode)); $aligned_min_ttl = (int) variable_get('protected_download_aligned_min_ttl_' . $scheme, PROTECTED_DOWNLOAD_DEFAULT_ALIGNED_MIN_TTL); $aligned_max_ttl = (int) variable_get('protected_download_aligned_max_ttl_' . $scheme, PROTECTED_DOWNLOAD_DEFAULT_ALIGNED_MAX_TTL); $exact_ttl = (int) variable_get('protected_download_exact_ttl_' . $scheme, PROTECTED_DOWNLOAD_DEFAULT_EXACT_TTL); return array( 'cache_control' => $cache_control, 'ttl_mode' => $ttl_mode, 'aligned_min_ttl' => $aligned_min_ttl, 'aligned_max_ttl' => $aligned_max_ttl, 'exact_ttl' => $exact_ttl, ); } /** * Returns an expiry date which is generated according to the given settings. * * @param array $settings * The settings as returned from protected_download_settings(). * @param int $current_time * (Optional) The current time as a UNIX timestamp. * * @return int * Returns a UNIX timestamp representing the aligned expiry date. */ function protected_download_expire_create_from_settings($settings, $current_time = REQUEST_TIME) { switch ($settings['ttl_mode']) { case 'aligned': $expire = protected_download_expire_create_aligned($settings['aligned_min_ttl'], $settings['aligned_max_ttl'], $current_time); break; case 'exact': $expire = $current_time + $settings['exact_ttl']; break; default: $expire = $current_time; break; } return $expire; } /** * Returns an expiry date which is aligned according to fixed time frames. * * @param int $min_ttl * The minimum time to live. Defaults to one day. * @param int $max_ttl * The maximum time to live. Defaults to one week plus one day. * @param int $current_time * (Optional) The current time as a UNIX timestamp. * * @return int * Returns a UNIX timestamp representing the aligned expiry date. */ function protected_download_expire_create_aligned($min_ttl = NULL, $max_ttl = NULL, $current_time = REQUEST_TIME) { $delta_ttl = $max_ttl - $min_ttl; return (int) ($min_ttl + $delta_ttl * ceil($current_time / $delta_ttl)); } /** * Compares strings in constant time. * * @param string $known_string * The expected string. * @param string $user_string * The user supplied string to check. * * @return bool * Returns TRUE when the two strings are equal, FALSE otherwise. */ function _protected_download_hash_equals($known_string, $user_string) { if (function_exists('hash_equals')) { return hash_equals($known_string, $user_string); } else { // Backport of hash_equals() function from PHP 5.6. // @see https://github.com/php/php-src/blob/PHP-5.6/ext/hash/hash.c#L739 if (!is_string($known_string)) { trigger_error(sprintf('Expected known_string to be a string, %s given', gettype($known_string)), E_USER_WARNING); return FALSE; } if (!is_string($user_string)) { trigger_error(sprintf('Expected user_string to be a string, %s given', gettype($user_string)), E_USER_WARNING); return FALSE; } $known_len = strlen($known_string); if ($known_len !== strlen($user_string)) { return FALSE; } // This is security sensitive code. Do not optimize this for speed. $result = 0; for ($i = 0; $i < $known_len; $i++) { $result |= (ord($known_string[$i]) ^ ord($user_string[$i])); } return $result === 0; } }