PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / fields / form-custom-css.php

form-custom-css.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management trunk, at inc/fields/form-custom-css.php

562 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 `&amp;lt;` degraded to
78 * `&lt;` 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