Wordpress - How to restrict attachment download to a specific user?

What needs to happen is that you need to proxy download requests for the file types you want through WordPress. Let's assume you're going to restrict access to ".doc" files.

1. Define a query variable that indicates the requested file

function add_get_file_query_var( $vars ) {
    $vars[] = 'get_file';
    return $vars;
}
add_filter( 'query_vars', 'add_get_file_query_var' );

2. Update .htaccess to forward requests for restricted files to WordPress

This will capture requests to the files you want to restrict and send them back to WordPress using the custom query variable above. Insert the following rule before the RewriteCond lines.

RewriteRule ^wp-content/uploads/(.*\.docx)$ /index.php?get_file=$1

3. Capture the requested file name in custom query variable; and verify access to the file:

function intercept_file_request( $wp ) {
    if( !isset( $wp->query_vars['get_file'] ) )
        return;

    global $wpdb, $current_user;

    // Find attachment entry for this file in the database:
    $query = $wpdb->prepare("SELECT ID FROM {$wpdb->posts} WHERE guid='%s'", $_SERVER['REQUEST_URI'] );
    $attachment_id = $wpdb->get_var( $query );

    // No attachment found. 404 error.  
    if( !$attachment_id ) {
        $wp->query_vars['error'] = '404';
        return;
    }

    // Get post from database 
    $file_post = get_post( $attachment_id );
    $file_path = get_attached_file( $attachment_id );

    if( !$file_post || !$file_path || !file_exists( $file_path ) ) {
        $wp->query_vars['error'] = '404';
        return;
    }

    // Logic for validating current user's access to this file...
    // Option A: check for user capability
    if( !current_user_can( 'required_capability' ) ) {
        $wp->query_vars['error'] = '404';
        return;
    }

    // Option B: check against current user
    if( $current_user->user_login == "authorized_user" ) {
        $wp->query_vars['error'] = '404';
        return;
    }

    // Everything checks out, user can see this file. Simulate headers and go:
    header( 'Content-Type: ' . $file_post->post_mime_type );
    header( 'Content-Dispositon: attachment; filename="'. basename( $file_path ) .'"' );
    header( 'Content-Length: ' . filesize( $file_path ) );

    echo file_get_contents( $file_path );
    die(0);
}
add_action( 'wp', 'intercept_file_request' );

NB This solution works for single-site installs only! This is because WordPress MU already forwards uploaded file requests in sub-sites through wp-includes/ms-files.php. There is a solution for WordPress MU as well, but it's a bit more involved.


I've recently had a related problem and wrote this article about it.

I'll assume that the downloads are uploaded via WordPress' media handling - or otherwise you have an attachment ID for the download.

Outline of solution

  • Make the uploads directory 'secure' ( In this sense I just mean use .htaccess to block any attempt to directly access of files in uploads directory (or a sub-directory thereof) - e.g. via mysite.com/wp-content/uploads/conf/2012/09/myconfidentialfile.pdf)
  • Create a download link including the attachment ID - this goes through WordPress to check the user's permission to view the attachment allows/denies access.

Caveats

  • This makes use of .htaccess to provide security. If this isn't available / turned on (nginx servers for example), then you won't get much security. You can prevent the user browsing the uplods directory. But direct access will work.
  • As per above. This should not be used in distribution if you require absolute security. It is fine if your specific set up works - but in general, it can't be guaranteed. My linked article is in part trying to address this.
  • You will loose thumbnails. Blocking direct access to a folder or sub-folder will mean that thumbnails of files in that folder cannot be viewed. My linked article is in part attempting to address this.

Blocking direct access

To do this in your uploads folder (or a subfolder - all confidential material must reside, at any depth, inside this folder). Place a .htaccess file with the following:

Order Deny,Allow
Deny from all

In the following I'm assuming that you'll be attaching confidential material to post type 'client'. Any media uploaded on on the client-edit page will be stored in the uploads/conf/ folder

The function to setup the protected uploads directory

function wpse26342_setup_uploads_dir(){

    $wp_upload_dir = wp_upload_dir();
    $protected_folder = trailingslashit($wp_upload_dir['basedir']) . 'conf';    

    // Do not allow direct access to files in protected folder
    // Add rules to /uploads/conf/.htacess
    $rules = "Order Deny,Allow\n";
    $rules .= "Deny from all";

    if( ! @file_get_contents( trailingslashit($protected_folder).'.htaccess' ) ) {
            //Protected directory doesn't exist - create it.
        wp_mkdir_p( $protected_folder);
    }
    @file_put_contents( trailingslashit($protected_folder).'.htaccess', $rules );

     //Optional add blank index.php file to each sub-folder of protected folder.
}

Uploading confidential material

   /**
    * Checks if content is being uploaded on the client edit-page
    * Calls a function to ensure the protected file has the .htaccess rules
    * Filters the upload destination to the protected file
    */
    add_action('admin_init', 'wpse26342_maybe_change_uploads_dir', 999);
    function wpse26342_maybe_change_uploads_dir() {
        global $pagenow;

        if ( ! empty( $_POST['post_id'] ) && ( 'async-upload.php' == $pagenow || 'media-upload.php' == $pagenow ) ) {
                if ( 'client' == get_post_type( $_REQUEST['post_id'] ) ) {
                       //Uploading content on the edit-client page

                       //Make sure uploads directory is protected
                       wpse26342_setup_uploads_dir();

                       //Change the destination of the uploaded file to protected directory.
                       add_filter( 'upload_dir', 'wpse26342_set_uploads_dir' );
                }
        }

    }

Having done that, uploaded content should be inside uploads/conf and trying to access it directly using your browser should not work.

Downloading Content

This is easy. The download url can be something www.site.com?wpse26342download=5 (where 5 is the attachment ID of the uploaded content). We use this to identify the attachment, check permissions of the current user and allow them to download.

First, set up the query variable

/**
 * Adds wpse26342download to the public query variables
 * This is used for the public download url
 */
add_action('query_vars','wpse26342_add_download_qv');
function wpse26342_add_download_qv( $qv ){
    $qv[] = 'wpse26342download';
    return $qv;
}}

Now set up a listener to (maybe) trigger the download...

add_action('request','wpse26342_trigger_download');
function wpse26342_trigger_download( $query_vars ){

        //Only continue if the query variable set and user is logged in...
    if( !empty($query_vars['wpse26342download']) && is_user_logged_in() ){

        //Get attachment download path
        $attachment = (int) $query_vars['wpse26342download'];
        $file = get_attached_file($attachment);

        if( !$file )
             return;

        //Check if user has permission to download. If not abort.       
        header('Content-Description: File Transfer');
        header('Content-Type: application/octet-stream');
        header('Content-Disposition: attachment; filename='.basename($file));
        header('Content-Transfer-Encoding: binary');
        header('Expires: 0');
        header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
        header('Pragma: public');
        header('Content-Length: ' . filesize($file));

        ob_clean();
        flush();
        readfile($file);
        exit();
    }
    return $query_vars;
}

Final comments

The code above may contain bugs/syntax errors and is untested, and you use it at your own risk :).

The download url can be 'prettified' using rewrites. As stated in the comments you can add an blank index.php inside every child of the protected folder to prevent browsing - but this should be prevented by the .htaccess rules anyway.

A more secure method would be to store the public files outside of a public directory. Or on an external service like Amazon S3. For the latter you'll need to generate a valid url to fetch the file from Amazon (using your private key). Both of these require a certain level of trust in your Host / third party service.

I would be wary about using any plug-ins that suggest they offer 'protected downloads'. I've not found any that provide good enough security. Please not the caveats of this solution too - and I'd welcome any suggestions or criticisms.