- 统一验证入口 aksrt_require_captcha(),返回 true|WP_Error,启用场景配置缺失时 fail-closed 拒绝 - 前端公共 API 重构为 window.AKSRTCaptcha(verify/collect/reset/run),单飞锁防并发重复验证,一次性 proof 绑定浏览器 cookie+场景+服务商+主机名 - challenge/exchange REST 端点:challenge 原子占用防票据放大厂商调用,增加频率限制 - 新增 OAuth bind pass:服务端签发 5 分钟一次性令牌,绑定用户+服务商+浏览器,微信扫码延迟消费 - 覆盖场景:评论、登录、注册、绑定第三方、绑定手机、找回密码、修改密码、更改邮箱、资料下载、签到、前台投稿 - 移除 Service 层重复验证与 _auth_code_verified/_defer_login 等客户端可伪造标志,删除下载共享 pass 死代码 - 修复腾讯 TC3 签名 scope、Cloudflare action/cdata/hostname 严格校验、阿里云验签错误码映射 - 后台 Schema 保存对 secret 字段做掩码/保留合并;导入导出过滤 aksrt_captcha_config - 重建 public bundle 与 admin SPA;新增 captcha 运行时/安全/业务契约测试与 harness
349 lines
15 KiB
PHP
349 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Auth;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class Moderation {
|
|
use ModerationPart1;
|
|
use ModerationPart2;
|
|
|
|
|
|
private static $instance = null;
|
|
private $config = [];
|
|
|
|
/**
|
|
* Runtime map: fingerprint => moderation decision payload.
|
|
*
|
|
* @var array<string, array<string, mixed>>
|
|
*/
|
|
private $moderation_result_map = [];
|
|
|
|
/**
|
|
* Runtime map: fingerprint => forced comment_approved value.
|
|
*
|
|
* @var array<string, string|int>
|
|
*/
|
|
private $forced_approved_map = [];
|
|
|
|
/**
|
|
* Request-local depth for comment submissions already admitted by the REST controller.
|
|
*
|
|
* @var int
|
|
*/
|
|
private $captcha_verified_comment_request_depth = 0;
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
private function __construct() {
|
|
$this->config = get_option('aksrt_moderation_config', []);
|
|
}
|
|
|
|
public function init(): void {
|
|
add_filter('preprocess_comment', [$this, 'process'], 5);
|
|
add_filter('pre_comment_approved', [$this, 'enforce_pre_comment_approved'], 50, 2);
|
|
add_action('comment_post', [$this, 'after_post'], 10, 3);
|
|
|
|
// Native WP Admin > Comments column.
|
|
add_filter('manage_edit-comments_columns', [$this, 'register_comments_columns']);
|
|
add_action('manage_comments_custom_column', [$this, 'render_comments_custom_column'], 10, 2);
|
|
}
|
|
|
|
public function begin_captcha_verified_comment_request(): void {
|
|
$this->captcha_verified_comment_request_depth++;
|
|
}
|
|
|
|
public function end_captcha_verified_comment_request(): void {
|
|
$this->captcha_verified_comment_request_depth = max(0, $this->captcha_verified_comment_request_depth - 1);
|
|
}
|
|
|
|
public function get_config(): array {
|
|
$defaults = [
|
|
'enabled' => false,
|
|
'akismet_enabled' => false,
|
|
'akismet_site_url' => '',
|
|
'akismet_blog_lang' => 'zh-CN',
|
|
'akismet_api_key' => '',
|
|
'ai_enabled' => false,
|
|
'ai_moderate_comments' => true,
|
|
'ai_moderate_background' => true,
|
|
'auto_approve_low_risk' => true,
|
|
'auto_reject_high_risk' => true,
|
|
'low_risk_max_score' => 35,
|
|
'high_risk_min_score' => 80,
|
|
'blocked_keywords' => '',
|
|
'rate_limit_enabled' => false,
|
|
'rate_limit_min_interval' => 8,
|
|
'rate_limit_max_per_hour' => 15,
|
|
'geoip_enabled' => false,
|
|
'geoip_provider' => 'ipapi',
|
|
'geoip_api_key' => '',
|
|
'geoip_block_countries' => [],
|
|
];
|
|
return array_merge($defaults, $this->config);
|
|
}
|
|
|
|
/**
|
|
* Main moderation entry.
|
|
*/
|
|
public function process(array $commentdata): array {
|
|
|
|
$actor = get_current_user_id();
|
|
if (!function_exists('aksrt_user_can') || !aksrt_user_can('comment_add', $actor, (int) ($commentdata['comment_post_ID'] ?? 0))) {
|
|
wp_die(
|
|
esc_html__('当前账号没有发表评论的权限。', 'aksrt'),
|
|
esc_html__('评论提交失败', 'aksrt'),
|
|
['response' => $actor > 0 ? 403 : 401, 'back_link' => true]
|
|
);
|
|
}
|
|
|
|
// 人机验证:原生 wp-comments-post 必须验证;REST 已在 Controller 准入时验证一次。
|
|
if ($this->captcha_verified_comment_request_depth === 0) {
|
|
$payload = is_array($_POST ?? null) ? wp_unslash($_POST) : [];
|
|
if (isset($payload['captcha']) && is_array($payload['captcha'])) {
|
|
$payload = array_merge($payload, $payload['captcha']);
|
|
}
|
|
$captcha_result = aksrt_require_captcha('comment', $payload);
|
|
if ($captcha_result !== true) {
|
|
$msg = is_wp_error($captcha_result)
|
|
? $captcha_result->get_error_message()
|
|
: __('人机验证失败', 'aksrt');
|
|
wp_die(
|
|
esc_html($msg !== '' ? $msg : __('人机验证失败', 'aksrt')),
|
|
esc_html__('评论提交失败', 'aksrt'),
|
|
['response' => 400, 'back_link' => true]
|
|
);
|
|
}
|
|
}
|
|
|
|
$config = $this->get_config();
|
|
|
|
if (empty($config['enabled'])) {
|
|
return $commentdata;
|
|
}
|
|
|
|
$client_ip = $this->get_client_ip();
|
|
$decision = $this->new_default_result();
|
|
$decision['ip'] = $client_ip;
|
|
|
|
try {
|
|
// 1) Rate limit.
|
|
$rate_result = $this->check_rate_limit($commentdata, $config, $client_ip);
|
|
if ($rate_result !== true) {
|
|
$this->remember_result($commentdata, [
|
|
'result' => 'blocked',
|
|
'source' => 'rate_limit',
|
|
'reason' => 'rate_limit_triggered',
|
|
]);
|
|
wp_die(
|
|
'<p>' . esc_html($rate_result) . '</p>',
|
|
__('Rate limit', 'aksrt'),
|
|
['response' => 429]
|
|
);
|
|
}
|
|
|
|
// 2) GeoIP restriction.
|
|
$geoip_result = $this->check_geoip($config, $client_ip);
|
|
if ($geoip_result !== true) {
|
|
$this->remember_result($commentdata, [
|
|
'result' => 'blocked',
|
|
'source' => 'geoip',
|
|
'reason' => 'geoip_blocked',
|
|
]);
|
|
wp_die(
|
|
'<p>' . esc_html($geoip_result) . '</p>',
|
|
__('Geo restriction', 'aksrt'),
|
|
['response' => 403]
|
|
);
|
|
}
|
|
|
|
// 3) Blocked keywords: force pending and write AI-like reason.
|
|
$keyword = $this->check_blocked_keywords($commentdata, $config);
|
|
$keyword_hit = !empty($keyword['hit']);
|
|
if ($keyword_hit) {
|
|
$commentdata['comment_approved'] = 0;
|
|
$decision['result'] = 'pending';
|
|
$decision['source'] = 'ai';
|
|
$decision['reason'] = 'keyword_hit';
|
|
$decision['ai_flagged'] = '1';
|
|
$decision['ai_score'] = '100';
|
|
$decision['ai_top_category'] = 'blocked_keyword';
|
|
$decision['keyword'] = (string) ($keyword['keyword'] ?? '');
|
|
$decision['keyword_match_type'] = (string) ($keyword['match_type'] ?? 'plain');
|
|
}
|
|
|
|
// 4) Akismet.
|
|
$akismet = $this->check_akismet($commentdata, $config, $client_ip);
|
|
if ($akismet === 'spam') {
|
|
$commentdata['comment_approved'] = 'spam';
|
|
$decision['result'] = 'spam';
|
|
$decision['source'] = 'akismet';
|
|
$decision['reason'] = 'spam';
|
|
$decision['akismet'] = 'spam';
|
|
} elseif ($akismet === 'ham') {
|
|
$decision['akismet'] = 'ham';
|
|
if ($decision['source'] === 'wp_default') {
|
|
$decision['source'] = 'akismet';
|
|
$decision['reason'] = 'ham';
|
|
}
|
|
} elseif ($akismet === 'error') {
|
|
$decision['akismet'] = 'error';
|
|
} else {
|
|
$decision['akismet'] = 'unknown';
|
|
}
|
|
|
|
// 5) AI moderation provider (Baidu only).
|
|
// If keyword already hit, keep keyword decision locked and skip AI override.
|
|
if (!$keyword_hit) {
|
|
$ai = $this->check_ai_moderation($commentdata, $config);
|
|
if (is_array($ai)) {
|
|
$score = (float) ($ai['score'] ?? 0);
|
|
$flagged = !empty($ai['flagged']) ? '1' : '0';
|
|
$top_category = (string) ($ai['top_category'] ?? '');
|
|
|
|
$decision['ai_score'] = (string) round($score, 2);
|
|
$decision['ai_flagged'] = $flagged;
|
|
$decision['ai_top_category'] = $top_category;
|
|
|
|
if (!empty($config['auto_reject_high_risk']) && $score >= (float) ($config['high_risk_min_score'] ?? 80)) {
|
|
// High-risk comments are auto rejected into spam list.
|
|
$commentdata['comment_approved'] = 'spam';
|
|
$decision['result'] = 'spam';
|
|
$decision['source'] = 'ai';
|
|
$decision['reason'] = 'ai_high_risk_auto_reject';
|
|
} elseif (!empty($config['auto_approve_low_risk']) && $score <= (float) ($config['low_risk_max_score'] ?? 35)) {
|
|
// Do not override stricter decision.
|
|
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
|
|
$commentdata['comment_approved'] = 1;
|
|
$decision['result'] = 'approved';
|
|
$decision['source'] = 'ai';
|
|
$decision['reason'] = 'ai_low_risk_auto_approve';
|
|
}
|
|
} else {
|
|
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
|
|
$commentdata['comment_approved'] = 0;
|
|
$decision['result'] = 'pending';
|
|
$decision['source'] = 'ai';
|
|
$decision['reason'] = 'ai_medium_risk_pending';
|
|
}
|
|
}
|
|
} elseif ($ai === 'rejected') {
|
|
$commentdata['comment_approved'] = 'spam';
|
|
$decision['result'] = 'spam';
|
|
$decision['source'] = 'ai';
|
|
$decision['reason'] = 'ai_rejected_auto_reject';
|
|
} elseif ($ai === 'error') {
|
|
// Fail-safe: AI enabled but response is invalid/unusable, do not auto approve.
|
|
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
|
|
$commentdata['comment_approved'] = 0;
|
|
$decision['result'] = 'pending';
|
|
}
|
|
$decision['source'] = 'ai';
|
|
$decision['reason'] = 'ai_response_invalid_pending';
|
|
$decision['ai_flagged'] = '1';
|
|
}
|
|
}
|
|
|
|
$decision = $this->enrich_moderation_result($decision, $commentdata);
|
|
$this->remember_result($commentdata, $decision);
|
|
} catch (\Exception $e) {
|
|
// Intentionally silent: keep moderation flow without writing runtime logs.
|
|
}
|
|
|
|
return $commentdata;
|
|
}
|
|
|
|
/**
|
|
* Keep pre_comment_approved in sync with preprocess_comment outcome.
|
|
*/
|
|
public function enforce_pre_comment_approved($approved, array $commentdata) {
|
|
if (!is_array($commentdata)) {
|
|
return $approved;
|
|
}
|
|
|
|
$fingerprint = $this->comment_fingerprint($commentdata);
|
|
if ($fingerprint !== '' && array_key_exists($fingerprint, $this->forced_approved_map)) {
|
|
$forced = $this->forced_approved_map[$fingerprint];
|
|
return $forced;
|
|
}
|
|
|
|
if (array_key_exists('comment_approved', $commentdata)) {
|
|
return $commentdata['comment_approved'];
|
|
}
|
|
|
|
return $approved;
|
|
}
|
|
|
|
/**
|
|
* Persist moderation details to comment meta.
|
|
*/
|
|
public function after_post(int $comment_id, $comment_approved, array $commentdata = []): void {
|
|
$comment = get_comment($comment_id);
|
|
if (!$comment instanceof \WP_Comment) {
|
|
return;
|
|
}
|
|
|
|
$decision = $this->resolve_result_from_runtime_or_default($commentdata, $comment);
|
|
$decision = $this->enrich_moderation_result($decision, (array) $comment);
|
|
|
|
update_comment_meta($comment_id, 'aksrt_moderation_result', (string) $decision['result']);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_source', (string) $decision['source']);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_reason', (string) $decision['reason']);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_akismet', (string) $decision['akismet']);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_ai_score', (string) $decision['ai_score']);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_ai_flagged', (string) $decision['ai_flagged']);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_ai_top_category', (string) $decision['ai_top_category']);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_keyword', (string) $decision['keyword']);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_keyword_match_type', (string) $decision['keyword_match_type']);
|
|
|
|
$moderation_user_id = isset($comment->user_id) ? (int) $comment->user_id : 0;
|
|
$moderation_user_ip = trim((string) ($comment->comment_author_IP ?? ''));
|
|
if ($moderation_user_ip === '') {
|
|
$moderation_user_ip = trim((string) ($decision['ip'] ?? ''));
|
|
}
|
|
if ($moderation_user_ip === '') {
|
|
$moderation_user_ip = trim((string) ($commentdata['comment_author_IP'] ?? ($commentdata['comment_author_ip'] ?? '')));
|
|
}
|
|
|
|
update_comment_meta($comment_id, 'aksrt_moderation_user_id', (string) $moderation_user_id);
|
|
update_comment_meta($comment_id, 'aksrt_moderation_user_ip', $moderation_user_ip);
|
|
|
|
// Convenience marker for external tools expecting Akismet style keys.
|
|
if ($decision['source'] === 'akismet' && $decision['akismet'] === 'spam') {
|
|
update_comment_meta($comment_id, 'akismet_result', 'true');
|
|
} elseif ($decision['source'] === 'akismet' && $decision['akismet'] === 'ham') {
|
|
update_comment_meta($comment_id, 'akismet_result', 'false');
|
|
}
|
|
|
|
// Clean runtime maps.
|
|
$fp_comment = $this->comment_fingerprint((array) $comment);
|
|
$fp_data = $this->comment_fingerprint($commentdata);
|
|
foreach (array_unique(array_filter([$fp_comment, $fp_data])) as $fp) {
|
|
unset($this->moderation_result_map[$fp], $this->forced_approved_map[$fp]);
|
|
}
|
|
}
|
|
|
|
public function register_comments_columns(array $columns): array {
|
|
$new = [];
|
|
foreach ($columns as $key => $label) {
|
|
$new[$key] = $label;
|
|
if ($key === 'comment') {
|
|
$new['aksrt_moderation'] = __('Moderation', 'aksrt');
|
|
}
|
|
}
|
|
if (!isset($new['aksrt_moderation'])) {
|
|
$new['aksrt_moderation'] = __('Moderation', 'aksrt');
|
|
}
|
|
return $new;
|
|
}
|
|
|
|
}
|