Hardening BuddyPress Group Documents

The BuddyPress Group Documents plugin allows groups a handy way for users to share documents with fellow members of a BP group. It’s crucial to the work that is done on the CUNY Academic Commons. But, by default, the plugin stores documents in a subdirectory of your WP uploads folder (usually /wp-content/blogs.dir/ on multisite). That means that documents are available directly, to anyone who has the URL, regardless of the public/private/hidden status of groups. This isn’t a problem from within BuddyPress, since URLs for the documents only appear inside of the protected group interface. But if the URL is shared, then the document becomes publicly available. Worse, if someone posts the URL of a document in a public place, search engine bots will find it, and the contents of the document could end up in Google.

I wrote a few helper functions to change this behavior. The strategy is this: Move the files so that they are not accessible via URL, ie in a directory above the web root. (In my case, it’s a directory called bp-group-documents, just above my web root.) Then, catch requests of a certain type (I’ve chosen to go with a URL parameter get_group_doc=), and check them to see whether the current user has the adequate permission to access the document in question. Finally, make sure that all of the URLs and paths that BPGD uses to upload and display documents are filtered to the updated versions. I’ve provided my code below – use and modify at your pleasure. You should be able to place it in your plugins/bp-custom.php file, and then move your existing docs from their current location (probably something like wp-content/blogs.dir/1/files/group-documents) to the new directory.

I also added a line to my .htaccess file to ensure that requests to the old URLs are redirected to the new, hardened URL. That line is this:


RewriteRule ^wp\-content/blogs\.dir/1/files/group\-documents/(.*) /?get_group_doc=$1 [R,L]
 

Obviously, you may have to modify it for different file paths.

EDITED Feb 8, 2011 to include the code for creating directories when none exist


define( 'BP_GROUP_DOCUMENTS_SECURE_PATH', substr( ABSPATH, 0, strrpos( rtrim( ABSPATH, '/' ), '/' ) ) . '/bp-group-documents/' );

function cac_filter_doc_url( $doc_url, $group_id, $file ) {
	$url = bp_get_root_domain() . '?get_group_doc=' . $group_id . '/' . $file;
	return $url;
}
add_filter( 'bp_group_documents_file_url', 'cac_filter_doc_url', 10, 3 );

function cac_filter_doc_path( $doc_url, $group_id, $file ) {
	$document_dir = BP_GROUP_DOCUMENTS_SECURE_PATH . $group_id . '/';
	
	if ( !is_dir( $document_dir ) )
		mkdir( $document_dir, 0775, true );

	$path = BP_GROUP_DOCUMENTS_SECURE_PATH . $group_id . '/' . $file;
	return $path;
}
add_filter( 'bp_group_documents_file_path', 'cac_filter_doc_path', 10, 3 );

function cac_catch_group_doc_request() {
	$error = false;

	if ( empty( $_GET['get_group_doc'] ) )
		return;
	
	$doc_id = $_GET['get_group_doc'];
	
	// Check to see whether the current user has access to the doc in question
	$file_deets 	= explode( '/', $doc_id );
	$group_id 	= $file_deets[0];	
	$group		= new BP_Groups_Group( $group_id );
	
	if ( empty( $group->id ) ) {
		$error = array(
			'message' 	=> 'That group does not exist.',
			'redirect'	=> bp_get_root_domain()
		);
	} else {
		if ( $group->status != 'public' ) {
			// If the group is not public, then the user must be logged in and
			// a member of the group to download the document
			if ( !is_user_logged_in() || !groups_is_user_member( bp_loggedin_user_id(), $group_id ) ) {
				$error = array(
					'message' 	=> sprintf( 'You must be a logged-in member of the group %s to access this document. If you are a member of the group, please log into the site and try again.', $group->name ),
					'redirect'	=> bp_get_group_permalink( $group )
				);
			}
		}
		
		// If we have gotten this far without an error, then the download can go through
		if ( !$error ) {
			
			$doc_path = BP_GROUP_DOCUMENTS_SECURE_PATH . $doc_id;
			
			if ( file_exists( $doc_path ) ) {
				$mime_type = mime_content_type( $doc_path );
				$doc_size = filesize( $doc_path );
				
				header("Cache-Control: public, must-revalidate, post-check=0, pre-check=0");
				header("Pragma: hack");
					
				header("Content-Type: $mime_type; name='" . $file_deets[1] . "'");
				header("Content-Length: " . $doc_size );
				
				header('Content-Disposition: attachment; filename="' . $file_deets[1] . '"');
				header("Content-Transfer-Encoding: binary");              
				ob_clean();
				flush();  		
				readfile( $doc_path );
				die();
		       
			} else {
				// File does not exist
				$error = array(
					'message' 	=> 'The file could not be found.',
					'redirect'	=> bp_get_group_permalink( $group ) . '/documents'
				);
			}
		}
	}
		
	// If we have gotten this far, there was an error. Add a message and redirect
	bp_core_add_message( $error['message'], 'error' );
	bp_core_redirect( $error['redirect'] );
}
add_filter( 'wp', 'cac_catch_group_doc_request', 1 );

