WP_Cron

Důležité pro pochopení akcí naplánovaných pomocí WP_Cron je to, že WP_Cron není cron takový, jaký známe ze světa serverů – spolehlivý pracovník, který v daný čas udělá to, co mu řeknete. V případě WP_Cron se nelze spoléhat na to, že se naplánovaná akce spustí v čas. Nicméně se můžete spolehnout na to, že se nespustí dříve.

Je to dáno tím, že WordPressu se snaží veškerou svou funkcionalitu poskytnout i na tom nejlevnějším sdíleném hostingu ihned po světoznámé 5 minutové instalaci.

WP_Cron je systém postaven čistě na záznamu v MySQL databázi, systému zámků využívajícího Transients API (tedy buď databázi či objektovou cache), HTTP požadavcích a PHP. V takovém setupu jsou proto stále přítomny timeouty, PHP memory limit či max-execution time.

Základ pro funkcionalitu, kterou WP_Cron nabízí tedy není zcela ideální, ale i tak je WP_Cron užitečným pomocníkem – pokud tedy víme jak se k němu chovat a co od něj očekávat. To vše se pokusím aspoň nastínit v tomto článku.

Jak naplánovat akci ze svého kódu

Na webu prakticky okamžitě naleznete obstojný tutorial o tom, jak nějakou událost naplánovat. Já budu v dalším výkladu spoléhat na to, že jste si tu práci již dali a níže uvádím jen kód, který by měl být výsledkem takových tutoriálů. Poté přejdu rovnou k popisu mechanismu, který tento kód spouští. Začínáme tedy tam, kde tutoriály končí.

//následující funkce je spouštěna nějakou uživatelskou akcí, typicky odesláním formuláře
function my_example_function_called_as_a_result_of_user_action( ... ) {
    $post_id = 2; //typicky dynamicky získaná proměnná.
    $variable = 'Hello WP_Cron World!';
    wp_schedule_single_event( time(), 'trigger_my_cron_function', array( $post_id, $variable ) );
}

add_action( 'trigger_my_cron_function', 'run_my_cron_function', 20, 2 );

function run_my_cron_function( $post_id, $variable ) {
    update_post_meta( intval( $post_id ), 'cron_set_variable', sanitize_text_field( $variable ) );
}

Pokud vám ukázka kódu nic neříká, je nejvyšší čas si přečíst některý z tutoriálů, či se podívat do kodexu

Jak WordPress plánuje události

Pokaždé, když je z kódu zavolána funkce wp_schedule_single_event, dojde k aktualizaci záznamu v tabulce wp_options. Jedním ze záznamů v této tabulce jsou naplánované události – uložené jsou všechny pěkně pohromadě v option s názvem cron podobně serializovaného pole s údajem o čase na místě klíče a naplánovaných volání funkce v hodnotě záznamu pole. Zde je podoba cron option na mé lokální instalaci založené na VVV získaná pomocí WP_CLI a příkazu shell

$ wp shell
wp> get_option( 'cron' );
array(4) {
  [1451331840]=>
  array(1) {
    ["wp_maybe_auto_update"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(10) "twicedaily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(43200)
      }
    }
  }
  [1451332812]=>
  array(1) {
    ["wp_scheduled_delete"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(5) "daily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(86400)
      }
    }
  }
  [1451333427]=>
  array(3) {
    ["wp_version_check"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(10) "twicedaily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(43200)
      }
    }
    ["wp_update_plugins"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(10) "twicedaily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(43200)
      }
    }
    ["wp_update_themes"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(10) "twicedaily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(43200)
      }
    }
  }
  ["version"]=>
  int(2)
}

Ukládání do wp_options, namísto použití transients API či jiného dočasného mechanismu je zvoleno proto, že tyto záznamy musí zůstat zachovány až do té doby, dokud nedojde k jejich zpracování. V případě, že by na webu byla použita externí cache využívaly by zmíněné transients tuto cache a ta nám nikdy negarantuje minimální dobu, po kterou je záznam dostupný, pouze maximální – tedy tu, od kdy už záznam dostupný nebude.

autoload options, cron a object cache

Skutečnost, že WP_Cron využívá wp_options má ještě minimálně jeden háček, který je nutné brát v potaz. WordPress využívá options pro skladování informací o svém nastavení, uživatelských rolí, nastavení šablony, pro transients a stejná tabulka také slouží často jako úložiště dat pro pluginy. Protože některá z těchto nastavení jsou důležitá při každém načtění WordPressu a některá nikoli, WordPress využívá sloupeček autoload pro uchovávání informace o tom, které options mají být automaticky z databáze načteny v samotném začátku načítání aplikace. WP_Cron je jednou z takových autoloaded options:

$ wp shell
wp> global $wpdb
wp> $wpdb->get_results( "SELECT * FROM {$wpdb->options} WHERE option_name = 'cron'" );
array(1) {
  [0]=>
  object(stdClass)#82 (4) {
    ["option_id"]=>
    string(3) "103"
    ["option_name"]=>
    string(4) "cron"
    ["option_value"]=>
    string(828) "a:4:{i:1451331840;a:1:{s:20:"wp_maybe_auto_update";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:10:"twicedaily";s:4:"args";a:0:{}s:8:"interval";i:43200;}}}i:1451332812;a:1:{s:19:"wp_scheduled_delete";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:5:"daily";s:4:"args";a:0:{}s:8:"interval";i:86400;}}}i:1451333427;a:3:{s:16:"wp_version_check";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:10:"twicedaily";s:4:"args";a:0:{}s:8:"interval";i:43200;}}s:17:"wp_update_plugins";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:10:"twicedaily";s:4:"args";a:0:{}s:8:"interval";i:43200;}}s:16:"wp_update_themes";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:10:"twicedaily";s:4:"args";a:0:{}s:8:"interval";i:43200;}}}s:7:"version";i:2;}"
    ["autoload"]=>
    string(3) "yes"
  }
}

Proč to říkám. Říkám to proto, že funkce wp_load_alloptions využívá WP_Cache a ta může být poháněná pomocí externí objektové cache a to může mít svá úskalí. Například defaultní maximální velikost objektu skladovaného v Memcache je 1M. Může se tak velice snadno stát, že tenhle 1M záznamy WP_Cron zaplníte a externí objektová cache v případě alloptions přestane fungovat. A přitom je alloptions často jedním z prvních důvodů, proč se člověk k Memcache či jiné objektové cache dostane.

Kdy WordPress vykoná naplánovanou událost

Jak jsem již zmínil, WP_Cron je jakýsi kvazi cron, nikoli cron v pravém slova smyslu. To, jakým způsobem je realizován v rámci WordPressu není nic neobvyklého a vlastně dost lidí překvapí, že využívá stejný mechanismu, jako vše ostatní – filtry a akce.

Vezměme si modelový problém. Není to problém čistě akademický, ale něco, co skutečně vývojáře trápí.

Naplánujeme publikaci článku na půlnoc. Klikneme tedy na tlačítko “Schedule”. Záznam o této budoucí akci je zapsán do tabulky wp_options a WordPress vás přesměruje zpět na výpis článků. To je vše, nic dalšího se nekoná a WordPress i server spí.

A spí tak dlouho, dokud jej nikdo nenavštíví. A dokud WordPress spí, nemůže vykonávat žádné akce. WordPress vzbudíte tím, že navštívíte nějakou stránku – ať již na frontendu či v administraci. Ale pozor, v případě frontendu se musí jednat o necachovanou stránku. Pokud tedy web pro nepřihlášené uživatele cachujete pomocí nějaké full page cache, je nutné se přihlásit.

No a pokud vy, ani nikdo jiný web kolem půlnoci nenavštíví (a nebo navštíví, ale je mu předložena cachovaná verze), může se dost dobře stát, že druhý den ráno si vzpomenete, že jste na půlnoc měli naplánovanou publikaci článku a rádi byste se podívali na komentáře.

Ale jelikož jste prvním návštěvníkem webu od doby, kdy jste dokončili článek a naplánovali jej, žádné komentáře nečekejte. WordPress se právě probudil a hodlá dohnat vše, co zameškal. Včetně publikace vašeho článku, která nastane až krátce poté, co jste web navštívli. Již bychom měli mít představu o tom, proč nedošlo k publikaci článku o půlnoci – protože se na web nikdo nepodíval nebo se podíval na cachovaný frontend a WordPress samotný tedy spal. Proč ale nedojde k publikaci článku ihned ve chvíli, kdy se přihlásím do administrace?

WP_Cron a výkon

Jelikož WP_Cron není odbavován přesně v čas, kdy to bylo naplánováno, může se stát, že na pořadu je v danou dobu více akcí, které již měly proběhnout, nicméně kvůli tomu, že web nikdo nenavštívil, neproběhly. Kdyby se WordPress snažil okamžitě vše zpracovat, došlo by k tomu, že by takové načtení stránky trvalo nesnesitelně dlouho a dost pravdědopodobně by došlo k timeoutu požadavku. Tomu ale WordPress předchází.

