repository = $repository; $this->saveUseCase = $saveUseCase; $this->parser = $parser; $this->capProbe = $capProbe ?? self::defaultCapProbe(); $this->nonceVerifier = $nonceVerifier ?? self::defaultNonceVerifier(); $this->redirector = $redirector ?? self::defaultRedirector(); $this->urlBuilder = $urlBuilder ?? self::defaultUrlBuilder(); $this->auditWriter = $auditWriter; $this->userIdResolver = $userIdResolver ?? self::defaultUserIdResolver(); $this->affiliateRoutingRepository = $affiliateRoutingRepository; $this->renderCache = $renderCache; } /** * Handle a single `admin-post.php?action=affilun_save_settings` * POST request. * * See the file-level docblock for the four-stage flow * (capability check → nonce check → parse → save) and the * status-flash literal set the redirector receives. * * @param array $postData `$_POST`-shaped payload. */ public function handle( array $postData ): void { if ( ! ( $this->capProbe )( AdminCapability::CAP_FALLBACK_MANAGE ) ) { $this->redirectWithStatus( 'forbidden' ); return; } $nonceRaw = $postData[ SettingsFormToken::NONCE_FIELD ] ?? ''; $nonceValue = is_scalar( $nonceRaw ) ? self::sanitizeNonceValue( (string) $nonceRaw ) : ''; if ( ! ( $this->nonceVerifier )( SettingsFormToken::NONCE_ACTION, $nonceValue ) ) { $this->redirectWithStatus( 'invalid_nonce' ); return; } $current = $this->repository->load(); $rawSettings = $postData[ SettingsFormToken::FORM_FIELD_PREFIX ] ?? array(); if ( ! is_array( $rawSettings ) ) { $rawSettings = array(); } $rawSettings = $this->prepareYahooValueCommerceCredentials( $rawSettings ); if ( $rawSettings === null ) { $this->redirectWithStatus( 'yahoo_vc_invalid' ); return; } $newSettings = $this->parser->parse( $rawSettings, $current ); try { $this->saveUseCase->execute( $newSettings ); $this->saveAffiliateRouting( $rawSettings ); $this->flushRenderCache(); } catch ( Throwable $unused ) { unset( $unused ); $this->redirectWithStatus( 'error' ); return; } $this->redirectWithStatus( 'saved' ); } /** * Flush rendered card HTML after settings changes. */ private function flushRenderCache(): void { if ( $this->renderCache === null ) { return; } try { $this->renderCache->flush(); } catch ( Throwable $unused ) { unset( $unused ); } } /** * Save ASP affiliate-routing settings posted through the main settings form. * * @param array $rawSettings Form payload rooted at `affilun_settings`. */ private function saveAffiliateRouting( array $rawSettings ): void { if ( $this->affiliateRoutingRepository === null ) { return; } $section = $rawSettings['affiliate_routing'] ?? array(); if ( ! is_array( $section ) ) { return; } $rawTemplates = $section['templates'] ?? array(); $templates = array(); if ( is_array( $rawTemplates ) ) { foreach ( OptionStoreAffiliateRoutingRepository::PROVIDERS as $provider ) { $value = $rawTemplates[ $provider ] ?? ''; $templates[ $provider ] = is_scalar( $value ) ? self::unslash( (string) $value ) : ''; } } $this->affiliateRoutingRepository->saveRaw( $templates, self::readBool( $section, 'enabled' ) ); } /** * Extract Yahoo ValueCommerce sid / pid from a pasted HTML or URL field. * * The pasted blob is intentionally never persisted or rendered back. This * method only saves numeric sid / pid values after decoding common * ValueCommerce link formats and validating both values against a strict * allow-list. * * @param array $rawSettings Form payload rooted at `affilun_settings`. * * @return array|null Prepared settings, or null when extraction failed. */ private function prepareYahooValueCommerceCredentials( array $rawSettings ): ?array { $providers = $rawSettings['providers'] ?? array(); if ( ! is_array( $providers ) ) { return $rawSettings; } $yahoo = $providers[ self::YAHOO_PROVIDER_KEY ] ?? array(); if ( ! is_array( $yahoo ) || ! array_key_exists( self::YAHOO_VALUECOMMERCE_HTML_FIELD, $yahoo ) ) { return $rawSettings; } $rawPaste = $yahoo[ self::YAHOO_VALUECOMMERCE_HTML_FIELD ]; $paste = is_scalar( $rawPaste ) ? trim( self::unslash( (string) $rawPaste ) ) : ''; if ( $paste === '' ) { return $rawSettings; } $ids = self::extractValueCommerceSidPid( $paste ); if ( $ids === null ) { return null; } $rawSettings['providers'][ self::YAHOO_PROVIDER_KEY ]['affiliate_sid'] = $ids['sid']; $rawSettings['providers'][ self::YAHOO_PROVIDER_KEY ]['affiliate_pid'] = $ids['pid']; unset( $rawSettings['providers'][ self::YAHOO_PROVIDER_KEY ][ self::YAHOO_VALUECOMMERCE_HTML_FIELD ] ); return $rawSettings; } /** * Extract numeric ValueCommerce sid / pid from pasted HTML or URL text. * * @param string $paste Raw pasted text. * * @return array{sid:string,pid:string}|null Extracted ids, or null on failure. */ private static function extractValueCommerceSidPid( string $paste ): ?array { if ( strlen( $paste ) > self::VALUECOMMERCE_HTML_MAX_BYTES ) { return null; } $normalized = str_replace( "\0", '', $paste ); $decoded = html_entity_decode( $normalized, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $candidates = array( $decoded ); $urlDecoded = rawurldecode( $decoded ); if ( $urlDecoded !== $decoded ) { $candidates[] = $urlDecoded; } foreach ( $candidates as $candidate ) { $matchCount = preg_match_all( '#https?://ck\.jp\.ap\.valuecommerce\.com/servlet/referral\?[^\\s"\'<>]+#i', $candidate, $matches ); if ( $matchCount === false || $matchCount === 0 ) { $fallback = self::extractValueCommerceSidPidFromReferralText( $candidate ); if ( $fallback !== null ) { return $fallback; } continue; } foreach ( $matches[0] as $url ) { if ( preg_match( '/\?([^#]*)/', $url, $queryMatch ) !== 1 ) { continue; } $query = $queryMatch[1]; parse_str( $query, $parameters ); $sid = $parameters['sid'] ?? null; $pid = $parameters['pid'] ?? null; if ( is_string( $sid ) && is_string( $pid ) && preg_match( '/\A[0-9]{1,20}\z/', $sid ) === 1 && preg_match( '/\A[0-9]{1,20}\z/', $pid ) === 1 ) { return array( 'sid' => $sid, 'pid' => $pid, ); } } } return null; } /** * Extract sid / pid from text that contains a referral marker but no clean URL. * * @param string $candidate Decoded pasted text candidate. * * @return array{sid:string,pid:string}|null Extracted ids, or null. */ private static function extractValueCommerceSidPidFromReferralText( string $candidate ): ?array { if ( stripos( $candidate, 'ck.jp.ap.valuecommerce.com/servlet/referral' ) === false ) { return null; } if ( preg_match( '/(?:\?|&|&)sid=([0-9]{1,20})(?:[^0-9][\s\S]*?)(?:\?|&|&)pid=([0-9]{1,20})/i', $candidate, $matches ) !== 1 ) { return null; } return array( 'sid' => $matches[1], 'pid' => $matches[2], ); } /** * Read a checkbox-style boolean from a form payload. * * @param array $source Source array. * @param string $key Field key. */ private static function readBool( array $source, string $key ): bool { if ( ! array_key_exists( $key, $source ) ) { return false; } $value = $source[ $key ]; return ! ( $value === false || $value === 0 || $value === '0' || $value === '' || $value === null ); } /** * Unslash a posted scalar value when WordPress helpers are available. * * @param string $value Posted value. */ private static function unslash( string $value ): string { return function_exists( 'wp_unslash' ) ? (string) wp_unslash( $value ) : $value; } /** * Build the redirect URL by appending `affilun_status=$status` * to the settings-page URL, emit a §10.1 audit row keyed on * the same status (when an audit writer is wired), and * forward through the injected redirector. * * The `add_query_arg` global is used when available; the * fallback string concatenation keeps the handler testable * in pure-unit harnesses where WordPress is not loaded. * * Audit emission runs BEFORE the redirector so production * hosts (whose default redirector calls `exit`) never skip * the audit row. Audit failures are swallowed inside * {@see self::emitAudit()} per the {@see AuditEventWriter} * "tolerate audit emit failure" contract — losing one * audit row must never mask the business outcome. * * @param string $status One of `'saved'` / `'error'` / * `'forbidden'` / `'invalid_nonce'`. */ private function redirectWithStatus( string $status ): void { $this->emitAudit( $status ); $base = ( $this->urlBuilder )(); $url = self::appendStatusQueryArg( $base, $status ); ( $this->redirector )( $url ); } /** * Emit a `configuration_change` audit row describing the * terminal outcome of {@see self::handle()}. * * Status → §10.2 result token mapping: * * - `saved` → {@see AuditEventDraft::RESULT_SUCCESS} * - `error` → {@see AuditEventDraft::RESULT_FAILED} * - `forbidden` → {@see AuditEventDraft::RESULT_REJECTED} * - `invalid_nonce` → {@see AuditEventDraft::RESULT_REJECTED} * * No-op when no audit writer was injected at construction * time (production wiring may opt in via * {@see \Affilun\Presentation\Admin\AdminServiceProvider}; * the pure-unit handler tests stay silent so they remain * focused on the redirector contract). * * Failure semantics: a {@see RuntimeException} from the * writer is swallowed here so the surrounding redirect path * is not derailed — losing one audit row is preferable to * losing the business operation it described, per the * {@see AuditEventWriter} contract. * * @param string $status One of `'saved'` / `'error'` / * `'forbidden'` / `'invalid_nonce'`. */ private function emitAudit( string $status ): void { if ( $this->auditWriter === null ) { return; } $result = self::resultTokenForStatus( $status ); $summary = self::safeSummaryForStatus( $status ); $context = array( 'affilun_status' => $status ); $resolver = $this->userIdResolver; $userId = $resolver(); try { if ( $userId !== null ) { $draft = AuditEventDraft::forUserAction( 'settings.save', 'configuration_change', 'update', $result, $summary, $userId, 'settings', 'plugin', array(), $context ); } else { $draft = AuditEventDraft::forSystemAction( 'settings.save', 'configuration_change', 'update', $result, $summary, 'settings', 'plugin', array(), $context ); } $this->auditWriter->record( $draft ); } catch ( RuntimeException $unused ) { // Per the AuditEventWriter contract, the call site // log-and-continues on persistence failure; the // business outcome (saved / error / forbidden / // invalid_nonce) MUST still flow to the redirector. unset( $unused ); } } /** * Map a redirect status literal onto the §10.2 audit result * token. * * @param string $status Redirect status literal. */ private static function resultTokenForStatus( string $status ): string { switch ( $status ) { case 'saved': return AuditEventDraft::RESULT_SUCCESS; case 'error': return AuditEventDraft::RESULT_FAILED; case 'forbidden': case 'invalid_nonce': default: return AuditEventDraft::RESULT_REJECTED; } } /** * Build the human-readable safe summary for a redirect * status. The summary deliberately carries no secret * material — every persisted value (including provider * secrets) is omitted so the §6.4 "do not leak persistence * details" rule is honored. * * @param string $status Redirect status literal. */ private static function safeSummaryForStatus( string $status ): string { switch ( $status ) { case 'saved': return 'Settings save accepted by the §13 admin form.'; case 'error': return 'Settings save failed in the persistence layer.'; case 'forbidden': return 'Settings save denied by the capability gate.'; case 'invalid_nonce': return 'Settings save rejected by the nonce verifier.'; default: return sprintf( 'Settings save terminal status "%s".', $status ); } } /** * Append the `affilun_status` query argument to `$base`, * preferring the WordPress `add_query_arg()` helper and * falling back to manual string concatenation when the * helper is unavailable. * * @param string $base Base URL. * @param string $status Status literal. */ private static function appendStatusQueryArg( string $base, string $status ): string { if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( array( 'affilun_status' => $status ), $base ); } $separator = str_contains( $base, '?' ) ? '&' : '?'; return $base . $separator . 'affilun_status=' . rawurlencode( $status ); } /** * Default capability probe. * * Forwards to `current_user_can()` when available; returns * false otherwise so the pure-unit harness fails closed. * * @phpstan-return callable(string): bool */ private static function defaultCapProbe(): callable { return static function ( string $capability ): bool { if ( ! function_exists( 'current_user_can' ) ) { return false; } return (bool) current_user_can( $capability ); }; } /** * Default nonce verifier. * * Forwards to `wp_verify_nonce()` when available; returns * false otherwise so the pure-unit harness fails closed. * The argument order is swapped here because WordPress's * native signature is `wp_verify_nonce($nonce, $action)` * but this handler invokes the verifier as * `($action, $value)` for consistency with * {@see SettingsFormToken::validateNonce()}. * * @phpstan-return callable(string, string): bool */ private static function defaultNonceVerifier(): callable { return static function ( string $action, string $nonceValue ): bool { if ( ! function_exists( 'wp_verify_nonce' ) ) { return false; } return (bool) wp_verify_nonce( $nonceValue, $action ); }; } /** * Sanitize a nonce string before verification. * * @param string $value Raw nonce. */ private static function sanitizeNonceValue( string $value ): string { $value = function_exists( 'wp_unslash' ) ? (string) wp_unslash( $value ) : $value; if ( function_exists( 'sanitize_text_field' ) ) { return (string) sanitize_text_field( $value ); } $value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value; return trim( (string) preg_replace( '/\s+/', ' ', $value ) ); } /** * Default redirector. * * Forwards to `wp_safe_redirect()` and then calls `exit;` * so the surrounding `admin-post.php` dispatcher does not * continue and emit a second response. Tests MUST inject a * recording closure that returns without calling `exit;`. * * @phpstan-return callable(string): void */ private static function defaultRedirector(): callable { return static function ( string $url ): void { if ( function_exists( 'wp_safe_redirect' ) ) { wp_safe_redirect( $url ); exit; } // In production hosts WordPress short-circuits the // rest of admin-post.php on its own; tests inject an // exit-free recorder so PHPUnit can keep running. exit; }; } /** * Default settings-page URL builder. * * Forwards to `admin_url()` when available; falls back to a * synthetic absolute URL in pure-unit harnesses where * WordPress is not loaded. * * @phpstan-return callable(): string */ private static function defaultUrlBuilder(): callable { return static function (): string { if ( function_exists( 'admin_url' ) ) { return (string) admin_url( 'options-general.php?page=affilun-settings' ); } return '/wp-admin/options-general.php?page=affilun-settings'; }; } /** * Default current-user-id resolver. * * Forwards to `get_current_user_id()` when available, returning * the integer id (or null when no user is logged in / the * helper is unavailable so the audit row downgrades to a * system action rather than recording an unknown actor as id 0). * * @phpstan-return callable(): ?int */ private static function defaultUserIdResolver(): callable { return static function (): ?int { if ( ! function_exists( 'get_current_user_id' ) ) { return null; } $id = (int) get_current_user_id(); return $id !== 0 ? $id : null; }; } }