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
69 changes: 66 additions & 3 deletions plugin/plan-your-day/assets/js/plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -1008,14 +1008,19 @@
browse: config.initialData?.browse || {},
route: config.initialData?.route || {},
};
const canBootstrapEndpointToken =
typeof config.rest?.bootstrapUrl === 'string' &&
config.rest.bootstrapUrl !== '';
let endpointToken = typeof config.rest?.endpointToken === 'string' ? config.rest.endpointToken : '';
let hasBootstrappedEndpointToken = false;
let endpointTokenRequest = null;
const hasRestConfig =
refs.form instanceof HTMLFormElement &&
typeof config.rest?.browseUrl === 'string' &&
config.rest.browseUrl !== '' &&
typeof config.rest?.routeUrl === 'string' &&
config.rest.routeUrl !== '' &&
typeof config.rest?.endpointToken === 'string' &&
config.rest.endpointToken !== '';
(endpointToken !== '' || canBootstrapEndpointToken);
const shouldHydrateOnLoad = Boolean(config.hydration?.shouldHydrateOnLoad);
const colorModeDefault = normalizeColorModeDefault(
config.colorModeDefault || root.getAttribute('data-plan-color-mode-default')
Expand Down Expand Up @@ -1190,6 +1195,58 @@
animateStartPanel(false);
};

const ensureEndpointToken = async () => {
if (!canBootstrapEndpointToken) {
return endpointToken;
}

if (hasBootstrappedEndpointToken && endpointToken !== '') {
return endpointToken;
}

if (endpointTokenRequest) {
return endpointTokenRequest;
}

endpointTokenRequest = fetch(config.rest.bootstrapUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
})
.then(async (response) => {
const responseBody = await response.json().catch(() => ({}));
debugLog(config, response.ok ? 'info' : 'warn', 'request:bootstrap', {
status: response.status,
ok: response.ok,
body: responseBody,
});

if (!response.ok) {
throw new Error(responseBody?.message || strings.requestFailed || '');
}

const freshToken = String(responseBody?.endpointToken || '');

if (freshToken === '') {
throw new Error(strings.requestFailed || '');
}

endpointToken = freshToken;
hasBootstrappedEndpointToken = true;

return endpointToken;
})
.finally(() => {
endpointTokenRequest = null;
});

return endpointTokenRequest;
};

const sendRequest = async (endpointKey, payload, requestOptions = {}) => {
if (!hasRestConfig) {
return 'unsupported';
Expand Down Expand Up @@ -1259,9 +1316,15 @@
});

