Skip to main content

Cyber AI România

Vineri, 6 noiembrie 2026

CDN Cache Tags în WordPress: invalidezi doar articolul și arhivele afectate

Editorul schimbă categoria unui articol publicat, dar CDN-ul continuă să afișeze versiunea veche în pagina categoriei. Un purge complet ar rezolva neconcordanța, însă ar elimina simultan din cache toate paginile site-ului și ar trimite următorii vizitatori către serverul WordPress.

În acest proiect, fiecare răspuns HTML primește etichete precum content-post-123, term-category-8 sau listing-home. Când articolul este actualizat, WordPress trimite către Fastly numai etichetele legate de articol, autor, pagina principală și arhivele vechi sau noi. Restul cache-ului rămâne intact.

Implementarea este destinată unui site propriu, aflat deja în spatele unui serviciu Fastly configurat să stocheze paginile publice pentru vizitatorii neautentificați. Nu activează caching pentru wp-admin, pagini personalizate sau utilizatori autentificați.

Rezultatul urmărit: un articol actualizat fără golirea întregului CDN

Scenariul este un site editorial WordPress găzduit la domeniul de documentare www.example.com. La salvarea articolului cu ID-ul 123, pluginul va invalida un grup restrâns de chei:

content-post-123
listing-home
listing-post-type-post
author-4
term-category-8
term-post_tag-17

Dacă articolul este mutat din categoria cu ID-ul 8 în categoria 12, sunt trimise și cheia veche, și cheia nouă. Astfel sunt revalidate atât arhiva din care articolul a dispărut, cât și cea în care a apărut.

Vom folosi un must-use plugin, încărcat automat din wp-content/mu-plugins. Soluția nu adaugă o pagină de configurare în WordPress și nu păstrează tokenul Fastly în baza de date. Pentru operațiunile administrative, păstrează în continuare reguli separate de securitate, inclusiv cele din ghidul despre protejarea unui site WordPress după lansare.

Fixăm laboratorul la WordPress 7.1, PHP 8.3 și Fastly

Configurația folosită aici este:

  • Ubuntu Server 24.04 LTS;
  • PHP 8.3;
  • WordPress 7.1;
  • Nginx și PHP-FPM;
  • WP-CLI instalat;
  • un serviciu Fastly care deservește www.example.com;
  • acces SSH cu un cont care poate folosi sudo;
  • un token Fastly limitat la serviciul respectiv și la operațiunea purge_select;
  • aproximativ 45 de minute.

Directorul WordPress din exemple este /var/www/example.com/public. Înlocuiește domeniul și calea dacă instalarea ta folosește alte valori.

Verifică mediul înainte să modifici ceva:

cd /var/www/example.com/public

cat /etc/os-release
php --version
wp core version
wp option get home
wp option get siteurl
curl --version

Exemplul de rezultat relevant este:

PRETTY_NAME="Ubuntu 24.04 LTS"
PHP 8.3.x
7.1
https://www.example.com
https://www.example.com
curl 8.x

Numerele secundare pentru Ubuntu, PHP și curl pot diferi după actualizările instalate. Oprește implementarea dacă wp option get home indică alt site: un purge trimis cu identificatorul serviciului greșit poate afecta un alt proiect Fastly.

Instalează numai uneltele locale necesare verificării:

sudo apt update
sudo apt install --yes php-cli curl

sudo install -d 
  -o www-data 
  -g www-data 
  -m 0755 
  /var/www/example.com/public/wp-content/mu-plugins

sudo install -d 
  -o root 
  -g root 
  -m 0755 
  /etc/wordpress

Nu este necesară o bibliotecă Fastly pentru PHP. Pluginul va utiliza WordPress HTTP API, disponibil în WordPress Core.

Cele două fișiere separă codul de tokenul Fastly

La final, componentele au următoarea structură:

/etc/wordpress/
└── fastly-cache-tags.php
    └── identificatorul serviciului și tokenul Fastly, accesibile doar PHP-FPM

