Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.01% covered (danger)
49.01%
174 / 355
39.13% covered (danger)
39.13%
9 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatEditor
49.01% covered (danger)
49.01%
174 / 355
39.13% covered (danger)
39.13%
9 / 23
1019.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 register_rest_routes
90.91% covered (success)
90.91%
30 / 33
0.00% covered (danger)
0.00%
0 / 1
1.00
 get_config
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
72
 get_brand_id
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_site_editor_assets
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 is_site_editor_chat_screen
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_post_editor_chat_screen
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
13.12
 register_assets
4.44% covered (danger)
4.44%
2 / 45
0.00% covered (danger)
0.00%
0 / 1
49.75
 get_plan_upgrade_banner_data
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
17.71
 load_script_translation_file
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 add_admin_body_class
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 add_editor_canvas_styles
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 get_site_context
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 load_text_domain
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 admin_bar_menu
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_admin_bar_assets
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 get_allowed_temp_mime_types
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 get_allowed_temp_extensions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 get_temp_upload_paths
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 ensure_temp_upload_dir
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 get_allowed_temp_file_type
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
12.91
 upload_temp_file
61.36% covered (warning)
61.36%
27 / 44
0.00% covered (danger)
0.00%
0 / 1
8.08
 delete_temp_file
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace NewfoldLabs\WP\Module\EditorChat;
4
5/**
6 * ChatEditor main class
7 *
8 * Handles the registration and loading of the AI chat editor assets
9 * in the WordPress block editor, and provides a config REST endpoint
10 * for the CF AI Gateway Worker handshake.
11 */
12final class ChatEditor {
13    /**
14     * Subdirectory under wp_upload_dir() for temporary chat uploads.
15     */
16    const TEMP_UPLOAD_SUBDIR = 'nfd-chat-temp';
17
18    /**
19     * Array of allowed referrers for site editor access
20     *
21     * @var array
22     */
23    protected static $allowed_referrers = array(
24        'nfd-editor-chat',
25    );
26
27    /**
28     * Constructor.
29     */
30    public function __construct() {
31        \add_action( 'init', array( __CLASS__, 'ensure_temp_upload_dir' ), 5 );
32        \add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
33        \add_action( 'init', array( __CLASS__, 'load_text_domain' ), 100 );
34        \add_filter( 'load_script_translation_file', array( __CLASS__, 'load_script_translation_file' ), 10, 3 );
35
36        if ( Permissions::is_editor() ) {
37            \add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_site_editor_assets' ) );
38            \add_action( 'admin_bar_menu', array( __CLASS__, 'admin_bar_menu' ), 99 );
39            \add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_bar_assets' ) );
40            \add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_admin_bar_assets' ) );
41            // Editor settings build before admin_enqueue_scripts, so register here.
42            \add_filter( 'block_editor_settings_all', array( __CLASS__, 'add_editor_canvas_styles' ), 10, 2 );
43        }
44    }
45
46    /**
47     * Register REST API routes.
48     */
49    public static function register_rest_routes() {
50        \register_rest_route(
51            'nfd-editor-chat/v1',
52            '/config',
53            array(
54                'methods'             => \WP_REST_Server::READABLE,
55                'callback'            => array( __CLASS__, 'get_config' ),
56                'permission_callback' => function () {
57                    return Permissions::is_editor();
58                },
59            )
60        );
61        \register_rest_route(
62            'nfd-editor-chat/v1',
63            '/upload',
64            array(
65                'methods'             => \WP_REST_Server::CREATABLE,
66                'callback'            => array( __CLASS__, 'upload_temp_file' ),
67                'permission_callback' => function () {
68                    return Permissions::is_editor();
69                },
70            )
71        );
72
73        \register_rest_route(
74            'nfd-editor-chat/v1',
75            '/upload/(?P<filename>[a-zA-Z0-9_\-\.]+)',
76            array(
77                'methods'             => \WP_REST_Server::DELETABLE,
78                'callback'            => array( __CLASS__, 'delete_temp_file' ),
79                'permission_callback' => function () {
80                    return Permissions::is_editor();
81                },
82            )
83        );
84    }
85
86    /**
87     * Get configuration for the editor chat frontend.
88     *
89     * Performs a server-to-server handshake with the CF Worker to exchange
90     * the Hiive auth token for a short-lived session JWT. The Hiive token
91     * never reaches the browser.
92     *
93     * @return \WP_REST_Response|\WP_Error
94     */
95    public static function get_config() {
96        $worker_url = defined( 'NFD_EDITOR_CHAT_WORKER_URL' )
97            ? \NFD_EDITOR_CHAT_WORKER_URL
98            : 'https://cf-worker-ai-chat.bluehost.workers.dev';
99
100        if ( empty( $worker_url ) ) {
101            return new \WP_Error(
102                'worker_url_not_configured',
103                __( 'Editor chat Worker URL is not configured. Set NFD_EDITOR_CHAT_WORKER_URL in wp-config.php.', 'nfd-editor-chat' ),
104                array( 'status' => 500 )
105            );
106        }
107
108        $worker_url = \untrailingslashit( $worker_url );
109
110        // Get Hiive auth token for server-to-server handshake
111        $hiive_token = '';
112        if ( class_exists( '\NewfoldLabs\WP\Module\Data\HiiveConnection' ) ) {
113            $hiive_token = \NewfoldLabs\WP\Module\Data\HiiveConnection::get_auth_token();
114        }
115
116        if ( empty( $hiive_token ) ) {
117            return new \WP_Error(
118                'hiive_token_unavailable',
119                __( 'Unable to retrieve Hiive authentication token.', 'nfd-editor-chat' ),
120                array( 'status' => 500 )
121            );
122        }
123
124        // Server-to-server handshake with Worker
125        $handshake_response = \wp_remote_post(
126            $worker_url . '/handshake',
127            array(
128                'headers' => array(
129                    'X-Hiive-Token' => $hiive_token,
130                    'Content-Type'  => 'application/json',
131                ),
132                'body'    => \wp_json_encode(
133                    array(
134                        'site_url' => \get_site_url(),
135                        'brand_id' => self::get_brand_id(),
136                    )
137                ),
138                'timeout' => 10,
139            )
140        );
141
142        if ( \is_wp_error( $handshake_response ) ) {
143            return new \WP_Error(
144                'handshake_failed',
145                $handshake_response->get_error_message(),
146                array( 'status' => 502 )
147            );
148        }
149
150        $status_code = \wp_remote_retrieve_response_code( $handshake_response );
151        if ( 200 !== $status_code ) {
152            return new \WP_Error(
153                'handshake_failed',
154                /* translators: %d: HTTP status code from the Worker handshake. */
155                \sprintf( __( 'Worker handshake returned HTTP %d.', 'nfd-editor-chat' ), $status_code ),
156                array( 'status' => 502 )
157            );
158        }
159
160        $data = json_decode( \wp_remote_retrieve_body( $handshake_response ), true );
161
162        if ( empty( $data['session_token'] ) ) {
163            return new \WP_Error(
164                'handshake_failed',
165                __( 'Worker handshake did not return a session token.', 'nfd-editor-chat' ),
166                array( 'status' => 502 )
167            );
168        }
169
170        return new \WP_REST_Response(
171            array(
172                'worker_url'    => $worker_url,
173                'session_token' => $data['session_token'],
174                'expires_in'    => $data['expires_in'] ?? 3600,
175            )
176        );
177    }
178
179    /**
180     * Get the brand identifier for the current plugin.
181     *
182     * @return string
183     */
184    private static function get_brand_id() {
185        if ( defined( 'STARTER_PLUGIN_BRAND' ) ) {
186            return \STARTER_PLUGIN_BRAND;
187        }
188        // Fallback: derive from plugin directory name
189        $plugin_dir = \basename( \dirname( __DIR__, 3 ) );
190        $brand_map  = array(
191            'wp-plugin-bluehost'      => 'bluehost',
192            'wp-plugin-hostgator'     => 'hostgator',
193            'wp-plugin-web'           => 'web',
194            'wp-plugin-crazy-domains' => 'crazydomains',
195        );
196
197        return $brand_map[ $plugin_dir ] ?? 'bluehost';
198    }
199
200    /**
201     * Enqueue editor chat assets on Site Editor (referrer) or post block editor screens.
202     *
203     * @return void
204     */
205    public static function enqueue_site_editor_assets() {
206        global $pagenow;
207
208        if ( self::is_site_editor_chat_screen( $pagenow ) ) {
209            self::register_assets( 'site' );
210            \add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_class' ) );
211            return;
212        }
213
214        if ( self::is_post_editor_chat_screen( $pagenow ) ) {
215            self::register_assets( 'post' );
216            \add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_class' ) );
217        }
218    }
219
220    /**
221     * Whether the current request is the Site Editor with an allowed referrer.
222     *
223     * @param string $pagenow Current admin page.
224     * @return bool
225     */
226    private static function is_site_editor_chat_screen( $pagenow ) {
227        if ( 'site-editor.php' !== $pagenow ) {
228            return false;
229        }
230
231        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Referrer parameter is validated against allowed list, no data modification.
232        return isset( $_GET['referrer'] ) && \in_array( $_GET['referrer'], self::$allowed_referrers, true );
233    }
234
235    /**
236     * Whether the current request is a block post editor screen (post.php / post-new.php).
237     *
238     * @param string $pagenow Current admin page.
239     * @return bool
240     */
241    private static function is_post_editor_chat_screen( $pagenow ) {
242        if ( ! \in_array( $pagenow, array( 'post.php', 'post-new.php' ), true ) ) {
243            return false;
244        }
245
246        if ( ! Permissions::is_editor() ) {
247            return false;
248        }
249
250        $screen = \get_current_screen();
251
252        if ( $screen && \method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor() ) {
253            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Referrer parameter is validated against allowed list, no data modification.
254            return isset( $_GET['referrer'] ) && \in_array( $_GET['referrer'], self::$allowed_referrers, true );
255        }
256
257        return false;
258    }
259
260    /**
261     * Register and enqueue chat editor assets.
262     *
263     * @param string $editor_type `site` or `post`.
264     */
265    private static function register_assets( $editor_type = 'site' ) {
266
267        $asset_file = NFD_EDITOR_CHAT_BUILD_DIR . '/chat-editor.asset.php';
268
269        if ( \is_readable( $asset_file ) ) {
270            $asset = include_once $asset_file;
271
272            \wp_register_script(
273                'nfd-editor-chat',
274                NFD_EDITOR_CHAT_BUILD_URL . '/chat-editor.js',
275                array_merge( $asset['dependencies'], array() ),
276                $asset['version'],
277                true
278            );
279
280            // In dev, version by file mtime so CSS-only rebuilds bust the cache
281            // ($asset['version'] only changes with the JS bundle).
282            $style_version = $asset['version'];
283
284            if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
285                $style_path = NFD_EDITOR_CHAT_BUILD_DIR . '/chat-editor.css';
286
287                if ( \is_readable( $style_path ) ) {
288                    $style_version = (string) \filemtime( $style_path );
289                }
290            }
291
292            \wp_register_style(
293                'nfd-editor-chat',
294                NFD_EDITOR_CHAT_BUILD_URL . '/chat-editor.css',
295                array(),
296                $style_version
297            );
298
299            $args = array(
300                'nonce'          => \wp_create_nonce( 'wp_rest' ),
301                'nfdRestURL'     => \get_home_url() . '/index.php?rest_route=/nfd-editor-chat/v1',
302                'mcpUrl'         => \esc_url_raw( \rest_url( 'blu/mcp' ) ),
303                'configEndpoint' => \esc_url_raw( \rest_url( 'nfd-editor-chat/v1/config' ) ),
304                'homeUrl'        => \esc_url( \get_home_url() ),
305                'wpVer'          => \esc_html( \get_bloginfo( 'version' ) ),
306                'nfdChatVersion' => \esc_html( NFD_EDITOR_CHAT_VERSION ),
307                'model'          => defined( 'NFD_EDITOR_CHAT_MODEL' ) ? \NFD_EDITOR_CHAT_MODEL : '',
308                'site'           => self::get_site_context(),
309                'pagesCount'     => \array_sum( (array) \wp_count_posts( 'page' ) ),
310                'editorType'     => $editor_type,
311            );
312
313            $upgrade_banner_data = self::get_plan_upgrade_banner_data();
314            if ( $upgrade_banner_data ) {
315                $args['planUpgradeBanner'] = $upgrade_banner_data;
316            }
317
318            \wp_localize_script( 'nfd-editor-chat', 'nfdEditorChat', $args );
319
320            \wp_set_script_translations(
321                'nfd-editor-chat',
322                'nfd-editor-chat',
323                NFD_EDITOR_CHAT_DIR . '/languages'
324            );
325
326            \wp_enqueue_script( 'nfd-editor-chat' );
327            \wp_enqueue_style( 'nfd-editor-chat' );
328        }
329    }
330
331    /**
332     * Retrieve the upgrade banner data.
333     *
334     * @return array
335     */
336    private static function get_plan_upgrade_banner_data() {
337        static $data = null;
338
339        if ( is_null( $data ) ) {
340            $data      = array();
341            $plan_data = \get_option( 'wvc_plan_data', '{}' );
342            $plan_data = (bool) $plan_data && \is_string( $plan_data ) ? \json_decode( $plan_data, true ) : array();
343
344            if ( $plan_data ) {
345                $plan_data   = \is_array( $plan_data ) ? $plan_data : array();
346                $message     = \sanitize_text_field( $plan_data['infoBannerText'] ?? '' );
347                $upgrade_url = \esc_url_raw( $plan_data['upgrade_url'] ?? '' );
348
349                if ( $message && $upgrade_url ) {
350                    $data = array(
351                        'message'    => $message,
352                        'upgradeUrl' => $upgrade_url,
353                    );
354                }
355            }
356        }
357
358        return $data;
359    }
360
361    /**
362     * Filter default WP script translations file to load the correct one
363     *
364     * @param string $file   The translations file.
365     * @param string $handle Script handle.
366     * @param string $domain The strings textdomain.
367     *
368     * @return string
369     */
370    public static function load_script_translation_file( $file, $handle, $domain ) {
371
372        if ( 'nfd-editor-chat' === $handle ) {
373            $locale = \determine_locale();
374            $key    = \md5( 'build/' . NFD_EDITOR_CHAT_VERSION . '/chat-editor.js' );
375            $file   = NFD_EDITOR_CHAT_DIR . "/languages/{$domain}-{$locale}-{$key}.json";
376        }
377
378        return $file;
379    }
380
381    /**
382     * Add custom admin class on block editor pages.
383     *
384     * @param string $classes Body classes.
385     *
386     * @return string
387     */
388    public static function add_admin_body_class( $classes ) {
389        global $pagenow;
390        $current_screen = \get_current_screen();
391
392        if ( $current_screen && \method_exists( $current_screen, 'is_block_editor' ) && $current_screen->is_block_editor() ) {
393            $classes .= ' nfd-editor-chat-enabled';
394
395            if ( \in_array( $pagenow, array( 'post.php', 'post-new.php' ), true ) ) {
396                $classes .= ' nfd-editor-chat--post-editor';
397            }
398
399            if ( self::get_plan_upgrade_banner_data() ) {
400                $classes .= ' nfd-editor-chat--has-plan-upgrade-banner';
401            }
402        }
403
404        return $classes;
405    }
406
407    /**
408     * Round the top block's selection outline to match the framed canvas corner
409     * (the outline is drawn inside the iframe, out of reach of our stylesheet).
410     *
411     * @param array                    $settings Block editor settings.
412     * @param \WP_Block_Editor_Context $context  Editor context.
413     *
414     * @return array
415     */
416    public static function add_editor_canvas_styles( $settings, $context ) {
417        if ( ! isset( $context->name ) || 'core/edit-site' !== $context->name ) {
418            return $settings;
419        }
420
421        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Referrer parameter is validated against allowed list, no data modification.
422        if ( ! isset( $_GET['referrer'] ) || ! \in_array( $_GET['referrer'], self::$allowed_referrers, true ) ) {
423            return $settings;
424        }
425
426        // 12px matches $radius in _main.scss.
427        $css = '.is-root-container > .block-editor-block-list__block:first-child,'
428            . '.is-root-container > .block-editor-block-list__block:first-child::before,'
429            . '.is-root-container > .block-editor-block-list__block:first-child::after'
430            . '{border-start-start-radius:12px;}';
431
432        $settings['styles'][] = array( 'css' => $css );
433
434        return $settings;
435    }
436
437    /**
438     * Get site context data for the AI assistant.
439     *
440     * @return array
441     */
442    private static function get_site_context() {
443        $onboarding = \get_option( 'nfd_module_onboarding_state_input', array() );
444
445        return array(
446            'title'          => \get_bloginfo( 'name' ),
447            'description'    => ! empty( $onboarding['prompt'] ) ? $onboarding['prompt'] : \get_bloginfo( 'description' ),
448            'siteType'       => $onboarding['siteType'] ?? '',
449            'locale'         => \get_locale(),
450            'classification' => \get_option( 'nfd-ai-site-gen-siteclassification', '' ),
451        );
452    }
453
454    /**
455     * Load text domain for Module
456     *
457     * @return void
458     */
459    public static function load_text_domain() {
460
461        \load_plugin_textdomain(
462            'nfd-editor-chat',
463            false,
464            NFD_EDITOR_CHAT_DIR . '/languages'
465        );
466
467        \load_script_textdomain(
468            'nfd-editor-chat',
469            'nfd-editor-chat',
470            NFD_EDITOR_CHAT_DIR . '/languages'
471        );
472    }
473
474    /**
475     * Add menu in admin bar.
476     *
477     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
478     */
479    public static function admin_bar_menu( $wp_admin_bar ) {
480        $icon = '<svg width="14" height="13" viewBox="0 0 14 13" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.99937 1.85729C5.108 1.85731 5.21368 1.8902 5.3004 1.95096C5.38712 2.01173 5.45017 2.09707 5.48001 2.19408L6.02198 3.95604C6.13867 4.33549 6.35759 4.68106 6.65806 4.9601C6.95853 5.23915 7.33063 5.44246 7.73922 5.55083L9.63645 6.05416C9.74084 6.08193 9.83266 6.14051 9.89802 6.22104C9.96338 6.30157 9.99874 6.39968 9.99874 6.50053C9.99874 6.60137 9.96338 6.69948 9.89802 6.78001C9.83266 6.86054 9.74084 6.91912 9.63645 6.9469L7.73922 7.45022C7.33063 7.5586 6.95853 7.76191 6.65806 8.04095C6.35759 8.31999 6.13867 8.66556 6.02198 9.04502L5.48001 10.807C5.4501 10.9039 5.38703 10.9892 5.30031 11.0499C5.2136 11.1106 5.10796 11.1434 4.99937 11.1434C4.89078 11.1434 4.78514 11.1106 4.69843 11.0499C4.61171 10.9892 4.54863 10.9039 4.51873 10.807L3.97676 9.04502C3.86006 8.66556 3.64115 8.31999 3.34068 8.04095C3.04021 7.76191 2.66811 7.5586 2.25952 7.45022L0.362287 6.9469C0.257896 6.91912 0.166078 6.86054 0.100716 6.78001C0.0353537 6.69948 0 6.60137 0 6.50053C0 6.39968 0.0353537 6.30157 0.100716 6.22104C0.166078 6.14051 0.257896 6.08193 0.362287 6.05416L2.25952 5.55083C2.66811 5.44246 3.04021 5.23915 3.34068 4.9601C3.64115 4.68106 3.86006 4.33549 3.97676 3.95604L4.51873 2.19408C4.54857 2.09707 4.61162 2.01173 4.69834 1.95096C4.78506 1.8902 4.89073 1.85731 4.99937 1.85729ZM10.999 7.20546e-08C11.1106 -5.7614e-05 11.2189 0.0345233 11.3069 0.0982423C11.3948 0.161961 11.4573 0.251159 11.4844 0.351648L11.6563 0.993033C11.8137 1.57499 12.303 2.0294 12.9296 2.17551L13.6202 2.33524C13.7286 2.36018 13.8249 2.41811 13.8938 2.49979C13.9626 2.58148 14 2.68222 14 2.78594C14 2.88966 13.9626 2.9904 13.8938 3.07209C13.8249 3.15377 13.7286 3.21171 13.6202 3.23664L12.9296 3.39637C12.303 3.54248 11.8137 3.9969 11.6563 4.57885L11.4844 5.22023C11.4575 5.32091 11.3951 5.41035 11.3072 5.47427C11.2192 5.53819 11.1107 5.57292 10.999 5.57292C10.8874 5.57292 10.7789 5.53819 10.6909 5.47427C10.603 5.41035 10.5406 5.32091 10.5137 5.22023L10.3417 4.57885C10.2648 4.29309 10.1057 4.03212 9.88145 3.82384C9.65718 3.61556 9.37618 3.46781 9.06848 3.39637L8.37785 3.23664C8.26944 3.21171 8.17314 3.15377 8.10431 3.07209C8.03548 2.9904 7.99809 2.88966 7.99809 2.78594C7.99809 2.68222 8.03548 2.58148 8.10431 2.49979C8.17314 2.41811 8.26944 2.36018 8.37785 2.33524L9.06848 2.17551C9.37618 2.10407 9.65718 1.95632 9.88145 1.74804C10.1057 1.53976 10.2648 1.27879 10.3417 0.993033L10.5137 0.351648C10.5408 0.251159 10.6033 0.161961 10.6912 0.0982423C10.7792 0.0345233 10.8875 -5.7614e-05 10.999 7.20546e-08ZM9.9991 8.35782C10.1041 8.35776 10.2065 8.38841 10.2917 8.44542C10.3769 8.50243 10.4406 8.5829 10.4737 8.67542L10.7364 9.40781C10.8364 9.68455 11.0697 9.90247 11.3684 9.99471L12.157 10.2393C12.2563 10.2702 12.3426 10.3294 12.4038 10.4083C12.4649 10.4873 12.4978 10.5822 12.4978 10.6794C12.4978 10.7767 12.4649 10.8715 12.4038 10.9505C12.3426 11.0295 12.2563 11.0887 12.157 11.1196L11.3684 11.3642C11.0704 11.457 10.8357 11.6737 10.7364 11.9511L10.4731 12.6835C10.4397 12.7757 10.376 12.8559 10.291 12.9126C10.206 12.9694 10.1038 13 9.9991 13C9.89435 13 9.79224 12.9694 9.70719 12.9126C9.62215 12.8559 9.55846 12.7757 9.52512 12.6835L9.2618 11.9511C9.2127 11.8144 9.13004 11.6903 9.02035 11.5884C8.91067 11.4865 8.77698 11.4098 8.62984 11.3642L7.84121 11.1196C7.74191 11.0887 7.65558 11.0295 7.59443 10.9505C7.53327 10.8715 7.50037 10.7767 7.50037 10.6794C7.50037 10.5822 7.53327 10.4873 7.59443 10.4083C7.65558 10.3294 7.74191 10.2702 7.84121 10.2393L8.62984 9.99471C8.92782 9.90185 9.16248 9.68517 9.2618 9.40781L9.52512 8.67542C9.55827 8.583 9.62187 8.5026 9.70693 8.4456C9.792 8.3886 9.89421 8.35789 9.9991 8.35782Z" fill="white"/></svg>';
481        // translators: %s is the "Bluehost" brand name and should not be translated. Example: "Bluehost AI Editor".
482        $title = \sprintf( __( '%s AI Editor', 'nfd-editor-chat' ), 'Bluehost' );
483
484        $editor_args = array(
485            'canvas'   => 'edit',
486            'referrer' => 'nfd-editor-chat',
487        );
488        $url         = \add_query_arg( $editor_args, \admin_url( 'site-editor.php' ) );
489
490        $args = array(
491            'id'     => 'nfd-editor-chat',
492            'parent' => 'top-secondary',
493            'title'  => $icon . $title,
494            'href'   => $url,
495        );
496
497        $wp_admin_bar->add_node( $args );
498    }
499
500    /**
501     * Enqueue styles for admin-bar.
502     */
503    public static function enqueue_admin_bar_assets() {
504        if ( is_admin_bar_showing() ) {
505            \wp_enqueue_style( 'nfd-editor-chat-admin-bar', \NFD_EDITOR_CHAT_ASSETS_URL . 'css/admin-bar.css', array(), NFD_EDITOR_CHAT_VERSION );
506        }
507    }
508
509    /**
510     * Allowed MIME types for temporary chat uploads.
511     *
512     * @return string[]
513     */
514    private static function get_allowed_temp_mime_types() {
515        return array(
516            'image/png',
517            'image/jpeg',
518            'image/webp',
519            'image/gif',
520            'application/pdf',
521            'text/plain',
522            'text/csv',
523            'text/markdown',
524        );
525    }
526
527    /**
528     * Allowed file extensions for temporary chat uploads.
529     *
530     * @return string[]
531     */
532    private static function get_allowed_temp_extensions() {
533        return array(
534            'png',
535            'jpg',
536            'jpeg',
537            'webp',
538            'gif',
539            'pdf',
540            'txt',
541            'csv',
542            'md',
543        );
544    }
545
546    /**
547     * Paths for the temporary chat upload directory.
548     *
549     * @return array{basedir: string, baseurl: string}
550     */
551    private static function get_temp_upload_paths() {
552        $upload_dir = \wp_upload_dir();
553
554        return array(
555            'basedir' => \trailingslashit( $upload_dir['basedir'] ) . self::TEMP_UPLOAD_SUBDIR . '/',
556            'baseurl' => \trailingslashit( $upload_dir['baseurl'] ) . self::TEMP_UPLOAD_SUBDIR . '/',
557        );
558    }
559
560    /**
561     * Ensure the temporary upload directory exists with a guard index.php.
562     *
563     * @return bool True when the directory exists or was created.
564     */
565    public static function ensure_temp_upload_dir() {
566        $paths    = self::get_temp_upload_paths();
567        $temp_dir = $paths['basedir'];
568
569        if ( \file_exists( $temp_dir ) ) {
570            return true;
571        }
572
573        if ( ! \wp_mkdir_p( $temp_dir ) ) {
574            return false;
575        }
576
577        // Prevent directory listing and PHP execution.
578        \file_put_contents( $temp_dir . 'index.php', '<?php // Silence is golden.' );
579
580        return true;
581    }
582
583    /**
584     * Validate a temp upload using extension and MIME (browser type is unreliable).
585     *
586     * @param array $file $_FILES-style file array.
587     * @return string|false Detected MIME type when allowed, false otherwise.
588     */
589    private static function get_allowed_temp_file_type( array $file ) {
590        $allowed_mimes      = self::get_allowed_temp_mime_types();
591        $allowed_extensions = self::get_allowed_temp_extensions();
592        $extension          = \strtolower( (string) \pathinfo( $file['name'], \PATHINFO_EXTENSION ) );
593
594        if ( '' !== $extension && \in_array( $extension, $allowed_extensions, true ) ) {
595            $filetype = \wp_check_filetype( $file['name'] );
596
597            if ( ! empty( $filetype['type'] ) && \in_array( $filetype['type'], $allowed_mimes, true ) ) {
598                return $filetype['type'];
599            }
600
601            if ( ! empty( $file['type'] ) && \in_array( $file['type'], $allowed_mimes, true ) ) {
602                return $file['type'];
603            }
604
605            // Extension is allowlisted even when the browser sends application/octet-stream.
606            return ! empty( $file['type'] ) ? $file['type'] : 'application/octet-stream';
607        }
608
609        if ( ! empty( $file['type'] ) && \in_array( $file['type'], $allowed_mimes, true ) ) {
610            return $file['type'];
611        }
612
613        return false;
614    }
615
616    /**
617     * Upload a file to temporary storage (not Media Library).
618     *
619     * @param \WP_REST_Request $request The REST request.
620     * @return \WP_REST_Response|\WP_Error
621     */
622    public static function upload_temp_file( \WP_REST_Request $request ) {
623        $files = $request->get_file_params();
624
625        if ( empty( $files['file'] ) || ! empty( $files['file']['error'] ) ) {
626            return new \WP_Error(
627                'no_file',
628                __( 'No file provided or upload error.', 'nfd-editor-chat' ),
629                array( 'status' => 400 )
630            );
631        }
632
633        $file = $files['file'];
634        $type = self::get_allowed_temp_file_type( $file );
635
636        if ( false === $type ) {
637            return new \WP_Error(
638                'invalid_file_type',
639                __( 'File type not allowed.', 'nfd-editor-chat' ),
640                array( 'status' => 400 )
641            );
642        }
643
644        if ( ! self::ensure_temp_upload_dir() ) {
645            return new \WP_Error(
646                'upload_dir_unavailable',
647                __( 'Temporary upload directory is not available.', 'nfd-editor-chat' ),
648                array( 'status' => 500 )
649            );
650        }
651
652        $paths    = self::get_temp_upload_paths();
653        $temp_dir = $paths['basedir'];
654        $temp_url = $paths['baseurl'];
655
656        $filename = \wp_unique_filename( $temp_dir, \sanitize_file_name( $file['name'] ) );
657        $filepath = $temp_dir . $filename;
658
659        if ( ! \move_uploaded_file( $file['tmp_name'], $filepath ) ) {
660            return new \WP_Error(
661                'upload_failed',
662                __( 'Failed to save uploaded file.', 'nfd-editor-chat' ),
663                array( 'status' => 500 )
664            );
665        }
666
667        $file_url = $temp_url . $filename;
668
669        // Track file for cleanup after 24h.
670        \set_transient( 'nfd_chat_temp_' . \md5( $filename ), $filepath, \DAY_IN_SECONDS * 3 );
671
672        return new \WP_REST_Response(
673            array(
674                'url'      => $file_url,
675                'filename' => $filename,
676                'name'     => $file['name'],
677                'type'     => $type,
678                'size'     => $file['size'],
679            ),
680            201
681        );
682    }
683
684    /**
685     * Delete a temporary uploaded file.
686     *
687     * @param \WP_REST_Request $request The REST request.
688     * @return \WP_REST_Response|\WP_Error
689     */
690    public static function delete_temp_file( \WP_REST_Request $request ) {
691        $filename = $request->get_param( 'filename' );
692
693        $paths    = self::get_temp_upload_paths();
694        $filepath = $paths['basedir'] . \sanitize_file_name( $filename );
695
696        if ( ! \file_exists( $filepath ) ) {
697            return new \WP_Error(
698                'file_not_found',
699                __( 'File not found.', 'nfd-editor-chat' ),
700                array( 'status' => 404 )
701            );
702        }
703
704        \wp_delete_file( $filepath );
705        \delete_transient( 'nfd_chat_temp_' . \md5( $filename ) );
706
707        return new \WP_REST_Response( null, 204 );
708    }
709}