avatar-privacy/includes/avatar-privacy/core/class-user-fields.php

526 lines
15 KiB
PHP

<?php
/**
* This file is part of Avatar Privacy.
*
* Copyright 2018-2023 Peter Putzer.
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* ***
*
* @package mundschenk-at/avatar-privacy
* @license http://www.gnu.org/licenses/gpl-2.0.html
*/
namespace Avatar_Privacy\Core;
use Avatar_Privacy\Core\API;
use Avatar_Privacy\Data_Storage\Filesystem_Cache;
use Avatar_Privacy\Exceptions\Upload_Handling_Exception; // phpcs:ignore ImportDetection.Imports.RequireImports -- needed for PHPDoc annotation.
use Avatar_Privacy\Tools\Hasher;
use Avatar_Privacy\Tools\Images\Image_File;
use Avatar_Privacy\Upload_Handlers\User_Avatar_Upload_Handler;
use function Avatar_Privacy\Tools\delete_file;
/**
* The API for handling data attached to registered users as part of the
* Avatar Privacy Core API.
*
* @since 2.4.0
*
* @author Peter Putzer <github@mundschenk.at>
*
* @phpstan-type AvatarDefinition array{ file: string, type: string }
*/
class User_Fields implements API {
/**
* The user meta key for the hashed email.
*
* @var string
*/
const EMAIL_HASH_META_KEY = 'avatar_privacy_hash';
/**
* The user meta key for the gravatar use flag.
*
* @var string
*/
const GRAVATAR_USE_META_KEY = 'avatar_privacy_use_gravatar';
/**
* The user meta key for the gravatar use flag.
*
* @var string
*/
const ALLOW_ANONYMOUS_META_KEY = 'avatar_privacy_allow_anonymous';
/**
* The user meta key for the local avatar.
*
* @var string
*/
const USER_AVATAR_META_KEY = 'avatar_privacy_user_avatar';
/**
* The hashing helper.
*
* @var Hasher
*/
private Hasher $hasher;
/**
* The filesystem cache handler.
*
* @var Filesystem_Cache
*/
private Filesystem_Cache $file_cache;
/**
* The image file handler.
*
* @var Image_File
*/
private Image_File $image_file;
/**
* A request-level cache for user lookups.
*
* @var array<string,\WP_User|null>
*/
private array $user_by_email = [];
/**
* Creates a new instance.
*
* @param Hasher $hasher The hashing helper..
* @param Filesystem_Cache $file_cache The file cache handler.
* @param Image_File $image_file The image file handler.
*/
public function __construct( Hasher $hasher, Filesystem_Cache $file_cache, Image_File $image_file ) {
$this->hasher = $hasher;
$this->file_cache = $file_cache;
$this->image_file = $image_file;
}
/**
* Retrieves the hash for the given user ID. If there currently is no hash,
* a new one is generated.
*
* @param int $user_id The user ID.
*
* @return string|false The hashed email, or `false` on failure.
*/
public function get_hash( $user_id ) {
$hash = \get_user_meta( $user_id, self::EMAIL_HASH_META_KEY, true );
if ( ! \is_string( $hash ) || empty( $hash ) ) {
$user = \get_user_by( 'ID', $user_id );
if ( empty( $user->user_email ) ) {
return false;
}
$hash = $this->hasher->get_hash( $user->user_email );
\update_user_meta( $user_id, self::EMAIL_HASH_META_KEY, $hash );
}
return $hash;
}
/**
* Retrieves a user by email hash.
*
* @param string $hash The user's email hash.
*
* @return \WP_User|null
*/
public function get_user_by_hash( $hash ) {
// No extra caching necessary, WP Core already does that for us.
$args = [
'number' => 1,
'meta_key' => self::EMAIL_HASH_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $hash, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'meta_compare' => '=',
];
$users = \get_users( $args );
if ( empty( $users ) ) {
return null;
}
return $users[0];
}
/**
* Retrieves a user by email.
*
* This method differs from `get_user_by` in that it caches the result even
* if no user is found for the duration of the request.
*
* @since 2.6.0
*
* @param string $email The email to query.
*
* @return \WP_User|null
*/
public function get_user_by_email( $email ) {
if ( isset( $this->user_by_email[ $email ] ) || \array_key_exists( $email, $this->user_by_email ) ) {
return $this->user_by_email[ $email ];
}
$user = \get_user_by( 'email', $email );
if ( empty( $user ) ) {
$user = null;
}
// Cache lookup result.
$this->user_by_email[ $email ] = $user;
return $user;
}
/**
* Retrieves the full-size local avatar for a user (if one exists).
*
* @param int $user_id The user ID.
*
* @return array {
* An avatar definition, or the empty array.
*
* @type string $file The local filename.
* @type string $type The MIME type.
* }
*
* @phpstan-return AvatarDefinition|array{}
*/
public function get_local_avatar( $user_id ) {
/**
* Filters whether to retrieve the user avatar early. If the filtered result
* contains both a filename and a MIME type, those will be returned immediately.
*
* @since 2.2.0
*
* @param array|null $avatar {
* Optional. The user avatar information. Default null.
*
* @type string $file The local filename.
* @type string $type The MIME type.
* }
* @param int $user_id The user ID.
*/
$avatar = \apply_filters( 'avatar_privacy_pre_get_user_avatar', null, $user_id );
if ( ! empty( $avatar ) && ! empty( $avatar['file'] ) && ! empty( $avatar['type'] ) ) {
return $avatar;
}
$avatar = \get_user_meta( $user_id, self::USER_AVATAR_META_KEY, true );
if ( ! \is_array( $avatar ) ) {
$avatar = [];
}
return $avatar;
}
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- until PHPCS bug is fixed.
/**
* Sets the local avatar for the given user.
*
* Please note that the calling function is responsible for cleaning up the
* provided image if it is a temporary file (i.e the image is copied before
* being used as the new avatar).
*
* @param int $user_id The user ID.
* @param string $image_url The image URL or filename.
*
* @return void
*
* @throws \InvalidArgumentException An exception is thrown if the user ID does
* not exist or the upload result does not
* contain the 'file' key.
* @throws Upload_Handling_Exception An exceptions is thrown if the sideloading
* fails for some reason.
*/
public function set_local_avatar( $user_id, $image_url ) {
$filename = \parse_url( $image_url, \PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- we only support PHP 7.0 and higher.
if ( empty( $filename ) ) {
throw new \InvalidArgumentException( "Malformed URL {$image_url}" );
}
// Prepare arguments.
$overrides = [
'global_upload' => true,
'upload_dir' => User_Avatar_Upload_Handler::UPLOAD_DIR,
'filename' => $this->get_local_avatar_filename( $user_id, $filename ),
];
$sideloaded_avatar = $this->image_file->handle_sideload( $image_url, $overrides );
$this->set_uploaded_local_avatar( $user_id, $sideloaded_avatar );
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- until PHPCS bug is fixed.
/**
* Sets the local avatar to the uploaded image.
*
* @internal
*
* @param int $user_id The user ID.
* @param string[] $uploaded_avatar {
* The uploaded avatar information (the result of Image_File::handle_upload()).
*
* @type string $file The image file path.
* @type string $type The MIME type of the uploaded image.
* }
*
* @return void
*
* @throws \InvalidArgumentException An exception is thrown if the user ID does
* not exist or the upload result does not
* contain the 'file' key.
*/
public function set_uploaded_local_avatar( $user_id, $uploaded_avatar ) {
if ( ! $this->user_exists( $user_id ) ) {
throw new \InvalidArgumentException( "Invalid user ID {$user_id}" );
} elseif ( empty( $uploaded_avatar['file'] ) ) {
throw new \InvalidArgumentException( 'Missing upload file path' );
} elseif ( empty( $uploaded_avatar['type'] ) ) {
throw new \InvalidArgumentException( 'Missing image MIME type' );
} elseif ( ! isset( Image_File::FILE_EXTENSION[ $uploaded_avatar['type'] ] ) ) {
throw new \InvalidArgumentException( "Invalid MIME type {$uploaded_avatar['type']}" );
}
// Delete old images.
$this->delete_local_avatar( $user_id );
// Save user information (overwriting previous).
\update_user_meta( $user_id, self::USER_AVATAR_META_KEY, $uploaded_avatar );
}
/**
* Checks whether the given user ID is valid.
*
* @param int $user_id The user ID.
*
* @return bool
*/
protected function user_exists( $user_id ) {
$args = [
'include' => [ $user_id ],
'fields' => 'ID',
];
if ( \is_network_admin() ) {
$args['blog_id'] = 0;
}
return (bool) \get_users( $args );
}
/**
* Deletes the local avatar of the given user.
*
* @param int $user_id The user ID.
*
* @return bool
*/
public function delete_local_avatar( $user_id ) {
// Invalidate cached avatar images.
$this->invalidate_local_avatar_cache( $user_id );
// Delete original upload.
$avatar = \get_user_meta( $user_id, self::USER_AVATAR_META_KEY, true );
if ( \is_array( $avatar ) && ! empty( $avatar['file'] ) && \is_string( $avatar['file'] ) && \file_exists( $avatar['file'] ) && delete_file( $avatar['file'] ) ) {
return \delete_user_meta( $user_id, self::USER_AVATAR_META_KEY );
}
return false;
}
/**
* Invalidates cached avatar images.
*
* @param int $user_id The user ID.
*
* @return void
*/
public function invalidate_local_avatar_cache( $user_id ) {
$hash = $this->get_hash( $user_id );
if ( ! empty( $hash ) ) {
$this->file_cache->invalidate( 'user', "#/{$hash}-[1-9][0-9]*\.[a-z]{3}\$#" );
}
}
/**
* Retrieves the base filename (without the extension) for a local avatar image
* for the given user.
*
* @internal
*
* @param int $user_id The user ID.
* @param string $filename The original filename.
*
* @return string
*/
public function get_local_avatar_filename( $user_id, $filename ) {
$user = \get_user_by( 'id', $user_id );
if ( ! $user instanceof \WP_User ) {
return $filename;
}
$extension = \pathinfo( $filename, \PATHINFO_EXTENSION );
return \sanitize_file_name( "{$user->display_name}_avatar.{$extension}" );
}
/**
* Checks whether a user has opted-in to Gravatar usage.
*
* @param int $user_id The user ID.
*
* @return bool
*/
public function allows_gravatar_use( $user_id ) {
return 'true' === \get_user_meta( $user_id, self::GRAVATAR_USE_META_KEY, true );
}
/**
* Checks whether a user has set a Gravatar usage policy.
*
* @param int $user_id The user ID.
*
* @return bool
*/
public function has_gravatar_policy( $user_id ) {
return ! empty( \get_user_meta( $user_id, self::GRAVATAR_USE_META_KEY, true ) );
}
/**
* Updates a user's gravatar policy.
*
* @param int $user_id The user ID.
* @param bool $use_gravatar Whether using Gravatar should be allowed or not.
*
* @return void
*/
public function update_gravatar_use( $user_id, $use_gravatar ) {
// Use true/false instead of 1/0 since a '0' value is removed from
// the database and then we can't differentiate between "has opted-out"
// and "never saved a value".
\update_user_meta( $user_id, self::GRAVATAR_USE_META_KEY, $use_gravatar ? 'true' : 'false' );
}
/**
* Checks whether a user has opted-in to anonymous commenting.
*
* @param int $user_id The user ID.
*
* @return bool
*/
public function allows_anonymous_commenting( $user_id ) {
return 'true' === \get_user_meta( $user_id, self::ALLOW_ANONYMOUS_META_KEY, true );
}
/**
* Checks whether a user has set an anonymous commenting policy.
*
* @param int $user_id The user ID.
*
* @return bool
*/
public function has_anonymous_commenting_policy( $user_id ) {
return ! empty( \get_user_meta( $user_id, self::ALLOW_ANONYMOUS_META_KEY, true ) );
}
/**
* Updates a user's anonymous commenting policy.
*
* @param int $user_id The user ID.
* @param bool $anonymous Whether anonymous commenting should be allowed or not.
*
* @return void
*/
public function update_anonymous_commenting( $user_id, $anonymous ) {
// Use true/false instead of 1/0 since a '0' value is removed from
// the database and then we can't differentiate between "has opted-out"
// and "never saved a value".
\update_user_meta( $user_id, self::ALLOW_ANONYMOUS_META_KEY, $anonymous ? 'true' : 'false' );
}
/**
* Deletes the stored metadata for a user.
*
* Currently this includes:
* - the email hash,
* - the Gravatar usage policy,
* - the anonymous commenting policy, and
* - the local avatar.
*
* @internal
*
* @param int $user_id The user ID.
*
* @return int The number of removed metadata fields.
*/
public function delete( $user_id ) {
$count = 0;
// Delete the "simple" meta fields.
$count += (int) \delete_user_meta( $user_id, self::EMAIL_HASH_META_KEY );
$count += (int) \delete_user_meta( $user_id, self::GRAVATAR_USE_META_KEY );
$count += (int) \delete_user_meta( $user_id, self::ALLOW_ANONYMOUS_META_KEY );
// Also delete a local avatar if one has been set (including all image files).
$count += (int) $this->delete_local_avatar( $user_id );
return $count;
}
/**
* Removes local avatar files "orphaned" by the deletion of the referencing
* user meta data (e.g. when a user is deleted).
*
* @internal
*
* @since 2.5.2
*
* @param string[] $meta_ids An array of metadata entry IDs to delete.
* @param int $object_id ID of the object metadata is for.
* @param string $meta_key Metadata key.
* @param mixed $meta_value Metadata value.
*
* @return void
*/
public function remove_orphaned_local_avatar( array $meta_ids, $object_id, $meta_key, $meta_value ) {
if ( self::USER_AVATAR_META_KEY !== $meta_key ) {
return;
}
/**
* The filter provides inconsistent data depending on whether it is called
* by `delete_metadata` or `delete_metadata_by_mid` (@see https://core.trac.wordpress.org/ticket/53102).
* When run through `delete_metadata`, `$meta_value` is equal to the optional
* argument of the same name, not the actual metadata value.
*
* Fortunately, both `wp_delete_user` and `wpmu_delete_user` use `delete_metadata_by_mid`,
* so we can use `$meta_value`. Contrary to the documentation, non-scalar
* values are not serialized.
*/
if ( \is_array( $meta_value ) && ! empty( $meta_value['file'] ) && \is_string( $meta_value['file'] ) && \file_exists( $meta_value['file'] ) ) {
delete_file( $meta_value['file'] );
}
}
}