/var/www/example.com/public/
├── wp-config.php
│   └── încarcă fișierul protejat înainte de pornirea WordPress
└── wp-content/
    └── mu-plugins/
        └── cyberai-fastly-cache-tags.php
            └── adaugă etichetele și solicită invalidarea lor selectivă

Fișierul secret nu se află sub rădăcina publică a site-ului. Tokenul trebuie creat din Fastly cu acces numai la serviciul WordPress și cu domeniul purge_select, nu cu acces global la întregul cont.

Dacă modificările sunt introduse printr-un pipeline, tratează pluginul ca artefact verificabil. Procesul poate fi asociat cu un pipeline WordPress cu staging și promovare controlată, astfel încât codul să fie testat înainte să ajungă în producție.

Pasul 1 — Păstrăm credențialele Fastly în afara directorului public

Tokenul permite invalidarea conținutului din CDN și nu trebuie inclus în plugin, în Git sau într-un pachet de backup public.

Citește valorile fără să le scrii în istoricul shell-ului, generează local fișierul PHP și instalează-l cu permisiunea 0600:

cd /var/www/example.com/public

read -r -p "Fastly Service ID: " FASTLY_SERVICE_ID
read -r -s -p "Fastly API token: " FASTLY_API_TOKEN
printf 'n'

export FASTLY_SERVICE_ID FASTLY_API_TOKEN

umask 077
php -r '
$service = getenv("FASTLY_SERVICE_ID");
$token = getenv("FASTLY_API_TOKEN");

if ($service === false || $service === "" || $token === false || $token === "") {
    fwrite(STDERR, "Lipsesc valorile Fastly.n");
    exit(1);
}

$content = "<?phpn"
    . "define("CYBERAI_FASTLY_SERVICE_ID", "
    . var_export($service, true)
    . ");n"
    . "define("CYBERAI_FASTLY_API_TOKEN", "
    . var_export($token, true)
    . ");n";

if (file_put_contents("/tmp/fastly-cache-tags.php", $content) === false) {
    fwrite(STDERR, "Nu am putut crea fisierul temporar.n");
    exit(1);
}
'

sudo install 
  -o www-data 
  -g www-data 
  -m 0600 
  /tmp/fastly-cache-tags.php 
  /etc/wordpress/fastly-cache-tags.php

rm -f /tmp/fastly-cache-tags.php
unset FASTLY_SERVICE_ID FASTLY_API_TOKEN

umask 077 împiedică acordarea accidentală a permisiunilor pentru grup sau alți utilizatori. install -m 0600 permite citirea numai de către contul www-data, sub care rulează PHP-FPM în acest exemplu.

Punct de control: comanda următoare trebuie să arate proprietarul www-data și permisiunile -rw-------, fără să afișeze conținutul:

sudo stat -c '%A %U:%G %n' /etc/wordpress/fastly-cache-tags.php

Exemplu:

-rw------- www-data:www-data /etc/wordpress/fastly-cache-tags.php

Pasul 2 — Încărcăm configurația înainte ca WordPress să pornească pluginurile

Fă mai întâi o copie a configurației existente:

cd /var/www/example.com/public
sudo cp --preserve=all wp-config.php wp-config.php.bak

Adaugă încărcarea secretelor înainte de linia care include wp-settings.php:

sudo sed -i 
  "|require_once ABSPATH . 'wp-settings.php';|i require_once '/etc/wordpress/fastly-cache-tags.php';" 
  wp-config.php

php -l wp-config.php
sudo -u www-data php -r '
require "/etc/wordpress/fastly-cache-tags.php";

if (
    !defined("CYBERAI_FASTLY_SERVICE_ID")
    || !defined("CYBERAI_FASTLY_API_TOKEN")
) {
    fwrite(STDERR, "Configuratia Fastly nu a fost incarcata.n");
    exit(1);
}

echo "Configuratia Fastly este accesibila procesului PHP.n";
'

Rezultatul așteptat este:

No syntax errors detected in wp-config.php
Configuratia Fastly este accesibila procesului PHP.

