als haakje. Het thema, de Customizer en de eigen posts van de * klant blijven onaangeroerd. * * Zonder optie doet dit bestand niets: een site die 1.0.2 binnenhaalt, ziet er byte voor byte * hetzelfde uit. Wie de optie weghaalt (DELETE) of de plugin uitzet, krijgt de site terug zoals * hij was. * * Wat de CSS NIET kan, en waarom (spec 2026-10-13 §risico's): * - uit \n"; // phpcs:ignore WordPress.Security.EscapeOutput -- getoetste CSS zonder '<' } catch ( \Throwable $e ) { return; } } /* ------------------------------------------------------------------ */ /* De toets */ /* ------------------------------------------------------------------ */ /** * Toets CSS. Leeg = geldig; anders de redenen (hoogstens tien). * * Dezelfde regels staan in services/blogstijl.py::valideer; tests/test_connector_blogstijl.py * legt beide over één set goede en foute CSS en eist hetzelfde oordeel. * * @param mixed $css CSS. * @return string[] */ public static function valideer_css( $css ) { if ( ! is_string( $css ) ) { return array( 'css is geen tekst' ); } $n = strlen( $css ); if ( 0 === $n || '' === trim( $css ) ) { return array( 'css is leeg' ); } if ( $n > self::MAX_BYTES ) { return array( 'css is groter dan ' . self::MAX_BYTES . ' bytes' ); } if ( ! preg_match( '//u', $css ) ) { return array( 'css is geen geldige UTF-8' ); } $redenen = array(); if ( false !== strpos( $css, '<' ) ) { $redenen[] = "'<' mag niet (kan uit het style-element breken)"; } if ( false !== strpos( $css, '\\' ) ) { $redenen[] = 'een backslash mag niet (geen escapes)'; } if ( preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $css ) ) { $redenen[] = 'stuurtekens mogen niet'; } if ( $redenen ) { return $redenen; } $fout = ''; $skelet = self::skelet( $css, $fout ); if ( '' !== $fout ) { return array( $fout ); } // Functies: alleen de allowlist. Een functie is een naam DIRECT gevolgd door '('. Een // naam na '@' is een at-regel (@media(…) is geldige CSS), die toetst structuur(). if ( preg_match_all( '/(?= $n ) { $fout = 'tekst zonder afsluitend aanhalingsteken'; return ''; } $uit .= $c . $c; $i = $j + 1; continue; } $uit .= $c; ++$i; } return $uit; } /** * Toets de bouw: regels met selectors, @media/@supports eromheen, geen nesting in een regel, * geen losse statements, en elke selector begint met .aigeschikt-blog. * * @param string $s Skelet. * @return string[] */ private static function structuur( $s ) { $redenen = array(); $diepte = 0; $voor = ''; $n = strlen( $s ); $i = 0; while ( $i < $n ) { $c = $s[ $i ]; if ( '{' === $c ) { $prelude = trim( $voor ); $voor = ''; if ( '' !== $prelude && '@' === $prelude[0] ) { if ( ! preg_match( '/^@([A-Za-z-]+)(?:\s|\(|$)/', $prelude, $m ) || ! in_array( strtolower( $m[1] ), self::AT_REGELS, true ) ) { return array( 'at-regel ' . ( isset( $m[1] ) ? '@' . $m[1] : self::kort( $prelude ) ) . ' mag niet' ); } if ( ++$diepte > self::MAX_DIEPTE ) { return array( 'te diep genest' ); } ++$i; continue; } $fout = self::selectors( $prelude ); if ( '' !== $fout ) { return array( $fout ); } // De declaraties: tot de eerstvolgende '}', zonder '{' of '@' ertussen. $eind = strpos( $s, '}', $i + 1 ); if ( false === $eind ) { return array( 'regel zonder sluitende accolade' ); } $blok = substr( $s, $i + 1, $eind - $i - 1 ); if ( false !== strpos( $blok, '{' ) || false !== strpos( $blok, '@' ) ) { return array( 'nesting of een at-regel binnen een regel mag niet' ); } $i = $eind + 1; continue; } if ( '}' === $c ) { if ( '' !== trim( $voor ) ) { return array( 'losse tekst vóór een sluitende accolade' ); } if ( --$diepte < 0 ) { return array( 'sluitende accolade zonder opening' ); } ++$i; continue; } if ( ';' === $c ) { return array( 'een los statement (zoals @import of @charset) mag niet' ); } $voor .= $c; ++$i; } if ( 0 !== $diepte ) { $redenen[] = 'een @media- of @supports-blok sluit niet'; } if ( '' !== trim( $voor ) ) { $redenen[] = 'losse tekst aan het einde'; } return $redenen; } /** * Elke selector in de lijst begint met .aigeschikt-blog (niet .aigeschikt-blogger). * * @param string $prelude Selectorlijst. * @return string '' of de reden. */ private static function selectors( $prelude ) { if ( '' === $prelude ) { return 'regel zonder selector'; } $delen = array(); $huidig = ''; $haakje = 0; $len = strlen( $prelude ); for ( $i = 0; $i < $len; $i++ ) { $c = $prelude[ $i ]; if ( '(' === $c ) { ++$haakje; } elseif ( ')' === $c ) { --$haakje; } if ( ',' === $c && 0 === $haakje ) { $delen[] = $huidig; $huidig = ''; continue; } $huidig .= $c; } $delen[] = $huidig; foreach ( $delen as $sel ) { $sel = trim( $sel ); if ( ! preg_match( '/^\.aigeschikt-blog(?![A-Za-z0-9_-])/', $sel ) ) { return "selector '" . self::kort( $sel ) . "' begint niet met ." . self::KLASSE; } } return ''; } /** * @param string $tekst Tekst. * @return string */ private static function kort( $tekst ) { $tekst = preg_replace( '/\s+/', ' ', (string) $tekst ); return function_exists( 'mb_substr' ) ? mb_substr( $tekst, 0, 60, 'UTF-8' ) : substr( $tekst, 0, 60 ); } /* ------------------------------------------------------------------ */ /* REST: /blogstijl */ /* ------------------------------------------------------------------ */ public static function register_routes() { register_rest_route( AIGESCHIKT_REST_NS, '/blogstijl', array( // Site-breed, dus Editor (edit_others_pages), net als /schema-aanvulling. array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'rest_get' ), 'permission_callback' => 'aigeschikt_can_site_write', ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( __CLASS__, 'rest_put' ), 'permission_callback' => 'aigeschikt_can_site_write', ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( __CLASS__, 'rest_delete' ), 'permission_callback' => 'aigeschikt_can_site_write', ), ) ); } public static function rest_get( WP_REST_Request $request ) { return rest_ensure_response( self::antwoord() ); } /** * Zet de stijl. Body: {"versie": 1, "css": "…"}. Klopt er iets niet, dan wordt alles * geweigerd en blijft de vorige staan. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function rest_put( WP_REST_Request $request ) { $body = $request->get_json_params(); $redenen = array(); if ( ! is_array( $body ) ) { $redenen[] = 'de body is geen object'; } elseif ( ! isset( $body['versie'] ) || ! is_int( $body['versie'] ) || self::VERSIE !== $body['versie'] ) { $redenen[] = 'versie moet ' . self::VERSIE . ' zijn'; } else { $redenen = self::valideer_css( isset( $body['css'] ) ? $body['css'] : null ); } if ( $redenen ) { return new WP_Error( 'aigeschikt_blogstijl_geweigerd', 'De stijl is geweigerd; er is niets opgeslagen.', array( 'status' => 400, 'redenen' => $redenen, ) ); } $css = $body['css']; // Geen autoload: de optie is alleen nodig op onze posts, niet in elke request. update_option( self::OPTIE, array( 'versie' => self::VERSIE, 'css' => $css, 'sha' => hash( 'sha256', $css ), 'gezet_op' => time(), ), false ); self::vergeet(); self::purge(); return rest_ensure_response( self::antwoord() ); } public static function rest_delete( WP_REST_Request $request ) { delete_option( self::OPTIE ); self::vergeet(); self::purge(); return rest_ensure_response( self::antwoord() ); } /** * @return array */ private static function antwoord() { $a = self::opgeslagen(); return array( 'blogstijl' => null === $a ? null : array( 'versie' => $a['versie'], 'sha' => $a['sha'], 'bytes' => $a['bytes'], 'gezet_op' => $a['gezet_op'], ), 'status' => self::status(), ); } /** * Na een schrijfactie de onthouden stand van deze request vergeten. */ private static function vergeet() { self::$gelezen = false; self::$hier = null; } /** * Leeg de cache van precies de pagina's die de stijl dragen: onze gepubliceerde posts. * Meer dan MAX_PURGE: dan de hele cache, want een halve purge laat bezoekers een mengsel * zien. Een fout hier kost geen schrijfactie: de stijl staat al, de cache verloopt vanzelf. */ private static function purge() { try { $ids = get_posts( array( 'post_type' => 'post', 'post_status' => 'publish', 'meta_key' => self::marker(), // phpcs:ignore WordPress.DB.SlowDBQuery 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery 'fields' => 'ids', 'posts_per_page' => self::MAX_PURGE + 1, 'no_found_rows' => true, 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- the purge must find our own marked posts regardless of third-party query filters (e.g. language filters). ) ); if ( ! is_array( $ids ) ) { return; } if ( count( $ids ) > self::MAX_PURGE ) { AIGeschikt_Cache::purge_all(); return; } foreach ( $ids as $id ) { AIGeschikt_Cache::purge_post( (int) $id ); } } catch ( \Throwable $e ) { return; } } } AIGeschikt_Blogstijl::init();