Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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 );
@Gerkinfeltser
Copy link

Gerkinfeltser commented Feb 27, 2012

This is exactly what I'd like to do with the contents of a WP upload folder on my site.
I stumbled on your solution at the wordpress.stackexchange.com but am unsure how to use the script.

You mention these two lines of code:

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

Are these lines added to a .htaccess file or somewhere else?
I apologize for this [most likely] basic question but am a php beginner.

Thanks!

@hakre
Copy link
Author

hakre commented Feb 27, 2012

Yes those Apache Directives belong into the .htaccess file of Wordpress, see as well Apache Module mod_rewrite­Docs and .htaccess Glossary Entry­Codex. You can leave comments and questions as well on How to Protect Uploads, if User is not Logged In? (Wordpress Answers).

@mrclaytorres
Copy link

mrclaytorres commented Jun 29, 2012

Hello hakre,

I found this sulotion perfect for protecting the media files on Wordpress. I ran a membership Wordpress website. I tried this solution, but the thing is, the videos on my website's pages are not playing anymore. I embedded some premium .SWF files on my pages, and wanted to protect those .SWF files from direct URL access.

Can you please suggest some solution on this?

Thank you,

enes9

@hakre
Copy link
Author

hakre commented Jun 30, 2012

Hi @Enes9, this requires additional debugging. I assume this is related to headers being send in the request or the response. Also ensure that the flash plugin of your browser sends the needed cookies. You should be able to gather more information by tracking the network connections with Firebug in Firefox or a similar tool in the browser of your choice.

If you're looking for individual support, please leave an email address.

@lifepix
Copy link

lifepix commented Aug 25, 2012

Hi hakre,

Sorry for noob question, but how would this be modified to work on WP Multisite for all the subsites? So the user would have be a subscriber of the site (rather than the network preferably) to access the files.

I noticed that my multisite .htaccess file has these rewrite rules:

RewriteRule ^files/(.+) wp-includes/ms-files.php?file=$1 [L]

RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule . index.php [L]

Thanks,
lifepix

@hakre
Copy link
Author

hakre commented Oct 12, 2012

Hi @lifepix, the solution has not been written for a multisite installation.

@beezwings
Copy link

beezwings commented Nov 16, 2012

Hi! Thanks for this. I'm a total noob and stumbled upon this. How would I change the .php file and .htaccess for the folder named, "work-files" which at the same level as "wp-content" (ie it's in the root folder). Also, can it work recursively through all the subfolders? Thanks!

@jorditost
Copy link

jorditost commented Feb 19, 2013

Hi! Thanks for this script! I have a basic (and silly) question, but which ist the better folder to place the script? I have placed it inside wp-content/uploads and I'm getting a 404 error (accessing a file directly from URL), as the file is seen as a post.

Another question: I want it just to check the uploads when accessing them by URL, but not the images attached to a post. How could I parse it? Thanks in advance!

@JeanLoureiro
Copy link

JeanLoureiro commented May 30, 2013

HI hakre,

I am trying to use your script on a subfolder inside uploads. at the moment it is '/wp-content/uploads/private/'.
The redirect script is working super well, but when I put the credentials it leads to a '404 - file not found.'

I have add 'private' in the code below, now it open the files, but you can access the file without be logged in.
$file = rtrim($basedir,'/').'/private/'.str_replace('..', '', isset($_GET[ 'file' ])?$_GET[ 'file' ]:'');

Any suggestion?

Thanks a lot

@JeanLoureiro
Copy link

JeanLoureiro commented Jun 14, 2013

Ok guys,
apparently to achieve what I need, we have to change the 'structure' of the wordpress, so I gave up on it for time consuming issue and decided to create a folder called public and do the other way around, now I am facing another problem.
My images sitting on the uploads folder will not show if I am not logged in. How can I solve that?

Kind Regards

@hakre
Copy link
Author

hakre commented Jun 15, 2013

@Braus: I see no technical limitation with the private folder you aimed for first, just ensure all paths are correct and you shouldn't have any issues. Albeit I didn't test it, so let me know if you require additional support.

@jorditost: Your 404 issues needs troubleshooting and the feature request needs customization, Wordpress doesn't differ here between uploads, so one need to add this differentiation.

@beezwings: This should be merely a configuration setting, also as the traversal is by the file-system and supported from your OS, I don't see any recursion issues here. With a little modification of the Worpdress upload path configuration, I see no technical showstoppers to just do what you want to do.

@sogen
Copy link

sogen commented Aug 23, 2013

1.- Hi, how do I use this?
2.- Where do I put it?
3.- Does it protect access to folders?

I can't get it to work just using:

/file.pdf

or

dl-file.php?file.pdf

Thanks.

@agskills
Copy link

agskills commented Sep 25, 2013

Hi,

