has( $key ) ) { throw new LogicException( 'AffiliateRoutingAdminServiceProvider requires OptionStore.' ); } $options = $registry->get( $key ); if ( ! $options instanceof OptionStore ) { throw new LogicException( 'AffiliateRoutingAdminServiceProvider expected OptionStore.' ); } $this->repository = new OptionStoreAffiliateRoutingRepository( $options ); } /** * Register hooks. * * @param HookRegistrar $hooks Hooks. */ public function boot( HookRegistrar $hooks ): void { $hooks->addAction( 'admin_post_' . self::FORM_ACTION, array( $this, 'onSavePost' ) ); } /** * Add submenu page. */ public function onAdminMenu(): void { if ( ! function_exists( 'add_submenu_page' ) ) { return; } add_submenu_page( 'affilun-products', 'affilun ASPルーティング(上級者向け)', 'ASPルーティング', 'manage_options', 'affilun-affiliate-routing', array( $this, 'renderPage' ) ); } /** * Render page. */ public function renderPage(): void { if ( $this->repository === null ) { return; } $templates = $this->repository->loadRaw(); echo '

' . esc_html( 'ASPルーティング(上級者向け)' ) . '

'; echo '

' . esc_html( '通常は各プロバイダーの設定タブを使用してください。独自ASPや移行用URLテンプレートが必要な場合のみ、プロバイダー別テンプレートを任意設定できます。空欄は通常URLのままです。' ) . '

'; echo '

' . esc_html( '利用可能な変数: {url}, {encoded_url}, {provider}, {product_id}' ) . '

'; echo '
'; echo ''; if ( function_exists( 'wp_nonce_field' ) ) { wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); } echo '

'; foreach ( OptionStoreAffiliateRoutingRepository::PROVIDERS as $provider ) { $value = $templates[ $provider ] ?? ''; echo '


'; echo '

'; } echo '

'; echo '
'; } /** * Save posted templates. */ public function onSavePost(): void { if ( $this->repository === null || ! self::canManage() || ! self::verifyNonce() ) { self::redirect( 'forbidden' ); return; } $postData = self::currentPostData(); $raw = $postData['templates'] ?? array(); $values = array(); if ( is_array( $raw ) ) { foreach ( OptionStoreAffiliateRoutingRepository::PROVIDERS as $provider ) { $value = $raw[ $provider ] ?? ''; $values[ $provider ] = is_scalar( $value ) ? (string) $value : ''; } } try { $this->repository->saveRaw( $values, self::readBool( $postData, 'enabled' ) ); self::redirect( 'saved' ); } catch ( Throwable $unused ) { unset( $unused ); self::redirect( 'error' ); } } /** * 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 ); } /** * Capability check. */ private static function canManage(): bool { return function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ); } /** * Nonce check. */ private static function verifyNonce(): bool { $postData = self::currentPostData(); $value = $postData[ self::NONCE_FIELD ] ?? ''; $nonce = is_scalar( $value ) ? self::sanitizeTextField( (string) $value ) : ''; return $nonce !== '' && function_exists( 'wp_verify_nonce' ) && (bool) wp_verify_nonce( $nonce, self::NONCE_ACTION ); } /** * Return sanitized POST data for the affiliate-routing form. * * @return array */ private static function currentPostData(): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified before callers use the payload. $raw = $_POST; $unslashed = function_exists( 'wp_unslash' ) ? wp_unslash( $raw ) : $raw; if ( ! is_array( $unslashed ) ) { return array(); } $result = array(); foreach ( $unslashed as $key => $value ) { if ( ! is_string( $key ) ) { continue; } $result[ $key ] = self::sanitizePostValue( $key, $value ); } return $result; } /** * Sanitize one POST value recursively. * * @param string $key Field key. * @param mixed $value Field value. * * @return mixed */ private static function sanitizePostValue( string $key, $value ) { if ( is_array( $value ) ) { $result = array(); foreach ( $value as $childKey => $childValue ) { if ( ! is_string( $childKey ) ) { continue; } $result[ $childKey ] = self::sanitizePostValue( $childKey, $childValue ); } return $result; } if ( ! is_scalar( $value ) ) { return ''; } $string = (string) $value; if ( $key === 'action' || $key === 'enabled' || $key === self::NONCE_FIELD ) { return self::sanitizeTextField( $string ); } return self::sanitizeRoutingTemplate( $string ); } /** * Sanitize a routing template while preserving URL-template placeholders. * * @param string $value Raw template. */ private static function sanitizeRoutingTemplate( string $value ): string { $value = self::sanitizeTextField( $value ); return trim( preg_replace( '/[\x00-\x1F\x7F]/', '', $value ) ?? $value ); } /** * Sanitize a single-line text field. * * @param string $value Raw value. */ private static function sanitizeTextField( string $value ): string { if ( function_exists( 'sanitize_text_field' ) ) { return (string) sanitize_text_field( $value ); } $value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value; return trim( (string) preg_replace( '/\s+/', ' ', $value ) ); } /** * Redirect back to settings page. * * @param string $status Status token. */ private static function redirect( string $status ): void { $url = self::adminPageUrl(); $url .= ( str_contains( $url, '?' ) ? '&' : '?' ) . 'affilun_status=' . rawurlencode( $status ); if ( function_exists( 'wp_safe_redirect' ) ) { wp_safe_redirect( $url ); exit; } } /** * Admin-post URL. */ private static function adminPostUrl(): string { return function_exists( 'admin_url' ) ? (string) admin_url( 'admin-post.php' ) : 'admin-post.php'; } /** * Admin page URL. */ private static function adminPageUrl(): string { return function_exists( 'admin_url' ) ? (string) admin_url( 'options-general.php?page=affilun-settings#affilun-settings-tab-asp' ) : 'options-general.php?page=affilun-settings#affilun-settings-tab-asp'; } }