Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revamp avatar system, remove MC avatars from core #3313

Open
wants to merge 27 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bf886be
initial commit
tadhgboyle Apr 6, 2023
fa80f4b
wip
tadhgboyle Apr 11, 2023
e2a928f
wip
tadhgboyle Apr 15, 2023
7ec6d2a
higher res on leaderboard page
tadhgboyle Apr 15, 2023
eaa1312
fix missing translation
tadhgboyle Apr 15, 2023
68042fd
auto-detect if uuid or not, fix old references
tadhgboyle Apr 20, 2023
a6027de
fix style
tadhgboyle Apr 20, 2023
7540cb1
clear avatar caches
tadhgboyle Apr 21, 2023
dedfd23
fix variable
tadhgboyle Apr 21, 2023
7969f87
fix caching
tadhgboyle Apr 24, 2023
6acd7f2
fix email message
tadhgboyle Apr 24, 2023
524a8e0
don't hardcode source names
tadhgboyle Apr 24, 2023
0af2554
use svg for initials when possible
tadhgboyle Apr 24, 2023
6029208
remove unused terms
tadhgboyle Apr 24, 2023
1f8335c
Merge branch 'develop' into feat/avatar-revamp
tadhgboyle Apr 24, 2023
8ebc31a
clear cache
tadhgboyle Apr 24, 2023
f9adb31
Merge branch 'develop' into feat/avatar-revamp
tadhgboyle May 7, 2023
29e862e
fix merge issues
tadhgboyle May 7, 2023
967b783
fix merge issues
tadhgboyle May 7, 2023
bb8808f
fix merge issues
tadhgboyle May 7, 2023
b781ee1
wip
tadhgboyle May 8, 2023
633d834
wip
tadhgboyle May 8, 2023
b6395a2
Merge branch 'develop' into feat/avatar-revamp
tadhgboyle Jun 9, 2023
304dbb2
Merge branch 'develop' into feat/avatar-revamp
tadhgboyle Jun 9, 2023
b87c366
Merge branch 'develop' into feat/avatar-revamp
tadhgboyle Jun 21, 2023
c4392f2
fix forum post CSS + add mention border
tadhgboyle Jun 25, 2023
f77e8fa
use `Settings` class
tadhgboyle Jun 25, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
252 changes: 69 additions & 183 deletions core/classes/Avatars/AvatarSource.php
@@ -1,241 +1,127 @@
<?php
/**
* Manages avatar sources and provides static methods for fetching avatars.
* Manages avatar sources and provides methods for fetching avatars.
*
* @package NamelessMC\Avatars
* @author Aberdeener
* @version 2.0.0-pr10
* @license MIT
*/
class AvatarSource {
class AvatarSource extends Instanceable {

protected static array $_sources = [];
/** @var AvatarSourceBase[] */
protected array $_sources = [];
private Cache $_cache;

protected static AvatarSourceBase $_active_source;

/**
* Main usage of this class.
* Uses active avatar source to get the URL of their Minecraft avatar.
*
* @param string $uuid UUID of avatar to get.
* @param int $size Size in pixels to render avatar at. Default 128
*
* @return string Compiled URL of avatar image.
*/
public static function getAvatarFromUUID(string $uuid, int $size = 128): string {
return self::getActiveSource()->getAvatar($uuid, self::getDefaultPerspective(), $size);
protected function __construct() {
$this->_cache = new Cache(['name' => 'nameless', 'extension' => '.cache', 'path' => ROOT_PATH . '/cache/']);
}

/**
* Get a user's avatar from their raw data object.
* Used by the API for TinyMCE mention avatars to avoid reloading the user from the database.
*
* @param object $data User data to use
* @param bool $allow_gifs Whether to allow GIFs or not ()
* @param int $size Size in pixels to render avatar at. Default 128
* @param bool $full Whether to return the full URL or just the path
* Get an avatar URL for a user.
*
* @return string Full URL of avatar image.
* @param int|User $user User to fetch avatar for, or their ID.
* @param int $size Size of avatar to fetch in pixels.
* @param bool $full_url Whether to return the full external URL (ie: for display in Discord embed) or just the path.
* @return string The URL to the avatar.
*/
public static function getAvatarFromUserData(object $data, bool $allow_gifs = false, int $size = 128, bool $full = false): string {
// If custom avatars are enabled, first check if they have gravatar enabled, and then fallback to normal image
if (defined('CUSTOM_AVATARS')) {
if ($data->gravatar) {
return 'https://secure.gravatar.com/avatar/' . md5(strtolower(trim($data->email))) . '?s=' . $size;
}

if ($data->has_avatar) {
$exts = ['png', 'jpg', 'jpeg'];

if ($allow_gifs) {
$exts[] = 'gif';
}

foreach ($exts as $ext) {
if (file_exists(ROOT_PATH . '/uploads/avatars/' . $data->id . '.' . $ext)) {
// We don't check the validity here since we know the file exists for sure
return ($full ? rtrim(URL::getSelfURL(), '/') : '') . ((defined('CONFIG_PATH')) ? CONFIG_PATH . '/' : '/') . 'uploads/avatars/' . $data->id . '.' . $ext . '?v=' . urlencode($data->avatar_updated);
}
}
}
public function getAvatarForUser($user, int $size = 128, bool $full_url = false): string {
if ($user instanceof User) {
$user_id = $user->data()->id;
} else {
$user_id = (int) $user;
}

// Fallback to default avatar image if it is set and the avatar type is custom
if (defined('DEFAULT_AVATAR_TYPE') && DEFAULT_AVATAR_TYPE == 'custom' && DEFAULT_AVATAR_IMAGE !== '') {
if (file_exists(ROOT_PATH . '/uploads/avatars/defaults/' . DEFAULT_AVATAR_IMAGE)) {
// We don't check the validity here since we know the file exists for sure
return ($full ? rtrim(URL::getSelfURL(), '/') : '') . ((defined('CONFIG_PATH')) ? CONFIG_PATH . '/' : '/') . 'uploads/avatars/defaults/' . DEFAULT_AVATAR_IMAGE;
$this->_cache->setCache('avatars');

foreach ($this->getAllSources() as $source) {
if (!$source->isEnabled() && $source->canBeDisabled()) {
continue;
}
}

// Attempt to get their MC avatar if Minecraft integration is enabled
if (Util::getSetting('mc_integration')) {
if ($data->uuid != null && $data->uuid != 'none') {
$uuid = $data->uuid;
} else {
$uuid = $data->username;
// Fallback to steve avatar if they have an invalid username
if (preg_match('#[^][_A-Za-z0-9]#', $uuid)) {
$uuid = 'Steve';
$cache_key = $user_id . '_' . $source->getSafeName() . '_' . $size . '_' . (int) $full_url;
if ($this->_cache->isCached($cache_key)) {
$avatar = $this->_cache->retrieve($cache_key);
if ($avatar) {
return $avatar;
}
}

$url = self::getAvatarFromUUID($uuid, $size);
// The avatar might be invalid if they are using
// an MC avatar service that uses only UUIDs
// and this user doesn't have one
if (self::validImageUrl($url)) {
return $url;
if (!($user instanceof User)) {
$user = new User($user_id);
}
}

return "https://api.dicebear.com/5.x/initials/png?seed={$data->username}&size={$size}";
}

/**
* Determine if a URL is a valid image URL for avatars.
*
* @param string $url URL to check
* @return bool Whether the URL is a valid image URL
*/
private static function validImageUrl(string $url): bool {
$cache = new Cache(['name' => 'nameless', 'extension' => '.cache', 'path' => ROOT_PATH . '/cache/']);
$cache->setCache('avatar_validity');

if ($cache->isCached($url)) {
return $cache->retrieve($url);
}

$is_valid = false;
try {
$response = HttpClient::createClient()->head($url);
$headers = $response->getHeaders();
if (isset($headers['Content-Type']) && $headers['Content-Type'][0] === 'image/png') {
$is_valid = true;
$avatar = $source->getAvatar($user, $size, $full_url);
if ($avatar) {
$url = $avatar;
// Cache for an hour incase a module does not reset the users avatar cache
$this->_cache->store($cache_key, $url, 3600);
break;
}
} catch (Exception $ignored) {
}

$cache->store($url, $is_valid, 3600);
return $is_valid;
}
// Fallback to initials avatar
if (!isset($url)) {
$url = $this->_sources[InitialsAvatarSource::class]->getAvatar($user, $size, $full_url);
}

/**
* Get the currently active avatar source.
*
* @return AvatarSourceBase The active source.
*/
public static function getActiveSource(): AvatarSourceBase {
return self::$_active_source;
return $url;
}

/**
* Set the active source to the source by name.
* Fallsback to Cravatar if name was not found.
*
* @param string $name Name of source to set as active.
* @param int|User $user
* @param string|null $source_class
* @return void
*/
public static function setActiveSource(string $name): void {
$source = self::getSourceByName($name);
if ($source === null) {
$source = self::getSourceByName('cravatar');
public function clearUserAvatarCache($user, string $source_class = null): void {
if ($user instanceof User) {
$user_id = $user->data()->id;
} else {
$user_id = (int) $user;
}

self::$_active_source = $source;
}
$this->_cache->setCache('avatars');

/**
* Get default perspective to pass to the active avatar source.
*
* @return string Perspective.
*/
private static function getDefaultPerspective(): string {
if (defined('DEFAULT_AVATAR_PERSPECTIVE')) {
return DEFAULT_AVATAR_PERSPECTIVE;
foreach (array_keys($this->_cache->retrieveAll()) as $cache_key) {
if (str_starts_with($cache_key, $user_id . '_' . ($source_class ?? ''))) {
$this->_cache->erase($cache_key);
}
}

return 'face';
}

/**
* Find an avatar source instance by it's name.
*
* @return AvatarSourceBase|null Instance if found, null if not found.
*/
public static function getSourceByName(string $name): ?AvatarSourceBase {
foreach (self::getAllSources() as $source) {
if (strtolower($source->getName()) == strtolower($name)) {
return $source;
public function clearSourceAvatarCache(string $source_class): void {
$this->_cache->setCache('avatars');

foreach (array_keys($this->_cache->retrieveAll()) as $cache_key) {
if (str_contains($cache_key, $source_class)) {
$this->_cache->erase($cache_key);
}
}

return null;
}

/**
* Get all registered sources.
*
* @return AvatarSourceBase[]
*/
public static function getAllSources(): iterable {
return self::$_sources;
public function getAllSources(): array {
$sources = $this->_sources;
uasort($sources, static function (AvatarSourceBase $a, AvatarSourceBase $b) {
return $a->getOrder() - $b->getOrder();
});
return $sources;
}

/**
* Get raw url of active avatar source with placeholders.
*
* @return string URL with placeholders.
*/
public static function getUrlToFormat(): string {
// Default to Cravatar
if (!isset(self::$_active_source)) {
require_once(ROOT_PATH . '/modules/Core/classes/Avatars/CravatarAvatarSource.php');
return (new CravatarAvatarSource())->getUrlToFormat(self::getDefaultPerspective());
}

return self::getActiveSource()->getUrlToFormat(self::getDefaultPerspective());
public function getSourceBySafeName(string $safe_name): ?AvatarSourceBase {
return $this->_sources[$safe_name] ?? null;
}

/**
* Register avatar source.
*
* @param AvatarSourceBase $source Instance of avatar source to register.
*/
public static function registerSource(AvatarSourceBase $source): void {
self::$_sources[] = $source;
}

/**
* Get the names and base urls of all the registered avatar sources for displaying.
* Used for showing list of sources in staffcp.
*
* @return array<string, string> List of names.
*/
public static function getAllSourceNames(): array {
$names = [];

foreach (self::getAllSources() as $source) {
$names[$source->getName()] = rtrim($source->getBaseUrl(), '/');
}

return $names;
}

/**
* Get key value array of all registered sources and their available perspectives.
* Used for autoupdating dropdown selector in staffcp.
*
* @return array<string, array<string>> Array of source => [] perspectives.
*/
public static function getAllPerspectives(): array {
$perspectives = [];

foreach (self::getAllSources() as $source) {
foreach ($source->getPerspectives() as $perspective) {
$perspectives[$source->getName()][] = $perspective;
}
}

return $perspectives;
public function registerSource(AvatarSourceBase $source): void {
$this->_sources[$source->getSafeName()] = $source;
}
}