Can I change it to redirect to login page? With auth_redirect() it redirects to home page of the website. I tried to change it to

if (!is_user_logged_in())
{
wp_redirect( wp_login_url( site_url( '/login/ ' ) )); exit;
}

But that doesnt have any effect. It just redirects to same old home page. I am doing it right?

@sgissinger
Copy link

sgissinger commented Oct 30, 2013

With WP 3.6.1 I added the following lines after require_once('wp-load.php'); otherwise is_user_logged_in() didn't work correctly in my case

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();

@stewartadam
Copy link

stewartadam commented Feb 25, 2014

I would recommend changing the start of the script to this:

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();

is_user_logged_in() ||  auth_redirect();


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

$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.');
}

This includes the fix for Wordpress 3.6.1 mentioned above (thanks sgissinger!) as well as checks the true filesystem path of the file using realpath() in order to prevents directory traversal attacks. The unmodified script has a simpler version of this in place, but handles it via string replacement - realpath() ensures that any symbolic links and references are resolved first.

@christiansyl
Copy link

christiansyl commented Mar 6, 2014

After a lot of painful hours I found out that the WP include (require_once('wp-load.php');) is adding a linefeed in the output. As a result binary files are seen as "corrupted", e.g. my png files starts with a linefeed.

I tried to use the PHP OB functions to trap this extra linefeed but it is not working, i.e. the linefeed is still there.

Anyone faced the same challenge in the past? Anyone with a potential solution?

Thanks,

Christian

@dsantuc
Copy link

dsantuc commented Mar 20, 2014

How about adding an action hook after line 16 (e.g. "do_action('dl_file_before_download');", or somesuch) to allow plugins to register callbacks for authority checks?

@BrianKopp
Copy link

BrianKopp commented Oct 6, 2014

If you're a PHP/.htaccess noob like me, and you're trying to implement this, please read before attempting to implement. It may save you some head banging and throat punching...

  1. dl-file.php file goes in your public_html directory (the same place as your wp-config.php files and such)
  2. Add the additional lines posted by stewartadam for new version of WordPress in dl-file.php
  3. The .htaccess file you should be modifying is the one in the above directory.
  4. This solution locks EVERYTHING in your wp-content/uploads directory. This was the issue I was facing and was having misunderstandings with.

I'd imagine most people don't want their entire wp-content/uploads directory to be locked. Header images, images in public posts, etc. are all stored in this wp-content/uploads directory by default. If you're wanting to have a dedicated "membersonly" folder inside your wp-content/uploads directory, leaving alone existing content that you don't want to restrict, you'll have to make some modifications to your .htaccess file.

Here's my .htaccess:

# Use PHP5.4 as default
AddHandler application/x-httpd-php54 .php

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /~MyWebsiteFolder/
RewriteRule ^index\.php$ - [L]

# BEGIN THIS DL-FILE.PHP ADDITION
RewriteCond %{REQUEST_URI} ^.*wp-content/uploads/membersonly/.*
RewriteRule ^wp-content/uploads/(membersonly/.*)$ dl-file.php?file=$1 [QSA,L]
# END THIS DL-FILE.PHP ADDITION

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /~MyWebsiteFolder/index.php [L]
</IfModule>

# END WordPress

Options -Indexes

Notice:

  • Options -Indexes to prevent directory browsing.
  • The location is important here. It must come before the two lines of RewriteCond and one line of RewriteRule. These three lines send all file requests to index.php, which is not what we want. Putting THIS DL-FILE.PHP ADDITION before those three lines ensures dl-file.php is getting called when it should be.
  • In the RewriteRule ^wp-content/uploads/(membersonly/.*)$ line, it's important if you want a specific directory to be protected that you encapsulate it in parenthesis like that. What is in parenthesis gets sent to dl-file.php. This basically says, any calls to anything inside membersonly get sent to dl-file.php, with the 'file' variable being set to membersonly/SomeFile.

Hopefully this helps at least one person save a few unnecessary hours of anger and frustration...

@simonbernard
Copy link

simonbernard commented Oct 8, 2014

Did anyone face the same problem as christiansyl? I'm facing the problem, that the image which is send by dl-file.php is somehow invalid and I can't find out the reason.

I can also see, that there is a newline which disapears if I comment the require_once('wp-load.php') line.

Any idea how this could be solved? I tried a ob_end_clean() just before the readfile() but it has no effect.

Thanks a lot!

@deas
Copy link

deas commented Oct 13, 2014

I am seeing the same thing @simonbernard

@deas
Copy link

deas commented Oct 13, 2014

For me, it was a closing ?> in wp-config.php. Removed it and now it works.

@youngmicroserf
Copy link

youngmicroserf commented Oct 14, 2014

Hi -

