collect( Request::has( 'include_secrets' ) ), JSON_PRETTY_PRINT ); $payload = is_string( $payload ) ? $payload : '{}'; /* * Through the same gate as every other download. Sending the headers by * hand meant no buffers were discarded and `headers_sent()` was never * asked, so a notice emitted by any other plugin ended up inside the JSON — * producing a file that will not import, preceded by a "headers already * sent" warning. */ if ( ! Download::start( $this->filename(), 'application/json; charset=utf-8' ) ) { wp_die( esc_html__( 'The export could not be sent because output had already started. Set WP_DEBUG_DISPLAY to false and try again.', 'devplugins-custom-form-builder' ), esc_html__( 'Export failed', 'devplugins-custom-form-builder' ), array( 'response' => 500 ) ); } header( 'Content-Length: ' . strlen( $payload ) ); echo $payload; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON document. exit; } /** * Reads an uploaded file back into the settings. */ public function handle_import(): void { SecurityManager::verify_admin_request( self::IMPORT_ACTION, Capabilities::MANAGE_SETTINGS ); $file = Request::upload( 'settings_file' ); if ( null === $file ) { $this->redirect( 'import_missing', 0 ); } // PHP refuses anything over upload_max_filesize before this runs; say so. if ( in_array( $file['error'], array( UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE ), true ) ) { $this->redirect( 'import_large', 0 ); } if ( UPLOAD_ERR_OK !== $file['error'] || ! is_uploaded_file( $file['tmp_name'] ) ) { $this->redirect( 'import_missing', 0 ); } // Measured on disk: `size` is what PHP counted, but the file is what gets read. if ( max( $file['size'], (int) filesize( $file['tmp_name'] ) ) > self::MAX_BYTES ) { $this->redirect( 'import_large', 0 ); } /* * The file is read from PHP's temporary directory and never stored, so * wp_handle_upload() - which exists to move an upload into the media * directory - has nothing to offer here, and would leave a copy of the * site's credentials somewhere public. The name is checked so an obviously * wrong file is refused with a useful message; what actually decides is the * content, which must decode and is then read key by key below. */ if ( 'json' !== wp_check_filetype( $file['name'], array( 'json' => 'application/json' ) )['ext'] ) { $this->redirect( 'import_foreign', 0 ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading an upload PHP has already placed in the temp directory. $contents = file_get_contents( $file['tmp_name'] ); $decoded = is_string( $contents ) ? json_decode( $contents, true ) : null; if ( ! is_array( $decoded ) ) { $this->redirect( 'import_unreadable', 0 ); } // An export from this plugin names itself; one that names another plugin is not ours. if ( isset( $decoded['plugin'] ) && self::EXPORT_MARKER !== $decoded['plugin'] ) { $this->redirect( 'import_foreign', 0 ); } $values = isset( $decoded['settings'] ) && is_array( $decoded['settings'] ) ? $decoded['settings'] : $decoded; $this->redirect( 'imported', $this->apply( $values ) ); } /** * Builds the exported document. * * @param bool $include_secrets Whether credentials are written out. * * @return array The document. */ private function collect( bool $include_secrets ): array { $settings = array(); $skipped = array(); foreach ( SettingsRegistry::map() as $key => $field ) { if ( ! $include_secrets && SettingsRegistry::is_secret( $field ) ) { $skipped[] = $key; continue; } $settings[ $key ] = Settings::get( $key ); } return array( 'plugin' => self::EXPORT_MARKER, 'version' => FORMPILOT_VERSION, 'exported_at' => gmdate( 'c' ), 'site' => home_url( '/' ), /* * Named rather than silently absent. An export that quietly drops the * API keys and an export that never had any look identical on the * other end, and the difference matters when a site does not work * after an import. */ 'omitted' => $skipped, 'settings' => $settings, ); } /** * Stores the values an import supplied. * * @param array $values Values read from the file. */ private function apply( array $values ): int { $fields = SettingsRegistry::map(); $settings = array_merge( Settings::defaults(), (array) get_option( Settings::OPTION, array() ) ); $changed = 0; foreach ( $values as $key => $value ) { $key = (string) $key; // Anything the file carries that this version does not register is // dropped, which is what makes importing a newer file safe. if ( ! isset( $fields[ $key ] ) ) { continue; } $clean = SettingsRegistry::clean( $fields[ $key ], $value ); if ( $clean !== ( $settings[ $key ] ?? null ) ) { ++$changed; } $settings[ $key ] = $clean; } update_option( Settings::OPTION, $settings ); Settings::flush(); return $changed; } /** * Returns the name the download is offered under. */ private function filename(): string { $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); $host = is_string( $host ) ? sanitize_file_name( $host ) : 'site'; return sprintf( 'formpilot-settings-%1$s-%2$s.json', $host, gmdate( 'Y-m-d-His' ) ); } /** * Sends the browser back to the Developer tab with a message. * * @param string $message Message key describing the outcome. * @param int $count Number of settings changed. */ private function redirect( string $message, int $count ): void { wp_safe_redirect( add_query_arg( array( 'page' => SettingsPage::SLUG, 'tab' => 'developer', 'formpilot_message' => $message, 'formpilot_count' => $count, ), admin_url( 'admin.php' ) ) ); exit; } }