Comanda de verificare confirmă existența constantelor, dar nu afișează valorile lor. Dacă prima comandă sed este rulată de două ori, verifică să nu fi fost adăugată aceeași instrucțiune de două ori:

grep -n "fastly-cache-tags.php" wp-config.php

Trebuie să existe un singur rezultat.

Pasul 3 — Etichetăm articolul, pagina principală și arhivele WordPress

Fișierul /var/www/example.com/public/wp-content/mu-plugins/cyberai-fastly-cache-tags.php trebuie creat integral astfel:

<?php
/**
 * Plugin Name: CyberAI Fastly Cache Tags
 * Description: Adaugă Surrogate-Key și invalidează selectiv paginile afectate.
 * Version: 1.0.0
 */

declare(strict_types=1);

if (!defined('ABSPATH')) {
    exit;
}

final class CyberAI_Fastly_Cache_Tags
{
    /** @var array<int, string[]> */
    private static array $old_keys = [];

    /** @var array<string, bool> */
    private static array $pending_keys = [];

    public static function boot(): void
    {
        add_action('send_headers', [self::class, 'send_surrogate_keys']);
        add_action('pre_post_update', [self::class, 'remember_old_keys'], 10, 2);
        add_action('save_post_post', [self::class, 'queue_saved_post'], 10, 3);
        add_action('before_delete_post', [self::class, 'queue_deleted_post'], 10, 2);
        add_action('shutdown', [self::class, 'flush_purge_queue']);
    }

    public static function send_surrogate_keys(): void
    {
        if (
            is_admin()
            || headers_sent()
            || !isset($_SERVER['REQUEST_METHOD'])
            || !in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)
        ) {
            return;
        }

        $keys = [];

        if (is_singular('post')) {
            $post_id = get_queried_object_id();

            if ($post_id > 0) {
                $keys[] = 'content-post-' . $post_id;
            }
        }

        if (is_home() || is_front_page()) {
            $keys[] = 'listing-home';
        }

        if (is_category() || is_tag()) {
            $term = get_queried_object();

            if ($term instanceof WP_Term) {
                $keys[] = sprintf(
                    'term-%s-%d',
                    sanitize_key($term->taxonomy),
                    $term->term_id
                );
            }
        }

        if (is_author()) {
            $author_id = get_queried_object_id();

            if ($author_id > 0) {
                $keys[] = 'author-' . $author_id;
            }
        }

        if (is_post_type_archive('post') || is_date()) {
            $keys[] = 'listing-post-type-post';
        }

        $keys = self::normalize_keys($keys);