I was wondering if anyone has tried this with a multisite installation since ms-files was removed. Previously, it was apparently sufficient to change ms-files.php so it wouldn't use SHORINIT and add the check. I wonder how to add the dl-file.php back to to rewrite rules given how the structure of the blog/files has changed since WP 3.5. Any pointers? Thanks!

@nilpix
Copy link

nilpix commented Dec 3, 2014

Thank you very much at all but more to @BrianKopp for 'noob's explaination'! :)

Only I have one problem. I had to comment de following:

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

Because although I was logged-in, the website throws me a "access denied".

Did I right? It works so far. :D

EDIT

IMPORTANT - WARNING:
Please, change: RewriteCond %{REQUEST_URI} ^.*wp-content/uploads/membersonly/.*
for: RewriteCond %{REQUEST_FILENAME} -s is there a security hole in first expression.

Question
Why can I do if I want to download too big files like 5-8GB? This PHP code throws me a 404 error :-|

@melissagillard
Copy link

melissagillard commented Jan 22, 2015

I want to use this code to block access to a directory outside of my WordPress install. How do I modify the PHP file code to make it load the URL (not an image file like the code was written for)? When I use the dl-file.php code as-is, the URL loads as 404 I'm just not skilled in writing PHP to know how to modify it to say 'load URL'. Any thoughts?

Thanks.

@hexagongirl
Copy link

hexagongirl commented Feb 26, 2015

I was also having the problem as @christiansyl and @simonbernard. I think I've solved it by using output buffering - as follows:

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();
$discard = ob_get_clean();

The other thing to note when testing this is that the browser will normally cache things - so clear history before testing each change.

@scot90
Copy link

scot90 commented Apr 14, 2015

I can't seem to get this to work. I would be happy just to restrict all access to wp-content/uploads/ --

I have the following code in my dl-file.php file, and it is in my public_html directory as suggested by @BrianKopp. Here is what my dl-file.php looks like:

