<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
final class Cache
{
public const PUBLIC_STATE_OPTION = 'mw_public_cache_state';
public static function hooks(): void
{
add_action('save_post', [self::class, 'clean_post'], 20, 3);
add_action('deleted_post', [self::class, 'clean_post_by_id'], 20);
add_action('update_option_' . Settings::OPTION, [self::class, 'clean_domain'], 20);
}
public static function clean_post(int $post_id, WP_Post $post, bool $update): void
{
if (wp_is_post_revision($post_id) || !in_array($post->post_type, array_values(ContentTypes::public_post_types()), true)) {
return;
}
self::clean_post_by_id($post_id);
}
public static function clean_post_by_id(int $post_id): void
{
if (function_exists('rocket_clean_post')) {
rocket_clean_post($post_id);
}
clean_post_cache($post_id);
self::bump_public_version();
}
public static function clean_domain(): void
{
if (function_exists('rocket_clean_domain')) {
rocket_clean_domain();
}
self::bump_public_version();
}
/**
* Return deterministic validators for an anonymous public REST response.
*
* The key contains the route, normalized query, and requested language;
* the body hash keeps the validator correct when an existing record is
* edited without changing its publication timestamp.
*
* @return array{key:string,etag:string,last_modified:string,tags:string}
*/
public static function public_metadata(string $route, WP_REST_Request $request, WP_REST_Response $response): array
{
$query = $request->get_query_params();
self::sort_recursive($query);
$identity = [
'route' => $route,
'query' => $query,
'accept_language' => sanitize_text_field((string) $request->get_header('Accept-Language')),
];
$key = 'mw:v1:public:' . hash('sha256', (string) wp_json_encode($identity));
$body = (string) wp_json_encode($response->get_data());
$state = self::state();
$etag = 'W/"' . substr(hash('sha256', $key . '|' . (string) $state['version'] . '|' . $body), 0, 32) . '"';
return [
'key' => $key,
'etag' => $etag,
'last_modified' => self::last_modified($state),
'tags' => 'public,route:' . sanitize_key(str_replace('/', ':', trim($route, '/'))),
];
}
/**
* A normal WordPress option is sufficient for a version marker; no schema
* migration or direct SQL is involved. Reads never create this option.
*/
private static function bump_public_version(): void
{
$state = self::state();
update_option(self::PUBLIC_STATE_OPTION, [
'version' => max(0, (int) $state['version']) + 1,
'changed_at' => gmdate('c'),
], false);
}
/** @return array{version:int,changed_at:string} */
private static function state(): array
{
$state = get_option(self::PUBLIC_STATE_OPTION, []);
if (!is_array($state)) {
$state = [];
}
return [
'version' => max(0, (int) ($state['version'] ?? 0)),
'changed_at' => sanitize_text_field((string) ($state['changed_at'] ?? '')),
];
}
/** @param array<mixed> $value */
private static function sort_recursive(array &$value): void
{
foreach ($value as &$child) {
if (is_array($child)) {
self::sort_recursive($child);
}
}
unset($child);
if ([] !== $value && array_keys($value) !== range(0, count($value) - 1)) {
ksort($value);
}
}
/** @param array{version:int,changed_at:string} $state */
private static function last_modified(array $state): string
{
$timestamps = [];
if ('' !== $state['changed_at']) {
$changed_at = strtotime($state['changed_at']);
if (false !== $changed_at) {
$timestamps[] = $changed_at;
}
}
$post_modified = get_lastpostmodified('gmt');
if (is_string($post_modified) && '' !== $post_modified) {
$post_timestamp = strtotime($post_modified);
if (false !== $post_timestamp) {
$timestamps[] = $post_timestamp;
}
}
$timestamp = $timestamps ? max($timestamps) : 1;
return gmdate('D, d M Y H:i:s', $timestamp) . ' GMT';
}
}
