From 6f29e3d86505d9634ce030d442c11f5eb7d7adbc Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 4 Nov 2026 17:41:48 +0800 Subject: [PATCH] fix(oauth): actually use the third-party avatar and seed it only once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- inc/helpers/jio-avatar.php | 50 +++++++++++++-- .../UserControllerProfileMediaOperations.php | 2 + src/Auth/OAuthAccountService.php | 26 ++++++-- src/Frontend/WeChatShare.php | 8 +-- src/User/ProfileAdmin.php | 6 +- .../oauth-avatar-contract.test.cjs | 61 +++++++++++++++++++ 6 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 tests/widget-system/oauth-avatar-contract.test.cjs 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'\)/); +});