1)))+array(NULL); $file = rtrim($basedir, '/') . '/' . (isset($_GET['file']) ? $_GET['file'] : ''); $file = realpath($file); if ($file === FALSE || !$basedir || !is_file($file)) { status_header(404); die('404 — File not found.'); } if (strpos($file, $basedir) !== 0) { status_header(403); die('403 — 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 ); 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 ); --- My .htaccess file (the one that is also in my public_html directory): # BEGIN WordPress RewriteEngine On RewriteBase / RewriteRule ^index.php$ - [L] RewriteCond %{REQUEST_FILENAME} -s RewriteRule ^wp-content/uploads/(.*)$ dl-file.php?file=$1 [QSA,L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.php [L] # END WordPress --- Trying to follow along with @BrianKopp here...any insight would be really appreciated -- client is freaking out

@TIIUNDER
Copy link

TIIUNDER commented Apr 23, 2015

I tried the fix by @hexagongirl, but it didn't work to me. I add to add an ob_end_flush and ob_end_clean, after that it works for me

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_end_clean();
ob_end_flush();

@cywebd
Copy link

cywebd commented Jun 25, 2015

What is the Nginx equivalent for the rules below?

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

@blue0rbit
Copy link

blue0rbit commented Aug 13, 2015

Seems like this thread is pretty old, but still chugging along. I was able to get this to mostly work using the dl-file.php text that scot90 posted. The issue I have, however, is that upon accessing a file from the protected folder, I'm logged out of the Wordpress site. So I get the file, but then I have to log back in again. Has anyone else seen this?

@JanikWeb
Copy link

JanikWeb commented May 24, 2016

Just follow @BrianKopp 's comment. This works perfectly. His solution also shows how to restrict access not to the whole /uploads/ dir but to a subdir.

@spktklr
Copy link

spktklr commented Jun 9, 2016

@cywebd & al. this will work for nginx:

location ~* /(?:uploads|files)/* {
    rewrite /wp-content/uploads/(.*)$ /dl-file.php?file=$1;
}

@idabrogie
Copy link

idabrogie commented Jul 17, 2016

any solution to the problem that @simonbernad see?
I have no ?> in my config :/

@ahm-3d
Copy link

ahm-3d commented Jul 17, 2016

this worked for me...

in the .htaccess located in the root directory of my wordpress install:

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /wordpress/
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /wordpress/index.php [L]
</IfModule>

# END WordPress

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

and then i added wp-private.php in the root directory of my wordpress install with the code below:


<?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' ])? '/private/'.$_GET[ 'file' ]:'');

if (!$basedir || !is_file($file)) {
    status_header(404);
    wp_redirect(home_url());
    exit();
}

$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 );

@austinginder
Copy link

austinginder commented Aug 17, 2016

Discovered a php exhausted memory bug with this and serving large files. Adding the following block of code to the top of my file fixed it for me.

// Fix to allow for large file: http://stackoverflow.com/questions/6627952/why-does-readfile-exhaust-php-memory/38986798#38986798
if (ob_get_level()) {
    ob_end_clean();
}

@shaneg5525
Copy link

shaneg5525 commented Dec 5, 2016

This doesnt seem to be working? if i sure to mysite.com/wp-content/uploads/private i'm re directed to the login... if i surf to a file within private im am able to access it and i'm not directed to login?

I've added this to my .htaccess in the root

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

and then added wp-private.php to my root with the following code

<?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' ])? '/private/'.$_GET[ 'file' ]:'');

if (!$basedir || !is_file($file)) {
    status_header(404);
    wp_redirect(home_url());
    exit();
}

$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 );

@jonmspencer
Copy link

jonmspencer commented Apr 6, 2017

Nothing seems to be working here. Is this due to newer core updates? Working with WP 4.7.3.

@rbix1
Copy link

rbix1 commented Apr 10, 2017

Fix for 4.7.3:
Then name of the map used in your directory mine is restricted

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

But ALSO in the dl-file.php :
$file = rtrim($basedir,'/').'/'.str_replace('..', '', isset($_GET[ 'file' ])? '/restricted/'.$_GET[ 'file' ]:'');

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_end_clean();
ob_end_flush();

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' ])? '/**restricted**/'.$_GET[ 'file' ]:'');

if (!$basedir || !is_file($file)) {
    status_header(404);
    wp_redirect(home_url());
    exit();
}

$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 );

@yodarub
Copy link

yodarub commented Apr 12, 2017

@rbix1, I copied your code and it redirects to frontpage after login? It also directs to frontpage when logged in and try to view the file in restricted folder.

@tameroski
Copy link

tameroski commented Aug 30, 2017

Guys, don't forget to add a rewrite base if you're working in a subdir. The whole rewrite rule should look like this :

<IfModule mod_rewrite.c>
RewriteBase /my/site/path/
RewriteCond %{REQUEST_FILENAME} -s
RewriteRule ^wp-content/uploads/(restricted/.*)$ dl-file.php?file=$1 [QSA,L]
</IfModule>

@jirihon
Copy link

jirihon commented Sep 26, 2017

Hi,
the dl-file.php script blocks valid files with double dots to be downloaded. E.g. my-file..zip. At the line 20:

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

All double dots are stripped from the filepath, probably to prevent access to higher level folders by using the ../../ syntax. So why not to delete all occurances of ../ instead of ..?

@DigitalDesignerOnline
Copy link

DigitalDesignerOnline commented Oct 25, 2017

how would this be modified to perform the same actions but within a folder outside the uploads folder. such as within a plugin folder? could I just replace the wp_upload_dir calls to wp_plugin_dir and my file path? thanks for the insight

@mcfarnell
Copy link

mcfarnell commented Dec 7, 2017

I am on a "nginx" server. Can someone tell me how to do the equivalent of the .htaccess code above on a nginx server? Thank you!

@BhargavBhandari90
Copy link

BhargavBhandari90 commented Jun 6, 2018

What if I don't want to restrict images? Other than images I want to block.

@Rsmith-gs
Copy link

Rsmith-gs commented Jul 20, 2018

This doesn't seem to be working for me. I'm still able to access the files I have uploaded via their url in a separate browser.

@digitalsmithy
Copy link

digitalsmithy commented Aug 3, 2018

I wanted to protect media against all visitors, except those logged in, for a single directory in uploads.
If logged in - download the directly accessed file.
If not - redirect to homepage.
I'm no programmer - but I hacked a few ideas together and this simplified version worked for me. You don't need to modify the PHP for a different directory - just the HTACCESS.
I also found the above code simply served garbage - a screen full of nonsense which suggested the browser was trying to display a corrupt image (so probably missed the first bit of the file - or added one). The three " header('... " lines fixed this, as it tells the browser how to handle the file.
I also created an HTACCESS in /uploads/SOME_DIRECTORY to disable browsing (Options -Indexes).
Just for completeness I repeat - this is my version and I'm no pro, so it may not be 'right' in some strict sense. But it works.

HTACCESS

# block directory
<IfModule mod_rewrite.c>
RewriteCond %{REQUEST_URI} ^.*wp-content/uploads/SOME_DIRECTORY/.*
RewriteRule ^wp-content/uploads/(SOME_DIRECTORY/.*)$ dl-file.php?file=$1 [QSA,L]
</IfModule>

dl-file.php contents

<?php
// Protect uploaded files with login.
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_end_clean();
ob_end_flush();

if ( is_user_logged_in() ) {
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' ]:'');
header('Content-Type: application/octet-stream');
header("Content-Transfer-Encoding: Binary"); 
header("Content-disposition: attachment; filename=\"" . basename($file) . "\""); 
readfile($file);
}
else {
    wp_redirect( home_url(), 301 );
    exit;
}

@chrismheath
Copy link

chrismheath commented Oct 18, 2018

I had a site which started as a regular WP, which then turned into a multisite. I've managed to get the latest version of the script working.

There were two issues with the original working with multisites:

  1. Where the htaccess rule for accessing the dl-file.php went (as multisite added extra conditions and rules)
  2. The actual dl-file itself and working with multisite upload installation paths

.htaccess

The dl-file referencing code was moved in before the extra WordPress multisite code

<IfModule mod_rewrite.c>
	RewriteEngine On
	RewriteBase /
	RewriteRule ^index\.php$ - [L]

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

	# add a trailing slash to /wp-admin
	RewriteRule ^wp-admin$ wp-admin/ [R=301,L]

	RewriteCond %{REQUEST_FILENAME} -f [OR]
	RewriteCond %{REQUEST_FILENAME} -d
	RewriteRule ^ - [L]
	RewriteRule ^(wp-(content|admin|includes).*) $1 [L]
	RewriteRule ^(.*\.php)$ $1 [L]
	RewriteRule . index.php [L]
</IfModule>

dl-file.php

The main issue was that basedir was now using the multisite relative uploads path, so combined with the original $_GET file request, you'd see a duplicate in the url (eg uploads/sites/2/sites/2/2018/10/file.jpg). This now removes the extra /sites/{int}, as well as checking whether the current user is part of the current multisite. Just copy and replace everything in the original between the file comments/documentation and 404 check (if ( $file === FALSE || !$basedir || !is_file( $file ) ) {).

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();

is_user_member_of_blog() && is_user_logged_in() || auth_redirect();

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

// 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 = rtrim( $basedir, '/' ) . '/' . ( isset( $_GET['file'] ) ? $_GET['file'] : '' );
$file = realpath( $file );

@121940kz
Copy link

121940kz commented Feb 22, 2019

2019 and this still works great. Thank you @BrianKopp for your help and explanation above from many years ago. Still works!

@SteveHa2
Copy link

SteveHa2 commented Apr 16, 2019

Also in 2019 but having some problems, the directory locks down and redirects to the dl-file but i cant log in. I ran a test by repalcing the dl-file with a very simple test script and it seems that after the rewrite the user never appears as logged in using is_user_logged_in().

Has anybody else seen anything like this?

@driv3r333
Copy link

driv3r333 commented Jun 26, 2019

This works great if you want to restrict downloading of files.

I have a subfolder inside the uploads folder which has html and pdf files.
I just want to restrict direct access of html or PDF files via url when users are not logged in.

When the users are logged in it prompts the user to download the html file instead of displaying on the screen.

Could someone please help me modify this code so that it just displays the html or pdf in the browser instead of prompting the user to download the files?

Thanks

@farhadmoradik
Copy link

farhadmoradik commented Jul 12, 2019

SPECIAL THANKS to HAKRE for this great engineering and sharing. You are great!
Please help me here:line 16: is_user_logged_in() || auth_redirect();
So the files are visible to any logged in user. Can I change it to only file owner (post_author in wp_posts)?
Any thoughts on that please Hakre?
THANKS AGAIN for this sharing.

@browlry
Copy link

browlry commented Jul 18, 2019

Thanks for sharing this, Hakre. It works great.

One possibly unexpected side effect is that it sets the cache time to 3 years in the future.

It took me a while to figure out why users couldn't see updates to PDF files. I finally realized it was because of the header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + 100000000 ) . ' GMT' ); row in this file, which is setting the cache expiration time to about 3 years in the future. I changed this to header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + 1800 ) . ' GMT' ); for a cache time of 30 minutes, which is more reasonable for our purposes.

@kodiSPO
Copy link

kodiSPO commented Aug 27, 2019

It's August 27th 2019. WP Version 4.9.10

function_exists('is_user_logged_in') returns TRUE
BUT
is_user_logged_in() is always returning FALSE even when I'm logged in.

Any suggestions?

@whitenoise789
Copy link

whitenoise789 commented Sep 25, 2019

I've just joined to say thanks to all the contributers in this thread.

I had a similar problem to one of the other posters in that when logged in, a request for the file was being redirected to the home page. After doing some very basic debugging I realised this line was the issue.

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

This line apparently was looking like this: /blah my site/wp-content/uploads/private/private/myfile.xls

So simple changing it back to what was suggested earlier in the thread:

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

Fixed the problem, and it now works fine. For reference my htaccess:

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]

