connection = $connection; } /** * @param array $input a nyers POST (REST vagy admin-post) * @param string $ip a látogató IP-je (Spam::client_ip()) * @return array{ok: bool, status: string, public_status: string, code: string, message: string, hint: string} */ public function handle( array $input, string $ip ): array { $settings = FormSettings::load(); if ( Spam::is_spam( $input, $ip ) ) { Log::add( 'debug', 'Submission dropped as spam', array( 'ip' => $ip ) ); return $this->result( 'spam', $settings ); } // A captcha a néma spam-szűrés után jön, saját, látható hibával: a // valódi látogatónak tudnia kell, hogy újra kell próbálnia. A throttle-t // nem fogyasztja (a record_attempt() lent marad). if ( Turnstile::enabled() && Turnstile::FAIL === Turnstile::verify( (string) ( $input[ Turnstile::RESPONSE_FIELD ] ?? '' ), $ip ) ) { return $this->result( 'invalid', $settings, 'captcha_failed' ); } $email = sanitize_email( (string) ( $input['mowl_email'] ?? '' ) ); if ( '' === $email || ! is_email( $email ) ) { return $this->result( 'invalid', $settings, 'invalid_email' ); } Spam::record_attempt( $ip ); // A form aláírt (_mowl_cfg/_mowl_sig) felülírásai innentől helyettesítik // az admin alapértéket — a consent-kényszerítés, a DOI, a tagek, az // átirányítás és a visszaadott üzenetek is már ebből jönnek. $settings = $this->verified_overrides( $input, $settings ); if ( ! empty( $settings['consent_enabled'] ) && empty( $input['mowl_consent'] ) ) { return $this->result( 'invalid', $settings, 'consent_required' ); } $token = $this->connection->token(); if ( null === $token ) { Log::add( 'warning', 'Submission while not connected' ); return $this->result( 'not_connected', $settings ); } $page_url = esc_url_raw( (string) ( $input['_mowl_page'] ?? '' ) ); $payload = array( 'email' => strtolower( $email ), 'first_name' => $this->name( $input['mowl_first_name'] ?? '' ), 'last_name' => $this->name( $input['mowl_last_name'] ?? '' ), 'tags' => array_map( 'intval', (array) $settings['tags'] ), 'double_opt_in' => (bool) $settings['double_opt_in'], 'locale' => Locale::current(), 'confirm_redirect_url' => '' !== $settings['confirm_redirect_url'] ? $settings['confirm_redirect_url'] : null, 'consent' => array( 'text' => ! empty( $settings['consent_enabled'] ) ? (string) $settings['consent_text'] : null, 'ip' => $ip, 'user_agent' => substr( sanitize_text_field( wp_unslash( (string) ( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) ), 0, 500 ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated 'page_url' => $page_url, ), 'source' => array( 'type' => 'wordpress_form', 'form' => in_array( $input['_mowl_form'] ?? '', array( 'shortcode', 'block' ), true ) ? $input['_mowl_form'] : 'shortcode', 'page_url' => $page_url, ), ); try { $response = $this->connection->client()->subscribe( $token, $payload ); } catch ( ApiException $e ) { if ( $e->isUnauthorized() ) { $this->connection->mark_broken(); } Log::add( 'error', 'Subscribe call failed', array( 'status' => $e->getStatus(), 'message' => $e->getMessage(), ) ); return $this->result( 'error', $settings ); } $status = (string) ( $response['status'] ?? 'error' ); if ( 'quota_exceeded' === $status ) { // (string) time(): csak számjegyek, hogy a Notices dismiss-kulcsa // (sanitize_key()-en át) változatlanul visszaköszönjön — egy // gmdate('c') kettőspontjai/pluszjele elveszne a sanitize_key()-ben. Options::flag( 'quota_exceeded', (string) time() ); Log::add( 'warning', 'Mowl subscriber quota exceeded', array( 'email_domain' => substr( strrchr( $email, '@' ), 1 ) ) ); } do_action( 'mowl_after_subscribe', $status, $payload ); return $this->result( $status, $settings ); } /** * A REST/admin-post válasz csak ezt a nyilvános státuszt adja ki — a * belső "suppressed" vs. "subscribed" különbség egy e-mail-enumerációs * mellékcsatorna lenne, ha kifelé is látszana. */ public static function public_status( string $status ): string { if ( 'pending' === $status ) { return 'pending'; } if ( in_array( $status, self::SUCCESS_LIKE, true ) ) { return 'ok'; } return 'error'; } /** * A `message` mindig a whitelistelt `$code`-ból jön — a REST JSON és a * no-JS admin-post query string ugyanezt a leképezést használja * (`message_for()`), sosem szabad szöveget. * * @return array{ok: bool, status: string, public_status: string, code: string, message: string, hint: string} */ private function result( string $status, array $settings, ?string $code = null ): array { $code = $code ?? $status; $public_status = self::public_status( $status ); return array( 'ok' => 'error' !== $public_status, 'status' => $status, 'public_status' => $public_status, 'code' => $code, 'message' => self::message_for( $code, $public_status, $settings ), 'hint' => self::hint_for( $public_status ), ); } /** * A „nézd meg a levélszemét mappát is" kiegészítés a megerősítő levélre * váró (DOI) látogatónak, a `message` alatt, külön sorban — így egy * egyedi `pending_message` mellett is megmarad. Minden más státusznál * üres. A `mowl_pending_hint` szűrővel a site átírhatja vagy (üres * szöveggel) elrejtheti. */ public static function hint_for( string $public_status ): string { if ( 'pending' !== $public_status ) { return ''; } return (string) apply_filters( 'mowl_pending_hint', __( "If the email doesn't arrive within a few minutes, please check your Spam or Junk folder too. If you find it there, mark it as not spam, so our future emails land in your inbox.", 'mowl-email' ) ); } /** * A no-JS válasz (admin-post redirect) és a REST JSON is ebből a * whitelistelt leképezésből kapja a szöveget — sosem a kérés szabad * szövegéből, hogy egy `?mowl_code=...` ne tudjon tetszőleges szöveget * (pl. adathalász üzenetet) a form saját dobozába íratni. */ public static function message_for( string $code, string $public_status, array $settings ): string { if ( 'invalid_email' === $code ) { return __( 'Please enter a valid email address.', 'mowl-email' ); } if ( 'consent_required' === $code ) { return __( 'Please accept the consent checkbox.', 'mowl-email' ); } if ( 'captcha_failed' === $code ) { return __( 'We could not verify that you are not a robot. Please try again; the check needs JavaScript.', 'mowl-email' ); } if ( 'pending' === $public_status ) { return (string) $settings['pending_message']; } if ( 'ok' === $public_status ) { return (string) $settings['success_message']; } return (string) $settings['error_message']; } /** @param mixed $raw */ private function name( $raw ): ?string { $name = trim( sanitize_text_field( (string) $raw ) ); return '' === $name ? null : mb_substr( $name, 0, 100 ); } /** * A `_mowl_cfg` rejtett mező (base64 JSON blob) önmagában hamisítható * (egy szkriptelt POST bármit küldhet benne), ezért csak akkor hisszük el * a form felülírásait, ha a `_mowl_sig` aláírás egyezik azzal, amit a * Renderer a blob pontos szövegére számolt volna. Aláírás nélkül (pl. az * aláírás bevezetése előtt gyorsítótárazott oldal), hibás aláírásnál vagy * nem tömbre dekódolódó blobnál a form felülírásait figyelmen kívül * hagyjuk, és az admin alapértéket használjuk — ez a biztonságos irány. * * @return array a teljes (admin alapértékkel mergelt) beállítás */ private function verified_overrides( array $input, array $settings ): array { $cfg_raw = (string) ( $input['_mowl_cfg'] ?? '' ); $given_sig = (string) ( $input['_mowl_sig'] ?? '' ); if ( '' === $cfg_raw || '' === $given_sig || ! hash_equals( FormSettings::signature( $cfg_raw ), $given_sig ) ) { return $settings; } $decoded = json_decode( (string) base64_decode( $cfg_raw, true ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- nem obfuszkálás: az aláírt (fent ellenőrzött) `_mowl_cfg` rejtett mező kódolását fejti vissza, ugyanígy, ahogy a Renderer base64_encode()-olta. if ( ! is_array( $decoded ) ) { return $settings; } return array_merge( $settings, $this->sanitize_cfg_overrides( $decoded ) ); } /** * A dekódolt `_mowl_cfg` tömb → `FormSettings` kulcsok, sanitizálva. Csak * a blobban ténylegesen jelen lévő kulcsokat írjuk felül. */ private function sanitize_cfg_overrides( array $cfg ): array { $out = array(); if ( array_key_exists( 'doi', $cfg ) ) { $out['double_opt_in'] = (bool) $cfg['doi']; } if ( array_key_exists( 'tags', $cfg ) && is_array( $cfg['tags'] ) ) { $out['tags'] = FormSettings::int_list( $cfg['tags'] ); } if ( array_key_exists( 'consent', $cfg ) ) { $out['consent_enabled'] = (bool) $cfg['consent']; } foreach ( array( 'consent_text' => 'consent_text', 'success' => 'success_message', 'pending' => 'pending_message', ) as $cfg_key => $settings_key ) { if ( array_key_exists( $cfg_key, $cfg ) ) { $out[ $settings_key ] = sanitize_text_field( (string) $cfg[ $cfg_key ] ); } } if ( array_key_exists( 'redirect', $cfg ) ) { $out['confirm_redirect_url'] = FormSettings::sanitize_redirect_url( (string) $cfg['redirect'] ); } return $out; } }