Protecting static files with PHP script and WP Engine

There are many membership plugins for WordPress. A number of my customers run MemberPress which is a powerful and flexible membership plugin. I recently ran into a situation where images uploaded via the media library on restricted posts were getting shared by paid users and eventually picked up by Google and other search engines.

After digging around it turns out MemberPress can protect some static files however won’t do images due to perfermance reasons. I decided to attempt my own solution.

Redirection is the key to securing files

By redirecting a request to a static file, like an image, to a PHP script you can do some magic like check if the user is logged in, verify they have a valid membership, etc. With Apache redirections go in your .htaccess file. With Nginx rules goes in the server directive file.

Targeting a file pattern instead of folders

I didn’t want to use folders as that adds complication to using the WordPress media library. I decided to instead target any files with -protected in the file name. That means logo-protected.jpg would be run through the PHP script where as logo.jpg would be served directly.

The following WP Engine redirection rule does just that. For example /wp-content/uploads/2016/08/logo-protected.jpg with be redirected to /protected.php?file=/2016/08/logo-protected.jpg.

wp-engine-protect-redirection.png

PHP script that requires you to be logged in

The following protected.php script checks if your logged into WordPress. If so it will serve the originally requested file. If not it will redirect the user to the logged in page. This works great for WordPress sites which require a paid account in order to log in. It could be extended to handle other checks like if the membership valid. View gist here.

<?php
/*
 * protected.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
 */

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

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

is_user_logged_in() ||  auth_redirect();

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

$file = $basedir.''.str_replace('..', '', isset($_GET[ 'file' ])?$_GET[ 'file' ]:'');
if (!$basedir || !is_file($file)) {
    status_header(404);
    die('404 — 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 );