# BEGIN THIS DL-FILE.PHP ADDITION
RewriteCond %{REQUEST_FILENAME} -s
RewriteRule ^wp-content/uploads/(private/.*)$ dl-file.php?file=$1 [QSA,L]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>

# END WordPress

Options -Indexes

And 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
 * 
 * @author hakre <http://hakre.wordpress.com/>
 * @license GPL-3.0+
 * @registry SPDX
 */
 
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_end_clean();
ob_end_flush();

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);
    wp_redirect(home_url());
    exit();
}

$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() + 1800 ) . ' 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 );

Hope this helps someone :)

@whitenoise789
Copy link

whitenoise789 commented Sep 26, 2019

As an update, I was trying to add to this code to check if a user had certan capabiltities. However for the life of me I couldn't work out why I was getting no data for the user. It seems that the necessary files hadn't been called/created yet (I'm not an expert) so that's why I was getting blank/0. After doing much searching, I realised that changing the top part of the code to the below fixes this problem:

require_once('wp-blog-header.php');
wp_cookie_constants();
ob_end_clean();
ob_end_flush();

So I removed all the other requires, and replaced it with this one, which I think loads them anyway. Doing it the old way missed out an include which obviously dealt with the user. I could then use the user data how I wanted to check capabilities if(current_user_can('')).

Oh and where my manners - many thanks to Hakre for the this script to start with and for other users for their contributions.

@srcejon
Copy link

srcejon commented Nov 1, 2019

I find I need to call ob_start(); as originally suggested by @TIIUNDER for the CR fix, but appears to be missing from latter comments. Without this, when the WP Super Cache plugin is used, the MIME type is incorrect for some files (E.g. PDFs) and they are displayed as corrupted html instead of as a PDF.

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();

@ricks03
Copy link

ricks03 commented Jan 9, 2020

Comments from someone else who struggled to get this to work. If you want it to not require the file to exist at the location (should you have, for example, have your files stored outside of the Wordpress path), you can simply remove the check for the file's existence from .htaccess: RewriteCond %{REQUEST_FILENAME} -s

I too added the list of requires, but did not need the ob_* functions.

While there's a recommendation for using realpath() above, note it requires read permissions of the directory, which for me at least didn't work, where the original solution did:
$file = rtrim($basedir,'/').'/'.str_replace('..', '', isset($_GET[ 'file' ])?$_GET[ 'file' ]:'');

If you aren't using the uploads folder directly, you likely want:
RewriteRule ^wp-content/uploads/(secure/.*)$ dl-file.php?file=$1 [QSA,L]
instead of
RewriteRule ^wp-content/uploads/secure/(.*)$ dl-file.php?file=$1 [QSA,L]

If you want to check further that just logged in (say, if the user has certain permissions) you can modify:
if (strpos($file, $basedir) !== 0 ) {
to be
if (strpos($file, $basedir) !== 0 || current_user_can('user_capability_you care_about') == false ) {

For debugging, I found it useful to update the die statement(s) with the variables I cared about (not good for production)

$reports = current_user_can('user_capability_you care_about');
if ( !$basedir || !is_file($file)) {
    status_header(404);
    die("404 &#8212; File not found. basedir: $basedir   file: $file reports: $reports");
}

With that, it's working. There's still some quirks to be found, but it's working for me.

All that being said, this is an incredible solution, one which I've been trying to resolve with file manager plugins for a while.

The file manager plugins tend to fail in one of two ways:
You've kept your files in the wordpress path, so a URL directly to the file still works even though you're using a file manager plugin
or
You move the files out of the wordpress path (for security), at which point in time URLs to the files no longer work (no surprise, that's the point of moving them), but there's no process inside the file manager to let you create a link to the file that is role/security - aware.

@vpjm
Copy link

vpjm commented Jan 21, 2020

It doesn't work for me. I don't know if it's because the site I'm working on has its WordPress files in a subdirectory, because we are running WP Super Cache plugin, or some other cause. I scanned through all the comments here but it didn't seem like anyone was reporting a similar situation. Does anyone have this working in a situation where their WordPress files are in a subdirectory but the site runs from the root?

@ricks03
Copy link

ricks03 commented Jan 21, 2020

It would just require adjusting your .htaccess file and this line:
$file = rtrim($basedir,'/').'/'.str_replace('..', '', isset($_GET[ 'file' ])?$_GET[ 'file' ]:'');
I think.

this file is using the "true" path of the files, so where they are shouldn't matter.

@lamb73
Copy link

lamb73 commented Jan 29, 2020

I cannot seem to get this working whatsoever, and I have tried all suggestions in the comments, including copying and pasting others' working examples.

Am I doing this right?

  • PHP file in root directory (so alongside wp-config.php)
  • Ensure PHP filename matches that noted in the htaccess file
  • Ensure path to what you want to protect is correct in both the htaccess and PHP files
  • Load image directly in browser while logged out of WP, e.g. www.domain.com/wp-content/uploads/2020/01/image.jpg

@slehcimnad
Copy link

slehcimnad commented Feb 5, 2020

I don't want users to get redirected to the home page but to the 404 page. So, I've change that and it works fine in Firefox and even IE but it starts downloading 404.html in Chrome and Edge. Any ideas? My code:

$url = site_url().'/404';

//define users who have acces to the folder specified in htaccess
is_user_logged_in() || wp_redirect($url);

@tomas-eklund
Copy link

tomas-eklund commented Apr 2, 2020

@stewartadam wrote

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

The above code does not work on Windows where $basedir might be something like c:\wwwroot\wp-content/uploads and $file might be c:/wwwroot/wp-content/uploads/file.pdf. Notice how there's a mix of slashes and backslashes.

Copy link

ghost commented Apr 27, 2020

Hi @hakre this is brilliant!

Works for me in the remote host.

Is there any way to make this script work with this Wordpress plugin?

https://github.com/benhuson/password-protected

Entering the correct password in the password-protected home page doesn't count for this script as a logged-in user.

Many thanks!

@tdougla
Copy link

tdougla commented Jul 21, 2020

doesn't look like this thread is monitored by original author @hakre. I'm wondering if anyone has modified this script to only allow the author of the file to view the file, and any other user would be denied ?
thanks.

@rtpHarry
Copy link

rtpHarry commented Aug 14, 2020

@hacs-github
Copy link

hacs-github commented Feb 21, 2021

In a docker Wordpress nginx setup I ran into the issue, that pdf-viewer / pdf-embedder plugins would just show an error message about unavailable PDF resources.

Problem: In success case no status header code 200 was sent. I just added status_header(200); at the top of the good case part.
Also, I added NextGEN Gallery support (using wp-content/gallery instead of wp-content/upload folder). A rewrite rule had to be added in my nginx configuration too.

my dl-file.php version:

<?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-blog-header.php');
wp_cookie_constants();
ob_end_clean();
ob_end_flush();

is_user_logged_in() || auth_redirect();

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

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

if (!$basedir || !is_file($file)) {
    status_header(404);
    wp_redirect(home_url());
    exit();
}

$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 );

status_header(200);
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() + 1800 ) . ' 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 );

my nginx config part:

# redirect all access to uploads and files directory through access checking script
# in order to only grant logged in users access to privacy concerned media.
# Exclude media starting with "public" in filename
location ~* /wp-content/(?:uploads|gallery)/(?!.*public) {
  rewrite /wp-content/uploads/(.*)$ $scheme://$host/dl-file.php?file=$1 last;
  rewrite /wp-content/gallery/(.*)$ $scheme://$host/dl-file.php?g=1&file=$1 last;
}                                                                                

@jmeile
Copy link

jmeile commented May 21, 2021

First of all, I want to thank the original @hakre and the contributors. I made the script working with some purposed fixes on 2021 and WP 5.7.2 and only one site; however, the code has also a purposed fix for multi-site. I also added a way of supporting rules for allowing specific users or roles.

Here my code for the interested:

  • 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');
//$folder_permissions['ultimatemember']['users'] = array('my_user');
$folder_permissions['ultimatemember']['roles'] = array('subscriber', 'editor', 'contributor', 'autor');

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 );

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 );
  • Now the relevant parts of my .htaccess
