PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-server / Auth / OAuth / ConsentScreen.php

ConsentScreen.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/mcp-server/Auth/OAuth/ConsentScreen.php

644 lines 27.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The approval screen an administrator sees when a URL-only client asks to
4 * connect (spec 044, FR-031–FR-035, FR-038).
5 *
6 * ## Why this is a front-end page and not a REST route
7 *
8 * A browser returning from wp-login.php carries an authentication cookie but no
9 * REST nonce. Inside REST, core therefore does not treat the visitor as logged
10 * in — so the screen would bounce them to log in again, and again, forever.
11 * Serving it as an ordinary front-end page on `parse_request` is what satisfies
12 * FR-032. The reference implementation documents hitting exactly this.
13 *
14 * @package Templately\Modules\McpServer\Auth\OAuth
15 */
16
17 namespace Templately\Modules\McpServer\Auth\OAuth;
18
19 use Templately\Modules\McpCore\Registry\ToolDescriptor;
20
21 class ConsentScreen {
22
23 const NONCE_ACTION = 'templately_mcp_consent';
24
25 /**
26 * @param string $client_id
27 * @return string
28 */
29 private static function nonce_action( string $client_id ): string {
30 return self::NONCE_ACTION . '_' . $client_id;
31 }
32
33 public static function render(): void {
34 $params = self::request_params();
35
36 $client = RecordStore::get_client( (string) ( $params['client_id'] ?? '' ) );
37
38 if ( null === $client ) {
39 self::render_error( __( 'Unknown client. Ask the application to register again.', 'templately' ) );
40 }
41
42 $redirect_uri = (string) ( $params['redirect_uri'] ?? '' );
43
44 // Exact match against a PRE-REGISTERED destination. On mismatch we
45 // redirect NOWHERE — bouncing to an unverified destination is precisely
46 // the open redirect this guards against (FR-034).
47 if ( ! self::redirect_uri_registered( $redirect_uri, (array) $client['redirect_uris'] ) ) {
48 self::render_error( __( 'This application asked to return to an address it did not register.', 'templately' ) );
49 }
50
51 $challenge = (string) ( $params['code_challenge'] ?? '' );
52 $method = (string) ( $params['code_challenge_method'] ?? '' );
53
54 // Authentication comes FIRST, before any branch that can redirect off-site.
55 //
56 // Returning a protocol error to the client's registered address is
57 // correct per RFC 6749 — but registration here is public and
58 // unauthenticated, so doing it before the login check turned this page
59 // into an open redirect anyone could aim anywhere: register a client
60 // with redirect_uri https://evil.example/x, hand out a link to
61 // /templately/authorize?...&code_challenge_method=x on the victim's own
62 // domain, and every visitor gets 302'd to the attacker. Now an anonymous
63 // visitor only ever sees a rendered error on this site.
64 if ( ! is_user_logged_in() ) {
65 // Return here afterwards — no loop, because this is not a REST route.
66 wp_safe_redirect( wp_login_url( self::current_url() ) );
67 exit;
68 }
69
70 if ( ! current_user_can( 'manage_options' ) ) {
71 self::render_error( __( 'Only administrators can approve an agent connection for this site.', 'templately' ) );
72 }
73
74 if ( 'S256' !== $method || '' === $challenge ) {
75 self::redirect_error( $redirect_uri, 'invalid_request', (string) ( $params['state'] ?? '' ) );
76 }
77
78 // RFC 8707 / MCP: the client names the server it intends to use the
79 // token with. If that is not us, say so here rather than minting a
80 // credential the client will believe is scoped to someone else.
81 if ( ! RecordStore::resource_matches( (string) ( $params['resource'] ?? '' ) ) ) {
82 self::redirect_error( $redirect_uri, 'invalid_target', (string) ( $params['state'] ?? '' ) );
83 }
84
85 // Absent an explicit request, grant READ (FR-038). The reference
86 // implementation defaults the other way and silently grants write.
87 $requested = self::requested_level( (string) ( $params['scope'] ?? '' ) );
88
89 if ( 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) )
90 && isset( $_POST['templately_mcp_consent_nonce'] )
91 && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['templately_mcp_consent_nonce'] ) ), self::nonce_action( (string) $client['client_id'] ) ) ) {
92
93 // Explicit refusal. Without this the only way to decline was closing
94 // the tab, which leaves the client waiting on a request that will
95 // never resolve; the protocol expects `access_denied` instead.
96 $decision = isset( $_POST['templately_mcp_decision'] )
97 ? sanitize_text_field( wp_unslash( $_POST['templately_mcp_decision'] ) )
98 : 'approve';
99
100 if ( 'approve' !== $decision ) {
101 self::redirect_error( $redirect_uri, 'access_denied', (string) ( $params['state'] ?? '' ) );
102 }
103
104 // The approver decides; the client only PROPOSES. Without this the
105 // only way to get full access was for the client to ask for it in
106 // exactly the right way, leaving an administrator who wanted to
107 // grant more (or less) with no control at all.
108 $granted = isset( $_POST['templately_mcp_access'] )
109 ? self::normalize_level( sanitize_text_field( wp_unslash( $_POST['templately_mcp_access'] ) ) )
110 : $requested;
111
112 $code = RecordStore::create_grant(
113 [
114 'client_id' => $client['client_id'],
115 'user_id' => get_current_user_id(),
116 'access_level' => $granted,
117 'redirect_uri' => $redirect_uri,
118 'challenge' => $challenge,
119 // The MCP endpoint's canonical URI (audience), not the
120 // issuer — see RecordStore::audience().
121 'resource' => RecordStore::audience(),
122 'expires_at' => time() + RecordStore::GRANT_TTL,
123 ]
124 );
125
126 // Encoded for the same reason as current_url(): a `state` on the
127 // standard base64 alphabet contains `+`, which travels raw through
128 // add_query_arg and reaches the client decoded as a space — read as
129 // a CSRF mismatch, so the callback is rejected.
130 $target = add_query_arg(
131 array_map(
132 'rawurlencode',
133 array_filter(
134 [
135 'code' => $code,
136 'state' => (string) ( $params['state'] ?? '' ),
137 // RFC 9207. Lets a client that talks to more than one
138 // authorization server confirm WHICH one answered, which
139 // is the defence against mix-up attacks. A MUST for
140 // clients in the MCP 2026-07-28 revision, and the server
141 // has to emit it for them to check it.
142 'iss' => RecordStore::issuer(),
143 ]
144 )
145 ),
146 $redirect_uri
147 );
148
149 // Not wp_safe_redirect: the destination is external by design, but it
150 // was verified above as an exact pre-registered match.
151 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- see above; wp_safe_redirect() would strip every valid OAuth client callback.
152 wp_redirect( $target );
153 exit;
154 }
155
156 self::render_form( $client, $requested, $params );
157 }
158
159 /**
160 * Exact string match — EXCEPT the port for a loopback address.
161 *
162 * RFC 8252 §7.3: "The authorization server MUST allow any port to be
163 * specified at the time of the request for loopback IP redirect URIs, to
164 * accommodate clients that obtain an available ephemeral port from the
165 * operating system at the time of the request." RFC 9700 §2.1 restates the
166 * carve-out: exact matching "except for port numbers in localhost
167 * redirection URIs of native apps".
168 *
169 * Matching the port too meant a desktop agent whose OS handed it a different
170 * ephemeral port than the one it registered with was refused outright. It
171 * worked here only because the clients tested happen to re-register on every
172 * login attempt; one that registers once and reconnects later would not.
173 *
174 * The relaxation is scoped to loopback: any local port is by definition
175 * already the approver's own machine, so it grants no reach an attacker did
176 * not already have. It is NOT applied to https, where the port is part of
177 * the origin.
178 *
179 * @param string $presented
180 * @param array $registered
181 * @return bool
182 */
183 private static function redirect_uri_registered( string $presented, array $registered ): bool {
184 if ( in_array( $presented, $registered, true ) ) {
185 return true;
186 }
187
188 $parts = wp_parse_url( $presented );
189 $host = strtolower( trim( (string) ( $parts['host'] ?? '' ), '[]' ) );
190
191 if ( 'http' !== strtolower( (string) ( $parts['scheme'] ?? '' ) )
192 || ! in_array( $host, [ '127.0.0.1', '::1', 'localhost' ], true ) ) {
193 return false;
194 }
195
196 $without_port = static function ( $uri ) {
197 $p = wp_parse_url( $uri );
198
199 if ( empty( $p['host'] ) ) {
200 return null;
201 }
202
203 return strtolower( (string) ( $p['scheme'] ?? '' ) ) . '://'
204 . strtolower( trim( (string) $p['host'], '[]' ) )
205 . ( $p['path'] ?? '' )
206 . ( isset( $p['query'] ) ? '?' . $p['query'] : '' );
207 };
208
209 $target = $without_port( $presented );
210
211 if ( null === $target ) {
212 return false;
213 }
214
215 foreach ( $registered as $candidate ) {
216 if ( $target === $without_port( (string) $candidate ) ) {
217 return true;
218 }
219 }
220
221 return false;
222 }
223
224 /**
225 * The access level a client's `scope` parameter asks for.
226 *
227 * `scope` is a SPACE-DELIMITED LIST (RFC 6749 §3.3), not a single value.
228 * This previously compared the whole string against `full`, so a client
229 * asking for everything it could — ChatGPT sends `scope=read full`, which is
230 * both correct and exactly what this server's own discovery document
231 * advertises in `scopes_supported` — matched neither branch and silently
232 * fell through to read-only, with no way for the approver to override it.
233 *
234 * Unrecognised or absent scopes still resolve to READ: least privilege is
235 * the safe default for a value the CLIENT controls (FR-038). Note this is
236 * the opposite default to `Credentials::normalize_level()`, which fails
237 * closed toward full for a level an ADMINISTRATOR typed into the settings
238 * UI — different source of truth, different safe direction.
239 *
240 * @param string $scope
241 * @return string
242 */
243 private static function requested_level( string $scope ): string {
244 $scopes = preg_split( '/\s+/', trim( $scope ), -1, PREG_SPLIT_NO_EMPTY );
245
246 return in_array( ToolDescriptor::ACCESS_FULL, (array) $scopes, true )
247 ? ToolDescriptor::ACCESS_FULL
248 : ToolDescriptor::ACCESS_READ;
249 }
250
251 /**
252 * @param string $level
253 * @return string
254 */
255 private static function normalize_level( string $level ): string {
256 return ToolDescriptor::ACCESS_FULL === $level
257 ? ToolDescriptor::ACCESS_FULL
258 : ToolDescriptor::ACCESS_READ;
259 }
260
261 /**
262 * @return array
263 */
264 private static function request_params(): array {
265 $source = ( 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ) ) ? $_POST : $_GET; // phpcs:ignore WordPress.Security.NonceVerification -- read-only parameter collection; the state-changing branch verifies a nonce before acting.
266 $out = [];
267
268 foreach ( [ 'client_id', 'redirect_uri', 'state', 'scope', 'code_challenge', 'code_challenge_method', 'resource' ] as $key ) {
269 if ( isset( $source[ $key ] ) ) {
270 $out[ $key ] = sanitize_text_field( wp_unslash( $source[ $key ] ) );
271 }
272 }
273
274 return $out;
275 }
276
277 /**
278 * @param array $client
279 * @param string $requested
280 * @param array $params
281 * @return void
282 */
283 private static function render_form( array $client, string $requested, array $params ): void {
284 $user = wp_get_current_user();
285 $full = ToolDescriptor::ACCESS_FULL === $requested;
286
287 self::head( __( 'Approve agent connection', 'templately' ) );
288
289 self::brand();
290
291 echo '<h1>' . esc_html__( 'Approve agent connection', 'templately' ) . '</h1>';
292
293 printf(
294 '<p class="tmpl-lede">%s</p>',
295 esc_html__( 'An application is asking to connect to this site as an AI agent.', 'templately' )
296 );
297
298 printf(
299 '<div class="tmpl-row"><span class="tmpl-key">%s</span>'
300 . '<span class="tmpl-val" data-tlid="mcp-consent-client">%s</span></div>',
301 esc_html__( 'Application', 'templately' ),
302 // Self-reported by the client — untrusted, so escaped.
303 esc_html( '' !== $client['client_name'] ? $client['client_name'] : __( 'Unnamed application', 'templately' ) )
304 );
305
306 // The destination, shown plainly. MCP requires the authorization screen to
307 // display the redirect URI hostname: the application NAME above is
308 // self-reported and unverifiable, so where the credential is about to be
309 // sent is the only fact on this screen an approver can actually check.
310 $destination = (string) ( $params['redirect_uri'] ?? '' );
311 $host = (string) wp_parse_url( $destination, PHP_URL_HOST );
312 $loopback = in_array( strtolower( trim( $host, '[]' ) ), [ '127.0.0.1', '::1', 'localhost' ], true );
313
314 printf(
315 '<div class="tmpl-row"><span class="tmpl-key">%1$s</span>'
316 . '<span class="tmpl-val" data-tlid="mcp-consent-destination">%2$s</span></div>',
317 esc_html__( 'Returns to', 'templately' ),
318 esc_html( '' !== $host ? $host : $destination )
319 );
320
321 // Loopback means "an application running on the approver's own computer".
322 // That is normal for a desktop agent and wrong for anything else, and
323 // nothing about the address itself proves which — so say so.
324 if ( $loopback ) {
325 printf(
326 '<p class="tmpl-lede" style="margin:8px 0 0;font-size:12px">%s</p>',
327 esc_html__( 'This address is on your own computer — expected for a desktop or command-line agent.', 'templately' )
328 );
329 }
330
331 // Opened HERE, not just around the buttons: the access control below is a
332 // form field, so everything from this point must sit inside the form.
333 echo '<form method="post" data-tlid="mcp-consent-form">';
334
335 // A CONTROL, not a label. The client's `scope` only sets the default —
336 // the person approving is the resource owner and decides what is
337 // actually granted, without having to reconnect or hand-edit a URL.
338 printf(
339 '<div class="tmpl-row"><span class="tmpl-key">%1$s</span><span class="tmpl-val">'
340 . '<select name="templately_mcp_access" id="templately-mcp-access" class="tmpl-select" data-tlid="mcp-consent-access">'
341 . '<option value="%2$s"%4$s>%6$s</option>'
342 . '<option value="%3$s"%5$s>%7$s</option>'
343 . '</select></span></div>',
344 esc_html__( 'Access', 'templately' ),
345 esc_attr( ToolDescriptor::ACCESS_READ ),
346 esc_attr( ToolDescriptor::ACCESS_FULL ),
347 $full ? '' : ' selected',
348 $full ? ' selected' : '',
349 esc_html__( 'Read-only', 'templately' ),
350 esc_html__( 'Full access', 'templately' )
351 );
352
353 printf(
354 '<div class="tmpl-row"><span class="tmpl-key">%s</span><span class="tmpl-val">%s</span></div>',
355 esc_html__( 'Acting as', 'templately' ),
356 esc_html( $user->user_login )
357 );
358
359 printf(
360 '<div class="tmpl-row"><span class="tmpl-key">%s</span><span class="tmpl-val">%s</span></div>',
361 esc_html__( 'Site', 'templately' ),
362 esc_html( (string) wp_parse_url( home_url(), PHP_URL_HOST ) )
363 );
364
365 // Both consequences are rendered; the one matching the current selection
366 // is shown. With scripting off the initially-correct one is already
367 // visible, so the screen never misdescribes what it is about to grant.
368 printf(
369 '<p class="tmpl-lede tmpl-consequence" data-level="%1$s" style="margin:16px 0 0;font-size:13px%2$s">%3$s</p>'
370 . '<p class="tmpl-lede tmpl-consequence" data-level="%4$s" style="margin:16px 0 0;font-size:13px%5$s">%6$s</p>',
371 esc_attr( ToolDescriptor::ACCESS_READ ),
372 $full ? ';display:none' : '',
373 esc_html__( 'This application will be able to browse Templately designs only. It cannot change anything on this site.', 'templately' ),
374 esc_attr( ToolDescriptor::ACCESS_FULL ),
375 $full ? '' : ';display:none',
376 esc_html__( 'This application will be able to browse Templately designs and import them — creating pages, templates and media on this site.', 'templately' )
377 );
378
379 // Confused-deputy guard: the site cannot tell a genuine request from one a
380 // third party talked the user into opening, so it has to say so plainly.
381 printf(
382 '<div class="tmpl-note" data-tlid="mcp-consent-warning"><span>&#9888;</span><span>%s</span></div>',
383 esc_html__( 'Only approve if you just asked this application to connect. If you did not start this, close this page.', 'templately' )
384 );
385
386 // Bound to THIS client, so a nonce minted while approving one
387 // application cannot be replayed to approve a different one.
388 wp_nonce_field( self::nonce_action( $client['client_id'] ), 'templately_mcp_consent_nonce' );
389
390 // `resource` included: it is validated on POST as well as GET now, so
391 // losing it here would turn an approve into a resource mismatch.
392 foreach ( [ 'client_id', 'redirect_uri', 'state', 'scope', 'code_challenge', 'code_challenge_method', 'resource' ] as $key ) {
393 printf(
394 '<input type="hidden" name="%1$s" value="%2$s" />',
395 esc_attr( $key ),
396 esc_attr( (string) ( $params[ $key ] ?? '' ) )
397 );
398 }
399
400 printf(
401 '<div class="tmpl-actions">'
402 . '<button type="submit" name="templately_mcp_decision" value="deny" class="tmpl-btn tmpl-btn-ghost" data-tlid="mcp-consent-deny">%1$s</button>'
403 . '<button type="submit" name="templately_mcp_decision" value="approve" class="tmpl-btn tmpl-btn-primary" data-tlid="mcp-consent-approve">%2$s</button>'
404 . '</div>',
405 esc_html__( 'Cancel', 'templately' ),
406 esc_html__( 'Approve', 'templately' )
407 );
408
409 echo '</form>';
410
411 // Keeps the consequence copy honest when the selection changes. Inline
412 // and dependency-free so it works on a site with no scripts enqueued.
413 echo '<script>(function(){var s=document.getElementById("templately-mcp-access");if(!s)return;'
414 . 'function sync(){var v=s.value;Array.prototype.forEach.call('
415 . 'document.querySelectorAll(".tmpl-consequence"),function(p){'
416 . 'p.style.display=(p.getAttribute("data-level")===v)?"":"none";});}'
417 . 's.addEventListener("change",sync);sync();})();</script>';
418
419 self::foot( __( 'Templately · You can revoke this connection at any time from Templately → Settings → AI Agents.', 'templately' ) );
420 }
421
422 /**
423 * @param string $message
424 * @return void
425 */
426 private static function render_error( string $message ): void {
427 status_header( 400 );
428 self::head( __( 'Connection request refused', 'templately' ) );
429
430 self::brand();
431
432 echo '<h1>' . esc_html__( 'Connection request refused', 'templately' ) . '</h1>';
433
434 printf(
435 '<div class="tmpl-error" data-tlid="mcp-consent-error"><span>&#9888;</span><span>%s</span></div>',
436 esc_html( $message )
437 );
438
439 printf(
440 '<p class="tmpl-lede" style="margin-top:16px;font-size:13px">%s</p>',
441 esc_html__( 'Nothing was granted and no connection was created. You can close this page.', 'templately' )
442 );
443
444 self::foot();
445 }
446
447 /**
448 * @param string $redirect_uri
449 * @param string $error
450 * @param string $state
451 * @return void
452 */
453 private static function redirect_error( string $redirect_uri, string $error, string $state ): void {
454 // Not wp_safe_redirect: RFC 6749 §4.1.2.1 requires the error to be delivered
455 // to the client's own redirect_uri, which is external by design and was
456 // verified against the registration before this is reached.
457 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- see above.
458 wp_redirect(
459 add_query_arg(
460 array_map( 'rawurlencode', array_filter( [ 'error' => $error, 'state' => $state ] ) ),
461 $redirect_uri
462 )
463 );
464 exit;
465 }
466
467 /**
468 * Where to send the visitor back to once they have logged in.
469 *
470 * Rebuilt from the PARSED parameters — never from `$_SERVER['REQUEST_URI']`.
471 * `sanitize_text_field()` deletes every `%XX` sequence it finds, and
472 * REQUEST_URI is still percent-encoded at this point, so filtering it there
473 * silently destroys the exact characters a redirect_uri is made of:
474 * `http://127.0.0.1:53895/callback/abc` came back as
475 * `http127.0.0.153895callbackabc`, which then failed the exact-match check
476 * against the registered value with "This application asked to return to an
477 * address it did not register."
478 *
479 * It only ever fired for a visitor who was NOT already logged in — i.e. the
480 * ordinary first connection, and never in local testing from an open
481 * wp-admin session. Guarded by `tests/e2e/specs/046-mcp-oauth.spec.js`,
482 * which deliberately starts from a cold context so the login bounce runs.
483 *
484 * The parsed values are safe to re-encode: they were sanitized AFTER PHP
485 * decoded them, so no percent-encoding remained to be eaten.
486 *
487 * @return string
488 */
489 private static function current_url(): string {
490 // rawurlencode is NOT optional here. add_query_arg() urlencodes only the
491 // args ALREADY present in the base URL; the ones passed to it are
492 // serialised by build_query() with $urlencode = false, i.e. emitted raw.
493 // So an unencoded `&` in a redirect_uri truncates it on the way back
494 // (`…/cb?a=1&b=2` returns as `…/cb?a=1`) and a `+` in a base64 `state`
495 // decodes to a space — reintroducing the same "address it did not
496 // register" failure this method exists to prevent, just by a different
497 // route. Verified against add_query_arg directly, not assumed.
498 $params = array_map( 'rawurlencode', self::request_params() );
499
500 // Preserve the non-pretty entry point for sites without rewrites, where
501 // the screen is reached as `/?templately_mcp_authorize=1&…`.
502 if ( ! empty( $_GET['templately_mcp_authorize'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- read-only routing check; the state-changing branch verifies a nonce.
503 $params['templately_mcp_authorize'] = '1';
504
505 return add_query_arg( $params, home_url( '/' ) );
506 }
507
508 return add_query_arg( $params, home_url( '/templately/authorize' ) );
509 }
510
511 /**
512 * Styles are INLINE and self-contained on purpose.
513 *
514 * This is a front-end page, not wp-admin, so the admin bundle (and Tailwind
515 * with it) is not loaded here — and should not be, for one page. More
516 * importantly this screen must render correctly whatever theme is active,
517 * including a broken one: it is where a user grants an AI write access to
518 * their site, and an unstyled or theme-mangled consent screen is one a
519 * careful user is right to distrust.
520 *
521 * Values mirror the Templately tokens used by the settings UI
522 * (#5453fd primary, #1d2939 title, #667085 muted, #eaecf0 border) so the two
523 * surfaces read as the same product.
524 *
525 * @param string $title
526 * @return void
527 */
528 private static function head( string $title ): void {
529 nocache_headers();
530 header( 'Content-Type: text/html; charset=utf-8' );
531
532 // Must be set HERE. Core's send_frame_options_header() is hooked on
533 // admin_init/login_init, and this is deliberately a front-end page, so
534 // nothing else protects it. Framed, an attacker who registered their own
535 // client (registration is public by design) could overlay this screen and
536 // UI-redress the Approve button into granting themselves full access —
537 // and the confused-deputy warning below would never be seen.
538 header( 'X-Frame-Options: DENY' );
539 header( "Content-Security-Policy: frame-ancestors 'none'" );
540 header( 'Referrer-Policy: strict-origin-when-cross-origin' );
541
542 $css = '
543 *{box-sizing:border-box;margin:0;padding:0}
544 body{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
545 background:#f4f4f5;color:#1d2939;line-height:1.6;
546 display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}
547 .tmpl-card{width:100%;max-width:460px;background:#fff;border:1px solid #eaecf0;
548 border-radius:12px;padding:32px;box-shadow:0 1px 3px rgba(16,24,40,.06)}
549 .tmpl-brand{margin-bottom:22px;line-height:0}
550 .tmpl-brand svg{width:132px;height:auto;display:block}
551 .tmpl-brand-text{font-size:12px;font-weight:600;letter-spacing:.08em;
552 text-transform:uppercase;color:#5453fd;line-height:1.6}
553 h1{font-size:20px;font-weight:600;color:#1d2939;margin-bottom:8px;line-height:1.3}
554 .tmpl-lede{font-size:14px;color:#667085;margin-bottom:24px}
555 .tmpl-row{display:flex;justify-content:space-between;align-items:center;gap:16px;
556 padding:12px 0;border-top:1px solid #f2f4f7;font-size:14px}
557 .tmpl-row:last-of-type{border-bottom:1px solid #f2f4f7}
558 .tmpl-key{color:#667085}
559 .tmpl-val{color:#1d2939;font-weight:500;text-align:right;word-break:break-word}
560 .tmpl-badge{display:inline-block;border-radius:999px;padding:3px 10px;
561 font-size:12px;font-weight:500}
562 .tmpl-badge-read{background:#f2f4f7;color:#475467}
563 .tmpl-badge-full{background:#fef0c7;color:#b54708}
564 .tmpl-select{font:inherit;font-size:13px;font-weight:500;color:#1d2939;background:#fff;
565 border:1px solid #d0d5dd;border-radius:8px;padding:6px 10px;min-width:140px;
566 box-shadow:0 1px 2px rgba(16,24,40,.05);cursor:pointer}
567 .tmpl-select:focus{outline:none;border-color:#5453fd;box-shadow:0 0 0 3px rgba(84,83,253,.16)}
568 .tmpl-note{display:flex;gap:8px;margin-top:20px;padding:12px 14px;border-radius:8px;
569 background:#fffcf5;border:1px solid #fedf89;color:#93370d;font-size:12.5px;line-height:1.55}
570 .tmpl-actions{display:flex;gap:10px;margin-top:24px}
571 .tmpl-btn{flex:1;display:inline-block;text-align:center;border-radius:8px;padding:11px 16px;
572 font-size:14px;font-weight:500;cursor:pointer;border:1px solid transparent;
573 font-family:inherit;text-decoration:none;transition:background .15s}
574 .tmpl-btn-primary{background:#5453fd;border-color:#5453fd;color:#fff}
575 .tmpl-btn-primary:hover{background:#4341e0}
576 .tmpl-btn-ghost{background:#fff;border-color:#d0d5dd;color:#344054}
577 .tmpl-btn-ghost:hover{background:#f9fafb}
578 .tmpl-error{display:flex;gap:10px;padding:14px 16px;border-radius:8px;
579 background:#fffbfa;border:1px solid #fda29b;color:#b42318;font-size:14px;line-height:1.55}
580 .tmpl-foot{margin-top:20px;font-size:12px;color:#98a2b3;text-align:center}
581 ';
582
583 printf(
584 '<!DOCTYPE html><html %1$s><head><meta charset="utf-8">'
585 . '<meta name="viewport" content="width=device-width,initial-scale=1">'
586 . '<meta name="robots" content="noindex,nofollow">'
587 . '<title>%2$s</title><style>%3$s</style></head><body><div class="tmpl-card">',
588 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- WP core builds this attribute string itself; there is no escaping function for it.
589 get_language_attributes(),
590 esc_html( $title ),
591 $css // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static stylesheet, no dynamic input.
592 );
593 }
594
595 /**
596 * The Templately lockup, inlined.
597 *
598 * Read from the shipped asset rather than pasted into PHP so a brand update
599 * propagates on its own, and INLINED rather than linked because this page
600 * must render standalone — an <img> that 404s on a misconfigured site would
601 * leave the consent screen looking broken, which is the one impression it
602 * cannot afford. Falls back to the wordmark as text if the file is missing.
603 *
604 * @return void
605 */
606 private static function brand(): void {
607 $svg = defined( 'TEMPLATELY_PATH' )
608 ? TEMPLATELY_PATH . 'assets/images/logos/logo-full.svg'
609 : '';
610
611 if ( '' !== $svg && is_readable( $svg ) ) {
612 $markup = file_get_contents( $svg ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin asset, not a remote fetch.
613
614 if ( is_string( $markup ) && 0 === strpos( ltrim( $markup ), '<svg' ) ) {
615 printf(
616 '<div class="tmpl-brand" role="img" aria-label="%s">%s</div>',
617 esc_attr__( 'Templately', 'templately' ),
618 $markup // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- first-party asset shipped with the plugin.
619 );
620
621 return;
622 }
623 }
624
625 printf( '<div class="tmpl-brand tmpl-brand-text">%s</div>', esc_html__( 'Templately', 'templately' ) );
626 }
627
628 /**
629 * @param string $note Footer line. Empty on screens where the default
630 * "you can revoke this later" would be untrue — nothing
631 * is created on the error path.
632 * @return void
633 */
634 private static function foot( string $note = '' ): void {
635 if ( '' !== $note ) {
636 printf( '<div class="tmpl-foot">%s</div>', esc_html( $note ) );
637 }
638
639 echo '</div></body></html>';
640
641 exit;
642 }
643 }
644