// http://www.php.net/manual/en/function.mime-content-type.php#87856
if(!function_exists('mime_content_type')) {

    function mime_content_type($filename) {

        $mime_types = array(

            'txt' => 'text/plain',
            'htm' => 'text/html',
            'html' => 'text/html',
            'php' => 'text/html',
            'css' => 'text/css',
            'js' => 'application/javascript',
            'json' => 'application/json',
            'xml' => 'application/xml',
            'swf' => 'application/x-shockwave-flash',
            'flv' => 'video/x-flv',

            // images
            'png' => 'image/png',
            'jpe' => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'jpg' => 'image/jpeg',
            'gif' => 'image/gif',
            'bmp' => 'image/bmp',
            'ico' => 'image/vnd.microsoft.icon',
            'tiff' => 'image/tiff',
            'tif' => 'image/tiff',
            'svg' => 'image/svg+xml',
            'svgz' => 'image/svg+xml',

            // archives
            'zip' => 'application/zip',
            'rar' => 'application/x-rar-compressed',
            'exe' => 'application/x-msdownload',
            'msi' => 'application/x-msdownload',
            'cab' => 'application/vnd.ms-cab-compressed',

            // audio/video
            'mp3' => 'audio/mpeg',
            'qt' => 'video/quicktime',
            'mov' => 'video/quicktime',

            // adobe
            'pdf' => 'application/pdf',
            'psd' => 'image/vnd.adobe.photoshop',
            'ai' => 'application/postscript',
            'eps' => 'application/postscript',
            'ps' => 'application/postscript',

            // ms office
            'doc' => 'application/msword',
            'rtf' => 'application/rtf',
            'xls' => 'application/vnd.ms-excel',
            'ppt' => 'application/vnd.ms-powerpoint',

            // open office
            'odt' => 'application/vnd.oasis.opendocument.text',
            'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
        );

        $ext = strtolower(array_pop(explode('.',$filename)));
        if (array_key_exists($ext, $mime_types)) {
            return $mime_types[$ext];
        }
        elseif (function_exists('finfo_open')) {
            $finfo = finfo_open(FILEINFO_MIME);
            $mimetype = finfo_file($finfo, $filename);
            finfo_close($finfo);
            return $mimetype;
        }
        else {
            return 'application/octet-stream';
        }
    }
}


 

Commons 1.1.8

I’ve just released version 1.1.8 of the CUNY Academic Commons. This upgrade was a bit bumpy – I actually pushed the update yesterday afternoon, but because of a few technical problems, I was not able to debug and tag the upgrade as stable. Thanks to some help from our delightful sysadmin @apitanga, we sorted things out and I was able to wrap up the release.

Commons 1.1.8 features a relatively large number of fixes and improvements:

  • Many plugins and themes, including BuddyPress, have been upgraded to the most recent versions
  • A large number of new themes have been added to the site
  • Embedding of Prezi and Slideshare presentations has been enabled
  • A bug blocking group members from being auto-added to the group’s blog has been fixed
  • A few other small bugs fixed

For complete details on this release, see the 1.1.8 milestone.

New WordPress plugin: Prezi WP

A member of the Commons community recently requested the ability to embed Prezi presentations into her Commons blog. I had a look at the options available in the wordpress.org repository, and the pickings were slim indeed. So I wrote my own plugin, Prezi WP. It’s pretty simple: use the [prezi] shortcode in your posts to embed a presentation. See the plugin homepage for more details on how to use the plugin, and how to download it and use it on your own WP installation.

