Files
kate522 6f29e3d865 fix(oauth): actually use the third-party avatar and seed it only once
The OAuth flow wrote the provider avatar to `jio_oauth_avatar`, but nothing
ever read it — every user who signed up through GitHub/QQ saw a Gravatar hash
or the default avatar instead of their own picture. The reference seeds its
`custom_avatar` from the OAuth profile and its resolver reads it back, so both
halves were missing here.

jio_get_custom_avatar_url() now falls back to the stored OAuth avatar when no
uploaded one exists, which puts the chain at uploaded → third-party → external
service → default. The theme already swaps WordPress's get_avatar_data, so one
change covers every avatar surface.

Writes go through a single seed_avatar() guard: it no-ops when an uploaded
avatar or an existing OAuth avatar is present, matching the reference's
`if ($args['avatar'] && !$custom_avatar)`. Previously both the bind path and
the login refresh overwrote the meta unconditionally, which would have wiped a
user's own picture on their next social login.

Because the OAuth avatar now shows through once the upload is gone, both
"restore default" flows (the REST endpoint and the admin profile box) delete
it too; the admin checkbox also appears when only an OAuth avatar is set,
since $avatar_url is now non-empty in that case.

Baidu serves avatars from tb.himg.baidu.com, which 403s — rewritten to
himg.bdimg.com the way the reference does. On top of that the URL is upgraded
to https when the site runs over SSL, because the reference's scheme-relative
form still yields a plain http:// when copied verbatim, and browsers block it
as mixed content.

WeChatShare was reading `custom_avatar` — a reference-theme key this codebase
never writes — so its author branch always fell through to get_avatar_url();
it now uses the shared resolver like every other surface.

Verification: drove the resolver through five states with a stub (none /
OAuth only / both / upload only / Baidu legacy host) and confirmed the
expected URL each time. Added oauth-avatar-contract.test.cjs to pin the
resolution order, the seed guard, and the reset cleanup. 868 widget and
navigation tests pass; the PSR-4 path audit reports 0 errors.
2026-11-04 17:41:48 +08:00

363 lines
12 KiB
PHP

