Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ The format follows [Keep a Changelog] (https://keepachangelog.com/en/1.1.0/).

### Changed

- Internal (#1053) — o endpoint de fragmentos de página em cache passa a servir o desafio do provider configurado, via `CaptchaProvider::resolve()->challenge_payload()`, em vez de chamar o captcha matemático direto; o cliente despacha pelo campo `provider` do payload e ignora o que não reconhece, em vez de aplicar meio payload. É o quinto site de refresh, que a unificação do contrato não havia alcançado.
- Internal (#1053) — o captcha passa a ter um contrato de estratégia (`CaptchaProviderInterface` + `CaptchaProvider::resolve()`), com o desafio matemático atrás dele. Os 6 sites de verificação e os 4 de retry não mudam: `validate_security_fields()` continua sendo o ponto único e agora delega a metade captcha. As duas cópias do bloco de segurança viraram uma, em `templates/`.

### Removed

- `Shortcodes::get_new_captcha_data()` (#1053): método público sem nenhum chamador em produção — o único consumidor era o próprio teste. A geração de desafio já é responsabilidade do contrato de captcha.

### Fixed

- **Um agendamento era criado mas reportado como falha** (#1058): `$appointment['id']` chega do `$wpdb` como string e `AppointmentReceiptHandler::get_receipt_url()` declara `int` sob `strict_types` — o `TypeError` fatalava no e-mail de confirmação, *depois* do commit da reserva, e o visitante recebia HTTP 500 para um agendamento que existia. O `catch` do handler passou a `\Throwable` e o bloco pós-commit foi isolado por etapa: uma reserva commitada nunca mais é reportada como falha.
Expand Down
117 changes: 74 additions & 43 deletions assets/js/ffc-dynamic-fragments.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,55 +81,86 @@
}

/**
* Patch the DOM with fresh captcha, nonce, and user values.
* Every security block on the page, in DOM order.
*
* `.ffc-security-container` is the provider-independent wrapper each
* render site emits. A bare `.ffc-captcha-row` outside one is markup from
* before that wrapper existed — and cached HTML is exactly this
* endpoint's audience, so it is collected too rather than skipped.
*
* @returns {Element[]} Roots to patch, one per rendered challenge.
*/
function applyFragments(data) {
function securityBlocks() {
var blocks = [];
var i;

// --- Per-form captchas (multiple forms on one page) ---
var handledWrappers = {};
if (data.captchas) {
var formId;
for (formId in data.captchas) {
if (data.captchas.hasOwnProperty(formId)) {
var wrapper = document.getElementById('ffc-form-' + formId);
if (wrapper) {
var fc = data.captchas[formId];
var wLabel = wrapper.querySelector('.ffc-captcha-row .ffc-captcha-label-text');
var wHash = wrapper.querySelector('input[name="ffc_captcha_hash"]');
var wAns = wrapper.querySelector('input[name="ffc_captcha_ans"]');
if (wLabel) { wLabel.textContent = fc.label; }
if (wHash) { wHash.value = fc.hash; }
if (wAns) { wAns.value = ''; }
handledWrappers['ffc-form-' + formId] = true;
}
}
}
var containers = document.querySelectorAll('.ffc-security-container');
for (i = 0; i < containers.length; i++) {
blocks.push(containers[i]);
}

// --- Default captcha (single form or self-scheduling) ---
if (data.captcha) {
var labelTexts = document.querySelectorAll('.ffc-captcha-row .ffc-captcha-label-text');
var hashes = document.querySelectorAll('input[name="ffc_captcha_hash"]');
var answers = document.querySelectorAll('input[name="ffc_captcha_ans"]');

for (i = 0; i < labelTexts.length; i++) {
var el = labelTexts[i];
var parent = el.closest('.ffc-form-wrapper');
if (parent && handledWrappers[parent.id]) { continue; }
el.textContent = data.captcha.label;
}
for (i = 0; i < hashes.length; i++) {
var hEl = hashes[i];
var hParent = hEl.closest('.ffc-form-wrapper');
if (hParent && handledWrappers[hParent.id]) { continue; }
hEl.value = data.captcha.hash;
var rows = document.querySelectorAll('.ffc-captcha-row');
for (i = 0; i < rows.length; i++) {
if (!rows[i].closest('.ffc-security-container')) {
blocks.push(rows[i]);
}
for (i = 0; i < answers.length; i++) {
var aEl = answers[i];
var aParent = aEl.closest('.ffc-form-wrapper');
if (aParent && handledWrappers[aParent.id]) { continue; }
aEl.value = '';
}

return blocks;
}

/**
* Apply a challenge payload inside one security block.
*
* The payload is whatever the configured captcha strategy issued, and it
* names itself in `provider`. Dispatching on that — rather than assuming
* the math shape — is what keeps this honest once a strategy with
* different fields exists: an unrecognised provider is left alone instead
* of being half-applied, which would blank a challenge the visitor may
* already have solved.
*
* @param {Element} root Security block to patch within.
* @param {Object} payload Challenge payload from the server.
* @returns {void}
*/
function applyChallenge(root, payload) {
if (!payload || payload.provider !== 'math') {
return;
}

var label = root.querySelector('.ffc-captcha-label-text');
var hash = root.querySelector('input[name="ffc_captcha_hash"]');
var ans = root.querySelector('input[name="ffc_captcha_ans"]');

if (label) { label.textContent = payload.new_label; }
if (hash) { hash.value = payload.new_hash; }
// Clearing the answer matters as much as the token: a stale answer
// beside a fresh challenge submits a pair that cannot verify.
if (ans) { ans.value = ''; }
}

/**
* Patch the DOM with fresh captcha, nonce, and user values.
*/
function applyFragments(data) {
var i;

// --- Captchas ---
// One pass over the security blocks. A block inside a form wrapper
// takes that form's own payload when the server sent one (several
// forms on a page must never share a challenge — #1056); everything
// else takes the default. Scoping by block rather than by document
// is what keeps two challenges on one page independent.
if (data.captcha || data.captchas) {
var blocks = securityBlocks();
for (i = 0; i < blocks.length; i++) {
var wrapper = blocks[i].closest('.ffc-form-wrapper');
var formId = wrapper ? wrapper.id.replace('ffc-form-', '') : '';
var payload = (data.captchas && formId && data.captchas[formId])
? data.captchas[formId]
: data.captcha;

applyChallenge(blocks[i], payload);
}
}

Expand Down
2 changes: 1 addition & 1 deletion assets/js/ffc-dynamic-fragments.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 12 additions & 13 deletions includes/frontend/class-ffc-dynamic-fragments.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

namespace FreeFormCertificate\Frontend;

use FreeFormCertificate\Core\Captcha;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}
Expand Down Expand Up @@ -43,13 +45,13 @@ public function __construct() {
* (tied to the visitor's cookies) and safe to expose.
*/
public function handle(): void {
$captcha = \FreeFormCertificate\Core\SecurityService::generate_simple_captcha();

$fragments = array(
'captcha' => array(
'label' => $captcha['label'],
'hash' => $captcha['hash'],
),
// Forwarded verbatim from whichever strategy is configured. The
// endpoint deliberately does not name the fields: a proof-of-work
// challenge has no label and no answer hash, so any mapping here
// would only hold for the math one. The client dispatches on the
// payload's own `provider` key.
'captcha' => Captcha\CaptchaProvider::resolve()->challenge_payload(),
'nonces' => array(
'ffc_frontend_nonce' => wp_create_nonce( 'ffc_frontend_nonce' ),
'ffc_self_scheduling_nonce' => wp_create_nonce( 'ffc_self_scheduling_nonce' ),
Expand Down Expand Up @@ -82,17 +84,14 @@ public function handle(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Intentionally nonce-free; see class docblock.
$form_ids = isset( $_POST['form_ids'] ) ? array_map( 'absint', (array) $_POST['form_ids'] ) : array();

// Generate a unique captcha per form so multiple forms on the same
// page each show a different math question after cache refresh.
// Issue a distinct challenge per form, so two forms on one page never
// share a token — the resolver memoises the strategy instance, not the
// challenge, so each call mints a fresh one.
if ( count( $form_ids ) > 1 ) {
$per_form = array();
foreach ( $form_ids as $fid ) {
if ( $fid > 0 ) {
$c = \FreeFormCertificate\Core\SecurityService::generate_simple_captcha();
$per_form[ $fid ] = array(
'label' => $c['label'],
'hash' => $c['hash'],
);
$per_form[ $fid ] = Captcha\CaptchaProvider::resolve()->challenge_payload();
}
}
if ( ! empty( $per_form ) ) {
Expand Down
9 changes: 0 additions & 9 deletions includes/frontend/class-ffc-shortcodes.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,6 @@ class Shortcodes {
public function __construct() {
}

/**
* Generate new captcha data (math question + hash)
*
* @return array<string, mixed>
*/
public function get_new_captcha_data(): array {
return SecurityService::generate_simple_captcha();
}

/**
* Generate HTML for security fields (honeypot + captcha)
*
Expand Down
Loading
Loading