Group Announcements tab in BuddyPress

I had a request or two to explain how I built the Group Announcements feature on the CUNY Academic Commons. Here goes.

Brief background: When the Commons was upgraded to BuddyPress 1.2, we got the benefit of interactive activity streams everywhere, including groups. This caused some confusion, however, as users were uncertain where conversation best fit into the Commons’s architecture: in the Forums (where it had been traditionally), or in the activity stream. In some communities this kind of fracturing might be okay or even welcome, but in ours it was confusing. At the same time, we wanted a way for group admins and mods to send important notices to the members of their groups. By taking the group activity updates and repurposing it as a Group Announcements section, I was able to kill two birds with one stone: providing an announcement space for mods, while focusing extended discussion in the forums.

You can download the CAC Group Announcements plugin here.

I’m not putting this in the repo at the moment because I don’t want to build a proper admin UI and support it 🙂 For that reason, here is a primer on how the plugin works – if you want to customize or maintain it, you’re on your own, buster.

  1. The CAC_Group_Announcements class is an instance of the BuddyPress Group Extension API. It is responsible for creating the Announcements tab markup and adding it to the nav. You’ll notice that the majority of the markup is created by including bp-default’s activity-loop.php and post-form.php templates. You could customize this more if you wanted.
  2. bp_is_group_announcements() is a little template tag that can be used to test whether you’re looking at a group announcements page. This is needed for the activity filter, in step 3.
  3. cac_set_announcement_filter() adds a filter to the bp_has_activities query string when you are looking at an announcements page, so that it only displays activity items of the type activity_update. In other words, when you are looking at the regular activity stream for the group, you see all of the associated group activity items (new members, forum posts, etc) but when you’re on the announcements page you only see activity updates (which, remember, have been repurposed as announcements).

As I look at the code, I see that there are things I would definitely change if I were going to make this into a distributed plugin. If you want to make those changes, be my guest. You’re welcome to help each other in the comment section, but I won’t be formally supporting this, as it is a very basic hack that should happen at the theme level anyway. Good luck!

Commons 1.1.7

Happy New Year! I have just released version 1.1.7 of the CUNY Academic Commons. This bugfix release contained a very important security release of WordPress (v3.0.4), as well as a few other small changes:

  • Bugfixes in the Google Calendar widget WP plugin
  • Bugfixes in the Cityscape WP theme
  • Installed the Meebo Me WP plugin

For complete details about the release, visit the 1.1.7 milestone.

Commons 1.1.5