# BEGIN DL-FILE.PHP ADDITION
RewriteCond %{REQUEST_FILENAME} -s
#Protecs only wp-content/uploads/ultimatemember. Add more RewriteRules as needed
RewriteRule ^wp-content/uploads/(ultimatemember/.*)$ 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

I'm protecting only a subfolder: wp-content/uploads/ultimatemember, so, if you need it for another folder, then change that section. If you need it for the whole uploads folder, then uncomment the top line and drop the one with ultimatemember.

Best regards
Josef

@meow231a
Copy link

meow231a commented May 24, 2021

I've tried everything here and still can't get it to work. The closest I've gotten is with the version provided by @jmeile

The problem I am facing with this version now is that logged in as an administrator on a regular (single site) install of the latest WP, it gives me a 403 if I try to access the file. I uncommented the rewrite rule for all uploads, but haven't changed anything else. Any help would be greatly appreciated. What is it that I'm missing?

@jmeile
Copy link

jmeile commented May 27, 2021

Hi @meow231a

Could you post the following:

  • .htaccess -> It must be located under your WordPress root folder. You may hide sensitive date, eg: your ip address and website.
  • The value of $folder_permissions inside the file: dl-file.php. You need to set this according to your needs.
  • The roles that you want to access the files
  • The folders that you want to protect: on my case, I only want to protect everything inside: wp-content/uploads/ultimatemember. If you want to protect everything, then your folder would be: wp-content/uploads