        if ($keys !== []) {
            header('Surrogate-Key: ' . implode(' ', $keys), true);
        }
    }

    public static function remember_old_keys(int $post_id, array $data): void
    {
        $post = get_post($post_id);

        if ($post instanceof WP_Post && $post->post_type === 'post') {
            self::$old_keys[$post_id] = self::keys_for_post($post);
        }
    }

    public static function queue_saved_post(
        int $post_id,
        WP_Post $post,
        bool $update
    ): void {
        if (
            wp_is_post_revision($post_id)
            || wp_is_post_autosave($post_id)
        ) {
            return;
        }

        $keys = self::keys_for_post($post);

        if (isset(self::$old_keys[$post_id])) {
            $keys = array_merge($keys, self::$old_keys[$post_id]);
            unset(self::$old_keys[$post_id]);
        }

        self::queue_keys($keys);
    }

    public static function queue_deleted_post(
        int $post_id,
        WP_Post $post
    ): void {
        if ($post->post_type !== 'post') {
            return;
        }

        self::queue_keys(self::keys_for_post($post));
    }

    private static function keys_for_post(WP_Post $post): array
    {
        $keys = [
            'content-post-' . $post->ID,
            'listing-home',
            'listing-post-type-post',
            'author-' . (int) $post->post_author,
        ];

        $terms = wp_get_object_terms(
            $post->ID,
            ['category', 'post_tag']
        );

        if (!is_wp_error($terms)) {
            foreach ($terms as $term) {
                if ($term instanceof WP_Term) {
                    $keys[] = sprintf(
                        'term-%s-%d',
                        sanitize_key($term->taxonomy),
                        $term->term_id
                    );
                }
            }
        }

        return self::normalize_keys($keys);
    }

    private static function queue_keys(array $keys): void
    {
        foreach (self::normalize_keys($keys) as $key) {
            self::$pending_keys[$key] = true;
        }
    }

    private static function normalize_keys(array $keys): array
    {
        $normalized = [];

        foreach ($keys as $key) {
            $key = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $key);

            if ($key !== '') {
                $normalized[$key] = true;
            }
        }

        return array_keys($normalized);
    }

    public static function flush_purge_queue(): void
    {
        if (self::$pending_keys === []) {
            return;
        }

        if (
            !defined('CYBERAI_FASTLY_SERVICE_ID')
            || !defined('CYBERAI_FASTLY_API_TOKEN')
            || CYBERAI_FASTLY_SERVICE_ID === ''
            || CYBERAI_FASTLY_API_TOKEN === ''
        ) {
            error_log('CyberAI Fastly: configurarea lipsește.');
            return;
        }

        $keys = array_keys(self::$pending_keys);
        self::$pending_keys = [];

        $url = sprintf(
            'https://api.fastly.com/service/%s/purge',
            rawurlencode(CYBERAI_FASTLY_SERVICE_ID)
        );

        $response = wp_remote_post(
            $url,
            [
                'timeout' => 10,
                'headers' => [
                    'Fastly-Key' => CYBERAI_FASTLY_API_TOKEN,
                    'Accept' => 'application/json',
                    'Content-Type' => 'application/json',
                ],
                'body' => wp_json_encode(
                    ['surrogate_keys' => $keys],
                    JSON_UNESCAPED_SLASHES
                ),
            ]
        );

        if (is_wp_error($response)) {
            error_log(
                'CyberAI Fastly purge a eșuat: '
                . $response->get_error_message()
            );
            return;
        }

        $status = wp_remote_retrieve_response_code($response);

        if ($status < 200 || $status >= 300) {
            error_log(
                'CyberAI Fastly purge a eșuat: HTTP '
                . $status
            );
        }
    }
}

CyberAI_Fastly_Cache_Tags::boot();

Verifică sintaxa și încărcarea ca must-use plugin:

cd /var/www/example.com/public

php -l wp-content/mu-plugins/cyberai-fastly-cache-tags.php
wp plugin list --status=must-use

Exemplul de rezultat este:

No syntax errors detected in wp-content/mu-plugins/cyberai-fastly-cache-tags.php
name                         status   update version
cyberai-fastly-cache-tags    must-use none   1.0.0

Pluginul păstrează temporar cheile vechi prin pre_post_update. Această etapă contează când autorul sau taxonomiile sunt schimbate: după salvare, WordPress nu mai poate deduce numai din starea nouă ce arhivă veche trebuie invalidată.

shutdown grupează cheile într-o singură cerere API, în loc să deschidă câte o conexiune pentru fiecare categorie sau etichetă. Nu există un buton de purge complet și niciun token expus prin REST API. Dacă automatizezi administrarea WordPress din exterior, separă această integrare de Application Passwords și accesul REST API.

Pasul 4 — Confirmăm etichetele pe cele trei tipuri de pagină

Fastly elimină în mod normal antetul Surrogate-Key înainte de a trimite răspunsul vizitatorului. Antetul de cerere Fastly-Debug: 1 permite inspectarea cheilor asociate.

Alege un articol publicat și obține URL-ul, categoria și autorul său:

cd /var/www/example.com/public

POST_ID="$(wp post list 
  --post_type=post 
  --post_status=publish 
  --posts_per_page=1 
  --field=ID)"

POST_URL="$(wp post url "$POST_ID")"

CATEGORY_URL="$(wp eval '
$post_id = (int) getenv("POST_ID");
$terms = get_the_category($post_id);

if ($terms === []) {
    fwrite(STDERR, "Articolul nu are categorie.n");
    exit(1);
}

echo get_category_link($terms[0]);
' 2>/dev/null)"