I have just released version 1.1.5 of the CUNY Academic Commons. The main purpose for this release was to fix a recently introduced bug that was preventing users from inviting others to their BuddyPress groups. Several other issues were addresse as well:

  • A plugin WP Resume was included to allow the creation of resumés and CVs on your Commons blog
  • PDF export for MediaWiki pages and categories was activated
  • Some bugs with the way that the Atahualpa theme handles custom headers were fixed
  • See the 1.1.5 milestone for full details on the release.
  • Commons 1.1.4

    I’ve just finished tagging and releasing version 1.1.4 of the CUNY Academic Commons. This bugfix release addresses several issues and requests from the member community, including:

    • Bugfixes in several WordPress plugins (Widgetize Google Gadgets, Media Element HTML 5)
    • Fixed bug that was causing a few members to get email notification from groups they used to be members of
    • Installed the Widget Context WP plugin, which allows blog owners greater control over the way that widgets display on different parts of their sites
    • Improved the layout of the group header and added a Post Topic button to the group forum interface for greater usability

    As always, you can get full details about the release from the 1.1.4 milestone

    Wildcard email whitelists in WordPress and BuddyPress

    WordPress (and before that WPMU) has long had a feature that allows admins to set a whitelist of email domains for registration (Limited Email Registration). On the Commons, we need to account for a lot of different domains, some of which are actually dynamic – but they are all of the form *.cuny.edu. WP doesn’t support this kind of wildcards, but we’ve got it working through a series of customizations.

    These first two functions form the heart of the process. The first one hooks to the end of the BP registration process, looks for email domain errors, and then sends the request to the second function, which does some regex to check against the wildcard domains you’ve specified. This is BP-specific, but I think you could make it work with WPMS just by changing the hook name.

    
    function cac_signup_email_filter( $result ) {
    	global $limited_email_domains;
    
    	if ( !is_array( $limited_email_domains ) )
    		$limited_email_domains = get_site_option( 'limited_email_domains' );
    	
    	$valid_email_domain_check = cac_wildcard_email_domain_check( $result['user_email'] );	
    	
    	if( $valid_email_domain_check ) {
    		if ( isset( $result['errors']->errors['user_email'] ) )
    			unset( $result['errors']->errors['user_email'] );
    	}
    	
    	return $result;
    }
    add_filter( 'bp_core_validate_user_signup', 'cac_signup_email_filter', 8 );
    
    function cac_wildcard_email_domain_check( $user_email ) {
    	global $limited_email_domains;
    	
    	if ( !is_array( $limited_email_domains ) )
    		$limited_email_domains = get_site_option( 'limited_email_domains' );
    
    	if ( is_array( $limited_email_domains ) && empty( $limited_email_domains ) == false ) { 
    		$valid_email_domain_check = false;
    		$emaildomain = substr( $user_email, 1 + strpos( $user_email, '@' ) );
    		foreach ($limited_email_domains as $limited_email_domain) {
    			$limited_email_domain = str_replace( '.', '\.', $limited_email_domain);        // Escape your .s
    			$limited_email_domain = str_replace('*', '[-_\.a-zA-Z0-9]+', $limited_email_domain);     // replace * with REGEX for 1+ occurrence of anything
    			$limited_email_domain = "/^" . $limited_email_domain . "/";   // bracket the email with the necessary pattern markings
    			$valid_email_domain_check = ( $valid_email_domain_check or preg_match( $limited_email_domain, $emaildomain ) );
    		}
    	}	
    
    	return $valid_email_domain_check;
    }
    

    Before WP 3.0, this was enough to make it work. The latest WP does increased sanitization on the input of the limited_email_domains field, however, which makes it reject lines like *.cuny.edu. The following functions add an additional field to the ms-options.php panel that saves the limited domains without doing WP’s core checks. (Beware: bypassing WP’s checks like this means that there are no safeguards in place for well-formedness. Be careful about what you type in the field, or strange things may happen.)

    
    function cac_save_limited_email_domains() {
    	if ( $_POST['cac_limited_email_domains'] != '' ) {
    		$limited_email_domains = str_replace( ' ', "\n", $_POST['cac_limited_email_domains'] );
    		$limited_email_domains = split( "\n", stripslashes( $limited_email_domains ) );
    	
    		$limited_email = array();
    		foreach ( (array) $limited_email_domains as $domain ) {
    				$domain = trim( $domain );
    			//if ( ! preg_match( '/(--|\.\.)/', $domain ) && preg_match( '|^([a-zA-Z0-9-\.])+$|', $domain ) )
    				$limited_email[] = trim( $domain );
    		}
    		update_site_option( 'limited_email_domains', $limited_email );
    	} else {
    		update_site_option( 'limited_email_domains', '' );
    	}
    }
    add_action( 'update_wpmu_options', 'cac_save_limited_email_domains' );
    
    function cac_limited_email_domains_markup() {
    	?>
    	
    	<h3><?php _e( 'Limited Email Domains That Actually Work' ); ?></h3>
    	
    	<table class="form-table">
    	<tr valign="top">
    		<th scope="row"><label for="cac_limited_email_domains"><?php _e( 'Limited Email Registrations' ) ?></label></th>
    		<td>
    			<?php $limited_email_domains = get_site_option( 'limited_email_domains' );
    			$limited_email_domains = str_replace( ' ', "\n", $limited_email_domains ); ?>
    			<textarea name="cac_limited_email_domains" id="limited_email_domains" cols="45" rows="5">< ?php echo wp_htmledit_pre( $limited_email_domains == '' ? '' : implode( "\n", (array) $limited_email_domains ) ); ?>
    			<br />
    			<?php _e( 'If you want to limit site registrations to certain domains. One domain per line.' ) ?>
    		</td>
    	</tr>
    	</table>
    	
    	<?php
    }
    add_action( 'wpmu_options', 'cac_limited_email_domains_markup' );