You can also debug the problem as follows:

  • Put this on your wp-config.php file:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );

This will create the file: wp-content/degub.log. In order to see some debug, just add lines like these on the dl-file.php:

error_log("Before doing checks");
error_log("After doing checks");
error_log("auth_user_login: " . $auth_user_login);
error_log("auth_user_role: " . $auth_user_role);
//For a complex variable
error_log("folder_permissions: " . var_export($folder_permissions, 1));
error_log("is_user_allowed" . var_export($is_user_allowed, 1));
error_log("is_role_allowed: " . var_export($is_role_allowed, 1));

Just put them in the code at the proper places. Then you can just check the output of debug.log:
tail -f wp-content/debug.log

Put this on your Apache VirtualHost:
LogLevel alert rewrite:trace3

And then restart Apache and check for the error log:
tail -f /var/log/apache2/error.log | fgrep '[rewrite:' | grep 'dl\-file'

This will show you if the RewriteRule on your .htaccess is been applied.

You may also post the outputs of the tail commands. In order to make them short, paste only the link of an image that must be protected on the browser, then watch the output.

Anyway, this is the dl-file.php that I used for debugging:

<?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();

error_log("Before doing checks");
//Added is_user_member_of_blog as the multi-site fix recomended it
is_user_member_of_blog() && is_user_logged_in() || auth_redirect();
error_log("User is authenticated");

