diff --git a/inc/helpers/jio-avatar.php b/inc/helpers/jio-avatar.php index 032eedac..05d82f38 100644 --- a/inc/helpers/jio-avatar.php +++ b/inc/helpers/jio-avatar.php @@ -131,7 +131,11 @@ if (!function_exists('jio_get_external_avatar_url')) { if (!function_exists('jio_get_custom_avatar_url')) { /** - * Return the locally uploaded 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) { @@ -139,17 +143,51 @@ if (!function_exists('jio_get_custom_avatar_url')) { } $attachment_id = (int) get_user_meta($user_id, 'jio_avatar_attachment_id', true); - if ($attachment_id <= 0) { + 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 ''; } - $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)) { + $url = trim((string) get_user_meta($user_id, 'jio_oauth_avatar', true)); + if ($url === '') { return ''; } - $image = wp_get_attachment_image_url($attachment_id, [$size, $size]); - return is_string($image) ? $image : ''; + $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; } } diff --git a/src/Admin/UserControllerProfileMediaOperations.php b/src/Admin/UserControllerProfileMediaOperations.php index fb59c226..b9b7cf90 100644 --- a/src/Admin/UserControllerProfileMediaOperations.php +++ b/src/Admin/UserControllerProfileMediaOperations.php @@ -27,6 +27,8 @@ trait UserControllerProfileMediaOperations { $attachment_id = (int) get_user_meta($user_id, 'jio_avatar_attachment_id', true); delete_user_meta($user_id, 'jio_avatar_attachment_id'); + // 第三方头像会在上传头像被清除后重新顶上来,恢复默认时必须一并丢弃。 + delete_user_meta($user_id, 'jio_oauth_avatar'); $this->delete_owned_profile_attachment($attachment_id, $user_id); do_action('jio_user_avatar_changed', $user_id, 0, ''); diff --git a/src/Auth/OAuthAccountService.php b/src/Auth/OAuthAccountService.php index 74e083c8..9fcd0857 100644 --- a/src/Auth/OAuthAccountService.php +++ b/src/Auth/OAuthAccountService.php @@ -71,9 +71,7 @@ final class OAuthAccountService { $bindings[] = $provider; } update_user_meta($user_id, 'jio_oauth_bindings', array_values(array_unique($bindings))); - if (!empty($identity['avatar_url'])) { - update_user_meta($user_id, 'jio_oauth_avatar', esc_url_raw((string) $identity['avatar_url'])); - } + self::seed_avatar($user_id, $identity); return true; } finally { OAuthTransactionStore::release_lock($scope, (string) $lock); @@ -114,9 +112,27 @@ final class OAuthAccountService { $bindings[] = $provider; update_user_meta($user_id, 'jio_oauth_bindings', array_values(array_unique($bindings))); } - if (!empty($identity['avatar_url'])) { - update_user_meta($user_id, 'jio_oauth_avatar', esc_url_raw((string) $identity['avatar_url'])); + self::seed_avatar($user_id, $identity); + } + + /** + * 建档或首次绑定时以第三方头像补齐,之后不再覆盖。 + * + * 参考主题只在用户没有自定义头像时写入(`if ($args['avatar'] && !$custom_avatar)`), + * 这样用户换过的头像不会被后续第三方登录冲掉。 + */ + private static function seed_avatar(int $user_id, array $identity): void { + $avatar = trim((string) ($identity['avatar_url'] ?? '')); + if ($user_id <= 0 || $avatar === '') { + return; } + if ((int) get_user_meta($user_id, 'jio_avatar_attachment_id', true) > 0) { + return; + } + if (trim((string) get_user_meta($user_id, 'jio_oauth_avatar', true)) !== '') { + return; + } + update_user_meta($user_id, 'jio_oauth_avatar', esc_url_raw($avatar)); } private static function has_recovery_after_unbind(int $user_id, string $provider): bool { diff --git a/src/Frontend/WeChatShare.php b/src/Frontend/WeChatShare.php index 14e23205..56d3427b 100644 --- a/src/Frontend/WeChatShare.php +++ b/src/Frontend/WeChatShare.php @@ -166,10 +166,10 @@ window.WeChatShareDate = ID)) { - $pic = (string) get_user_meta((int) $author->ID, 'custom_avatar', true); - if ($pic === '') { - $pic = get_avatar_url((int) $author->ID, ['size' => 300]); - } + // 与本主题其余头像位一致:上传头像 → 第三方头像 → 外部服务。 + $pic = function_exists('jio_get_avatar_url') + ? (string) jio_get_avatar_url((int) $author->ID, ['size' => 300]) + : get_avatar_url((int) $author->ID, ['size' => 300]); } } elseif (is_category() || is_tag() || is_tax()) { $term = get_queried_object(); diff --git a/src/User/ProfileAdmin.php b/src/User/ProfileAdmin.php index eed6646d..65354764 100644 --- a/src/User/ProfileAdmin.php +++ b/src/User/ProfileAdmin.php @@ -33,6 +33,8 @@ final class ProfileAdmin { ? jio_get_user_profile_details($user_id) : ['gender' => 'secret', 'website' => '', 'qq' => '', 'wechat' => '', 'weibo' => '', 'github' => '', 'address' => '']; $avatar_url = function_exists('jio_get_custom_avatar_url') ? jio_get_custom_avatar_url($user_id, 96) : ''; + $avatar_custom = (int) get_user_meta($user_id, 'jio_avatar_attachment_id', true) > 0 + || trim((string) get_user_meta($user_id, 'jio_oauth_avatar', true)) !== ''; $cover_id = (int) get_user_meta($user_id, 'jio_background_attachment_id', true); $cover_url = esc_url_raw((string) get_user_meta($user_id, 'jio_background_image', true)); if ($cover_url === '' && $cover_id > 0) $cover_url = (string) wp_get_attachment_image_url($cover_id, 'full'); @@ -45,7 +47,7 @@ final class ProfileAdmin { display_name, ['loading' => 'eager']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>

- +

@@ -192,6 +194,8 @@ final class ProfileAdmin { $previous_id = (int) get_user_meta($user_id, 'jio_avatar_attachment_id', true); if (!empty($_POST['jio_avatar_reset'])) { delete_user_meta($user_id, 'jio_avatar_attachment_id'); + // 上传头像被清除后第三方头像会重新生效,恢复默认时一并丢弃。 + delete_user_meta($user_id, 'jio_oauth_avatar'); if (function_exists('jio_delete_owned_avatar_attachment')) jio_delete_owned_avatar_attachment($previous_id, $user_id); do_action('jio_user_avatar_changed', $user_id, 0, ''); return; diff --git a/tests/widget-system/oauth-avatar-contract.test.cjs b/tests/widget-system/oauth-avatar-contract.test.cjs new file mode 100644 index 00000000..70458ac8 --- /dev/null +++ b/tests/widget-system/oauth-avatar-contract.test.cjs @@ -0,0 +1,61 @@ +/** + * 第三方登录头像契约(参考主题 1:1)。 + * + * 硬约束: + * 1) 第三方头像只在建档/首次绑定时补齐,之后不覆盖用户自己的选择 + * (参考主题:`if ($args['avatar'] && !$custom_avatar)`)。 + * 2) 解析顺序:上传头像 → 第三方头像 → 外部服务 → 默认头像。 + * 3) 「恢复默认头像」必须同时清掉第三方头像,否则点了也换不掉。 + * 4) 百度头像域名需要换成可访问的 himg.bdimg.com。 + */ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('第三方头像在解析链里位于上传头像之后、外部服务之前', () => { + const helpers = read('inc/helpers/jio-avatar.php'); + + // 上传头像分支必须排在第三方头像之前。 + const uploadAt = helpers.indexOf("get_user_meta($user_id, 'jio_avatar_attachment_id', true)"); + const oauthAt = helpers.indexOf('jio_get_oauth_avatar_url($user_id)'); + assert.ok(uploadAt >= 0 && oauthAt > uploadAt, 'uploaded avatar must win over the OAuth one'); + + // 第三方头像整体仍在外部头像服务之前。 + const chain = helpers.slice(helpers.indexOf('function jio_get_avatar_url')); + const customAt = chain.indexOf('jio_get_custom_avatar_url'); + const externalAt = chain.indexOf('jio_get_external_avatar_url'); + assert.ok(customAt >= 0 && customAt < externalAt); +}); + +test('百度头像域名为可访问地址', () => { + const helpers = read('inc/helpers/jio-avatar.php'); + assert.match(helpers, /str_replace\('tb\.himg\.baidu\.com', 'himg\.bdimg\.com', \$url\)/); +}); + +test('第三方头像只在空缺时写入,不覆盖用户头像', () => { + const service = read('src/Auth/OAuthAccountService.php'); + + assert.match(service, /private static function seed_avatar\(int \$user_id, array \$identity\): void/); + // 已有上传头像或已存第三方头像时直接返回,不写 meta。 + assert.match(service, /\(int\) get_user_meta\(\$user_id, 'jio_avatar_attachment_id', true\) > 0\) \{\s*return;/); + assert.match(service, /get_user_meta\(\$user_id, 'jio_oauth_avatar', true\)\) !== ''\) \{\s*return;/); + + // 两条写入路径(绑定 / 登录刷新)都改走同一守卫,不再直接写 meta。 + const directWrites = service.match(/update_user_meta\(\$user_id, 'jio_oauth_avatar'/g) || []; + assert.equal(directWrites.length, 1, 'only seed_avatar may write the OAuth avatar'); + assert.equal((service.match(/self::seed_avatar\(/g) || []).length, 2); +}); + +test('恢复默认头像会一并清除第三方头像', () => { + const rest = read('src/Admin/UserControllerProfileMediaOperations.php'); + const admin = read('src/User/ProfileAdmin.php'); + + assert.match(rest, /delete_user_meta\(\$user_id, 'jio_oauth_avatar'\)/); + assert.match(admin, /delete_user_meta\(\$user_id, 'jio_oauth_avatar'\)/); +});