| 1 |
<?php |
| 2 |
/** |
| 3 |
* Per-form Custom CSS helper for donation forms. |
| 4 |
* |
| 5 |
* Reads the per-form Custom CSS meta and wraps it in a `<style>` block scoped to |
| 6 |
* the form's `.sd-form-container` wrapper, so the rules only ever affect the |
| 7 |
* donation form they were written for. Mirrors SureForms' per-form Custom CSS |
| 8 |
* behaviour (`_srfm_form_custom_css`). |
| 9 |
* |
| 10 |
* @package SureDonation |
| 11 |
* @since 1.5.0 |
| 12 |
*/ |
| 13 |
|
| 14 |
namespace SureDonation\Inc\Fields; |
| 15 |
|
| 16 |
use SureDonation\Inc\Post_Types\Donation_Form; |
| 17 |
|
| 18 |
if ( ! defined( 'ABSPATH' ) ) { |
| 19 |
exit; // Exit if accessed directly. |
| 20 |
} |
| 21 |
|
| 22 |
/** |
| 23 |
* Form_Custom_CSS class. |
| 24 |
* |
| 25 |
* @since 1.5.0 |
| 26 |
*/ |
| 27 |
class Form_Custom_CSS { |
| 28 |
|
| 29 |
/** |
| 30 |
* Form IDs whose Custom CSS has already been printed in this request. |
| 31 |
* |
| 32 |
* A form can be rendered more than once on a page (two blocks, a block plus |
| 33 |
* a shortcode, …). The style block is scoped by form ID rather than by the |
| 34 |
* per-render wrapper ID, so printing it once is enough for every instance. |
| 35 |
* |
| 36 |
* @var array<int, bool> |
| 37 |
* @since 1.5.0 |
| 38 |
*/ |
| 39 |
private static $emitted = []; |
| 40 |
|
| 41 |
/** |
| 42 |
* Sanitize the Custom CSS, both on save and on output. |
| 43 |
* |
| 44 |
* Registered as the meta's `sanitize_callback`. Two things have to hold: the |
| 45 |
* value must not close the `<style>` element it is printed inside, and it |
| 46 |
* must not close the container rule inside that element. The first is this |
| 47 |
* function's job, the second is {@see self::balance_braces()}'s. |
| 48 |
* |
| 49 |
* The element is RAWTEXT, so its only exit is the end-tag sequence, which |
| 50 |
* begins with `</`. That sequence is escaped rather than deleted: `<\/` can |
| 51 |
* never match an end tag for the HTML parser, while CSS reads `\/` as an |
| 52 |
* escaped solidus and gives back the `/`. So the guarantee holds and the |
| 53 |
* author's content survives — an inline SVG data URI keeps its `</svg>`, |
| 54 |
* which deleting the `</` outright would have silently corrupted. |
| 55 |
* |
| 56 |
* A bare `<` is left alone. It cannot leave text context: verified in a |
| 57 |
* browser, a `<script>` inside a style element creates no script element and |
| 58 |
* runs nothing, and only a real `</style>` ends the element. |
| 59 |
* |
| 60 |
* That makes the result safe *for this context specifically*. A stored value |
| 61 |
* may still contain a literal `<script`, harmless between `<style>` tags and |
| 62 |
* not harmless outside them, so anything that renders this meta anywhere |
| 63 |
* else has to escape it for wherever it is going. The editor preview does: |
| 64 |
* it assigns through `style.textContent`, which never parses markup. |
| 65 |
* |
| 66 |
* Escaping also avoids the splice that deletion invites. `str_replace()` |
| 67 |
* makes one pass and does not re-examine what it joins, so removing `</` |
| 68 |
* from `<<//` would leave a fresh `</` behind; inserting a backslash cannot |
| 69 |
* bring a `<` and a `/` together. |
| 70 |
* |
| 71 |
* This deliberately no longer runs `wp_kses_post()` and |
| 72 |
* `html_entity_decode()`. Neither contributed to the guarantee — the strip |
| 73 |
* carried it alone — and the pair actively cost three things: kses is the |
| 74 |
* expensive half of a function that runs on every render; it HTML-encodes |
| 75 |
* `<`, `>` and `&`, which then had to be decoded back so child combinators |
| 76 |
* (`.a > .b`) and the nesting parent selector (`&:hover`) survived; and that |
| 77 |
* round trip peeled one entity layer per pass, so `&lt;` degraded to |
| 78 |
* `<` and then to nothing, making repeated saves lossy. |
| 79 |
* |
| 80 |
* Stripping `<` outright, as this did before, was also overbroad: it breaks |
| 81 |
* Media Queries L4 range syntax (`@media (400px < width < 700px)`), |
| 82 |
* container queries and inline SVG data URIs, none of which can close |
| 83 |
* anything. |
| 84 |
* |
| 85 |
* @param mixed $value Raw CSS. |
| 86 |
* @return string Sanitized CSS ('' when empty/invalid). |
| 87 |
* @since 1.5.0 |
| 88 |
*/ |
| 89 |
public static function sanitize( $value ) { |
| 90 |
if ( ! is_string( $value ) || '' === trim( $value ) ) { |
| 91 |
return ''; |
| 92 |
} |
| 93 |
|
| 94 |
// Neither pass can be run last and trusted, because each one invalidates |
| 95 |
// the other's analysis: |
| 96 |
// |
| 97 |
// - Escaping first, balancing second: balancing *deletes* a `}`, which |
| 98 |
// can bring a `<` and a `/` together that were not adjacent when the |
| 99 |
// escape looked at them, splicing a live `</style>`. |
| 100 |
// - Balancing first, escaping second: escaping *inserts* a backslash, |
| 101 |
// and `\/` is no longer a comment opener — so `</*}body{…}` is one |
| 102 |
// unterminated comment to the counter and an open block plus a |
| 103 |
// top-level rule to the browser, which is a breakout the counter |
| 104 |
// already decided was safe. |
| 105 |
// |
| 106 |
// So run the pair to a fixed point instead. It converges: every escape |
| 107 |
// a later cycle adds was paid for by a character the previous balance |
| 108 |
// removed, so the total work is bounded by the input length. |
| 109 |
$css = $value; |
| 110 |
|
| 111 |
for ( $guard = strlen( $value ) + 2; $guard > 0; --$guard ) { |
| 112 |
$next = self::balance_braces( self::escape_html_starts( $css ) ); |
| 113 |
|
| 114 |
if ( $next === $css ) { |
| 115 |
break; |
| 116 |
} |
| 117 |
|
| 118 |
$css = $next; |
| 119 |
} |
| 120 |
|
| 121 |
// A no-op at a real fixed point, since reaching one means the value |
| 122 |
// already carries its escapes. It only does anything if the guard above |
| 123 |
// ran out, where failing closed on the HTML boundary is what matters. |
| 124 |
return trim( self::escape_html_starts( $css ) ); |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* Keep the CSS inside the container rule it is printed in. |
| 129 |
* |
| 130 |
* The value is emitted between the braces of the wrapper selector in |
| 131 |
* {@see self::get_style_block()}, so it starts at brace depth 1. A `}` that |
| 132 |
* arrives at depth 0 closes the wrapper rather than a rule of the author's, |
| 133 |
* and everything after it applies page-wide — so those are dropped. |
| 134 |
* |
| 135 |
* The counter has to agree with the browser's tokenizer about which braces |
| 136 |
* are structural, and only one direction of disagreement is dangerous: a |
| 137 |
* depth *higher* than the browser's means a `}` the browser spends on the |
| 138 |
* wrapper is kept here as if it closed a rule of the author's. |
| 139 |
* |
| 140 |
* Four constructs consume raw text, so a `{`, `}`, `/*` or `"` inside them |
| 141 |
* is inert and must be copied without counting. Together they are the whole |
| 142 |
* set — every other CSS token has its contents tokenized normally, so a |
| 143 |
* brace inside one is structural for the browser too, and plain counting |
| 144 |
* already matches: |
| 145 |
* |
| 146 |
* - escapes (4.3.7): `\{` is a literal. Up to six hex digits plus one |
| 147 |
* trailing whitespace belong to the escape, so `\61 ` is one unit and the |
| 148 |
* whitespace it swallows is not a string terminator. |
| 149 |
* - comments: an unterminated one runs to the end, as it does for a browser. |
| 150 |
* - strings: terminated by their quote, or by a newline (4.3.4 bad-string). |
| 151 |
* - url-tokens (4.3.6): once `url(` is followed by a non-quote, everything to |
| 152 |
* the first `)` is one token. This is the one that reads least like a |
| 153 |
* special case and bites hardest — `url(/*` would otherwise take the |
| 154 |
* comment branch and switch counting off for the rest of the value. |
| 155 |
* |
| 156 |
* Rules the author left *open* are left open. The wrapper's own closing |
| 157 |
* brace then closes the innermost one and the stylesheet ends at `</style>`, |
| 158 |
* which browsers close the rest of the way; every declaration still sits |
| 159 |
* inside the wrapper, so nothing escapes the form. Appending the missing |
| 160 |
* braces instead would be wrong: after an unterminated comment or string |
| 161 |
* they would land inside it, closing nothing, and the value would grow by a |
| 162 |
* brace on every render — sanitize() runs on output as well as on save. |
| 163 |
* |
| 164 |
* Idempotent, because it only ever removes: a second pass finds no `}` at |
| 165 |
* depth 0 and no trailing lone backslash left to remove. |
| 166 |
* |
| 167 |
* @param string $css CSS with `<` already stripped. |
| 168 |
* @return string CSS that cannot close the container rule. |
| 169 |
* @since 1.5.0 |
| 170 |
*/ |
| 171 |
private static function balance_braces( $css ) { |
| 172 |
$length = strlen( $css ); |
| 173 |
$out = ''; |
| 174 |
$depth = 0; |
| 175 |
$i = 0; |
| 176 |
|
| 177 |
while ( $i < $length ) { |
| 178 |
$char = $css[ $i ]; |
| 179 |
|
| 180 |
// url-token. Tested first because the ident may itself be written |
| 181 |
// with escapes (`\75 rl(` is `url(` to a browser), and only at an |
| 182 |
// ident boundary, so `myurl(` — an ordinary function token, whose |
| 183 |
// contents *are* tokenized normally — keeps plain counting. |
| 184 |
$prev = $i > 0 ? $css[ $i - 1 ] : ''; |
| 185 |
if ( '\\' !== $prev && ! self::is_ident_byte( $prev ) ) { |
| 186 |
$url_end = self::url_token_end( $css, $i, $length ); |
| 187 |
|
| 188 |
if ( null !== $url_end ) { |
| 189 |
$out .= substr( $css, $i, $url_end - $i ); |
| 190 |
$i = $url_end; |
| 191 |
continue; |
| 192 |
} |
| 193 |
} |
| 194 |
|
| 195 |
// Escape. Must come before the comment and string branches, so `\/*` |
| 196 |
// does not read as a comment opening and `\"` does not read as a |
| 197 |
// string opening — the latter would otherwise switch counting off |
| 198 |
// for the rest of the value. |
| 199 |
if ( '\\' === $char ) { |
| 200 |
$escape = self::escape_length( $css, $i, $length ); |
| 201 |
|
| 202 |
if ( 0 === $escape ) { |
| 203 |
// A lone trailing backslash would escape the wrapper's own |
| 204 |
// closing brace, leaving `…\}</style>` with the rule never |
| 205 |
// closed. Dropping it keeps this function idempotent, which |
| 206 |
// appending a space would not. |
| 207 |
++$i; |
| 208 |
continue; |
| 209 |
} |
| 210 |
|
| 211 |
$out .= substr( $css, $i, $escape ); |
| 212 |
$i += $escape; |
| 213 |
continue; |
| 214 |
} |
| 215 |
|
| 216 |
// Comment: copy verbatim. An unterminated one runs to the end of the |
| 217 |
// value, which is how a browser reads it too. |
| 218 |
if ( '/' === $char && $i + 1 < $length && '*' === $css[ $i + 1 ] ) { |
| 219 |
$end = strpos( $css, '*/', $i + 2 ); |
| 220 |
if ( false === $end ) { |
| 221 |
$out .= substr( $css, $i ); |
| 222 |
break; |
| 223 |
} |
| 224 |
$out .= substr( $css, $i, $end + 2 - $i ); |
| 225 |
$i = $end + 2; |
| 226 |
continue; |
| 227 |
} |
| 228 |
|
| 229 |
// Quoted string: copy verbatim, honouring escapes so an escaped |
| 230 |
// quote does not read as the closing one. |
| 231 |
if ( '"' === $char || "'" === $char ) { |
| 232 |
$out .= $char; |
| 233 |
++$i; |
| 234 |
|
| 235 |
while ( $i < $length ) { |
| 236 |
if ( '\\' === $css[ $i ] ) { |
| 237 |
$escape = self::escape_length( $css, $i, $length ); |
| 238 |
|
| 239 |
if ( 0 === $escape ) { |
| 240 |
++$i; |
| 241 |
continue; |
| 242 |
} |
| 243 |
|
| 244 |
$out .= substr( $css, $i, $escape ); |
| 245 |
$i += $escape; |
| 246 |
continue; |
| 247 |
} |
| 248 |
|
| 249 |
$out .= $css[ $i ]; |
| 250 |
|
| 251 |
// Closing quote, or a newline ending an unterminated string. |
| 252 |
// A newline reached *through* an escape never gets here, so |
| 253 |
// the string stays open exactly as long as it does for a |
| 254 |
// browser. |
| 255 |
if ( $css[ $i ] === $char || self::is_newline_byte( $css[ $i ] ) ) { |
| 256 |
++$i; |
| 257 |
break; |
| 258 |
} |
| 259 |
|
| 260 |
++$i; |
| 261 |
} |
| 262 |
|
| 263 |
continue; |
| 264 |
} |
| 265 |
|
| 266 |
if ( '{' === $char ) { |
| 267 |
++$depth; |
| 268 |
} elseif ( '}' === $char ) { |
| 269 |
if ( 0 === $depth ) { |
| 270 |
// Would close the wrapper rule — drop it. |
| 271 |
++$i; |
| 272 |
continue; |
| 273 |
} |
| 274 |
--$depth; |
| 275 |
} |
| 276 |
|
| 277 |
$out .= $char; |
| 278 |
++$i; |
| 279 |
} |
| 280 |
|
| 281 |
return $out; |
| 282 |
} |
| 283 |
|
| 284 |
/** |
| 285 |
* Neutralize the byte sequences that mean something to the HTML parser. |
| 286 |
* |
| 287 |
* Runs last, on the value as it will actually be emitted. Order matters: |
| 288 |
* {@see self::balance_braces()} *removes* characters, and a removal can bring |
| 289 |
* a `<` and a `/` together that were not adjacent when this ran — so escaping |
| 290 |
* first left `<}/style>` to become a live `</style>` once the stray brace was |
| 291 |
* dropped, which is a script-executing breakout rather than a CSS one. |
| 292 |
* |
| 293 |
* Both sequences are escaped rather than deleted, for the reason deletion |
| 294 |
* failed above: `str_replace()` makes one pass and does not re-examine what it |
| 295 |
* joins, so stripping `<script` would turn `<scr<scriptipt>` back into |
| 296 |
* `<script`, and stripping `</` would turn `<<//` back into `</`. Inserting a |
| 297 |
* backslash cannot splice. |
| 298 |
* |
| 299 |
* CSS reads `\/` and `\s` as escaped literals, so the author's `</svg>` and |
| 300 |
* `<script` survive into the rendered value; the HTML parser sees a `<` |
| 301 |
* followed by a backslash, which can begin neither an end tag nor a tag name. |
| 302 |
* |
| 303 |
* `<script` is handled even though it is inert between `<style>` tags, because |
| 304 |
* the meta is `show_in_rest` and the stored value therefore travels to places |
| 305 |
* that are not this element — and a `<script>` needs no closing tag to run. |
| 306 |
* |
| 307 |
* @param string $css Balanced CSS. |
| 308 |
* @return string CSS that cannot start an HTML tag. |
| 309 |
* @since 1.5.0 |
| 310 |
*/ |
| 311 |
private static function escape_html_starts( $css ) { |
| 312 |
// A `<` immediately before either sequence; the sequence itself is left |
| 313 |
// alone so its original casing survives. |
| 314 |
return (string) preg_replace( '#<(?=/|script)#i', '<\\\\', $css ); |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* Length in bytes of the CSS escape starting at a backslash. |
| 319 |
* |
| 320 |
* Per CSS Syntax 4.3.7 an escape is a backslash followed by either one code |
| 321 |
* point, or up to six hex digits and then one optional whitespace which |
| 322 |
* belongs to the escape rather than to whatever follows. Getting that |
| 323 |
* whitespace wrong is what lets `content:"\61 <LF>` read as a terminated |
| 324 |
* string here while the browser is still inside it. |
| 325 |
* |
| 326 |
* @param string $css Value being scanned. |
| 327 |
* @param int $i Offset of the backslash. |
| 328 |
* @param int $length Total length. |
| 329 |
* @return int Bytes consumed, or 0 for a trailing lone backslash. |
| 330 |
* @since 1.5.0 |
| 331 |
*/ |
| 332 |
private static function escape_length( $css, $i, $length ) { |
| 333 |
if ( $i + 1 >= $length ) { |
| 334 |
return 0; |
| 335 |
} |
| 336 |
|
| 337 |
// A backslash before a newline is not an escape at all (4.3.7). Inside a |
| 338 |
// string it is a line continuation that consumes the newline and leaves |
| 339 |
// the string open, so it has to be consumed whole here — leaving half of |
| 340 |
// a CRLF behind would hand the string branch a bare newline and end a |
| 341 |
// string the browser is still inside. CRLF is one newline (3.3). |
| 342 |
if ( self::is_newline_byte( $css[ $i + 1 ] ) ) { |
| 343 |
return "\r" === $css[ $i + 1 ] && $i + 2 < $length && "\n" === $css[ $i + 2 ] ? 3 : 2; |
| 344 |
} |
| 345 |
|
| 346 |
$j = $i + 1; |
| 347 |
$hex = 0; |
| 348 |
while ( $j < $length && $hex < 6 && ctype_xdigit( $css[ $j ] ) ) { |
| 349 |
++$j; |
| 350 |
++$hex; |
| 351 |
} |
| 352 |
|
| 353 |
if ( 0 === $hex ) { |
| 354 |
// A single escaped code point. Consuming one byte of a multi-byte |
| 355 |
// character is harmless: its continuation bytes are not delimiters. |
| 356 |
return 2; |
| 357 |
} |
| 358 |
|
| 359 |
// CRLF counts as the one permitted whitespace, not two. |
| 360 |
if ( $j + 1 < $length && "\r" === $css[ $j ] && "\n" === $css[ $j + 1 ] ) { |
| 361 |
return $j + 2 - $i; |
| 362 |
} |
| 363 |
|
| 364 |
if ( $j < $length && self::is_space_byte( $css[ $j ] ) ) { |
| 365 |
++$j; |
| 366 |
} |
| 367 |
|
| 368 |
return $j - $i; |
| 369 |
} |
| 370 |
|
| 371 |
/** |
| 372 |
* Offset just past a url-token starting at the given position, or null. |
| 373 |
* |
| 374 |
* Returns null for the quoted form (`url("…")`), which is an ordinary string |
| 375 |
* token and is better handled by the string branch, and for any ident that |
| 376 |
* is not `url`. |
| 377 |
* |
| 378 |
* @param string $css Value being scanned. |
| 379 |
* @param int $i Offset to test. |
| 380 |
* @param int $length Total length. |
| 381 |
* @return int|null Offset after the closing `)`, or null when this is not a url-token. |
| 382 |
* @since 1.5.0 |
| 383 |
*/ |
| 384 |
private static function url_token_end( $css, $i, $length ) { |
| 385 |
$j = $i; |
| 386 |
|
| 387 |
// Match u, r, l — each of which may be written literally or as an escape. |
| 388 |
foreach ( [ 'u', 'r', 'l' ] as $expected ) { |
| 389 |
if ( $j >= $length ) { |
| 390 |
return null; |
| 391 |
} |
| 392 |
|
| 393 |
if ( '\\' === $css[ $j ] ) { |
| 394 |
$escape = self::escape_length( $css, $j, $length ); |
| 395 |
|
| 396 |
if ( $escape < 2 || strtolower( self::decoded_escape( $css, $j, $escape ) ) !== $expected ) { |
| 397 |
return null; |
| 398 |
} |
| 399 |
|
| 400 |
$j += $escape; |
| 401 |
continue; |
| 402 |
} |
| 403 |
|
| 404 |
if ( strtolower( $css[ $j ] ) !== $expected ) { |
| 405 |
return null; |
| 406 |
} |
| 407 |
|
| 408 |
++$j; |
| 409 |
} |
| 410 |
|
| 411 |
if ( $j >= $length || '(' !== $css[ $j ] ) { |
| 412 |
return null; |
| 413 |
} |
| 414 |
|
| 415 |
++$j; |
| 416 |
|
| 417 |
while ( $j < $length && self::is_space_byte( $css[ $j ] ) ) { |
| 418 |
++$j; |
| 419 |
} |
| 420 |
|
| 421 |
if ( $j < $length && ( '"' === $css[ $j ] || "'" === $css[ $j ] ) ) { |
| 422 |
return null; |
| 423 |
} |
| 424 |
|
| 425 |
while ( $j < $length && ')' !== $css[ $j ] ) { |
| 426 |
$j += '\\' === $css[ $j ] ? max( 1, self::escape_length( $css, $j, $length ) ) : 1; |
| 427 |
} |
| 428 |
|
| 429 |
// Consume the ')'. An unterminated url runs to the end, as it does for a |
| 430 |
// browser, which is also why counting must not resume inside it. |
| 431 |
return min( $j + 1, $length ); |
| 432 |
} |
| 433 |
|
| 434 |
/** |
| 435 |
* The code point an escape stands for, for the few ASCII cases this needs. |
| 436 |
* |
| 437 |
* @param string $css Value being scanned. |
| 438 |
* @param int $i Offset of the backslash. |
| 439 |
* @param int $escape Escape length from {@see self::escape_length()}. |
| 440 |
* @return string Single character, or '' when it is not a plain ASCII one. |
| 441 |
* @since 1.5.0 |
| 442 |
*/ |
| 443 |
private static function decoded_escape( $css, $i, $escape ) { |
| 444 |
$body = rtrim( substr( $css, $i + 1, $escape - 1 ) ); |
| 445 |
|
| 446 |
if ( '' !== $body && ctype_xdigit( $body ) ) { |
| 447 |
$code = hexdec( $body ); |
| 448 |
|
| 449 |
return $code > 0 && $code < 0x80 ? chr( $code ) : ''; |
| 450 |
} |
| 451 |
|
| 452 |
return substr( $css, $i + 1, 1 ); |
| 453 |
} |
| 454 |
|
| 455 |
/** |
| 456 |
* Whether a byte can appear in a CSS identifier. |
| 457 |
* |
| 458 |
* @param string $byte Single byte, or '' at the start of the value. |
| 459 |
* @return bool |
| 460 |
* @since 1.5.0 |
| 461 |
*/ |
| 462 |
private static function is_ident_byte( $byte ) { |
| 463 |
if ( '' === $byte ) { |
| 464 |
return false; |
| 465 |
} |
| 466 |
|
| 467 |
return 1 === preg_match( '/[A-Za-z0-9_-]/', $byte ) || ord( $byte ) >= 0x80; |
| 468 |
} |
| 469 |
|
| 470 |
/** |
| 471 |
* Whether a byte is CSS whitespace (4.2), which includes the form feed. |
| 472 |
* |
| 473 |
* @param string $byte Single byte. |
| 474 |
* @return bool |
| 475 |
* @since 1.5.0 |
| 476 |
*/ |
| 477 |
private static function is_space_byte( $byte ) { |
| 478 |
return ' ' === $byte || "\t" === $byte || "\n" === $byte || "\r" === $byte || "\f" === $byte; |
| 479 |
} |
| 480 |
|
| 481 |
/** |
| 482 |
* Whether a byte ends a string as a newline does (4.3.4). |
| 483 |
* |
| 484 |
* Includes the form feed. It is currently unreachable because |
| 485 |
* wp_kses_no_null() strips it upstream, but relying on that would make this |
| 486 |
* function's correctness depend on a caller its docblock does not name. |
| 487 |
* |
| 488 |
* @param string $byte Single byte. |
| 489 |
* @return bool |
| 490 |
* @since 1.5.0 |
| 491 |
*/ |
| 492 |
private static function is_newline_byte( $byte ) { |
| 493 |
return "\n" === $byte || "\r" === $byte || "\f" === $byte; |
| 494 |
} |
| 495 |
|
| 496 |
/** |
| 497 |
* Read the saved Custom CSS for a form. |
| 498 |
* |
| 499 |
* @param int $form_id Form post ID. |
| 500 |
* @return string Saved CSS ('' when unset). |
| 501 |
* @since 1.5.0 |
| 502 |
*/ |
| 503 |
public static function get_css( $form_id ) { |
| 504 |
$raw = get_post_meta( (int) $form_id, Donation_Form::META_CUSTOM_CSS, true ); |
| 505 |
|
| 506 |
return is_string( $raw ) ? trim( $raw ) : ''; |
| 507 |
} |
| 508 |
|
| 509 |
/** |
| 510 |
* Build the scoped `<style>` block for a form's Custom CSS. |
| 511 |
* |
| 512 |
* The user's CSS is nested inside the container rule (native CSS nesting), |
| 513 |
* the same approach SureForms uses, so selectors resolve relative to the |
| 514 |
* form and cannot leak page-wide. Returns '' the second time it is called |
| 515 |
* for the same form so a page with repeated forms gets one style block. |
| 516 |
* |
| 517 |
* @param int $form_id Form post ID. |
| 518 |
* @return string Style block markup, or '' when there is nothing to print. |
| 519 |
* @since 1.5.0 |
| 520 |
*/ |
| 521 |
public static function get_style_block( $form_id ) { |
| 522 |
$form_id = (int) $form_id; |
| 523 |
|
| 524 |
if ( isset( self::$emitted[ $form_id ] ) ) { |
| 525 |
return ''; |
| 526 |
} |
| 527 |
|
| 528 |
$css = self::get_css( $form_id ); |
| 529 |
if ( '' === $css ) { |
| 530 |
return ''; |
| 531 |
} |
| 532 |
|
| 533 |
self::$emitted[ $form_id ] = true; |
| 534 |
|
| 535 |
// Sanitized again on output: the meta may have been written by something |
| 536 |
// that bypassed the registered sanitize_callback (direct SQL, an older |
| 537 |
// value, a filtered import). |
| 538 |
// |
| 539 |
// self::sanitize() is idempotent, so re-running it here cannot degrade a |
| 540 |
// value that was already sanitized on save. Both halves only ever |
| 541 |
// remove: `</` and the braces that would close the wrapper. |
| 542 |
return sprintf( |
| 543 |
'<style id="sd-form-custom-css-%1$d">.sd-form-container[data-form-id="%1$d"]{%2$s}</style>', |
| 544 |
$form_id, |
| 545 |
self::sanitize( $css ) |
| 546 |
); |
| 547 |
} |
| 548 |
|
| 549 |
/** |
| 550 |
* Forget which forms have already printed their Custom CSS. |
| 551 |
* |
| 552 |
* Only needed so tests can exercise the per-request de-duplication without |
| 553 |
* leaking state between cases. |
| 554 |
* |
| 555 |
* @return void |
| 556 |
* @since 1.5.0 |
| 557 |
*/ |
| 558 |
public static function reset_emitted() { |
| 559 |
self::$emitted = []; |
| 560 |
} |
| 561 |
} |
| 562 |
|