try {
const requestEndpointToken = await ensureEndpointToken();
Comment on lines 1318 to +1319

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-check request freshness after token bootstrap

When the first REST request has to wait for /bootstrap, aborting it does not stop execution here because ensureEndpointToken() is not tied to the request's AbortController. If a user quickly changes searches/categories before bootstrap resolves, the stale request resumes after the token arrives and sends its browse/route fetch using the newer global activeRequestController.signal, so cancelled requests still hit the REST API and can consume rate limit/API quota before their response is discarded. Capture the controller/signal for this request or check requestId !== activeRequestId immediately after the bootstrap await before issuing the endpoint fetch.

Useful? React with 👍 / 👎.


if (requestEndpointToken === '') {
throw new Error(strings.requestFailed || '');
}

const requestBody = {
...payload,
endpoint_token: config.rest.endpointToken,
endpoint_token: requestEndpointToken,
};

if (endpointKey === 'browse') {
Expand Down
2 changes: 1 addition & 1 deletion plugin/plan-your-day/assets/js/plan.min.js

Large diffs are not rendered by default.

26 changes: 11 additions & 15 deletions plugin/plan-your-day/src/Frontend/PlannerRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Acodebeard\PlanYourDay\Planner\PlannerStateBuilder;
use Acodebeard\PlanYourDay\Planner\RequestStateParser;
use Acodebeard\PlanYourDay\Rest\PlannerRoutes;
use Acodebeard\PlanYourDay\Security\VisitorTokenManager;
use Acodebeard\PlanYourDay\Settings\Settings;

defined( 'ABSPATH' ) || exit;
Expand All @@ -19,22 +18,19 @@ final class PlannerRenderer {
private RequestStateParser $request_state_parser;
private PlannerStateBuilder $planner_state_builder;
private PlannerPayloadBuilder $planner_payload_builder;
private VisitorTokenManager $visitor_token_manager;

public function __construct(
Settings $settings,
CategoryCatalog $category_catalog,
RequestStateParser $request_state_parser,
PlannerStateBuilder $planner_state_builder,
PlannerPayloadBuilder $planner_payload_builder,
VisitorTokenManager $visitor_token_manager
PlannerPayloadBuilder $planner_payload_builder
) {
$this->settings = $settings;
$this->category_catalog = $category_catalog;
$this->request_state_parser = $request_state_parser;
$this->planner_state_builder = $planner_state_builder;
$this->planner_payload_builder = $planner_payload_builder;
$this->visitor_token_manager = $visitor_token_manager;
}

public function render( array $request = [], string $action_url = '' ): string {
Expand Down Expand Up @@ -67,7 +63,6 @@ public function render( array $request = [], string $action_url = '' ): string {
$action_url = '' !== $action_url ? $action_url : $this->get_current_url();
$form_action = $action_url . '#' . $instance_id;
$maps_link_enabled = '' !== $planner_state['maps_url'];
$endpoint_token = $this->visitor_token_manager->get_endpoint_token();
$color_mode_default = $this->settings->get_color_mode_default();
$initial_color_mode = Settings::COLOR_MODE_SYSTEM === $color_mode_default ? '' : $color_mode_default;

Expand Down Expand Up @@ -126,7 +121,7 @@ class="plan-your-day__layout"
</form>
</div>

<script type="application/json" data-plan-config><?php echo wp_json_encode( $this->build_config( $instance_id, $action_url, $planner_state, $start_points, $category_catalog, $endpoint_token, $should_hydrate_on_load ) ); ?></script>
<script type="application/json" data-plan-config><?php echo wp_json_encode( $this->build_config( $instance_id, $action_url, $planner_state, $start_points, $category_catalog, $should_hydrate_on_load ) ); ?></script>
</section>
<?php

Expand Down Expand Up @@ -677,7 +672,7 @@ private function get_start_points(): array {
return $start_points;
}

private function build_config( string $instance_id, string $action_url, array $planner_state, array $start_points, array $category_catalog, string $endpoint_token, bool $should_hydrate_on_load ): array {
private function build_config( string $instance_id, string $action_url, array $planner_state, array $start_points, array $category_catalog, bool $should_hydrate_on_load ): array {
$browse_payload = $this->planner_payload_builder->build_browse_payload( $planner_state );
$route_payload = $this->planner_payload_builder->build_route_payload( $planner_state );

Expand All @@ -697,14 +692,15 @@ private function build_config( string $instance_id, string $action_url, array $p
'actionUrl' => $action_url,
'sectionId' => $instance_id,
'colorModeDefault' => $this->settings->get_color_mode_default(),
'startPoints' => $start_points,
'categoryCatalog' => $category_catalog,
'rest' => [
'browseUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/browse' ),
'routeUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/route' ),
'endpointToken' => $endpoint_token,
'startPoints' => $start_points,
'categoryCatalog' => $category_catalog,
'rest' => [
'bootstrapUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/bootstrap' ),
'browseUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/browse' ),
'routeUrl' => rest_url( PlannerRoutes::REST_NAMESPACE . '/route' ),
'endpointToken' => '',
],
'hydration' => [
'hydration' => [
'shouldHydrateOnLoad' => $should_hydrate_on_load,
],
'strings' => [
Expand Down
3 changes: 1 addition & 2 deletions plugin/plan-your-day/src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,7 @@ private function __construct() {
$this->category_catalog,
$this->request_state_parser,
$this->planner_state_builder(),
$this->planner_payload_builder,
$this->visitor_token_manager
$this->planner_payload_builder
);
$this->planner_shortcode = new PlannerShortcode( $this->planner_renderer, $this->frontend_assets );
$this->planner_block = new PlannerBlock( $this->planner_renderer, $this->frontend_assets );
Expand Down
50 changes: 50 additions & 0 deletions plugin/plan-your-day/src/Rest/PlannerRoutes.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ public function __construct(
}

public function register(): void {
register_rest_route(
self::REST_NAMESPACE,
'/bootstrap',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'bootstrap' ],
'permission_callback' => '__return_true',
]
);

register_rest_route(
self::REST_NAMESPACE,
'/browse',
Expand All @@ -79,6 +89,32 @@ public function register(): void {
);
}

public function bootstrap( WP_REST_Request $request ): WP_REST_Response|WP_Error {
$guard = $this->guard_bootstrap_request();

if ( $guard instanceof WP_Error ) {
return $guard;
}

$endpoint_token = $this->visitor_token_manager->get_endpoint_token();

if ( '' === $endpoint_token ) {
return new WP_Error(
'plan_your_day_token_unavailable',
$this->request_verification_failed_message(),
[
'status' => 403,
]
);
}

return new WP_REST_Response(
[
'endpointToken' => $endpoint_token,
]
);
}

public function browse( WP_REST_Request $request ): WP_REST_Response|WP_Error {
DebugLogger::log(
'rest.browse.request',
Expand Down Expand Up @@ -180,6 +216,20 @@ public function route( WP_REST_Request $request ): WP_REST_Response|WP_Error {
);
}

private function guard_bootstrap_request(): ?WP_Error {
if ( ! $this->request_origin_validator->is_same_site_request( $_SERVER ) ) {
return new WP_Error(
'plan_your_day_invalid_origin',
$this->request_verification_failed_message(),
[
'status' => 403,
]
);
}

return $this->rate_limiter->enforce( 'bootstrap', $_SERVER, self::RATE_LIMIT_BASE_COST );
}

private function guard_request( WP_REST_Request $request, string $scope, array $request_state ): ?WP_Error {
if ( ! $this->request_origin_validator->is_same_site_request( $_SERVER ) ) {
$error = new WP_Error(
Expand Down
8 changes: 5 additions & 3 deletions plugin/plan-your-day/tests/PlannerBlockTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ function wp_unique_id( string $prefix = '' ): string {
use Acodebeard\PlanYourDay\Planner\StartContextResolver;
use Acodebeard\PlanYourDay\Planner\WaypointList;
use Acodebeard\PlanYourDay\Security\RequestOriginValidator;
use Acodebeard\PlanYourDay\Security\VisitorTokenManager;
use Acodebeard\PlanYourDay\Settings\Settings;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -224,6 +223,10 @@ public function test_render_enqueues_frontend_assets_and_uses_shared_renderer_ou
self::assertStringContainsString( 'class="plan-your-day"', $output );
self::assertStringContainsString( 'data-plan-color-mode-default="dark"', $output );
self::assertStringContainsString( '"colorModeDefault":"dark"', $output );
self::assertStringContainsString( '"bootstrapUrl":"https:\/\/example.test\/wp-json\/plan-your-day\/v1\/bootstrap"', $output );
self::assertStringContainsString( '"endpointToken":""', $output );
self::assertStringNotContainsString( 'plan_your_day_visitor', $output );
self::assertStringNotContainsString( hash_hmac( 'sha256', str_repeat( 'ab', 24 ), 'tests-auth|plan-your-day' ), $output );
self::assertStringContainsString( 'action="https://example.test/planner#plan-your-day-1"', $output );
self::assertStringNotContainsString( 'Editable starting point helper.', $output );
self::assertStringNotContainsString( 'Editable custom start label', $output );
Expand Down Expand Up @@ -287,8 +290,7 @@ private function build_renderer(): PlannerRenderer {
$category_catalog,
$request_state_parser,
$planner_state_builder,
new PlannerPayloadBuilder( $settings ),
new VisitorTokenManager()
new PlannerPayloadBuilder( $settings )
);
}
}
Expand Down
40 changes: 40 additions & 0 deletions plugin/plan-your-day/tests/PlannerRoutesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,46 @@ public function test_route_rate_limiter_uses_trusted_proxy_forwarded_client_ip()
self::assertSame( 'plan_your_day_rate_limited', $second->get_error_code() );
}

public function test_bootstrap_returns_endpoint_token_for_same_site_request(): void {
$visitor_token = str_repeat( 'ab', 24 );
$routes = $this->build_routes( 10 );
$request = new WP_REST_Request( 'POST', '/plan-your-day/v1/bootstrap' );

$_SERVER = $this->same_site_server( '198.51.100.10' );
$_COOKIE['plan_your_day_visitor'] = $visitor_token;

$response = $routes->bootstrap( $request );

self::assertInstanceOf( WP_REST_Response::class, $response );
self::assertSame(
[
'endpointToken' => hash_hmac( 'sha256', $visitor_token, 'tests-auth|plan-your-day' ),
],
$response->get_data()
);
}

public function test_bootstrap_rejects_cross_site_request_before_token_creation(): void {
$routes = $this->build_routes( 10 );
$request = new WP_REST_Request( 'POST', '/plan-your-day/v1/bootstrap' );

$_SERVER = $this->same_site_server(
'198.51.100.10',
[
'HTTP_ORIGIN' => 'https://evil.example',
'HTTP_REFERER' => 'https://evil.example/planner',
'HTTP_SEC_FETCH_SITE' => 'cross-site',
]
);

$response = $routes->bootstrap( $request );

self::assertInstanceOf( WP_Error::class, $response );
self::assertSame( 'plan_your_day_invalid_origin', $response->get_error_code() );
self::assertSame( 403, $response->get_error_data()['status'] ?? null );
self::assertArrayNotHasKey( 'plan_your_day_visitor', $_COOKIE );
}

public function test_browse_append_results_uses_cached_search_context_ids_and_skips_route_refresh(): void {
$google_api_client = new PlannerRoutesGoogleApiClient(
GoogleApiResult::success(
Expand Down
17 changes: 12 additions & 5 deletions plugin/plan-your-day/tests/browser-app/router.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
plan_your_day_browser_render_page( 'plain' );
return;

case '/wp-json/plan-your-day/v1/bootstrap':
plan_your_day_browser_dispatch_rest( 'bootstrap' );
return;

case '/wp-json/plan-your-day/v1/browse':
plan_your_day_browser_dispatch_rest( 'browse' );
return;
Expand Down Expand Up @@ -109,8 +113,7 @@ function plan_your_day_browser_app(): array {
$category_catalog,
$request_state_parser,
$planner_state_builder,
$planner_payload_builder,
$visitor_token_manager
$planner_payload_builder
);

$planner_routes = new PlannerRoutes(
Expand Down Expand Up @@ -192,9 +195,13 @@ function plan_your_day_browser_dispatch_rest( string $route_name ): void {
$request->set_param( (string) $key, $value );
}

$result = 'browse' === $route_name
? $app['routes']->browse( $request )
: $app['routes']->route( $request );
if ( 'bootstrap' === $route_name ) {
$result = $app['routes']->bootstrap( $request );
} elseif ( 'browse' === $route_name ) {
$result = $app['routes']->browse( $request );
} else {
$result = $app['routes']->route( $request );
}

if ( $result instanceof WP_Error ) {
plan_your_day_browser_send_json(
Expand Down
Loading