<?php
/**
* Unified frontend avatar helpers.
*
* @package Jio
*/
defined('ABSPATH') || exit;
if (!function_exists('jio_resolve_avatar_user_id')) {
/**
* Resolve a WordPress user ID from an avatar subject.
*
* @param mixed $subject User ID, WP_User, comment, post, or email.
*/
function jio_resolve_avatar_user_id($subject): int {
if ($subject instanceof WP_User) {
return (int) $subject->ID;
}
if ($subject instanceof WP_Post) {
return (int) $subject->post_author;
}
if ($subject instanceof WP_Comment) {
return (int) $subject->user_id;
}
if (is_object($subject) && isset($subject->user_id)) {
return (int) $subject->user_id;
}
if (is_numeric($subject)) {
return max(0, (int) $subject);
}
if (is_string($subject) && is_email($subject)) {
$user = get_user_by('email', $subject);
return $user instanceof WP_User ? (int) $user->ID : 0;
}
return 0;
}
}
if (!function_exists('jio_resolve_avatar_email')) {
/**
* Resolve an email address from an avatar subject.
*
* @param mixed $subject User ID, WP_User, comment, post, or email.
*/
function jio_resolve_avatar_email($subject): string {
if ($subject instanceof WP_User) {
return sanitize_email((string) $subject->user_email);
}
if ($subject instanceof WP_Comment && is_email((string) $subject->comment_author_email)) {
return sanitize_email((string) $subject->comment_author_email);
}
if (is_object($subject) && isset($subject->comment_author_email) && is_email((string) $subject->comment_author_email)) {
return sanitize_email((string) $subject->comment_author_email);
}
if (is_string($subject) && is_email($subject)) {
return sanitize_email($subject);
}
$user_id = jio_resolve_avatar_user_id($subject);
$user = $user_id > 0 ? get_userdata($user_id) : false;
return $user instanceof WP_User ? sanitize_email((string) $user->user_email) : '';
}
}
if (!function_exists('jio_get_user_feature_settings')) {
/**
* Return the canonical user media and signature settings.
*
* @return array{avatar_upload_s:bool,avatar_external:string,avatar_default_img:string,user_cover_upload_s:bool,user_cover_img:string,user_desc_std:string}
*/
function jio_get_user_feature_settings(): array {
if (class_exists('\Jio\Settings\Settings')) {
return \Jio\Settings\Settings::get_user_feature_settings();
}
return [
'avatar_upload_s' => true,
'avatar_external' => '',
'avatar_default_img' => get_theme_file_uri('assets/img/avatar-default.png'),
'user_cover_upload_s' => true,
'user_cover_img' => get_theme_file_uri('assets/img/user_t.jpg'),
'user_desc_std' => '这家伙很懒,什么都没有写...',
];
}
}
if (!function_exists('jio_get_default_avatar_url')) {
/**
* Return the bundled avatar used when a user has no custom image.
*/
function jio_get_default_avatar_url(): string {
$settings = jio_get_user_feature_settings();
return (string) apply_filters(
'jio_default_avatar_url',
(string) $settings['avatar_default_img']
);
}
}
if (!function_exists('jio_get_external_avatar_url')) {
/**
* Return the configured external avatar for a subject.
*
* @param mixed $subject User subject.
*/
function jio_get_external_avatar_url($subject, int $size = 96): string {
$settings = jio_get_user_feature_settings();
$hosts = [
'gravatar' => 'https://www.gravatar.com/avatar/',
'weavatar' => 'https://weavatar.com/avatar/',
'cravatar' => 'https://cn.cravatar.com/avatar/',
'geekzu' => 'https://sdn.geekzu.org/avatar/',
'loli' => 'https://gravatar.loli.net/avatar/',
'sep' => 'https://cdn.sep.cc/avatar/',
'v2ex' => 'https://cdn.v2ex.com/gravatar/',
];
$host = $hosts[(string) $settings['avatar_external']] ?? '';
$email = jio_resolve_avatar_email($subject);
if ($host === '' || $email === '') {
return '';
}
$size = max(1, min(2048, $size));
return add_query_arg([
's' => $size,
'd' => jio_get_default_avatar_url(),
], $host . hash('sha256', strtolower(trim($email))));
}
}
if (!function_exists('jio_get_custom_avatar_url')) {
/**
* Return the avatar that replaces the external/default one.
*
* Uploaded avatars win; the third-party avatar stored at registration or
* binding fills the gap until the user uploads their own — the reference
* seeds its equivalent from the OAuth profile only when none exists.
*/
function jio_get_custom_avatar_url(int $user_id, int $size = 96): string {
if ($user_id <= 0) {
return '';
}
$attachment_id = (int) get_user_meta($user_id, 'jio_avatar_attachment_id', true);
if ($attachment_id > 0) {
$attachment = get_post($attachment_id);
if ($attachment instanceof WP_Post && $attachment->post_type === 'attachment' && (int) $attachment->post_author === $user_id && wp_attachment_is_image($attachment_id)) {
$image = wp_get_attachment_image_url($attachment_id, [$size, $size]);
if (is_string($image) && $image !== '') {
return $image;
}
}
}
return jio_get_oauth_avatar_url($user_id);
}
}
if (!function_exists('jio_get_oauth_avatar_url')) {
/**
* Return the avatar captured from the user's third-party profile.
*
* Providers hand back CDN URLs that go stale or start blocking hotlinks;
* Baidu in particular serves `tb.himg.baidu.com` which 403s, so the host is
* rewritten the way the reference does before the URL is used.
*/
function jio_get_oauth_avatar_url(int $user_id): string {
if ($user_id <= 0) {
return '';
}
$url = trim((string) get_user_meta($user_id, 'jio_oauth_avatar', true));
if ($url === '') {
return '';
}
$url = (string) str_replace('tb.himg.baidu.com', 'himg.bdimg.com', $url);
$url = esc_url_raw($url);
if (!is_http_url($url)) {
return '';
}
// 第三方 CDN 常回 http://,在 HTTPS 站点上会被浏览器当作混合内容拦截;
// 参考主题用去掉协议头的写法规避,这里直接对齐站点协议。
if (is_ssl()) {
$url = (string) preg_replace('#^http://#i', 'https://', $url);
}
return $url;
}
}
if (!function_exists('jio_get_avatar_url')) {
/**
* Resolve the canonical avatar URL used by all Jio frontend surfaces.
*
* @param mixed $subject User subject accepted by get_avatar_url().
* @param array|int $args WordPress avatar args or a numeric size.
*/
function jio_get_avatar_url($subject, $args = []): string {
if (is_numeric($args)) {
$args = ['size' => (int) $args];
}
$args = is_array($args) ? $args : [];
$size = max(16, min(512, (int) ($args['size'] ?? 96)));
$user_id = jio_resolve_avatar_user_id($subject);
$custom = jio_get_custom_avatar_url($user_id, $size);
if ($custom !== '') {
return $custom;
}
$external = jio_get_external_avatar_url($subject, $size);
if ($external !== '') {
return $external;
}
return jio_get_default_avatar_url();
}
}
if (!function_exists('jio_get_avatar_html')) {
/**
* Render a canonical avatar image.
*
* @param mixed $subject User subject.
* @param int $size Pixel size.
* @param array<string,mixed> $attrs Additional safe image attributes.
*/
function jio_get_avatar_html($subject, int $size = 96, array $attrs = []): string {
$size = max(16, min(512, $size));
$url = jio_get_avatar_url($subject, ['size' => $size]);
if ($url === '') {
return '';
}
$defaults = [
'class' => 'avatar avatar-' . $size . ' photo',
'alt' => '',
'width' => (string) $size,
'height' => (string) $size,
];
$attrs = array_merge($defaults, $attrs);
unset($attrs['loading']);
$class = (string) ($attrs['class'] ?? $defaults['class']);
unset($attrs['class']);
$html_attrs = '';
foreach ($attrs as $name => $value) {
$name = sanitize_key((string) $name);
if ($name === '' || $value === false || $value === null) {
continue;
}
$html_attrs .= ' ' . $name . '="' . esc_attr((string) $value) . '"';
}
return '<img' . jio_get_lazy_attr('lazy_avatar', $url, $class, jio_get_default_avatar_url()) . $html_attrs . '>';
}
}
add_filter('get_avatar_data', static function (array $args, $subject): array {
$args['url'] = jio_get_avatar_url($subject, ['size' => (int) ($args['size'] ?? 96)]);
$args['found_avatar'] = true;
return $args;
}, 20, 2);
if (!function_exists('jio_get_avatar_box')) {
/**
* legacy parity: 头像框(span.avatar-img + VIP 角标 + 可选主页链接)。
*
* @param mixed $id_or_email 用户 ID / WP_User / 邮箱。
* @param string $class 外层 span 类(avatar-img avatar-lg 等)。
* @param bool $link 是否包一层用户主页链接。
* @param bool $vip 是否叠加 VIP 角标图。
*/
function jio_get_avatar_box($id_or_email, string $class = 'avatar-img', bool $link = true, bool $vip = true): string {
$user_id = jio_resolve_avatar_user_id($id_or_email);
$subject = $user_id > 0 ? $user_id : $id_or_email;
$img = jio_get_avatar_html($subject, 80, ['class' => 'avatar']);
$badge = '';
if ($vip && $user_id > 0) {
// 禁封角标覆盖 VIP 角标位(参考主题 user_avatar_badge 过滤器覆盖语义)。
$ban_badge = function_exists('jio_get_user_ban_avatar_badge')
? jio_get_user_ban_avatar_badge($user_id)
: '';
if ($ban_badge !== '') {
$badge = $ban_badge;
} elseif (function_exists('jio_get_user_vip_level') && jio_get_user_vip_level($user_id) > 0) {
$icon_url = '';
if (class_exists('\Jio\Settings\Settings')) {
$vip_settings = \Jio\Settings\Settings::get_payment_vip_settings();
$level = jio_get_user_vip_level($user_id);
$icon_url = esc_url((string) ($vip_settings['levels'][$level]['img_icon'] ?? ''));
}
if ($icon_url !== '') {
$badge_text = function_exists('jio_get_vip_badge_text') ? jio_get_vip_badge_text($user_id) : 'VIP';
$badge = '<img class="avatar-badge" src="' . $icon_url . '" title="' . esc_attr($badge_text) . '" alt="' . esc_attr($badge_text) . '">';
}
}
}
$html = '<span class="' . esc_attr($class) . '">' . $img . $badge . '</span>';
if ($link && $user_id > 0) {
$home = function_exists('jio_get_user_home_url') ? jio_get_user_home_url($user_id) : get_author_posts_url($user_id);
$html = '<a href="' . esc_url($home) . '">' . $html . '</a>';
}
return $html;
}
}
add_action('user_edit_form_tag', static function (): void {
echo ' enctype="multipart/form-data"';
});
if (!function_exists('jio_is_avatar_upload_enabled')) {
/**
* Whether local avatar uploads are enabled by attachment policy.
*/
function jio_is_avatar_upload_enabled(): bool {
$settings = jio_get_user_feature_settings();
return !empty($settings['avatar_upload_s']);
}
}
if (!function_exists('jio_is_user_cover_upload_enabled')) {
function jio_is_user_cover_upload_enabled(): bool {
$settings = jio_get_user_feature_settings();
return !empty($settings['user_cover_upload_s']);
}
}
if (!function_exists('jio_get_user_cover_url')) {
function jio_get_user_cover_url(int $user_id): string {
$custom = $user_id > 0 ? esc_url_raw((string) get_user_meta($user_id, 'jio_background_image', true)) : '';
if ($custom !== '') {
return $custom;
}
$settings = jio_get_user_feature_settings();
return (string) $settings['user_cover_img'];
}
}
if (!function_exists('jio_get_user_description')) {
function jio_get_user_description(int $user_id): string {
$description = $user_id > 0 ? trim((string) get_user_meta($user_id, 'description', true)) : '';
if ($description !== '') {
return $description;
}
$settings = jio_get_user_feature_settings();
return (string) $settings['user_desc_std'];
}
}
if (!function_exists('jio_delete_owned_avatar_attachment')) {
/**
* Delete an avatar attachment only when it belongs to the target user.
*/
function jio_delete_owned_avatar_attachment(int $attachment_id, int $user_id): bool {
$attachment = $attachment_id > 0 ? get_post($attachment_id) : null;
if (!$attachment instanceof WP_Post || $attachment->post_type !== 'attachment' || (int) $attachment->post_author !== $user_id) {
return false;
}
return (bool) wp_delete_attachment($attachment_id, true);
}
}