repository = $repository; } /** * Hooks the invalidation. */ public function register(): void { // Late, so the coordinate mirrors (MetaBox at 10, MetaKeysSource at // 20, ACF inside its own save_post callback) have written before the // generation moves. The order is not load-bearing — a bump orphans // entries whenever it happens — but it keeps the story simple. add_action( 'save_post', array( $this, 'on_post_change' ), 99, 2 ); add_action( 'deleted_post', array( $this, 'on_post_deleted' ), 10, 2 ); add_action( 'trashed_post', array( $this, 'on_post_change' ) ); add_action( 'untrashed_post', array( $this, 'on_post_change' ) ); // Before the meta goes: the map id is what names the counter to drop. add_action( 'before_delete_post', array( $this, 'on_before_delete' ), 10, 2 ); add_action( 'edited_term', array( $this, 'on_term_change' ), 10, 3 ); add_action( 'delete_term', array( $this, 'on_term_change' ), 10, 3 ); add_action( 'set_object_terms', array( $this, 'on_object_terms' ), 10, 4 ); add_action( 'mapsemble_map_config_saved', array( $this, 'on_config_saved' ), 10, 2 ); add_filter( 'rest_pre_serve_request', array( $this, 'serve_304_without_body' ), 10, 3 ); } /** * Answers from the cache, or builds, caches and answers. * * Only a 200 is stored; anything else returns as built, untouched. A * stored answer keeps its own headers (the CORS `*`), gains * `Cache-Control` and `ETag`, and collapses to a bodiless 304 when the * request's `If-None-Match` names the same ETag. A logged-in request is * neither answered from nor written to the cache, and gets no headers. * * @param string $endpoint Route name, part of the key. * @param MapConfig $config The map being answered for. * @param WP_REST_Request $request The request, for `If-None-Match`. * @param array $args Every argument that shapes the answer. * @param callable $build Produces the response on a miss. * @return WP_REST_Response|WP_Error */ public function remember( string $endpoint, MapConfig $config, WP_REST_Request $request, array $args, callable $build ) { /** * Filters how long feed responses are cached, in seconds. * * Zero (or less) disables caching for the map: no entry is stored and * no `Cache-Control` or `ETag` header is sent. * * @param int $ttl Seconds; 60 by default. * @param MapConfig $config The map being answered for. */ $ttl = (int) apply_filters( 'mapsemble_feed_cache_ttl', self::DEFAULT_TTL, $config ); if ( $ttl <= 0 || is_user_logged_in() ) { return $build(); } $key = $this->key( $endpoint, $config, $args ); $found = false; $entry = wp_cache_get( $key, self::CACHE_GROUP, false, $found ); if ( ! $found || ! is_array( $entry ) || ! isset( $entry['body'], $entry['etag'], $entry['headers'] ) ) { $built = $build(); if ( ! $built instanceof WP_REST_Response || 200 !== $built->get_status() ) { return $built; } $entry = array( 'body' => $built->get_data(), 'headers' => $built->get_headers(), 'etag' => '"' . md5( (string) wp_json_encode( $built->get_data() ) ) . '"', ); wp_cache_set( $key, $entry, self::CACHE_GROUP, $ttl ); } $response = $this->matches_etag( $request, (string) $entry['etag'] ) ? new WP_REST_Response( null, 304 ) : new WP_REST_Response( $entry['body'] ); foreach ( (array) $entry['headers'] as $name => $value ) { $response->header( (string) $name, (string) $value ); } $response->header( 'Cache-Control', 'public, max-age=' . $ttl ); $response->header( 'ETag', (string) $entry['etag'] ); return $response; } /** * Keeps the REST server from printing `null` as the body of a 304. * * Status and headers are already sent by the time this filter runs; * returning true only skips the echo. Scoped to this plugin's routes. * The server's `_envelope` / `_fields` post-processing is skipped with * it — not expected on a GeoJSON feed, and a 304 has nothing to shape. * * @param bool $served Whether the request was already served. * @param WP_REST_Response $result The response about to be served. * @param WP_REST_Request $request The request. */ public function serve_304_without_body( $served, $result, $request ): bool { if ( $served ) { return true; } return $result instanceof WP_REST_Response && 304 === $result->get_status() && $request instanceof WP_REST_Request && str_starts_with( $request->get_route(), '/mapsemble/v1/' ); } /** * A post was saved, trashed or untrashed. * * For a map configuration this is the editor's save reaching * `save_post`; the programmatic route announces itself through * `mapsemble_map_config_saved` instead, because its meta is written * only after this hook has fired. Both bump — one bump too many is * free, one too few is a stale minute. * * @param int|string $post_id The post. * @param WP_Post|mixed $post The post, where the hook passes it. */ public function on_post_change( $post_id, $post = null ): void { if ( ! $post instanceof WP_Post ) { $post = get_post( (int) $post_id ); } if ( ! $post instanceof WP_Post || wp_is_post_autosave( $post ) || wp_is_post_revision( $post ) ) { return; } if ( MapPostType::POST_TYPE === $post->post_type ) { $map_id = (string) get_post_meta( $post->ID, MapPostType::META_MAP_ID, true ); // A config whose binding is not yet written (a programmatic save) // could be any map's. if ( '' !== $map_id ) { $this->bump( $map_id ); } else { $this->bump_all(); } return; } foreach ( $this->repository->all() as $config ) { if ( ! $config->is_hosted() && $config->post_type === $post->post_type ) { $this->bump( $config->map_id ); } } } /** * A post was deleted outright. * * A deleted map configuration was handled on `before_delete_post`, while * its map id was still readable; by now it would only read as "any * map's" and bump them all. * * @param int|string $post_id The post. * @param WP_Post|mixed $post The post as it was. */ public function on_post_deleted( $post_id, $post = null ): void { if ( $post instanceof WP_Post && MapPostType::POST_TYPE === $post->post_type ) { return; } $this->on_post_change( $post_id, $post ); } /** * A map configuration is about to be deleted: drop its counter. * * The entries it orphans are unreachable anyway — every cached answer * sits behind the config lookup, which now finds nothing. * * @param int|string $post_id The post. * @param WP_Post|mixed $post The post. */ public function on_before_delete( $post_id, $post = null ): void { if ( ! $post instanceof WP_Post ) { $post = get_post( (int) $post_id ); } if ( ! $post instanceof WP_Post || MapPostType::POST_TYPE !== $post->post_type ) { return; } $this->forget( (string) get_post_meta( $post->ID, MapPostType::META_MAP_ID, true ) ); } /** * A term was edited or deleted. * * @param int|string $term_id Term id. * @param int|string $tt_id Term taxonomy id. * @param string $taxonomy Taxonomy name. */ public function on_term_change( $term_id, $tt_id, $taxonomy ): void { $this->bump_taxonomy( (string) $taxonomy ); } /** * An object's terms were set. * * @param int|string $object_id Object id. * @param array $terms Terms. * @param array $tt_ids Term taxonomy ids. * @param string $taxonomy Taxonomy name. */ public function on_object_terms( $object_id, $terms, $tt_ids, $taxonomy ): void { $this->bump_taxonomy( (string) $taxonomy ); } /** * A configuration's meta was written. * * @param MapConfig $config The saved config. * @param string $previous_map_id The map id the config had before. */ public function on_config_saved( MapConfig $config, string $previous_map_id = '' ): void { // The write may have gone through another repository instance; the // listing this cache consults must not remember the config's old self. $this->repository->flush(); if ( '' !== $previous_map_id && $previous_map_id !== $config->map_id ) { // Rebound to another map: the old id's counter would otherwise // outlive every config that could ever read it. $this->forget( $previous_map_id ); } if ( '' !== $config->map_id ) { $this->bump( $config->map_id ); } else { $this->bump_all(); } } /** * Bumps every map whose post type carries a taxonomy. * * A taxonomy not registered for a map's post type cannot reach that * map's answers — a condition on it fails closed, an option lookup on it * is empty — so those maps keep their entries. Everything else is * bumped without looking further: telling which of a map's rows a * particular term touches is not worth a wrong answer. * * @param string $taxonomy Taxonomy name. */ private function bump_taxonomy( string $taxonomy ): void { foreach ( $this->repository->all() as $config ) { if ( ! $config->is_hosted() && is_object_in_taxonomy( $config->post_type, $taxonomy ) ) { $this->bump( $config->map_id ); } } } /** * Advances one map's generation. * * @param string $map_id Remote map id. */ private function bump( string $map_id ): void { if ( '' === $map_id ) { return; } update_option( self::GENERATION_OPTION_PREFIX . $map_id, $this->generation( $map_id ) + 1, true ); } /** * Advances every site-fed map's generation. */ private function bump_all(): void { foreach ( $this->repository->all() as $config ) { if ( ! $config->is_hosted() ) { $this->bump( $config->map_id ); } } } /** * Drops one map's counter. * * @param string $map_id Remote map id. */ private function forget( string $map_id ): void { if ( '' !== $map_id ) { delete_option( self::GENERATION_OPTION_PREFIX . $map_id ); } } /** * One map's current generation. * * @param string $map_id Remote map id. */ private function generation( string $map_id ): int { return (int) get_option( self::GENERATION_OPTION_PREFIX . $map_id, 0 ); } /** * The cache key for one answer. * * The map's post id and remote id keep two maps' answers apart even at * the same generation; the generation alone carries freshness, since * every change that matters — the config post's own save included — * bumps it. * * @param string $endpoint Route name. * @param MapConfig $config The map. * @param array $args Arguments that shape the answer. */ private function key( string $endpoint, MapConfig $config, array $args ): string { ksort( $args ); return md5( $endpoint . '|' . $config->id . '|' . $config->map_id . '|' . $this->generation( $config->map_id ) . '|' . wp_json_encode( $args ) ); } /** * Whether the request's `If-None-Match` names this ETag. * * @param WP_REST_Request $request The request. * @param string $etag The strong, quoted ETag. */ private function matches_etag( WP_REST_Request $request, string $etag ): bool { $header = (string) $request->get_header( 'if_none_match' ); if ( '' === $header ) { return false; } foreach ( explode( ',', $header ) as $candidate ) { $candidate = trim( $candidate ); if ( str_starts_with( $candidate, 'W/' ) ) { // A weak validator still names the same representation. $candidate = substr( $candidate, 2 ); } if ( '*' === $candidate || $candidate === $etag ) { return true; } } return false; } }