AUTHOR_URL="$(POST_ID="$POST_ID" wp eval '
$post = get_post((int) getenv("POST_ID"));

if (!$post instanceof WP_Post) {
    fwrite(STDERR, "Articolul nu a fost găsit.n");
    exit(1);
}

echo get_author_posts_url((int) $post->post_author);
')"

printf 'Articol: %snCategorie: %snAutor: %sn' 
  "$POST_URL" "$CATEGORY_URL" "$AUTHOR_URL"

La evaluarea categoriei, variabila trebuie transmisă explicit procesului WP-CLI. Rulează din nou atribuirea astfel:

CATEGORY_URL="$(POST_ID="$POST_ID" wp eval '
$post_id = (int) getenv("POST_ID");
$terms = get_the_category($post_id);

if ($terms === []) {
    fwrite(STDERR, "Articolul nu are categorie.n");
    exit(1);
}

echo get_category_link($terms[0]);
')"

Inspectează răspunsurile:

curl -sS -D - -o /dev/null 
  -H 'Fastly-Debug: 1' 
  "$POST_URL" 
  | grep -Ei '^(surrogate-key|x-cache|age):'

curl -sS -D - -o /dev/null 
  -H 'Fastly-Debug: 1' 
  "https://www.example.com/" 
  | grep -Ei '^(surrogate-key|x-cache|age):'

curl -sS -D - -o /dev/null 
  -H 'Fastly-Debug: 1' 
  "$CATEGORY_URL" 
  | grep -Ei '^(surrogate-key|x-cache|age):'

Exemplul de structură, cu ID-uri care vor diferi, este:

surrogate-key: content-post-123
x-cache: HIT

surrogate-key: listing-home
x-cache: HIT

surrogate-key: term-category-8
x-cache: HIT

Punct de control: dacă vezi Surrogate-Key, dar X-Cache indică permanent MISS sau PASS, etichetarea funcționează, însă regulile Fastly nu păstrează răspunsul respectiv în cache.

Pasul 5 — Modificăm un articol și urmărim numai paginile dependente

Deschide articolul ales în wp-admin, schimbă-i titlul sau categoria și apasă Actualizează. Nu modifica simultan configurația CDN.

Imediat după salvare, verifică articolul, pagina principală și categoria:

for URL in "$POST_URL" "https://www.example.com/" "$CATEGORY_URL"; do
  printf 'n%sn' "$URL"

  curl -sS -D - -o /dev/null 
    -H 'Fastly-Debug: 1' 
    "$URL" 
    | grep -Ei '^(x-cache|age|surrogate-key):'
done

Prima cerere către fiecare obiect invalidat ar trebui să indice un cache miss, iar o cerere ulterioară poate deveni hit:

x-cache: MISS
age: 0

x-cache: HIT
age: 1

Valorile Age, ordinea serverelor și forma exactă a antetului X-Cache pot varia. Condiția testului este ca paginile dependente să nu mai livreze copia veche.

Alege apoi o pagină WordPress fără legătură cu articolul și verific-o înainte și după actualizare. Aceasta nu trebuie să primească content-post-ID, listing-home sau cheia categoriei respective și nu trebuie eliminată din cache de operațiunea efectuată.

Defecțiuni care indică exact unde s-a rupt traseul

CyberAI Fastly: configurarea lipsește. apare în jurnalul PHP când fișierul secret nu a fost încărcat sau constantele sunt goale. Verifică includerea și accesul contului PHP:

grep -n "fastly-cache-tags.php" /var/www/example.com/public/wp-config.php
sudo -u www-data test -r /etc/wordpress/fastly-cache-tags.php

CyberAI Fastly purge a eșuat: HTTP 401 indică un token respins. Creează un token nou, limitat la serviciul corect și la purge_select, apoi înlocuiește fișierul secret folosind din nou Pasul 1.

CyberAI Fastly purge a eșuat: HTTP 403 indică de regulă că tokenul este valid, dar nu poate opera asupra serviciului ales. Compară identificatorul serviciului cu cel din Fastly și verifică restricțiile tokenului.