//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');
//$folder_permissions['ultimatemember']['users'] = array('my_user');
$folder_permissions['ultimatemember']['roles'] = array('subscriber', 'editor');
error_log("Folder permissions: " . var_export($folder_permissions, 1));

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 );
  error_log("Multisite detected");
}
error_log("basedir: " . $basedir);

$file =  isset( $_GET['file'] ) ? $_GET['file'] : '';
error_log("file: " . $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';
}
error_log("file_root_folder: " . $file_root_folder);

$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();

error_log("folder_permission: " . var_export($folder_permission, 1));
error_log("folder_users: " . var_export($folder_users, 1));
error_log("folder_roles: " . var_export($folder_roles, 1));

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

error_log("auth_user_login: " . $auth_user_login);
error_log("auth_user_role: " . $auth_user_role);

$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);

error_log("is_user_allowed: " . var_export($is_user_allowed, 1));
error_log("is_role_allowed: " . var_export($is_role_allowed, 1));

if (!$is_user_allowed && !$is_role_allowed) {
  error_log("Issue 403, user doesn't have any permission");
  status_header(403);
  die('403 &#8212; Access denied.');
}

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

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

if (strpos($file, $basedir) !== 0) {
  error_log("Issue 403");
  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);

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))
   ) {
  error_log("Isue 304, got it from cache");
  status_header( 304 );
  exit;
}

error_log("Success, read file");
// If we made it this far, just serve the file
readfile( $file );

Best regards
Josef

@JPOak
Copy link

JPOak commented Jun 21, 2021

@jmeile Just want to say thanks for the consolidation you did it seems to be working well. I've actually had to use this for my design assets so that they are not public (header images, galleries). How much overhead do you think this ads to load times?

@jmeile
Copy link

jmeile commented Jun 22, 2021

@JPOak: This is difficult for me to say. I'm using it on a Website that doesn't have much users accessing it at the same time nor I have big images. On my case, I barely note a big delay. Anyway, I think there is one because php is anyhow reading the contents of the files on memory. I would say that the best is that you monitor the computer were the server runs and see how memory and CPU gets affected.

I really tried to do this with a different approach, but it didn't work. Here is what I did:

  1. WordPress set always a custom HTTP Header with the authenticated user through a hook. If none, then it was set to: "Anonymous"
  2. On my Apache VirtualHost, I cached that header and according to its value, I allowed or denied the request

It worked perfectly for php pages; however, the header wasn't set on media files, eg: .jpg, .gif, .pdf, etc.. The thing is that I didn't found were WordPress was handling this. I tried even several hooks and tried to look on the internet, but found no answer.

I would bet that an approach with Apache must be faster since media handling will be done the same way it is done by other requests.

Best regards
Josef

@JPOak
Copy link

JPOak commented Jun 22, 2021

@jmeile Thanks for your response. I guess I will just need to monitor. This gist seems to be the best discussion for this situation.

@jmeile
Copy link

jmeile commented Jun 23, 2021

@JPOak: You may look at this approach:
https://wordpress.stackexchange.com/questions/37144/how-to-protect-uploads-if-user-is-not-logged-in

See the answer by: bueltge: "2. Apache check for the Cookie". The good thing: it will be handled by Apache. The possible bad things:

  1. It depends on Cookies. I guess WP won't work without cookies. My major concern here would be security; a cookie can be faked, so, if a cookie with the name of: "wordpress_logged_in_xxxxxxx" is passed, the protected contents will be visible. I know that the "wordpress_logged_in" cookies have some kind of hash, but that answer there is not checking for that.
  2. It will only check whether or not the user is logged in, so, you won't actually see which WP role that user has. Perfect if you don't need roles.

It would be nice for instance if one could set two Http headers: current logged user and its WP role; however, as I said, I didn't find any hook where to do this for media files.

Best regards
Josef

@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

Dalias96 commented Jul 21, 2021

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.

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