Private User Avatars - Session Summary
Session date: 2026-09-06
Subject: Replacing the "Choose from Media Library" avatar picker on two WordPress sites with a private per-user avatar uploader.
Sites: amarketplaceofideas.com (DreamHost, SSH, no root) and drudgingtonpost.com (bare-metal Ubuntu/Apache, SSH as root).
Result: MU-plugin private-user-avatars.php installed and verified on both sites. The old Custom Profile Picture plugin left deactivated and safe to delete.
Summary
The user wanted to remove the "Choose from Media Library" button that appeared on the WordPress user profile page, because it let ordinary users browse the site's shared media. The button came from the Custom Profile Picture plugin. The user still wanted registered users to be able to upload a personal avatar for use beside their comments, but without any access to the Media Library.
After discussing three approaches (hide the button cosmetically, scope Media Library results per user, or replace the
whole mechanism), the user chose the strongest and most theme-independent option: a dedicated Must-Use plugin that
stores avatars in a separate directory outside the Media Library and substitutes them into comment output through the
get_avatar filter.
Installation was carried out one small verified step at a time, first on the DreamHost site, then on the bare-metal
site. Both installations were tested end-to-end with a non-administrator account: upload, replace, remove, comment
display, and confirmation that the test user could not reach /wp-admin/upload.php.
Where an MU-plugin lives in the WordPress directory tree
In the WordPress directory tree, must-use plugins live in a dedicated mu-plugins directory directly inside wp-content:
your-wordpress-root/ ├── wp-admin/ ├── wp-includes/ ├── wp-content/ │ ├── plugins/ ← normal plugins │ ├── themes/ │ ├── uploads/ │ └── mu-plugins/ ← must-use plugins go here │ └── private-user-avatars.php ├── wp-config.php └── index.php
Applied to your two sites:
/home/politico/amarketplaceofideas.com/wp-content/mu-plugins/private-user-avatars.php /var/www/drudgingtonpost.com/wp-content/mu-plugins/private-user-avatars.php
Rules WordPress uses to load MU-plugins
.php file directly at the top level of wp-content/mu-plugins/. Every such file is executed on every request, before ordinary plugins load.mu-plugins/ are not auto-loaded. To organise code in a subfolder, place a small "loader" .php file at the top level that does require __DIR__ . '/subdir/whatever.php';.mkdir -m 755 wp-content/mu-plugins on DreamHost and install -d -o www-data -g www-data -m 755 .../mu-plugins on the bare-metal box).wp-config.php:
define( 'WPMU_PLUGIN_DIR', '/absolute/path' ); define( 'WPMU_PLUGIN_URL', 'https://example.com/whatever' );Neither of your sites has done this, so the default
wp-content/mu-plugins/ applies.How to inspect what is loaded
From the site's WordPress root, either of these confirms it:
ls -la wp-content/mu-plugins/ wp plugin list --status=must-use
The WP-CLI form is the same command we used during the DreamHost install, which returned the row confirming private-user-avatars version 1.0.0 was recognised as must-use.
Final state on both sites
| Item | amarketplaceofideas.com (DreamHost) | drudgingtonpost.com (bare metal) |
|---|---|---|
| Access used | SSH as politico, no root |
SSH as root |
| WordPress root | /home/politico/amarketplaceofideas.com |
/var/www/drudgingtonpost.com |
| MU-plugin path | wp-content/mu-plugins/private-user-avatars.php |
wp-content/mu-plugins/private-user-avatars.php |
| File owner / mode | politico:pg9056 / 644 |
www-data:www-data / 644 |
| Avatar storage directory | wp-content/uploads/user-avatars/ |
wp-content/uploads/user-avatars/ |
| Old plugin | Custom Profile Picture deactivated (safe to delete) | Custom Profile Picture deactivated (safe to delete) |
| Test user Media Library access | Blocked - redirected to login on /wp-admin/upload.php |
Blocked - redirected to login on /wp-admin/upload.php |
| Upload / replace / remove | Verified | Verified |
| Avatar visible beside a comment | Verified | Verified |
What the plugin does
Users -> Profile and Users -> Edit User.wp-content/uploads/user-avatars/ using a random filename of the form user-<id>-<20-random-chars>.<ext>._pua_avatar_url. No WordPress attachment record is created.upload_files capability.wp_get_image_mime(). Allowed types are JPEG, PNG, GIF, WebP.enctype="multipart/form-data" to the profile form via the user_edit_form_tag hook.get_avatar() (including comment threads) via a priority-20 get_avatar filter.pua_delete_file() refuses to delete anything outside the plugin's own subdirectory, or any path containing directory separators..disabled suffix. Rename back to re-enable.Full PHP source: wp-content/mu-plugins/private-user-avatars.php
<?php
/**
* Plugin Name: Private User Avatars
* Description: Lets users upload a comment avatar without using the WordPress Media Library.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
const PUA_META_KEY = '_pua_avatar_url';
const PUA_UPLOAD_SUBDIR = 'user-avatars';
const PUA_MAX_BYTES = 2 * 1024 * 1024; // 2 MiB.
/*
* File inputs require multipart form encoding.
*/
add_action( 'user_edit_form_tag', 'pua_add_profile_form_enctype' );
function pua_add_profile_form_enctype() {
echo ' enctype="multipart/form-data"';
}
/*
* Allowed image formats.
*/
function pua_allowed_mimes() {
return array(
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
);
}
/*
* A person may edit their own avatar; administrators may edit an account
* only when WordPress allows them to edit that user.
*/
function pua_user_can_manage_avatar( $user_id ) {
return (int) get_current_user_id() === (int) $user_id
|| current_user_can( 'edit_user', $user_id );
}
/*
* Add a dedicated avatar field to Users > Profile and Users > All Users > Edit.
*/
add_action( 'show_user_profile', 'pua_add_profile_field' );
add_action( 'edit_user_profile', 'pua_add_profile_field' );
function pua_add_profile_field( $user ) {
if ( ! pua_user_can_manage_avatar( $user->ID ) ) {
return;
}
$avatar_url = get_user_meta( $user->ID, PUA_META_KEY, true );
?>
<h2>User icon</h2>
<table class="form-table" role="presentation">
<tr>
<th><label for="pua_avatar">Comment avatar</label></th>
<td>
<?php if ( $avatar_url ) : ?>
<p>
<img
src="<?php echo esc_url( $avatar_url ); ?>"
alt=""
width="96"
height="96"
style="max-width:96px;height:auto;border-radius:50%;"
>
</p>
<?php endif; ?>
<input
type="file"
id="pua_avatar"
name="pua_avatar"
accept=".jpg,.jpeg,.png,.gif,.webp,image/jpeg,image/png,image/gif,image/webp"
>
<p class="description">
Upload a JPG, PNG, GIF, or WebP image up to 2 MiB.
This does not use or display the WordPress Media Library.
</p>
<?php if ( $avatar_url ) : ?>
<label>
<input type="checkbox" name="pua_avatar_remove" value="1">
Remove my current user icon
</label>
<?php endif; ?>
<?php wp_nonce_field( 'pua_save_avatar', 'pua_avatar_nonce' ); ?>
</td>
</tr>
</table>
<?php
}
/*
* Remove an old avatar only if its URL and filename belong to this plugin.
*/
function pua_delete_file( $avatar_url ) {
if ( empty( $avatar_url ) ) {
return;
}
$uploads = wp_upload_dir();
$base_url = trailingslashit( $uploads['baseurl'] ) . PUA_UPLOAD_SUBDIR . '/';
$base_dir = trailingslashit( $uploads['basedir'] ) . PUA_UPLOAD_SUBDIR . '/';
if ( 0 !== strpos( $avatar_url, $base_url ) ) {
return;
}
$filename = rawurldecode( substr( $avatar_url, strlen( $base_url ) ) );
if ( basename( $filename ) !== $filename ) {
return;
}
$path = $base_dir . $filename;
if ( is_file( $path ) ) {
wp_delete_file( $path );
}
}
/*
* Save, replace, or remove the avatar during a profile update.
*/
add_action( 'personal_options_update', 'pua_save_profile_field' );
add_action( 'edit_user_profile_update', 'pua_save_profile_field' );
function pua_save_profile_field( $user_id ) {
if ( ! pua_user_can_manage_avatar( $user_id ) ) {
return;
}
$nonce = isset( $_POST['pua_avatar_nonce'] )
? sanitize_text_field( wp_unslash( $_POST['pua_avatar_nonce'] ) )
: '';
if ( ! wp_verify_nonce( $nonce, 'pua_save_avatar' ) ) {
return;
}
$old_url = get_user_meta( $user_id, PUA_META_KEY, true );
if ( ! empty( $_POST['pua_avatar_remove'] ) ) {
pua_delete_file( $old_url );
delete_user_meta( $user_id, PUA_META_KEY );
return;
}
if (
empty( $_FILES['pua_avatar'] ) ||
UPLOAD_ERR_NO_FILE === (int) $_FILES['pua_avatar']['error']
) {
return;
}
$file = $_FILES['pua_avatar'];
if ( UPLOAD_ERR_OK !== (int) $file['error'] ) {
return;
}
if ( (int) $file['size'] < 1 || (int) $file['size'] > PUA_MAX_BYTES ) {
return;
}
$mime_type = wp_get_image_mime( $file['tmp_name'] );
if ( ! $mime_type || ! in_array( $mime_type, pua_allowed_mimes(), true ) ) {
return;
}
$uploads = wp_upload_dir();
if ( ! empty( $uploads['error'] ) ) {
return;
}
$avatar_dir = trailingslashit( $uploads['basedir'] ) . PUA_UPLOAD_SUBDIR;
if ( ! wp_mkdir_p( $avatar_dir ) ) {
return;
}
$extensions = array(
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
);
$filename = sprintf(
'user-%d-%s.%s',
(int) $user_id,
wp_generate_password( 20, false, false ),
$extensions[ $mime_type ]
);
$destination = trailingslashit( $avatar_dir ) . $filename;
if ( ! move_uploaded_file( $file['tmp_name'], $destination ) ) {
return;
}
@chmod( $destination, 0644 );
$new_url = trailingslashit( $uploads['baseurl'] )
. PUA_UPLOAD_SUBDIR
. '/'
. rawurlencode( $filename );
update_user_meta( $user_id, PUA_META_KEY, esc_url_raw( $new_url ) );
if ( $old_url ) {
pua_delete_file( $old_url );
}
}
/*
* Find the local WordPress user represented by get_avatar() input.
*/
function pua_extract_user_id( $id_or_email ) {
if ( $id_or_email instanceof WP_User ) {
return (int) $id_or_email->ID;
}
if ( $id_or_email instanceof WP_Comment ) {
return (int) $id_or_email->user_id;
}
if ( is_object( $id_or_email ) && ! empty( $id_or_email->user_id ) ) {
return (int) $id_or_email->user_id;
}
if ( is_numeric( $id_or_email ) ) {
return (int) $id_or_email;
}
if ( is_string( $id_or_email ) && is_email( $id_or_email ) ) {
$user = get_user_by( 'email', $id_or_email );
return $user ? (int) $user->ID : 0;
}
return 0;
}
/*
* Display the local avatar wherever WordPress calls get_avatar(), including comments.
*/
add_filter( 'get_avatar', 'pua_replace_avatar', 20, 6 );
function pua_replace_avatar( $avatar, $id_or_email, $size, $default_value, $alt, $args ) {
$user_id = pua_extract_user_id( $id_or_email );
if ( ! $user_id ) {
return $avatar;
}
$avatar_url = get_user_meta( $user_id, PUA_META_KEY, true );
if ( ! $avatar_url ) {
return $avatar;
}
$size = max( 1, (int) $size );
$classes = array(
'avatar',
'avatar-' . $size,
'photo',
'pua-local-avatar',
);
if ( ! empty( $args['class'] ) ) {
$additional = is_array( $args['class'] )
? $args['class']
: preg_split( '/\s+/', (string) $args['class'] );
$classes = array_merge( $classes, array_filter( $additional ) );
}
$alt_text = '' !== $alt ? $alt : ( $args['alt'] ?? '' );
return sprintf(
'<img alt="%1$s" src="%2$s" class="%3$s" height="%4$d" width="%4$d" loading="lazy" decoding="async">',
esc_attr( $alt_text ),
esc_url( $avatar_url ),
esc_attr( implode( ' ', array_unique( $classes ) ) ),
$size
);
}
Post-deployment actions
wp-content/uploads/user-avatars/ in the routine backup set alongside the WordPress database dump. User meta rows with key _pua_avatar_url are captured by any full DB dump..disabled suffix; rename back to re-enable.End of session summary.