Asynchronní PHP

Asynchronní PHP je něco, co by mnoho vývojářů velmi ocenilo. Ovšem ještě stále nejsme tak daleko, abychom jej mohli plně využívat na libovolném hostingu. A tak WP_Cron využívá techniky, která spoléhá na HTTP requesty.

Nejspíš jste si všimli, že v rootu vaší WordPress instalace naleznete soubor wp-cron.php. Ten hraje klíčovou roli v tom, jak WordPress spouští cron tak, aby neblokoval načítání stránky pro daného návštěvníka.

Vždy když navštívíte necachovanou verzi stránky,

// WP Cron
if ( !defined( 'DOING_CRON' ) )
	add_action( 'init', 'wp_cron' );

WordPress vyšle HTTP požadavek na http://example.com/wp-cron.php?doing_wp_cron a přidá k tomu nějaké parametry.

Relevantní kód: https://github.com/WordPress/WordPress/blob/77e365efbf2e499e2ed11d29c101ea466cf1ceed/wp-includes/cron.php#L352

Důležité je, že se jedná o tzv. non-blocking HTTP request. Tedy WordPress odešle požadavek, ale již nečeká na odpověď. Ta jej vůbec nezajímá a načítání vaší stránky může nerušeně pokračovat.

Jak WordPress vykonává naplánované akce

WordPress vykonává naplánované akce tak, že vybere již zmíněný záznam z tabulky wp_options (cron) obsahující serializované pole z databáze a prochází celé pole, položku po položce a vždy porovnává časový záznam s aktuálním časem. Pokud je naplánovaný čas v minulosti je tato položka z pole odebrána a zavolá se funkce, která je vedle času v dané položce uložena a předají se ji uložené argumenty.

Teoreticky se tedy může stát, že mezi odstraněním položky z cronu a vykonáním akce dojde k timeoutu či vyčerpání PHP memory limitu. V takovém případě je akce navždy ztracena a již se nikdy nevykoná. Je to ovšem lepší varianta, než aby se naplánovaná akce spustila opakovaně.

Vždy běží jen jeden proces

Jak jsem zmínil, vždy, když někdo navštíví necachovanou stránku, je odeslán dotaz na wp-cron.php. Nebýt systému zámků, docházelo by k tomu, že vedle sebe poběží dva a více konkurenčních procesů zpracovávajících záznam o naplánovaných akcích. To by opět vedlo v problémům – ať již výkonnostním, tak i funkčním, jelikož by se některé akce vykonávali vícekrát, než bylo původně myšleno.

Systém zámků jsem již nastínil v úvodu. WP_Cron využívá transients API (ať jsou již ukládané do databáze, či do object-cache). Pokud existuje specifický transients záznam, wp-cron.php svou činnost ukončí a nepustí se do zpracování naplánovaných akcí. Čili velké množství requestů na http://example.com/wp-cron.php?doing_wp_cron končí velmi záhy.

Relevantní kód:

Nasazení podpory opravdového cronu

Nicméně pokud je váš web hojně navštěvován, může i velké množství HTTP requestů spouštějícíh wp-cron.php způsobit problémy na straně serveru. A zde se již dostáváme k technice, která umožňuje zajistit, že WP_Cron se bude vykonávat periodicky i tehdy, pokud web nikdo nenavštíví (nebo je vhodně a silně cachován) a navíc zajistí, že návštěva necachované stránky nevytvoří nový HTTP požadavek na wp-cron.php, tedy na server.

WordPress umožňuje mechanismus HTTP requestů vypnout pomocí konstanty DISABLE_WP_CRON.

//file: wp-config.php
define(‘DISABLE_WP_CRON’, true);

Pokud je definována s hodnotou true ve vašem wp-config.php, žádné požadavky na wp-crong.php se neprovádí. To ovšem znamená, že se žádná z naplánovaných akcí nikdy neprovede.

Je totiž třeba dalšího kroku. A sice nastavení opravdového cronu tak, aby odesílal HTTP požadavky na wp-cron.php:

wget -q -O - http://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1

Podrobnější návod jak na to určitě naleznete na googlu.

Závěrem

Tento článek by měl pomoci vysvětlit mechanismus stojícím za WP_Cron a to, co tato konkrétní implementace způsobuje. Určitě jste se všichni někdy setkali s “Missed Schedule” hláškou v administraci označující příspěvek, který nebyl doposud publikován, ačkoli měl být. Důvody pro existenci tohoto fenoménu by vám nyní již měly být jasné.

Určitě doporučuji podívat se nyní do souboru wp-cron.php. To se v něm odehrává by vám nyní mělo být jasnější.

Pokud je pro vás pravidelnost publikování příspěvků či provádění některých akcí, zkuste popřemýšlet o podpoře WP_Cronu skrze reálný cron. U více navštěvovaných webů tak navíc můžete ulehčit vašim serverům.

Zrádné počty zobrazení příspěvku

K napsání tohoto příspěvku mě inspiroval blogpost “Admin-ajax.php zpomaluje stránky”, který správně uvádí proč byl daný web pomalý a jak byl problém, aspoň částečně, odstraněn. Proč je problém odstraněn jen částečně rozvádím ke konci tohoto příspěvku.

Vývojář se čas od času setká s poždavkem na zobrazování počtu zobrazení příspěvku na frontentu. Ovšem taková feature je celkem zrádná. V čem je problém?

Na otázku kam s daty si většinou vývojář celkem rychle odpoví, že přeci logicky patří do post_meta.

Tím velmi jednoduše získá místo, kam své údaje může ukládat. Pokud navíc nechce skladovat žádné další údaje o tom, kdo kdy odkud a jak se na web podíval, bude se jednat jen o číslo a takové číslo moc místa v databázi nezabírá. Jednoduché.

První nástřel fungování může vypadat takto:

//Uložíme shlédnutí do databáze
add_action( 'wp_head', function() {
    if ( is_single() ) {
        //získáme uložená data
        $pageviews = get_post_meta( get_the_ID(), 'myawesome_pageview', true );
        //zvětšíme o jedno
        $pageviews = intval( $pageviews ) + 1;
        //znovu uložíme
        update_post_meta( get_the_ID(), 'myawesome_pageview', $pageviews );
    }
}, 10, 0 );

//funkce pro zobrazování počtu shlédnutí
add_filter( 'the_content', function( $content ) {
    if ( ! is_single() ) {
        return $content;
    }
    $content .=  "\nZobrazeno: " . get_post_meta( get_the_ID(), 'myawesome_pageview', true ) 'x';
}, 10, 0 );

Těhle pár řádků kódu dělá přesně to, co vývojář řekl. Ukládá a zobrazuje počty načtení příspěvku.

Jak na page level cache

Pokud ovšem web, na kterém je takový kód přítomný, používá nějaký cachovací plugin, nebude nepřihlášenému uživateli (tedy většině) kód fungovat, jelikož se akce wp_head nespustí, nic se neuloží a zobrazovat se bude stále stejný počet zobrazení uložený spolu s celou stránkou ve statickém HTML.

Ovšem i tento problém lze celkem jednoduše vyřešit pomocí AJAXu:

<?php
add_action( 'wp_ajax_add_pageview', 'myawesome_pageview_add_pageview' );
add_action( 'wp_ajax_nopriv_add_pageview', 'myawesome_pageview_add_pageview' );

//Uložíme shlédnutí do databáze
function myawesome_pageview_add_pageview() {
    $post_id = $_REQUEST['post_id'];
    //získáme uložená data
    $pageviews = get_post_meta( $post_id, 'myawesome_pageview', true );
    //zvětšíme o jedno
    $pageviews = intval( $pageviews ) + 1;
    //znovu uložíme
    update_post_meta( $post_id, 'myawesome_pageview', $pageviews );
    die( $pageviews );
}

//funkce pro zobrazování počtu shlédnutí
function myawesome_pageview( $post_id = null ) {
    if ( false === empty( $post_id ) && true === is_singular() ) {
        $post_id = get_the_ID();
    } else {
        return;
    }
    echo '<div id="myawesome_pageviews">'.get_post_meta( $post_id, 'myawesome_pageview', true ).'</div>';
}

add_action( 'wp_head', function(){
    if ( ! is_singular() )
        return;
    $ajax_nonce = wp_create_nonce( "my-special-string" );
?>
<script type="text/javascript">
jQuery(document).ready(function($){
	var data = {
		action: 'add_pageview',
		security: '<?php echo $ajax_nonce; ?>',
		post_id: '<?php echo get_post_ID(); ?>'
	};
	$.post( '<?php echo admin_url( "admin-ajax.php" ); ?>' , data, function(response) {
		$( "#myawesome_pageviews" ).html( response );
	});
});
</script>
<?php
} );

Tohle řešení bude fungovat i s libovolným cachovacím pluginem. Ovšem nastane jiný problém. A to s výkonem. Ten sice existoval již předtím, nicméně zůstal skryt cachovacím pluginem.

Co se nyní děje při požadavku na wp-admin/admin-ajax.php

WordPress, k tomu aby mohl fungovat, musí načíst jádro, pluginy, šablony, options z databáze a další data. To neplatí jen a pouze o necachovaném pageview, ale také o AJAX requestech, které se v podobně POST requestu určitě necachují.

Takže každé načtení stránky na daném webu produkuje nyní dva requesty. Jeden cachovaný a druhý přímo mířící na origin – tedy váš server, kde se WordPress načítá s veškerou parádou. Takový AJAX request nebude blokovat načtení stránky, uživatel se dostane k obsahu teoreticky celkem rychle a na ten počet zobrazení v hlavičce článku si holt počká. Ale server bude přetížen.

Tohle je ta část problému, jejíž řešení je uvedeno v článku odkazovaném v úvodu. Nicméně to není vše.

Nekončící zápisy do databáze

Už zmiňované necachované requesty na server jsou špatné, ale je to ještě horší. Daný AJAX request nejen, že načítá data z databáze, on také data zapisuje. To znamená, že co načtení stránky, to zápis do databáze. V malých objemech to sever nejspíš ustojí, ale v případě nějakého spiku v návštěvnosti se ukáže, kdo používá MyISAM a kdo InnoDB jako úložiště dat pro MySQL.

Zatímco stále ještě nejpoužívanější MyISAM zamyká při zápisu celé tabulky, InnoDB zamyká jen řádky. InnoDB pomůže hlavně v tom, že administrátor či editoři nebudou mít problémy s vytvářením novým příspěvků, kvůli tomu, že by tabulka wp_post_meta byla zamknutá kvůli všem těch zápisům z pageviews pluginu. Ale problém to stejně neřeší.

Když jsem nakousl MySQL, tak cítím, že je vhodné také zmínit pro nefunguje cache, kterou má MySQL zabudovanou. MySQL cachuje dotazy a v případě identického dotazu dokáže vrátit data bez toho, aniž by dotaz vykonala. Ovšem MySQL tuto cache maže vždy, když dojde ke změně v relevantních tabulkách. Takže i jednoduché čtení z databáze se nám začne prodražovat.

Nepomůže změnit cahovací plugin

Věřím, že řada provozovatelů webu na WordPressu se začne poohlížet po lepším cachovacím pluginu. Neznám podrobně všechny page level cachovací pluginy, ale dokážu si představit že mažou cache pro stránku v případě updatu relevantní post_meta. Aspoň tak tomu je u různých object cache pluginů, které znám lépe.

Uživatel se dost možná dočte o něčem, čemu se říká object cache a zkusí nějaký ten redis či memcache nasadit. Ovšem kýženého výsledku se opět nedosáhne. Jednoduše proto, že object cache pro daný post, který obsahuje nejen údaje z tabulky wp_posts, ale také z tabulky wp_post_meta, se bude s každým zápisem do databáze mazat, tedy vždy, když někdo spustí daný AJAX request – tedy navštíví danou stránku. Takže vlastně pořád. To je opět stejný problém, jako s MySQL cachí.

Opět se vracím k problému a řešení popsaném ve zmiňovaném blogpostu. Ten správně odstranil AJAX request, ovšem plugin/šablona použitá na webu není tak “elegantní”, pokud se o eleganci ještě vůbec dá hovořit, a neprovádí čtení i zápis v rámci jednoho requestu, nýbrž ve dvou. “Zapisovací” request je v době psaní tohoto článku stále ještě aktivní a vesele zapisuje do databáze, znovu a znovu.

Správné řešení?

Jediné, co můžeme provést je celý takovýto návrh smést ze stolu.

Statistiky jako pageviews patří na systémy, které jsou na nápor dat připraveny. A můžeme si vybírat. Jmenujme aspoň Google Analytics, Piwik či Jetpack a jeho modul Stats.

Pluginy na zobrazování počtu shlédnutí daného příspěvku poté budou muset využít API zmíněných nástrojů a získat tak kýžená data. Jaké pluginy to jsou a jak obecně na to je nad rámec tohoto článku, jehož cílem je především poučit o tom, proč se s page/post views pluginy většinou spálíte a proč.

PS: Původně jsem nechtěl žádný plugin jmenovat a veškerý kód jsem napsal jen ilustračně (aniž bych jej testoval), nicméně když jsem zjistil, že plugin WP-PostViews má více jak 200000 stažení a jeho kód je velmi podobný tomu, co jsem zde nastínil, nedalo mi to. Schválně se na kód pluginu podívejte.

KSES odstraňuje zlé skripty

WordPress je distribuován s knihovnou KSES Švédského autora Ulf Harnhammar. Knihovna je HTML/XHTML filtrem implementovaným v PHP. Odstraňuje nežádoucí HTML elementy a atributy a provádí kontrolu jejich hodnot.

Knihovna slouží k zamezení Cross-Site Scripting (XSS) útokům. KSES přitom zdaleka není jediným, ani jediným dostačujícím, způsobem jak takovým útokům bránit. Nicméně poslouží velmi dobře v případech, kdy chceme povolit uživatelům či vzdáleným serverům poskytovat našemu kódu text s podporou HTML.

V případě WordPressu samotného je to například psaní příspěvků uživatelům s různým druhem oprávnění, či podpory HTML v komentářích.

Název “KSES” je rekurzivní zkratka zmanenající

KSES Strips Evil Scripts

, což v překladu znamená KSES odstraňuje zlé skripty. S rekurzivními zkratkami se ve světě open source a free software můžeme setkat poměrně často.

Knihovnu naleznete v adresáři wp-includes/kses.php.

Rozdíl mezi wp_kses a strip_tags

PHP vývojář se může ptát, proč by měl využívat wp_kses namísto strip_tags s povolenými HTML elementy. Důvod je jednoduchý. Funkce strip_tags, ačkoli umožňuje definovat které elementy nemají být z textu odstraněny, neumožňuje definovat které attributy jsou povoleny.

A to může být pro některé případy celkem problém. Jeden příklad za všechny. Takový element img pro obrázek, zdánlivě neškodný, má attribut onerror, který spouští javascript v něm definovaný v případě, že se obrázek nepodaří načíst. Následující kód tudíž nelze považovat za dostatečné zabezpečení:

$post_content = '<h1>Title</h1><p>Lorem ipsum sit dolor ament</p><img src="x" alt="haxxored" onerror="alert(this.alt)"/>';
echo strip_tags( $post_content, '<h1><p><img>' );

wp_strip_all_tags

Zmínil jsem jeden důvod, proč strip_tags není vhodné používat, proč tedy ve WordPressu nalezneme funkci wp_strip_all_tags?

Důvodem je, že strip_tags odstraňuje pouze html tagy, nikoli celé elementy včetně textu. Podívejte se na tento kód:

$post_content = '<p>Lorem ipsum</p>';
echo strip_tags( $post_content, '' ); //vypíše "Lorem ipsum"

Toto chování může být v řadě případů žádoucí, ovšem existuje případ, kdy tomu tak není. A sice, pokud vstupní text obsahuje element style či script. Jejich obsah většinou opravdu nechat vypsat nechceme. Ačkoli se ve většině takových případech nejdená o bezpečnostní chybu, rozhodně to není něco, čeho bychom úmysleně chtěli docílit. Proto WordPress nabízí funkci wp_strip_all_tags, která před samotným odstraňováním HTML tagů kompletně odstraní script a style elementy i s textem, který je uvnitř.

Funkce wp_kses a její parametry

Knihovna obsahuje celou řadu funkcí, ne všechny jsou ovšem vhodné k přímému použití. Jsou využity v rámci větších celků. Přímo použitelná je funkce wp_kses, případně některý z wrapperů (funkce volající funkci s předdefinovanými atributy), například wp_kses_post.

Parametry funkce wp_kses jsou “$string”, “$allowed_html” a “$allowed_protocols”, který je jako jediný nepovinný (výchozí hodnota je prázdné pole – array):

wp_kses( $string, $allowed_html, $allowed_protocols = array() )

$string

“$string” je text, který chceme filtrovat. Ve WordPressu samém je to typicky obsah příspěvku – post_content. Obsah příspěvku je, například, zkontrolován funkcí wp_kses_post před tím, než je uložen do databáze. Pokud jste si někdy všimli, že uživatel s nižším oprávněním než administrátor nemůže vkládat do příspěvku JavaScript, tak za to může právě sanitizace pomocí funkce wp_ksess.

V rámci pluginu bude uživatel chtít sanitizovat text/vstup získaný od uživatele či z odpovědi vzdáleného serveru před tím, než jej vypíše na stránku či uloží do databáze.

$allowed_html

Tento parametr určuje, jaká sada HTML elementů a atributů se má použít. Vývojář může v rámci vlastního volání funkce definovat svou vlastní sadu, nebo využít sady již předdefinované – takovým potom říkáme kontext.

Mezi kontexty, neboli předdefinované sady, patří “post”, “user_description”, “pre_user_description”, “strip”, “entities” a “data” (který je také defaultním kontextem). Zde se vracíme k funkci “wp_kses_post”, která jediné co dělá, je že za vývojáře předává hodnotu “post” parametru $allowed_html funkce wp_kses.

$allowed_protocols

Tento parametr vyjmenovává povolené protokoly pro atributy, jejichž hodnotou je URI (typicky, “href” či “src”). Cílem je v textu ponechat jen protokoly, které považujeme za bezpečné, a odstranit například výskyty protokolou “javascript:”.

Vlastní sada HTML

Vlastní sadu povolených HTML elementů a atributů definuje vývojář v podobě pole (array), jehož klíče označují HTML elementy a hodnotami jsou opět pole (array), kde jsou klíči jednotlivé attributy a hodnotou je boolean hodnota (true či false). Definovat false není nutné, někdy ovšem může sloužit k názornému zakázání daného atributu:

Vlastní sadu lze definovat podle následujícího vzoru, ve kterém povolíme, aby text obsahoval elementy “img” a “a”. Pro “img” povolíme atributy “src”, “alt” a “title”. Pro odkaz poté “href”, “title”, “rel”, “class” a “id”:

$allowed_html = array(
   'img' => array( //obrázek
        'src' => true, //zdroj
        'alt' => true, //alternativní popisek
        'title' => true, //titulek
    ),
   'a' => array( //odkaz
        'href' => true, //url
        'title' => true, //titulek
        'rel' => true, //vztah k odkazovanému dokumentu
        'class' => true, //třída
        'id' => true, //id
    ),
);
$text = wp_kses( $text, $allowed_html );

Je důležité mít na paměti, že KSES opravdu ponechá v daném HTML textu jen a pouze ty elementy a atributy, které jsou vyjmenovány. Takže například atribut “class” pro obrázek (“img”) bude nemilosrdně odstraněn. Stejně tak budou z textu předaném v atributu “$string” odstraněny všechny nadpisy, odstavce, tučné písmo, kurzíva … na co si jen vzpomenete a co jste zapomněli uvést v proměnné “$allowed_html”.

Odvozená vlastní sada

Ovšem abyste nemuseli vždy vypisovat celý seznam existujících HTML elementů a jejich atributů, lze využít funcke wp_kses_allowed_html, které lze předat kontext (například již zmíněnmé “post”) a funkce poté vrací seznam předdefinovaných elementů a jejich atributů. To lze poté využít k modifikaci defaultních nastavení a jejich následné využití pro vlastní účely:

$allowed_html = wp_kses_allowed_html( 'post' ); //defaultní sada pro příspěvek
$allowed_html['script'] = array( //přidáme element script
    'src' => true, //povolíme zdroj (atribut src)
    'type' => true, //a typ (type)
);
$allowed_html['a']['rel'] = true; //povolíme elementu "a" mít attribut "rel"
$text = wp_kses( $text, $allowed_html );

Ovšem pozor, pokud povolíme elemet script v neznámém textu, tak jsou poté všechny snahy o zamezení XSS útoku v atributech jiných prvků naprosto zbytečné – útočník nemusí hledat zadní vrátky, když jsou vchodové dveře otevřeny dokořán. Výše uvedený kód, povolující element script, tedy v žádném případě nepoužívejte.

Filtry

WordPress sám funkce wp_kses využívá na mnoha místech. Ukládání příspěvku do databáze již bylo zmíněno několikrát.

V případě, že bychom chtěli předefinované sady HTML při použití v interních procesech naší WordPress instalace rozšířit, nebo některou naopak zůžit, přijdou nám vhod filtry ve funkci wp_kses_allowed_html.

Říkám filtry, ale v zásadě se jedná jen o jeden filtr. A sice “wp_kses_allowed_html”.

Důležité je vždy zkontrolovat o jaký kontext je jedná. V případě, že vývojář definoval vlastní sadu HMTL, tak druhým parametrem předaným našemu callbacku bude string “explicit”, defaultní hodnotou je poté string “data” a další hodnoty jsou rezervovány pro jednotlivé kontexty, ve kterých je funkce využívána:

CUSTOM_TAGS

Pokud byste chtěli, můžete si definovat vlastní základní sady elementů a atributů v rámci wp-config.php.

V takovém případě je třeba, ideálně dle vzoru, definovat následující proměnné: $allowedposttags, $allowedtags, $allowedentitynames a nezapomenout definovat konstantu “CUSTOM_TAGS” s hodnotou “true”.

Závěrem

Knihovna KSES je velmi robustní a dává vývojáři velmi detailní kontrolu nad tím, jaké HTML elementy s jakými atributy se mohou v textu přijatém od uživatele či cizího serveru vyskytovat v případě, že chceme těmto zdrojům povolit používat HTML v jejich vstupu.

Taková kontrola před tím, než je s textem nějak naloženo – například je uložen do databáze či zobrazen na stránce, efektivně zamezí XSS útokům, které si mohou brát na paškál povolené attributy typu “onerror” u obrázků, či “onclick” u odkazů a podobně.

Každý vývojář WordPress pluginů a šablon by funkci wp_kses měl znát a hlavně využívat ve svém kódu.

Ovšem “wp_kses” není jedinou funkcí, která je třeba k zabezpečení uživatelského vstupu. Pokud nechcete čekat na to, až se k napsání dalšího článku na téma “esc_*” funkcí dostanu, přečtěte si, co o takových funkcích říká kodex.

Dokumentace Best Practice pro WordPress

WordPress dlouho postrádal aktivitu podobnou příručce PHP The Right Way, kterou má k dispozici komunita PHP vývojářů. Ještě nedávno platilo, a obávám se, že stále ještě platí a nějakou dobu platit bude, že kdo se chtěl ponořit do WordPressu, musel začít studiem kodexu, prokousávat se zdrojovým kódem (vedle tradičního trac je tento již nějakou dobu také na GitHubu, což potěší určitě ty, kteří SVN opovrhují) a studovat řadu tutoriálů, mezi kterými navíc musel vybírat ty, které jsou kvalitní, správné a aktuální. Situace se ovšem, a hodí se říci i naštěstí, pomalu mění k lepšímu.

Continue reading Dokumentace Best Practice pro WordPress

Funkce wp_using_ext_object_cache

V nedávno jsem se podrobněji zabýval mechanismem stojícím za funkcí wp_suspend_cache_addition – popisoval jsem použití statické proměnné.

Při pohledu do těla funkce wp_using_ext_object_cache ovšem na statickou proměnnou nenarazíme – je použita globální proměnná. Proč?

function wp_using_ext_object_cache( $using = null ) {
	global $_wp_using_ext_object_cache;
	$current_using = $_wp_using_ext_object_cache;
	if ( null !== $using )
		$_wp_using_ext_object_cache = $using;
	return $current_using;
}

Při hledání ve zdrojovém kódu WordPressu jsem nenarazil na místo, kde by bylo ke globální proměnné přistupováno a bylo třeba ji inicializovat jako globální, namísto statické.

Doufám, že jsem jen něco přehlédl a že to není dáno jen tím, že úroveň PHP vývojářů má klesající tendenci (funkce wp_suspend_cache_addition je o dva roky starší než zde diskutovaná funkce).

Za jakékoli tipy předem děkuji.

Externí Object Cache ve WordPressu

V případě, že bychom ze své instalace WordPressu na serveru, kde máme k dispozici trochu víc, než jen Apache s PHP a MySQL, chtěli vymáčknou trochu víc, je možné dosadit vlastní backend pro Object Cache a změnit její podstatu z Run-Time Cache na persistentní.

Pojďme se společně podívat na to, jak je možnost vlastního backendu pro Object Cache řešena v samotném WordPressu a jak si, trochu si zapřeháním, třeba napsat vlastní backend.

Continue reading Externí Object Cache ve WordPressu

Object Cache ve WordPressu

Detailní studium implementace Object Cache API v jádře WordPressu nám dokáže odpovědět na to, jak lze optimalizovat náš kód, a to ať již tvoříme plugin či šablonu. Pokud budeme vědět, co za nás WordPress na poli výkonu řeší a co nikoli, můžeme bez obav svobodně tvořit. Kód, který nás zajímá lze nalézt převážně v souboru wp-includes/cache.php.

Continue reading Object Cache ve WordPressu