Skip to content

Instantly share code, notes, and snippets.

@hakre
Created January 2, 2012 21:41
Show Gist options
  • Save hakre/1552239 to your computer and use it in GitHub Desktop.
Save hakre/1552239 to your computer and use it in GitHub Desktop.
Wordpress login to download uploaded files
<?php
/*
* dl-file.php
*
* Protect uploaded files with login.
*
* @link http://wordpress.stackexchange.com/questions/37144/protect-wordpress-uploads-if-user-is-not-logged-in
*
* @author hakre <http://hakre.wordpress.com/>
* @license GPL-3.0+
* @registry SPDX
*/
require_once('wp-load.php');
is_user_logged_in() || auth_redirect();
list($basedir) = array_values(array_intersect_key(wp_upload_dir(), array('basedir' => 1)))+array(NULL);
$file = rtrim($basedir,'/').'/'.str_replace('..', '', isset($_GET[ 'file' ])?$_GET[ 'file' ]:'');
if (!$basedir || !is_file($file)) {
status_header(404);
die('404 &#8212; File not found.');
}
$mime = wp_check_filetype($file);
if( false === $mime[ 'type' ] && function_exists( 'mime_content_type' ) )
$mime[ 'type' ] = mime_content_type( $file );
if( $mime[ 'type' ] )
$mimetype = $mime[ 'type' ];
else
$mimetype = 'image/' . substr( $file, strrpos( $file, '.' ) + 1 );
header( 'Content-Type: ' . $mimetype ); // always send this
if ( false === strpos( $_SERVER['SERVER_SOFTWARE'], 'Microsoft-IIS' ) )
header( 'Content-Length: ' . filesize( $file ) );
$last_modified = gmdate( 'D, d M Y H:i:s', filemtime( $file ) );
$etag = '"' . md5( $last_modified ) . '"';
header( "Last-Modified: $last_modified GMT" );
header( 'ETag: ' . $etag );
header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + 100000000 ) . ' GMT' );
// Support for Conditional GET
$client_etag = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? stripslashes( $_SERVER['HTTP_IF_NONE_MATCH'] ) : false;
if( ! isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) )
$_SERVER['HTTP_IF_MODIFIED_SINCE'] = false;
$client_last_modified = trim( $_SERVER['HTTP_IF_MODIFIED_SINCE'] );
// If string is empty, return 0. If not, attempt to parse into a timestamp
$client_modified_timestamp = $client_last_modified ? strtotime( $client_last_modified ) : 0;
// Make a timestamp for our most recent modification...
$modified_timestamp = strtotime($last_modified);
if ( ( $client_last_modified && $client_etag )
? ( ( $client_modified_timestamp >= $modified_timestamp) && ( $client_etag == $etag ) )
: ( ( $client_modified_timestamp >= $modified_timestamp) || ( $client_etag == $etag ) )
) {
status_header( 304 );
exit;
}
// If we made it this far, just serve the file
readfile( $file );
@jmeile
Copy link

jmeile commented Jun 23, 2021

I just read a bit that StackExchange post where the original author of this Gist posted. He wrote:

Depending on how much traffic you have, it could be wise to better integrate this with your server, e.g. X-Accel-Redirect or X-Sendfile headers.

So, I found that there is a module that implements "X-Sendfile", which does what we need here: instead of handling the media files through php, they will be forwarded to Apache, which is more efficient.