Antetul Surrogate-Key nu apare. Confirmă mai întâi că cererea trece prin Fastly și conține Fastly-Debug: 1. Apoi verifică încărcarea pluginului:

wp plugin list --status=must-use
curl -sS -I -H 'Fastly-Debug: 1' "$POST_URL"

X-Cache: PASS rămâne neschimbat. Răspunsul nu este stocat de configurația Fastly, posibil din cauza cookie-urilor sau a antetelor originului. Nu elimina orbește Set-Cookie și nu pune în cache răspunsuri autentificate; revizuiește regulile serviciului pe staging.

Categoria veche păstrează articolul. Confirmă că editarea a trecut prin fluxul standard WordPress și că pluginul era activ înaintea salvării. O modificare directă în baza de date nu execută hook-urile pre_post_update și save_post_post.

Eliminăm integrarea fără să lăsăm tokenul pe server

Înainte de curățenie, păstrează o copie controlată a jurnalelor relevante dacă investighezi o eroare. Oprirea pluginului nu șterge obiectele existente din Fastly; acestea vor expira conform regulilor CDN.

Elimină pluginul și restaurează configurația:

cd /var/www/example.com/public

sudo rm -f wp-content/mu-plugins/cyberai-fastly-cache-tags.php
sudo cp --preserve=all wp-config.php.bak wp-config.php
sudo rm -f /etc/wordpress/fastly-cache-tags.php

php -l wp-config.php
wp plugin list --status=must-use

Revocă apoi tokenul din Fastly. Restaurarea fișierului local nu revocă o credențială deja emisă.

Lista de acceptare pentru producție

  • ☐ Serviciul Fastly deservește domeniul WordPress corect.
  • ☐ Tokenul are numai domeniul purge_select și acces la un singur serviciu.
  • ☐ Fișierul secret se află în /etc/wordpress, nu sub rădăcina web.
  • ☐ Permisiunile fișierului secret sunt 0600.
  • ☐ Pluginul apare în lista must-use.
  • ☐ Articolul publicat primește cheia content-post-ID.
  • ☐ Pagina principală și arhiva categoriei primesc chei diferite.
  • ☐ După actualizare, paginile dependente trec printr-un cache miss.
  • ☐ O pagină fără legătură rămâne în cache.
  • ☐ Jurnalul PHP nu conține răspunsuri Fastly 401 sau 403.

Următoarea extindere logică este adăugarea unor chei pentru tipurile de conținut personalizate, de exemplu product sau event, după același model. Pentru WooCommerce trebuie analizate separat coșul, contul și paginile personalizate, care nu trebuie introduse într-un cache public. Într-un flux de livrare matur, testul cu Fastly-Debug poate fi adăugat în staging înaintea promovării, iar integritatea pluginului poate fi controlată prin procesul de semnare și verificare a artefactelor WordPress.

Întrebări frecvente

De ce nu invalidăm întregul cache după fiecare actualizare?

Un purge complet elimină și paginile fără legătură cu articolul modificat. Cheile CDN permit invalidarea articolului, paginii principale, autorului și taxonomiilor afectate, păstrând restul obiectelor în cache.

De ce sunt memorate și categoriile vechi ale articolului?

După schimbarea categoriei, starea curentă nu mai arată arhiva din care articolul a fost eliminat. Hook-ul pre_post_update păstrează cheile vechi înainte ca WordPress să salveze noile relații.

Este vizibil tokenul Fastly în WordPress?

Nu în această implementare. Tokenul este păstrat într-un fișier din /etc/wordpress, cu permisiunea 0600, și nu este salvat în baza de date sau în codul pluginului.

Pot aplica pluginul direct unui magazin WooCommerce?

Nu fără adaptare și testare pe staging. Coșul, contul clientului și alte răspunsuri personalizate nu trebuie tratate ca pagini publice obișnuite.

Surse

Facebook
X
WhatsApp
CDN Cache Tags în WordPress: invalidezi doar articolul și arhivele afectate

Te-ar putea interesa si: