Files
kate522 6db34646ed feat(oauth): add the seven missing providers and open the full aggregate list
The theme shipped six direct providers while the reference carries fourteen,
and its aggregate-login channel offered three of sixteen platforms.

OAuthProviderClient now owns the providers the bundled SDK does not cover.
Douyin, Microsoft, Facebook, Twitter and Apple talk to their documented OAuth2
endpoints directly; Baidu and Alipay reuse yurunsoft, which already ships both
(Alipay needs its application private key, so provider_config learned an
extra_keys map to surface it). The broker dispatches through handles(), so the
existing start/callback routes stay provider-agnostic.

Three provider quirks needed handling rather than a plain copy:

Apple returns the callback as response_mode=form_post, and handle_callback
only read $_GET, so it now reads $_POST when the request is a POST — otherwise
every Apple login would fail with code_missing. Its client secret is built as
an ES256 JWT from Team ID / Key ID / .p8 rather than a static string.

Twitter requires PKCE. The verifier is derived from the state token, so it
travels with the existing transaction row and needs no extra storage.

Facebook hands back protocol-less avatar URLs, which are upgraded to https the
same way the OAuth avatar resolver does.

Settings gained matching toggles and credential groups on both sides — the PHP
schema and the Vue schema — and the aggregate platform list went from three to
sixteen, with CLoginClient's hard-coded allowlist replaced by the catalog
constant. Brand icons were extended so each new provider resolves to its
sprite; facebook and twitter moved to their colour sprites, and the
`twitter -> x` alias was dropped since it bypassed the new mapping.

The oauth-security contract previously asserted that Baidu and Alipay must not
appear on the OAuth surface. That was the old intent, so the assertion is
replaced with a positive one, keeping the separate check that the Alipay
payment gateway still exists.

Verification: a new harness stubs the HTTP layer and drives all five custom
clients — authorization URLs carry redirect/state (Twitter also a PKCE
challenge), identities normalize to the shared shape, Facebook's avatar is
upgraded to https, Apple's private-relay email survives, and an error payload
throws instead of yielding an empty identity. 871 widget/navigation tests pass
and the PSR-4 audit reports 0 errors.

Not covered: no live handshake happened — every provider needs real AppID and
secret pairs, and Apple/Douyin require developer-account review before the
full round trip can be exercised.
2026-11-04 18:00:29 +08:00

478 lines
17 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Jio Theme — Brand / contact icons (Font Awesome 5)
*
* Short-key → FA5 class map, escaped <i> markup, contact option labels,
* and public social-link renderer. Local SVG inventory removed.
*
* @package Jio
* @version 3.4.0
*/
defined('ABSPATH') || exit;
// ============================================================
// PHP 7.x 兼容辅助函数(替代 PHP 8.0+ 的 match 表达式)
// ============================================================
if (!function_exists('jio_parse_country_list')):
/**
* 解析 GeoIP 屏蔽国家列表字符串为数组
*
* @param string $raw 逗号分隔的国家代码
* @return array 大写国家代码数组
*/
function jio_parse_country_list($raw) {
if (empty($raw)) return array();
$parts = explode(',', wp_unslash($raw));
$result = array();
foreach ($parts as $p) {
$trimmed = trim($p);
if ($trimmed !== '') {
$result[] = strtoupper($trimmed);
}
}
return array_values(array_filter($result));
}
endif;
// ============================================================
// Brand icon map (FA 5.15.4)
// ============================================================
if (!function_exists('jio_brand_icon_map')) :
/**
* Short key → FA5 class string or built-in SVG sprite key (allowlisted).
*
* @return array<string, string>
*/
function jio_brand_icon_map(): array {
static $map = null;
if ($map !== null) {
return $map;
}
// FA5 Free ships no Gitee brand glyph and no multi-colour Google mark, so
// those two resolve to the theme's built-in SVG sprite (jiosvg-*) instead of
// a solid stand-in; every other key stays on FA5.
$map = [
'github' => 'fa fab fa-github',
'google' => 'jiosvg-google-color',
'x' => 'fa fab fa-twitter',
'email' => 'fa fas fa-envelope',
'envelope' => 'fa fas fa-envelope',
'wechat' => 'fa fab fa-weixin',
'weixin' => 'fa fab fa-weixin',
'qq' => 'fa fab fa-qq',
'weibo' => 'fa fab fa-weibo',
'telegram' => 'fa fab fa-telegram-plane',
'discord' => 'fa fab fa-discord',
'bilibili' => 'fa fas fa-tv',
'linkedin' => 'fa fab fa-linkedin',
'youtube' => 'fa fab fa-youtube',
'instagram' => 'fa fab fa-instagram',
'web' => 'fa fas fa-globe',
'globe' => 'fa fas fa-globe',
'gitee' => 'jiosvg-gitee',
'baidu' => 'fa fas fa-paw',
'alipay' => 'fa fab fa-alipay',
'douyin' => 'jiosvg-douyin',
// FA5 Free 没有 Microsoft 品牌字形,参考主题同样用 Windows 标。
'microsoft' => 'fa fab fa-windows',
'apple' => 'jiosvg-apple',
'dingtalk' => 'jiosvg-dingtalk',
'huawei' => 'jiosvg-huawei',
'xiaomi' => 'jiosvg-xiaomi',
'facebook' => 'jiosvg-facebook-color',
'twitter' => 'jiosvg-twitter-color',
];
return $map;
}
endif;
if (!function_exists('jio_normalize_icon_key')) :
/**
* Normalize short key or full FA class string to a short map key.
*
* Examples: 'fa fab fa-github' | 'fa-github' | 'github' | 'weixin' → 'github' | 'wechat'
*
* Legacy FA4 values (a bare `fa` token followed by `fa-<glyph>`) are stripped too.
*
* @param string $name Raw icon key or FA class string.
* @return string Short key (may be empty).
*/
function jio_normalize_icon_key(string $name): string {
$name = trim($name);
if ($name === '') {
return '';
}
// Strip the FA5 layout/style prefixes, the glyph prefix and the legacy FA4
// double-`fa` form (a bare `fa` token followed by `fa-<glyph>`). The site
// ships FA5 only, but stored values may predate it.
$name = preg_replace('/^fa\s+(?:fas|far|fab)\s+/i', '', $name);
$name = preg_replace('/^fa\s+fa-/i', '', (string) $name);
$name = preg_replace('/^(?:fas|far|fab)\s+/i', '', (string) $name);
$name = preg_replace('/^fa-/i', '', (string) $name);
$name = trim((string) $name, "- \t\n\r\0\x0B");
$name = strtolower($name);
// Aliases → canonical keys used by the map / contact options.
$aliases = [
'weixin' => 'wechat',
'weixingzh' => 'wechat',
'envelope' => 'email',
'globe' => 'web',
];
if (isset($aliases[$name])) {
return $aliases[$name];
}
return $name;
}
endif;
if (!function_exists('jio_brand_icon_class')) :
/**
* Resolve a short key, FA class string or sprite key to its icon value.
*
* @param string $name Short key, full FA class string or jiosvg-/customsvg- value.
* @return string e.g. 'fa fab fa-github' or 'jiosvg-gitee', or '' if unknown.
*/
function jio_brand_icon_class(string $name): string {
$raw = trim($name);
if ($raw === '') {
return '';
}
// Built-in SVG sprite and inline SVG values pass straight through; they are
// not FA class strings and must survive to the markup renderer untouched.
if (preg_match('/^(?:jiosvg|customsvg)-/i', $raw)) {
return $raw;
}
// Pass through already-valid full FA class strings (footer contact_more).
if (preg_match('/^fa\s+(?:fas|far|fab)\s+fa-[\w-]+(?:\s+fa-[\w-]+)*$/i', $raw)) {
// Prefer mapped short key when strip matches our catalog (normalizes weixin etc.).
$key = jio_normalize_icon_key($raw);
$map = jio_brand_icon_map();
if ($key !== '' && isset($map[$key])) {
return $map[$key];
}
// Allowlisted style + glyph only — collapse whitespace, lower style prefix.
$parts = preg_split('/\s+/', strtolower($raw));
$parts = array_values(array_filter((array) $parts));
return implode(' ', $parts);
}
$key = jio_normalize_icon_key($raw);
if ($key === '') {
return '';
}
$map = jio_brand_icon_map();
return $map[$key] ?? '';
}
endif;
if (!function_exists('jio_brand_icon')) :
/**
* Escaped markup for a brand/contact icon (FA5 <i> or built-in SVG sprite).
*
* @param string $name Short key, FA class string or jiosvg-/customsvg- value.
* @param array|string $extra_classes Optional extra classes.
* @return string Safe HTML or empty string when unknown.
*/
function jio_brand_icon(string $name, $extra_classes = []): string {
$class = jio_brand_icon_class($name);
// Sprite and inline-SVG values carry their own element, so the shared icon
// renderer owns the markup (it emits <svg class="icon"><use …>).
if (strpos($class, 'jiosvg-') === 0 || strpos($class, 'customsvg-') === 0) {
return jio_icon_markup($class, $extra_classes);
}
if ($class === '') {
return '';
}
if (is_string($extra_classes) && $extra_classes !== '') {
$extra_classes = preg_split('/\s+/', $extra_classes) ?: [];
}
if (is_array($extra_classes) && $extra_classes !== []) {
$extra = array_filter(array_map('sanitize_html_class', $extra_classes));
if ($extra !== []) {
$class .= ' ' . implode(' ', $extra);
}
}
return '<i class="' . esc_attr($class) . '" aria-hidden="true"></i>';
}
endif;
// ============================================================
// 图标 key → 显示名称
// ============================================================
if (!function_exists('jio_icon_key_to_name')) :
function jio_icon_key_to_name($key) {
$key = jio_normalize_icon_key((string) $key);
$map = array(
'github' => 'GitHub', 'google' => 'Google', 'x' => 'X',
'email' => '邮箱', 'wechat' => '微信', 'qq' => 'QQ',
'weibo' => '微博', 'telegram' => 'Telegram',
'discord' => 'Discord', 'bilibili' => 'Bilibili',
'linkedin' => 'LinkedIn', 'facebook' => 'Facebook',
'youtube' => 'YouTube', 'instagram' => 'Instagram',
'web' => '网站',
'gitee' => 'Gitee',
'baidu' => 'Baidu',
'alipay' => 'Alipay',
);
return isset($map[$key]) ? $map[$key] : ucwords(str_replace('-', ' ', $key));
}
endif;
// ============================================================
// 联系方式图标选项(仅保留选定的 15 个)
// ============================================================
if (!function_exists('jio_contact_icon_options')) :
function jio_contact_icon_options($selected = '') {
$icons = array(
'github' => 'GitHub',
'google' => 'Google',
'x' => 'X',
'email' => '邮箱',
'wechat' => '微信',
'qq' => 'QQ',
'weibo' => '微博',
'telegram' => 'Telegram',
'discord' => 'Discord',
'bilibili' => 'Bilibili',
'linkedin' => 'LinkedIn',
'facebook' => 'Facebook',
'youtube' => 'YouTube',
'instagram' => 'Instagram',
'web' => '网站',
);
$selected = jio_normalize_icon_key((string) $selected);
$html = '<option value="">—</option>';
foreach ($icons as $key => $label) {
$sel = $key === $selected ? ' selected' : '';
$html .= '<option value="' . esc_attr($key) . '"' . $sel . '>' . esc_html($label) . '</option>';
}
return $html;
}
endif;
// ============================================================
// 统一图标渲染(FA5 / 内置 SVG / 自定义 SVG)
// ============================================================
if (!function_exists('jio_enqueue_fontawesome')) :
/**
* Register (once) and enqueue the bundled Font Awesome 5 pack under the
* single canonical handle. Every context — public, admin screens, widgets,
* the icon picker — must go through this helper.
*
* @param array $deps Style dependencies for the first caller.
* @return string The canonical handle.
*/
function jio_enqueue_fontawesome(array $deps = []): string {
if (!wp_style_is('jio-font-awesome', 'registered')) {
wp_register_style('jio-font-awesome', JIO_URI . '/assets/fontawesome5/css/all.min.css', $deps, '5.15.4');
}
wp_enqueue_style('jio-font-awesome');
return 'jio-font-awesome';
}
endif;
if (!function_exists('jio_svg_kses_tags')) :
/**
* Allowed SVG tags/attributes for user-supplied custom SVG icon markup.
*
* @return array<string, array<string, bool>>
*/
function jio_svg_kses_tags(): array {
static $tags = null;
if ($tags !== null) {
return $tags;
}
// Shape list mirrors the admin picker's cleanSvg() accept test — every
// element it lets through must survive the kses pass, otherwise a pasted
// <circle>/<rect>/… SVG is silently saved without its shapes.
$shape_attrs = ['fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true];
$tags = [
'svg' => ['class' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'xmlns' => true, 'aria-hidden' => true],
'use' => ['href' => true, 'xlink:href' => true],
'path' => ['d' => true] + $shape_attrs,
'g' => ['fill' => true, 'transform' => true],
'circle' => ['cx' => true, 'cy' => true, 'r' => true] + $shape_attrs,
'ellipse' => ['cx' => true, 'cy' => true, 'rx' => true, 'ry' => true] + $shape_attrs,
'rect' => ['x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true] + $shape_attrs,
'polygon' => ['points' => true] + $shape_attrs,
'line' => ['x1' => true, 'y1' => true, 'x2' => true, 'y2' => true] + $shape_attrs,
];
return $tags;
}
endif;
if (!function_exists('jio_icon_markup')) :
/**
* Render an icon-picker value of any supported format.
*
* Supported values:
* - "fa fas fa-star" / "fa fab fa-weixin" — FA5 class string
* - "fa-star" — bare glyph, rendered as solid FA5
* - legacy FA4 string — double-`fa` form, upgraded to FA5
* - "jiosvg-hot" — theme built-in SVG sprite key
* - "customsvg-<svg …>" — inline user SVG markup
*
* Output is always FA5 — the legacy FA4 double-`fa` prefix is never emitted.
*
* @param string $value Raw stored value.
* @param array|string $extra_classes Extra classes for the rendered element.
* @return string Safe HTML, empty string for empty values.
*/
function jio_icon_markup(string $value, $extra_classes = []): string {
$value = trim($value);
if ($value === '') {
return '';
}
$extra = '';
if (is_string($extra_classes) && $extra_classes !== '') {
$extra_classes = preg_split('/\s+/', $extra_classes) ?: [];
}
if (is_array($extra_classes)) {
$clean = array_filter(array_map('sanitize_html_class', $extra_classes));
if ($clean !== []) {
$extra = ' ' . implode(' ', $clean);
}
}
if (str_starts_with($value, 'customsvg-')) {
$svg = wp_kses(substr($value, 10), jio_svg_kses_tags());
if ($svg === '') {
return '';
}
if ($extra !== '' && stripos($svg, '<svg') === 0 && stripos($svg, 'class=') === false) {
$svg = preg_replace('/<svg\b/i', '<svg class="' . ltrim($extra) . '"', $svg, 1);
}
return $svg;
}
if (str_starts_with($value, 'jiosvg-')) {
$key = sanitize_key(substr($value, 7));
if ($key === '') {
return '';
}
$class = $extra !== '' ? ltrim($extra) : 'icon';
return '<svg class="' . esc_attr($class) . '" aria-hidden="true"><use xlink:href="#icon-' . $key . '"></use></svg>';
}
// 本站统一 FA5 输出:遗留 FA4 双 fa 前缀、裸字形 "fa-xxx" 与缺少 fa 前缀的
// "fas/far/fab fa-xxx" 均归一为 "fa fas fa-xxx"(末尾附加类名原样保留)。
if (preg_match('/^fa\s+fa-/i', $value)) {
$value = preg_replace('/^fa\s+/i', 'fa fas ', $value, 1);
} elseif (preg_match('/^fa-/i', $value)) {
$value = 'fa fas ' . $value;
} elseif (preg_match('/^(?:fas|far|fab)\s+fa-/i', $value)) {
$value = 'fa ' . $value;
}
return '<i class="' . esc_attr($value . $extra) . '" aria-hidden="true"></i>';
}
endif;
if (!function_exists('jio_sanitize_icon_value')) :
/**
* Sanitize an icon-picker value. Keeps the SVG markup of customsvg- values
* (plain sanitize_text_field would strip the tags), filters FA/zip keys.
*
* @param string $value Raw value from a form/REST payload.
* @return string Sanitized value.
*/
function jio_sanitize_icon_value(string $value): string {
$value = trim($value);
if ($value === '') {
return '';
}
if (str_starts_with($value, 'customsvg-')) {
$svg = wp_kses(substr($value, 10), jio_svg_kses_tags());
return $svg === '' ? '' : 'customsvg-' . $svg;
}
return sanitize_text_field($value);
}
endif;
if (!function_exists('jio_empty_state_svgs')) :
/**
* Shared empty-state SVG icons (24px stroke style, not sprite keys).
*
* @return array{post:string,comment:string,favorite:string,follow:string}
*/
function jio_empty_state_svgs(): array {
return [
'post' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h6M9 9h1"/></svg>',
'comment' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
'favorite' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
'follow' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
];
}
endif;
// ============================================================
// Public social links (author hero / profile)
// ============================================================
if (!function_exists('jio_render_social_links')) :
/**
* Echo a list of public contact links with FA icons.
*
* @param array<int, array{name?:string,url?:string,icon?:string,title?:string,label?:string}> $contacts
*/
function jio_render_social_links(array $contacts): void {
$links = [];
foreach ($contacts as $contact) {
if (!is_array($contact)) {
continue;
}
$url = isset($contact['url']) ? (string) $contact['url'] : '';
$icon = isset($contact['icon']) ? (string) $contact['icon'] : '';
if ($url === '' || $icon === '') {
continue;
}
$icon_html = jio_brand_icon($icon);
if ($icon_html === '') {
$icon_html = '<i class="fa fas fa-globe" aria-hidden="true"></i>';
}
$label = (string) ($contact['name'] ?? $contact['title'] ?? $contact['label'] ?? '');
if ($label === '') {
$label = jio_icon_key_to_name($icon);
}
$links[] = [
'url' => $url,
'label' => $label,
'icon' => $icon_html,
];
}
if ($links === []) {
return;
}
echo '<div class="profile-social-links">';
foreach ($links as $link) {
printf(
'<a class="social-icon-link" href="%1$s" target="_blank" rel="noopener noreferrer" title="%2$s" aria-label="%2$s">%3$s</a>',
esc_url($link['url']),
esc_attr($link['label']),
$link['icon'] // pre-escaped via jio_brand_icon / fixed globe fallback
);
}
echo '</div>';
}
endif;