The only problem: although that module works with Apache 2.4, it seems to be deprecated (or at least that's what people says). I found that this might be because it is not a standard module, it was designed initially for 2.2, and it hasn't been updated since long time ago; however, it seems that the last version, from March 2012, solved issues, which made it working for Apache 2.4; I installed that version and got it working.

Anyway, for the interested, here is how this is done:

  1. If you are using Ubuntu 20.x or any other Debian based distro, then install "apache2-dev" as follows:
apt install apache2-dev

This will give you access to the command: "apxs", which is used to compile and install the mod_xsendfile module. If you are using Windows or any other distro, then you will have to research how to install this by yourself. There are some Visual Studio and binary files here: https://github.com/nmaier/mod_xsendfile

  1. Now get the module file: "mod_xsendfile.c" from here: https://github.com/nmaier/mod_xsendfile
    The documentation can be read here: http://htmlpreview.github.io/?https://github.com/nmaier/mod_xsendfile/blob/master/docs/Readme.html

  2. Then install it by running:

apxs -cia mod_xsendfile.c
  1. Next, you need to tell Apache, which folders are allowed to use "X-SendFile". This must be done on your Apache VirtualHost, eg:
<VirtualHost *:443>
  ServerName your_domain.com

  ... Other directives come here

  XSendFilePath /var/www/html/your_wp_root/wp-content/uploads/sub_folder
  # Use this for protecting all WP uploads
  # XSendFilePath /var/www/html/your_wp_root/wp-content/uploads
</VirtualHost>

Please note that if "/var/www/html/your_wp_root" is a symlink, then you need to include the real path because php won't use the symlink.

  1. Then on the .htaccess file add:
# BEGIN DL-FILE.PHP ADDITION
# Enables XSendFile for dl-file.php
<Files dl-file.php>
  XSendFile on
</Files>
# The rest is equal to my previous post
RewriteCond %{REQUEST_FILENAME} -s
# Protecs only wp-content/uploads/sub_folder. Add more RewriteRules as needed
RewriteRule ^wp-content/uploads/(sub_folder/.*)$ dl-file.php?file=$1 [QSA,L]
# Protect all inside wp-content/uploads, comment it out if your website is fully private
# Please note that the order matters, so, that's why the previous rewrite must go before
# RewriteRule ^wp-content/uploads/(.*)$ dl-file.php?file=$1 [QSA,L]
# END DL-FILE.PHP ADDITION

The only difference on those lines and my previous code is that there you are enabling mod_xsendfile for dl-file.php.

  1. Then the updated dl-file.php:
<?php
/*
 * dl-file.php
 *
 * Protect uploaded files with login.
 *
 * @link http://wordpress.stackexchange.com/questions/37144/protect-wordpress-uploads-if-user-is-not-logged-in
 * @link https://gist.github.com/hakre/1552239
 *
 * @author hakre <http://hakre.wordpress.com/>
 * @license GPL-3.0+
 * @registry SPDX
 *
 * Includes fixes proposed here:
 * https://gist.github.com/hakre/1552239#gistcomment-1439472
 *
 * And here:
 * https://gist.github.com/hakre/1552239#gistcomment-1851131
 * https://gist.github.com/austinginder/927cbc11ca394e713430e41c2dd4a27d
 *
 * And here:
 * https://gist.github.com/hakre/1552239#gistcomment-2735755
 *
 * .htaccess similar to this one:
 * https://gist.github.com/hakre/1552239#gistcomment-1313010
 *
 */

//Fix to allow for large files without exhausting PHP memory:
//https://gist.github.com/hakre/1552239#gistcomment-1851131
//https://gist.github.com/austinginder/927cbc11ca394e713430e41c2dd4a27d
if (ob_get_level()) {
  ob_end_clean();
}

//Fix proposed here
//https://gist.github.com/hakre/1552239#gistcomment-1439472
ob_start();
require_once('wp-load.php');
require_once ABSPATH . WPINC . '/formatting.php';
require_once ABSPATH . WPINC . '/capabilities.php';
require_once ABSPATH . WPINC . '/user.php';
require_once ABSPATH . WPINC . '/meta.php';
require_once ABSPATH . WPINC . '/post.php';
require_once ABSPATH . WPINC . '/pluggable.php';
wp_cookie_constants();
ob_get_clean();
ob_end_flush();

//Added is_user_member_of_blog as the multi-site fix recomended it
is_user_member_of_blog() && is_user_logged_in() || auth_redirect();

//Allowed users and roles to access protected folders.
//The main key always represent a subfolder of wp-content/uploads. Please note
//that the key: 'default' represent the folder: wp-content/uploads.
//You can set here the allowed users and roles for each folder. Please note that
//the 'administrator' role will be always allowed to access all the files
//Please also note that at this point, all users are already authenticated because
//they passed the cointraint:
//is_user_member_of_blog() && is_user_logged_in() || auth_redirect();
//So, if you want to say that all roles are allowed to access the website, then
//add the 'all' role.
$folder_permissions['default'       ]['roles'] = array('all');
//Replace "sub_folder" with the name of your folder
//$folder_permissions['sub_folder']['users'] = array('my_user');
$folder_permissions['sub_folder']['roles'] = array('subscriber', 'editor');

list($basedir) = array_values(
                   array_intersect_key(wp_upload_dir(), array('basedir' => 1))
                 ) + array(NULL);

//Fix for Multisite:
//https://gist.github.com/hakre/1552239#gistcomment-2735755
// Get the last occurence of the /sites/ part of the url
$sitepos = strrpos($basedir, '/sites/');
// Make sure the /sites/{int} is there
if ($sitepos !== false) {
  // Remove the /sites/{int}
  $basedir = preg_replace( '~\/sites\/\d+$~', '', $basedir );
}

$file =  isset( $_GET['file'] ) ? $_GET['file'] : '';
$file_root_folder = substr($file, 0, strpos($file, '/'));
if (!array_key_exists($file_root_folder, $folder_permissions)) {
  //This means it is either the uploads folder or any other subfolder not listed
  //here, the default will be assumed
  $file_root_folder = 'default';
}

$folder_permission = $folder_permissions[$file_root_folder];
$folder_users = array_key_exists('users', $folder_permission) ?
                $folder_permission['users'] : array();
$folder_roles = array_key_exists('roles', $folder_permission) ?
                $folder_permission['roles'] : array();

$auth_user = wp_get_current_user();
$auth_user_login = $auth_user->user_login;
$auth_user_role = $auth_user->roles[0];

$is_user_allowed = in_array($auth_user_login, $folder_users);
$is_role_allowed = ($auth_user_role == 'administrator') ||
                   in_array($auth_user_role, $folder_roles) ||
                   in_array('all', $folder_roles);

if (!$is_user_allowed && !$is_role_allowed) {
  status_header(403);
  die('403 &#8212; Access denied.');
}

$file = rtrim($basedir, '/') . '/' .
        (isset( $_GET['file'] ) ? $_GET['file'] : '');
$file = realpath($file);

if ($file === FALSE || !$basedir || !is_file($file)) {
  status_header(404);
  die('404 &#8212; File not found.');
}

if (strpos($file, $basedir) !== 0) {
  status_header(403);
  die('403 &#8212; Access denied.');
}

$mime = wp_check_filetype($file);
if(false === $mime[ 'type' ] && function_exists('mime_content_type'))
  $mime[ 'type' ] = mime_content_type( $file );

if ($mime['type'])
  $mimetype = $mime['type'];
else
  $mimetype = 'image/' . substr($file, strrpos($file, '.') + 1);

#X-SendFile will be enabled here
header("X-Sendfile: $file");

header( 'Content-Type: ' . $mimetype ); // always send this
if (false === strpos($_SERVER['SERVER_SOFTWARE'], 'Microsoft-IIS'))
  header('Content-Length: ' . filesize($file));

$last_modified = gmdate( 'D, d M Y H:i:s', filemtime($file));
$etag = '"' . md5($last_modified) . '"';
header("Last-Modified: $last_modified GMT");
header('ETag: ' . $etag);
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 100000000) . ' GMT');

// Support for Conditional GET
$client_etag = isset($_SERVER['HTTP_IF_NONE_MATCH'])?
                 stripslashes($_SERVER['HTTP_IF_NONE_MATCH']) : false;

if (! isset($_SERVER['HTTP_IF_MODIFIED_SINCE']))
  $_SERVER['HTTP_IF_MODIFIED_SINCE'] = false;

$client_last_modified = trim( $_SERVER['HTTP_IF_MODIFIED_SINCE'] );
// If string is empty, return 0. If not, attempt to parse into a timestamp
$client_modified_timestamp = $client_last_modified?
                               strtotime($client_last_modified) : 0;

// Make a timestamp for our most recent modification...
$modified_timestamp = strtotime($last_modified);

if (($client_last_modified && $client_etag)?
       (($client_modified_timestamp >= $modified_timestamp) && ($client_etag == $etag))
     : (( $client_modified_timestamp >= $modified_timestamp) || ($client_etag == $etag))
   ) {
  status_header( 304 );
  exit;
}

So, the only two differences are:

  • The inclusion of:
header("X-Sendfile: $file");

to instruct apache to handle the media file

  • and the deletion of:
readfile( $file );

which is no longer necessary.

I guess this will make serving the files faster. You may try both and see if there is a difference.

And finally, I found that Apache 2.4 included the directive: "EnableSendfile"; however, I don't know how to use it. Perhaps you even don't need that old mod_xsendfile module.

Best regards
Josef

@JPOak
Copy link

JPOak commented Jun 23, 2021

@jmeile The Cookie method is much more straightforward. However, from what I understand is not very secure and easily hackable. People can correct me if I am wrong.

Thanks for your alternative method. I am concerned about speed. I noticed that my galleries are loading slower. Maybe it's just in my mind, but worth me checking into it. I would love to find a solution that is stable.

Thanks again.

@hakre
Copy link
Author

hakre commented Jul 12, 2021

@jmeile: Yes, this info is dated. Please see a PHP application's documentation that also has dedicated documentation (no idea if Wordpress fails the docs here, have not checked) https://www.dokuwiki.org/config:xsendfile . It falls into the domain of server configuration, therefore I would not expand on it. But I'm happy if you or others share in comments here.

@jmeile: Yes, it depends and the rule of thumb: Insecure. I would consider a specific cookie method also as server configuration and would not extensively comment on it myself here.

@Dalias96
Copy link

Thanks for this solution, its great!

I had to make changes in favor of supporting custom folder in wp-content/uploads dir

So, if you want to use subfolder, your .htaccess should look like this:

RewriteCond %{REQUEST_FILENAME} -s
RewriteRule ^wp-content/uploads/subfolder/(.*)$ dl-file.php?file=$1 [QSA,L]

where subfolder stands for your custom folder.

Next, you should add variable on the very begining of dl-file:

$subfolder = 'subfolder/';

and then change line 20 (from original file, if you add var before, line number will change) to:

$file = rtrim($basedir,'/').'/'.$subfolder.''.str_replace('..', '', isset($_GET[ 'file' ])?$_GET[ 'file' ]:'');

@dabock
Copy link

dabock commented Sep 28, 2021

Thanks for this solutions and all the contributions.
The (adapted) code still works.

If you have a ngnix + apacher server, you may need to do a ngnix redirect (instead of the .htaccess redirect).

For me, the following code made it work (I only restrict access to the folder /uploads/subfolder)

rewrite ^/wp-content/uploads/subfolder/(.*)$ /dl-file.php?file=$1 permanent;

@JPOak
Copy link

JPOak commented Sep 29, 2021

This is a great gist, which I have on follow. I am actually surprised that Direct Access Protection isn't discussed more in the Wordpress community. A lot of people mistake the many "Members Only" plugins providing this, but they do not. There is a third party service, but it is pretty expensive for smaller private sites.

I implemented this solution and it did work, but it slowed down certain aspects of the site. For example galleries loaded much slower and some images failed to be served. I turned off lazy load that did improve some things, but still not great.

@anthony-curtis
Copy link

anthony-curtis commented Sep 30, 2022

There were a few items needed to make this work on a shared hosting environment:

Make sure RewriteEngine On was at top of the .htaccess file, or somewhere before your htaccess changes like so:

RewriteEngine On

RewriteCond %{REQUEST_FILENAME} -s
RewriteRule ^wp-content/uploads/(.*)$ dl-file.php?file=$1 [QSA,L]

Disable any special CDN in your site tools or cPanel, and purge server cache between changes for sanity's sake.

Otherwise, the original htaccess and dl-file.php worked perfectly.

@Tsjippy
Copy link

Tsjippy commented Nov 3, 2022

I have no clue why this doesn't work:
.htaccess:

# BEGIN THIS DL-FILE.PHP ADDITION
RewriteEngine  on
RewriteRule ^wp-content/uploads/private/(.*)$ dl-file.php?file=$1 [QSA,L]
# END THIS DL-FILE.PHP ADDITION

Then the normal dl-file.php but with this on top:

<?php
 file_put_contents(__DIR__.'/dl-file.log', $_GET['file']."\n", FILE_APPEND);

This somehow only works for .jpe files. I tried a .jpg, .jpeg, .doc file and they are not triggered by the RewriteRule. Why?

@joelseneque
Copy link

I can't get this to work with my Wordpress site.
The redirect to Login works fine but when displaying the image it shows a blank file

@meelad2
Copy link

meelad2 commented Jun 20, 2023

I can't get this to work with my Wordpress site. The redirect to Login works fine but when displaying the image it shows a blank file

The problem was an unwanted blank line at the first of file.
Add these codes before the last line(67):

ob_clean();
flush();

Source: https://stackoverflow.com/a/8041597

@davidstaab
Copy link

I used this thread and a couple of other sources to implement a "media access control" solution for WordPress. My code draws on what I learned from this gist, so I want to share what I made. I'm a novice PHP developer and would love feedback and suggestions from this community! wp-mac, a Media Access Control solution for WordPress sites

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment