| 1 |
<?php |
| 2 |
namespace ABlocks\Classes\PageCache; |
| 3 |
|
| 4 |
if ( ! defined( 'ABSPATH' ) ) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
use ABlocks\Helper; |
| 9 |
|
| 10 |
/** |
| 11 |
* Page Cache — the single source of truth for "may this request be cached?". |
| 12 |
* |
| 13 |
* Split into two layers on purpose: |
| 14 |
* |
| 15 |
* - {@see Rules::request_bypass_reason()} uses **superglobals only**. All three |
| 16 |
* serve tiers (plugins_loaded, the advanced-cache.php drop-in, and the nginx |
| 17 |
* rule) run before pluggable functions exist, so this layer must never call |
| 18 |
* is_user_logged_in() or any template conditional. Writing it once against |
| 19 |
* $_SERVER/$_COOKIE keeps every tier making the identical decision — if they |
| 20 |
* ever disagree, a logged-in visitor gets served an anonymous page. |
| 21 |
* |
| 22 |
* - {@see Rules::response_bypass_reason()} is WordPress-aware and runs only at |
| 23 |
* write time, once the query and the response are known. |
| 24 |
* |
| 25 |
* Both return a short machine-readable reason string (or null when cacheable) |
| 26 |
* rather than a bool, so `wp ablocks cache status` and the debug header can say |
| 27 |
* *why* a page is not being cached. That is the difference between a five-minute |
| 28 |
* and a two-hour support thread. |
| 29 |
* |
| 30 |
* Security note: these rules are not merely a correctness feature. Cached files |
| 31 |
* live under uploads/ and are web-reachable, so the invariant "only ever cache |
| 32 |
* the fully-anonymous view of an already-public URL" is what makes that safe. |
| 33 |
* Weakening any check below is a security change. See docs/PAGE-CACHE-PLAN.md §3.2. |
| 34 |
*/ |
| 35 |
class Rules { |
| 36 |
|
| 37 |
/** |
| 38 |
* Cookie name prefixes that mean "this response is personalised". |
| 39 |
* |
| 40 |
* Matched as prefixes because WordPress suffixes most of these with a hash |
| 41 |
* of the site URL (wordpress_logged_in_a1b2c3...). |
| 42 |
*/ |
| 43 |
const BYPASS_COOKIE_PREFIXES = [ |
| 44 |
// WordPress core: authenticated session, post password, comment author. |
| 45 |
'wordpress_logged_in_', |
| 46 |
'wordpressuser_', |
| 47 |
'wordpresspass_', |
| 48 |
'wp-postpass_', |
| 49 |
'comment_author_', |
| 50 |
'comment_author_email_', |
| 51 |
// WordPress core: "your comment is awaiting moderation" needs a fresh render. |
| 52 |
'wp-resetpass-', |
| 53 |
// StoreEngine / WooCommerce: a cart cookie means the header cart count, |
| 54 |
// and usually the page itself, differs per visitor. |
| 55 |
'storeengine_cart', |
| 56 |
'storeengine_session', |
| 57 |
'woocommerce_items_in_cart', |
| 58 |
'woocommerce_cart_hash', |
| 59 |
'wp_woocommerce_session_', |
| 60 |
]; |
| 61 |
|
| 62 |
/** |
| 63 |
* Reasons that came from the last evaluation, for debugging output. |
| 64 |
* |
| 65 |
* @var string|null |
| 66 |
*/ |
| 67 |
private static $last_reason = null; |
| 68 |
|
| 69 |
/** |
| 70 |
* May this *request* be served from, or written to, the cache? |
| 71 |
* |
| 72 |
* Superglobals only — see the class docblock. |
| 73 |
* |
| 74 |
* @return string|null Bypass reason, or null when the request is cacheable. |
| 75 |
*/ |
| 76 |
public static function request_bypass_reason() { |
| 77 |
if ( ! self::is_enabled() ) { |
| 78 |
return self::remember( 'disabled' ); |
| 79 |
} |
| 80 |
|
| 81 |
// Only GET. HEAD is deliberately excluded: serving a body for HEAD is |
| 82 |
// wrong, and the win is nil. |
| 83 |
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; |
| 84 |
if ( 'GET' !== $method ) { |
| 85 |
return self::remember( 'method:' . strtolower( $method ) ); |
| 86 |
} |
| 87 |
|
| 88 |
// A POST body on a GET is malformed; treat it as uncacheable rather than |
| 89 |
// reasoning about it. Nothing here reads a value — only whether the |
| 90 |
// superglobal is populated at all — so there is no input to verify or |
| 91 |
// sanitize, and a nonce check would be meaningless on an anonymous |
| 92 |
// cacheable request. |
| 93 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Presence check only; no form data is read or acted on. |
| 94 |
if ( ! empty( $_POST ) ) { |
| 95 |
return self::remember( 'post-data' ); |
| 96 |
} |
| 97 |
|
| 98 |
$cookie_reason = self::bypass_cookie_reason(); |
| 99 |
if ( null !== $cookie_reason ) { |
| 100 |
return self::remember( $cookie_reason ); |
| 101 |
} |
| 102 |
|
| 103 |
$query_reason = self::query_bypass_reason(); |
| 104 |
if ( null !== $query_reason ) { |
| 105 |
return self::remember( $query_reason ); |
| 106 |
} |
| 107 |
|
| 108 |
if ( self::is_excluded_url() ) { |
| 109 |
return self::remember( 'excluded-url' ); |
| 110 |
} |
| 111 |
|
| 112 |
return self::remember( null ); |
| 113 |
} |
| 114 |
|
| 115 |
/** |
| 116 |
* Convenience boolean wrapper around {@see Rules::request_bypass_reason()}. |
| 117 |
*/ |
| 118 |
public static function should_bypass_request() { |
| 119 |
return null !== self::request_bypass_reason(); |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* May this *response* be written to the cache? |
| 124 |
* |
| 125 |
* WordPress-aware; safe to call only at write time (shutdown), when the main |
| 126 |
* query has run and the status code is final. |
| 127 |
* |
| 128 |
* @return string|null Bypass reason, or null when the response is cacheable. |
| 129 |
*/ |
| 130 |
public static function response_bypass_reason() { |
| 131 |
// Everything the request-level layer rejects, the response layer does too. |
| 132 |
$request_reason = self::request_bypass_reason(); |
| 133 |
if ( null !== $request_reason ) { |
| 134 |
return $request_reason; |
| 135 |
} |
| 136 |
|
| 137 |
// Contexts that never produce a cacheable public page. |
| 138 |
if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) { |
| 139 |
return self::remember( 'admin-context' ); |
| 140 |
} |
| 141 |
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { |
| 142 |
return self::remember( 'rest-request' ); |
| 143 |
} |
| 144 |
if ( defined( 'WP_CLI' ) && \WP_CLI ) { |
| 145 |
return self::remember( 'wp-cli' ); |
| 146 |
} |
| 147 |
if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { |
| 148 |
return self::remember( 'xmlrpc' ); |
| 149 |
} |
| 150 |
|
| 151 |
// The de-facto standard opt-out other plugins set when they know a |
| 152 |
// response is personalised. Respecting it is what makes us a good citizen. |
| 153 |
if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { |
| 154 |
return self::remember( 'donotcachepage' ); |
| 155 |
} |
| 156 |
|
| 157 |
// Belt and braces: the cookie check above catches logged-in visitors |
| 158 |
// without loading pluggable functions, but by write time we can ask |
| 159 |
// properly, and a session could have been established mid-request. |
| 160 |
if ( is_user_logged_in() ) { |
| 161 |
return self::remember( 'logged-in' ); |
| 162 |
} |
| 163 |
|
| 164 |
// Never freeze a non-200 into a file that a later request would serve as 200. |
| 165 |
$status = self::response_status(); |
| 166 |
if ( 200 !== $status ) { |
| 167 |
return self::remember( 'status:' . $status ); |
| 168 |
} |
| 169 |
|
| 170 |
// Query types that are either personalised, unbounded, or not worth caching. |
| 171 |
if ( is_preview() ) { |
| 172 |
return self::remember( 'preview' ); |
| 173 |
} |
| 174 |
if ( is_404() ) { |
| 175 |
return self::remember( '404' ); |
| 176 |
} |
| 177 |
if ( is_search() ) { |
| 178 |
return self::remember( 'search' ); |
| 179 |
} |
| 180 |
if ( is_feed() || is_robots() || is_trackback() ) { |
| 181 |
return self::remember( 'non-html' ); |
| 182 |
} |
| 183 |
if ( function_exists( 'is_embed' ) && is_embed() ) { |
| 184 |
return self::remember( 'embed' ); |
| 185 |
} |
| 186 |
if ( is_customize_preview() ) { |
| 187 |
return self::remember( 'customizer' ); |
| 188 |
} |
| 189 |
|
| 190 |
// Password-protected content: the unlocked view must never reach disk. |
| 191 |
if ( is_singular() ) { |
| 192 |
$post = get_post(); |
| 193 |
if ( $post instanceof \WP_Post ) { |
| 194 |
if ( 'publish' !== $post->post_status ) { |
| 195 |
return self::remember( 'status:' . $post->post_status ); |
| 196 |
} |
| 197 |
if ( ! empty( $post->post_password ) || post_password_required( $post ) ) { |
| 198 |
return self::remember( 'password-protected' ); |
| 199 |
} |
| 200 |
} |
| 201 |
} |
| 202 |
|
| 203 |
// A response that sets a cookie is establishing per-visitor state, so the |
| 204 |
// body almost certainly depends on it. headers_list() catches raw header() |
| 205 |
// calls that never touch $_COOKIE. |
| 206 |
if ( self::response_sets_cookie() ) { |
| 207 |
return self::remember( 'sets-cookie' ); |
| 208 |
} |
| 209 |
|
| 210 |
$scope_reason = self::scope_bypass_reason(); |
| 211 |
if ( null !== $scope_reason ) { |
| 212 |
return self::remember( $scope_reason ); |
| 213 |
} |
| 214 |
|
| 215 |
$host_reason = self::host_bypass_reason(); |
| 216 |
if ( null !== $host_reason ) { |
| 217 |
return self::remember( $host_reason ); |
| 218 |
} |
| 219 |
|
| 220 |
// Logged-in editors previewing the frontend are excluded from every other |
| 221 |
// Performance Suite feature (see DelayJs); stay consistent. This is |
| 222 |
// unreachable while the is_user_logged_in() check above stands, but both |
| 223 |
// are cheap and the intent should survive future edits to either. |
| 224 |
if ( is_user_logged_in() && current_user_can( 'edit_posts' ) |
| 225 |
&& (bool) apply_filters( 'ablocks/perf/bypass_optimizations_for_editors', true ) ) { |
| 226 |
return self::remember( 'editor' ); |
| 227 |
} |
| 228 |
|
| 229 |
return self::remember( null ); |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* Is the response body itself cacheable? |
| 234 |
* |
| 235 |
* Guards against freezing a truncated page — a fatal error, an uncaught |
| 236 |
* exception or a bare exit() mid-render produces output that looks fine to |
| 237 |
* every check above but is missing its closing tags. Caching that would serve |
| 238 |
* a broken page to everyone until the next purge, which is the single worst |
| 239 |
* failure mode this feature has. |
| 240 |
* |
| 241 |
* @param string $html Buffered output. |
| 242 |
* @return string|null Bypass reason, or null when the body is cacheable. |
| 243 |
*/ |
| 244 |
public static function body_bypass_reason( $html ) { |
| 245 |
if ( strlen( $html ) < 255 ) { |
| 246 |
return self::remember( 'body-too-short' ); |
| 247 |
} |
| 248 |
if ( false === stripos( $html, '</html>' ) ) { |
| 249 |
return self::remember( 'body-truncated' ); |
| 250 |
} |
| 251 |
return self::remember( null ); |
| 252 |
} |
| 253 |
|
| 254 |
/** |
| 255 |
* The last bypass reason recorded, for debug headers and CLI output. |
| 256 |
* |
| 257 |
* @return string|null |
| 258 |
*/ |
| 259 |
public static function last_reason() { |
| 260 |
return self::$last_reason; |
| 261 |
} |
| 262 |
|
| 263 |
/** |
| 264 |
* Is the page cache switched on? |
| 265 |
*/ |
| 266 |
public static function is_enabled() { |
| 267 |
$enabled = (bool) Helper::get_settings( 'perf_page_cache', false ); |
| 268 |
return (bool) apply_filters( 'ablocks/perf/perf_page_cache', $enabled ); |
| 269 |
} |
| 270 |
|
| 271 |
/** |
| 272 |
* Cookie prefixes that force a bypass. Filterable so a site can add its own |
| 273 |
* personalisation cookie (membership plugins, geo redirectors, A/B tools). |
| 274 |
* |
| 275 |
* @return string[] |
| 276 |
*/ |
| 277 |
public static function bypass_cookie_prefixes() { |
| 278 |
return (array) apply_filters( 'ablocks/perf/page_cache/bypass_cookies', self::BYPASS_COOKIE_PREFIXES ); |
| 279 |
} |
| 280 |
|
| 281 |
/** |
| 282 |
* Query args that may appear without disabling the cache. |
| 283 |
* |
| 284 |
* Deliberately empty by default: any unrecognised query string bypasses. The |
| 285 |
* alternative — ignoring unknown args — lets ?utm_source=x overwrite the |
| 286 |
* canonical entry for a URL, which is cache poisoning by typo. |
| 287 |
* |
| 288 |
* @return string[] |
| 289 |
*/ |
| 290 |
public static function allowed_query_args() { |
| 291 |
$allowed = (array) Helper::get_settings( 'perf_page_cache_query_args', [] ); |
| 292 |
return array_filter( array_map( 'strval', (array) apply_filters( 'ablocks/perf/page_cache/allowed_query_args', $allowed ) ) ); |
| 293 |
} |
| 294 |
|
| 295 |
/** |
| 296 |
* Is the request's Host header one this site actually answers to? |
| 297 |
* |
| 298 |
* HTTP_HOST is attacker-controlled. Store::sanitize_host() already guarantees |
| 299 |
* containment — a spoofed `Host: ../../evil` cannot escape the cache |
| 300 |
* directory — but containment alone still lets an attacker create an |
| 301 |
* unbounded number of junk directories by varying the header, which is a |
| 302 |
* slow disk-fill. It also has no legitimate use: a request for a host we do |
| 303 |
* not serve should not populate the cache. |
| 304 |
* |
| 305 |
* Checked at write time only. Writing is the sole operation that creates |
| 306 |
* directories, so gating it here closes the vector without adding a database |
| 307 |
* read to the serve path (which must stay callable before WordPress loads). |
| 308 |
* A serve-time request for an unknown host simply finds no file. |
| 309 |
* |
| 310 |
* @return string|null |
| 311 |
*/ |
| 312 |
private static function host_bypass_reason() { |
| 313 |
$request_host = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : ''; |
| 314 |
$request_host = self::normalize_host( is_string( $request_host ) ? $request_host : '' ); |
| 315 |
|
| 316 |
if ( '' === $request_host ) { |
| 317 |
return 'host-missing'; |
| 318 |
} |
| 319 |
|
| 320 |
$allowed = []; |
| 321 |
foreach ( [ home_url(), site_url() ] as $known ) { |
| 322 |
$parts = wp_parse_url( $known ); |
| 323 |
if ( ! empty( $parts['host'] ) ) { |
| 324 |
$allowed[] = self::normalize_host( $parts['host'] ); |
| 325 |
} |
| 326 |
} |
| 327 |
|
| 328 |
// Sites behind a proxy, CDN or domain alias legitimately serve more than |
| 329 |
// one host; they add theirs here rather than losing the cache entirely. |
| 330 |
$allowed = array_filter( array_map( [ __CLASS__, 'normalize_host' ], (array) apply_filters( 'ablocks/perf/page_cache/allowed_hosts', $allowed ) ) ); |
| 331 |
|
| 332 |
if ( ! in_array( $request_host, $allowed, true ) ) { |
| 333 |
return 'host-mismatch'; |
| 334 |
} |
| 335 |
|
| 336 |
return null; |
| 337 |
} |
| 338 |
|
| 339 |
/** |
| 340 |
* Lowercase a host and drop any port, for comparison purposes. |
| 341 |
* |
| 342 |
* The port is deliberately kept in the *directory* name by Store, so that a |
| 343 |
* :8080 dev site cannot collide with production; it is only stripped here, |
| 344 |
* where the question is which site the request is for. |
| 345 |
* |
| 346 |
* @param string $host Raw host, possibly with a port. |
| 347 |
* @return string |
| 348 |
*/ |
| 349 |
public static function normalize_host( $host ) { |
| 350 |
$host = strtolower( trim( (string) $host ) ); |
| 351 |
$host = preg_replace( '/:\d+$/', '', $host ); |
| 352 |
return (string) $host; |
| 353 |
} |
| 354 |
|
| 355 |
/** |
| 356 |
* Does the configured coverage scope exclude this page? |
| 357 |
* |
| 358 |
* Default scope is `all`, which is what anyone flipping a switch labelled |
| 359 |
* "Page Cache" expects. `ablocks_only` is the conservative mode: cache just |
| 360 |
* the pages this plugin actually renders, so the blast radius is limited to |
| 361 |
* content aBlocks is responsible for. |
| 362 |
* |
| 363 |
* Only singular content can be judged this way — archives and the front page |
| 364 |
* assemble many posts plus template parts, so a content scan there would be |
| 365 |
* both expensive and wrong. Those are cached under either scope. |
| 366 |
* |
| 367 |
* @return string|null |
| 368 |
*/ |
| 369 |
private static function scope_bypass_reason() { |
| 370 |
$scope = (string) Helper::get_settings( 'perf_page_cache_scope', 'all' ); |
| 371 |
$scope = (string) apply_filters( 'ablocks/perf/page_cache/scope', $scope ); |
| 372 |
|
| 373 |
if ( 'ablocks_only' !== $scope || ! is_singular() ) { |
| 374 |
return null; |
| 375 |
} |
| 376 |
|
| 377 |
$post = get_post(); |
| 378 |
if ( ! $post instanceof \WP_Post ) { |
| 379 |
return null; |
| 380 |
} |
| 381 |
|
| 382 |
// `wp:block` counts: a reusable block may wrap aBlocks blocks, and the |
| 383 |
// same allowance is made in Blocks::prewarm_page_assets(). |
| 384 |
$content = (string) $post->post_content; |
| 385 |
if ( false === strpos( $content, 'wp:ablocks' ) && false === strpos( $content, 'wp:block' ) ) { |
| 386 |
return 'scope:no-ablocks-blocks'; |
| 387 |
} |
| 388 |
|
| 389 |
return null; |
| 390 |
} |
| 391 |
|
| 392 |
/** |
| 393 |
* Does a bypass cookie exist on this request? |
| 394 |
* |
| 395 |
* @return string|null |
| 396 |
*/ |
| 397 |
private static function bypass_cookie_reason() { |
| 398 |
if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) { |
| 399 |
return null; |
| 400 |
} |
| 401 |
$prefixes = self::bypass_cookie_prefixes(); |
| 402 |
foreach ( array_keys( $_COOKIE ) as $name ) { |
| 403 |
$name = (string) $name; |
| 404 |
foreach ( $prefixes as $prefix ) { |
| 405 |
if ( 0 === strpos( $name, $prefix ) ) { |
| 406 |
// The reason names the prefix, never the cookie value. |
| 407 |
return 'cookie:' . $prefix; |
| 408 |
} |
| 409 |
} |
| 410 |
} |
| 411 |
return null; |
| 412 |
} |
| 413 |
|
| 414 |
/** |
| 415 |
* Does the query string disqualify this request? |
| 416 |
* |
| 417 |
* @return string|null |
| 418 |
*/ |
| 419 |
private static function query_bypass_reason() { |
| 420 |
// Only argument *names* are inspected, never values, and nothing is acted |
| 421 |
// on beyond declining to cache — so there is no input to sanitize and a |
| 422 |
// nonce would be meaningless on an anonymous cacheable request. |
| 423 |
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reads query-arg names only, to decide cacheability. |
| 424 |
if ( empty( $_GET ) || ! is_array( $_GET ) ) { |
| 425 |
return null; |
| 426 |
} |
| 427 |
$allowed = self::allowed_query_args(); |
| 428 |
foreach ( array_keys( $_GET ) as $arg ) { |
| 429 |
if ( ! in_array( (string) $arg, $allowed, true ) ) { |
| 430 |
return 'query-arg'; |
| 431 |
} |
| 432 |
} |
| 433 |
// phpcs:enable WordPress.Security.NonceVerification.Recommended |
| 434 |
return null; |
| 435 |
} |
| 436 |
|
| 437 |
/** |
| 438 |
* Does the request path match a user-configured exclusion pattern? |
| 439 |
* |
| 440 |
* Patterns are simple wildcards (`/shop/*`), not regular expressions — a |
| 441 |
* malformed regex in a settings field would otherwise take the site down. |
| 442 |
*/ |
| 443 |
private static function is_excluded_url() { |
| 444 |
$patterns = (array) Helper::get_settings( 'perf_page_cache_exclusions', [] ); |
| 445 |
$patterns = (array) apply_filters( 'ablocks/perf/page_cache/exclusions', $patterns ); |
| 446 |
if ( empty( $patterns ) ) { |
| 447 |
return false; |
| 448 |
} |
| 449 |
$path = self::request_path(); |
| 450 |
foreach ( $patterns as $pattern ) { |
| 451 |
$pattern = trim( (string) $pattern ); |
| 452 |
if ( '' === $pattern ) { |
| 453 |
continue; |
| 454 |
} |
| 455 |
if ( fnmatch( $pattern, $path ) ) { |
| 456 |
return true; |
| 457 |
} |
| 458 |
} |
| 459 |
return false; |
| 460 |
} |
| 461 |
|
| 462 |
/** |
| 463 |
* The current request path, without query string, always leading-slashed. |
| 464 |
* |
| 465 |
* @return string |
| 466 |
*/ |
| 467 |
public static function request_path() { |
| 468 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; |
| 469 |
$uri = is_string( $uri ) ? $uri : '/'; |
| 470 |
$path = (string) strtok( $uri, '?' ); |
| 471 |
if ( '' === $path || '/' !== $path[0] ) { |
| 472 |
$path = '/' . $path; |
| 473 |
} |
| 474 |
return $path; |
| 475 |
} |
| 476 |
|
| 477 |
/** |
| 478 |
* Final HTTP status for this response. |
| 479 |
* |
| 480 |
* @return int |
| 481 |
*/ |
| 482 |
private static function response_status() { |
| 483 |
$status = function_exists( 'http_response_code' ) ? http_response_code() : 200; |
| 484 |
return is_int( $status ) ? $status : 200; |
| 485 |
} |
| 486 |
|
| 487 |
/** |
| 488 |
* Has anything queued a Set-Cookie header on this response? |
| 489 |
*/ |
| 490 |
private static function response_sets_cookie() { |
| 491 |
if ( ! function_exists( 'headers_list' ) ) { |
| 492 |
return false; |
| 493 |
} |
| 494 |
foreach ( headers_list() as $header ) { |
| 495 |
if ( 0 === stripos( $header, 'set-cookie:' ) ) { |
| 496 |
return true; |
| 497 |
} |
| 498 |
} |
| 499 |
return false; |
| 500 |
} |
| 501 |
|
| 502 |
/** |
| 503 |
* Record and return a reason, so callers can chain `return self::remember(...)`. |
| 504 |
* |
| 505 |
* @param string|null $reason Bypass reason or null. |
| 506 |
* @return string|null |
| 507 |
*/ |
| 508 |
private static function remember( $reason ) { |
| 509 |
self::$last_reason = $reason; |
| 510 |
return $reason; |
| 511 |
} |
| 512 |
} |
| 513 |
|