diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-faces-controller.php b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-faces-controller.php index 8a4040e3397e0c..cb89b5ad3c84ba 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-faces-controller.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-rest-font-faces-controller.php @@ -326,6 +326,14 @@ public function create_item( $request ) { $settings = $request->get_param( 'font_face_settings' ); $file_params = $request->get_file_params(); + if ( ! empty( $file_params ) && ! current_user_can( 'upload_fonts' ) ) { + return new WP_Error( + 'rest_cannot_upload_fonts', + __( 'You are not allowed to upload font files.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + // Check that the necessary font face properties are unique. $query = new WP_Query( array( diff --git a/lib/compat/wordpress-6.5/fonts/fonts.php b/lib/compat/wordpress-6.5/fonts/fonts.php index 8307d5217ad426..4c833f19ef90fe 100644 --- a/lib/compat/wordpress-6.5/fonts/fonts.php +++ b/lib/compat/wordpress-6.5/fonts/fonts.php @@ -85,19 +85,136 @@ function gutenberg_create_initial_post_types() { } /** - * Initializes REST routes. + * Modify the `delete_post` meta capability for font post types. + * + * For font families and font faces containing attached font files, file + * system access is required by the user in order to delete posts. * - * @since 6.5 + * @param string[] $caps The primitive capabilities required for the given capability. + * @param string $cap The capability being checked. + * @param int $user_id The user ID. + * @param array $args Context for the capability check. + * @return string[] The modified primitive capabilities required for the given capability. + */ +function gutenberg_delete_font_post_meta_caps( $caps, $cap, $user_id, $args ) { + if ( in_array( 'do_not_allow', $caps, true ) ) { + // It's already known that the user is not allowed to perform the requested capability. + return $caps; + } + + if ( 'delete_post' !== $cap ) { + // This filter is only concerned with the 'delete_post' meta capability. + return $caps; + } + + $post = get_post( $args[0] ); + if ( ! $post ) { + // Do not allow deleting posts that do not exist. + $caps[] = 'do_not_allow'; + return $caps; + } + + // Check for font post types. + $post_type = get_post_type( $post ); + if ( 'wp_font_face' === $post_type ) { + // Are there any font files associated with this font face? + $font_files = get_post_meta( $post->ID, '_wp_font_face_file', false ); + if ( empty( $font_files ) ) { + /* + * No font files. + * + * The user can delete the post based on the 'delete_post' meta capability. + */ + return $caps; + } + + // The user can only delete the post if they can modify the file system. + $caps[] = 'upload_fonts'; + return $caps; + } + + if ( 'wp_font_family' === $post_type ) { + // Are there any font faces associated with this font family? + $font_faces = get_children( + array( + 'post_parent' => $post->ID, + 'post_type' => 'wp_font_face', + ) + ); + + if ( empty( $font_faces ) ) { + /* + * No font faces. + * + * The user can delete the post based on the 'delete_post' meta capability. + */ + return $caps; + } + + // If any of the font faces contain files, the user needs to be able to modify the file system. + foreach ( $font_faces as $font_face ) { + $font_files = get_post_meta( $font_face->ID, '_wp_font_face_file', false ); + if ( ! empty( $font_files ) ) { + $caps[] = 'upload_fonts'; + // File system caps are required, so no need to check further. + break; + } + } + return $caps; + } + + // Return existing caps if the post type is not a font family or font face. + return $caps; +} +add_filter( 'map_meta_cap', 'gutenberg_delete_font_post_meta_caps', 10, 4 ); + +/** + * Filters the user capabilities to grant the 'upload_fonts' capability as necessary. + * + * To grant the 'upload_fonts' capability, file modifications must be allowed, the fonts directory must be + * writable, and the user must have the 'edit_theme_options' capability. + * + * @since 6.5.0 + * + * @param bool[] $allcaps An array of all the user's capabilities. + * @return bool[] Filtered array of the user's capabilities. + */ +function gutenberg_maybe_grant_upload_font_cap( $allcaps, $caps ) { + if ( ! in_array( 'upload_fonts', $caps, true ) ) { + return $allcaps; + } + + $fonts_dir = wp_get_font_dir()['path']; + $post_type = get_post_type_object( 'wp_font_face' ); + if ( + wp_is_file_mod_allowed( 'can_upload_fonts' ) && + wp_is_writable( $fonts_dir ) && + current_user_can( $post_type->cap->create_posts ) + ) { + $allcaps['upload_fonts'] = true; + } + + return $allcaps; +} +add_filter( 'user_has_cap', 'gutenberg_maybe_grant_upload_font_cap', 10, 2 ); + +/** + * Initializes REST routes. */ function gutenberg_create_initial_rest_routes() { - $font_collections_controller = new WP_REST_Font_Collections_Controller(); - $font_collections_controller->register_routes(); + global $wp_version; + + // Runs only if the Font Library is not available in core ( i.e. in core < 6.5-alpha ). + if ( version_compare( $wp_version, '6.5-alpha', '<' ) ) { + $font_collections_controller = new WP_REST_Font_Collections_Controller(); + $font_collections_controller->register_routes(); + } } +add_action( 'rest_api_init', 'gutenberg_create_initial_rest_routes' ); + /** * Initializes REST routes and post types. - * - * @since 6.5 */ function gutenberg_init_font_library() { global $wp_version; @@ -105,11 +222,10 @@ function gutenberg_init_font_library() { // Runs only if the Font Library is not available in core ( i.e. in core < 6.5-alpha ). if ( version_compare( $wp_version, '6.5-alpha', '<' ) ) { gutenberg_create_initial_post_types(); - gutenberg_create_initial_rest_routes(); } } -add_action( 'rest_api_init', 'gutenberg_init_font_library' ); +add_action( 'init', 'gutenberg_init_font_library' ); if ( ! function_exists( 'wp_register_font_collection' ) ) { @@ -303,6 +419,21 @@ function _wp_before_delete_font_face( $post_id, $post ) { add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 ); } +/** + * Filters the block editor settings to enable or disable font uploads according to user capability. + * + * @since 6.5.0 + * + * @param array $settings Block editor settings. + * @return array Block editor settings. + */ +function gutenberg_font_uploads_settings( $settings ) { + $settings['fontUploadsEnabled'] = current_user_can( 'upload_fonts' ); + + return $settings; +} +add_filter( 'block_editor_settings_all', 'gutenberg_font_uploads_settings' ); + // @core-merge: Do not merge this back compat function, it is for supporting a legacy font family format only in Gutenberg. /** * Convert legacy font family posts to the new format. diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js index 01f7a90357c8ba..955e48858e7089 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js @@ -28,6 +28,8 @@ import { DropdownMenu, } from '@wordpress/components'; import { debounce } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; import { sprintf, __, _x } from '@wordpress/i18n'; import { search, @@ -86,6 +88,11 @@ function FontCollection( { slug } ) { ( collection ) => collection.slug === slug ); + const fontUploadsEnabled = useSelect( ( select ) => { + const { getEditorSettings } = select( editorStore ); + return getEditorSettings().fontUploadsEnabled; + }, [] ); + useEffect( () => { const handleStorage = () => { setRenderConfirmDialog( @@ -416,7 +423,12 @@ function FontCollection( { slug } ) { variant="primary" onClick={ handleInstall } isBusy={ isInstalling } - disabled={ fontsToInstall.length === 0 || isInstalling } + disabled={ + fontsToInstall.length === 0 || + isInstalling || + ( ! fontUploadsEnabled && + selectedFont?.fontFace?.length ) + } __experimentalIsFocusable > { __( 'Install' ) } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/index.js b/packages/edit-site/src/components/global-styles/font-library-modal/index.js index dc0fcd7ea373b0..636545fa87ef26 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/index.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/index.js @@ -6,6 +6,9 @@ import { Modal, privateApis as componentsPrivateApis, } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; import { useContext } from '@wordpress/element'; /** @@ -19,16 +22,15 @@ import { unlock } from '../../../lock-unlock'; const { Tabs } = unlock( componentsPrivateApis ); -const DEFAULT_TABS = [ - { - id: 'installed-fonts', - title: __( 'Library' ), - }, - { - id: 'upload-fonts', - title: __( 'Upload' ), - }, -]; +const DEFAULT_TAB = { + id: 'installed-fonts', + title: __( 'Library' ), +}; + +const UPLOAD_TAB = { + id: 'upload-fonts', + title: __( 'Upload' ), +}; const tabsFromCollections = ( collections ) => collections.map( ( { slug, name } ) => ( { @@ -44,11 +46,24 @@ function FontLibraryModal( { defaultTabId = 'installed-fonts', } ) { const { collections, setNotice } = useContext( FontLibraryContext ); + const canUserCreate = useSelect( ( select ) => { + const { canUser } = select( coreStore ); + return canUser( 'create', 'font-families' ); + }, [] ); + const fontUploadsEnabled = useSelect( ( select ) => { + const { getEditorSettings } = select( editorStore ); + return getEditorSettings().fontUploadsEnabled; + }, [] ); + + const tabs = [ DEFAULT_TAB ]; + + if ( canUserCreate ) { + if ( fontUploadsEnabled ) { + tabs.push( UPLOAD_TAB ); + } - const tabs = [ - ...DEFAULT_TABS, - ...tabsFromCollections( collections || [] ), - ]; + tabs.push( ...tabsFromCollections( collections || [] ) ); + } // Reset notice when new tab is selected. const onSelect = () => { diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js index a72b7d59d150cf..6cc2233ac23dcb 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js @@ -18,6 +18,8 @@ import { Spinner, privateApis as componentsPrivateApis, } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; import { useContext, useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { chevronLeft } from '@wordpress/icons'; @@ -50,8 +52,24 @@ function InstalledFonts() { } = useContext( FontLibraryContext ); const [ isConfirmDeleteOpen, setIsConfirmDeleteOpen ] = useState( false ); + const customFontFamilyId = + libraryFontSelected?.source === 'custom' && libraryFontSelected?.id; + + const canUserDelete = useSelect( + ( select ) => { + const { canUser } = select( coreStore ); + return ( + customFontFamilyId && + canUser( 'delete', 'font-families', customFontFamilyId ) + ); + }, + [ customFontFamilyId ] + ); + const shouldDisplayDeleteButton = - !! libraryFontSelected && libraryFontSelected?.source !== 'theme'; + !! libraryFontSelected && + libraryFontSelected?.source !== 'theme' && + canUserDelete; const handleUninstallClick = () => { setIsConfirmDeleteOpen( true ); diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js index e4f86b3a7dfb21..1eac4c726ddd3a 100644 --- a/packages/editor/src/store/defaults.js +++ b/packages/editor/src/store/defaults.js @@ -10,6 +10,7 @@ import { SETTINGS_DEFAULTS } from '@wordpress/block-editor'; * @property {boolean} richEditingEnabled Whether rich editing is enabled or not * @property {boolean} codeEditingEnabled Whether code editing is enabled or not * @property {boolean} fontLibraryEnabled Whether the font library is enabled or not. + * @property {boolean} fontUploadsEnabled Whether uploading fonts in the font library is enabled or not. * @property {boolean} enableCustomFields Whether the WordPress custom fields are enabled or not. * true = the user has opted to show the Custom Fields panel at the bottom of the editor. * false = the user has opted to hide the Custom Fields panel at the bottom of the editor. @@ -28,6 +29,7 @@ export const EDITOR_SETTINGS_DEFAULTS = { richEditingEnabled: true, codeEditingEnabled: true, fontLibraryEnabled: true, + fontUploadsEnabled: true, enableCustomFields: undefined, defaultRenderingMode: 'post-only', }; diff --git a/phpunit/tests/fonts/font-library/deleteFontPostMetaCaps.php b/phpunit/tests/fonts/font-library/deleteFontPostMetaCaps.php new file mode 100644 index 00000000000000..063e75af798985 --- /dev/null +++ b/phpunit/tests/fonts/font-library/deleteFontPostMetaCaps.php @@ -0,0 +1,77 @@ +assertSame( $caps, $result, 'The original capabilities should not be changed when do_not_allow is present.' ); + } + + public function test_should_return_original_caps_if_not_delete_post_capability() { + $caps = array( 'my_capability' ); + $result = gutenberg_delete_font_post_meta_caps( $caps, 'some_cap', 1, array( 999 ) ); + ksort( $caps ); + ksort( $result ); + $this->assertSame( $caps, $result, 'The original capabilities should not be changed if no delete_post capability.' ); + } + + public function test_should_add_do_not_allow_for_non_existent_post() { + $caps = array( 'my_capability' ); + $result = gutenberg_delete_font_post_meta_caps( $caps, 'delete_post', 1, array( 999 ) ); + $this->assertContainsEquals( 'do_not_allow', $result ); + } + + public function test_should_return_original_caps_for_wp_font_face_posts_with_no_files() { + $post_id = $this->factory->post->create( array( 'post_type' => 'wp_font_face' ) ); + $result = gutenberg_delete_font_post_meta_caps( array(), 'delete_post', 1, array( $post_id ) ); + $this->assertEquals( array(), $result, 'Capabilities should remain unchanged for "wp_font_face" post type without associated files.' ); + } + + public function test_should_include_upload_fonts_cap_for_font_post_types_with_files() { + $post_id = $this->factory->post->create( array( 'post_type' => 'wp_font_face' ) ); + add_post_meta( $post_id, '_wp_font_face_file', 'path/to/font/file' ); + $caps = array(); + $result = gutenberg_delete_font_post_meta_caps( $caps, 'delete_post', 1, array( $post_id ) ); + $this->assertContainsEquals( 'upload_fonts', $result, 'Capabilities should include "upload_fonts" for "wp_font_face" post type with associated files.' ); + } + + public function test_should_return_original_caps_for_wp_font_family_posts_with_no_files() { + $post_id = $this->factory->post->create( array( 'post_type' => 'wp_font_family' ) ); + $caps = array(); + $result = gutenberg_delete_font_post_meta_caps( $caps, 'delete_post', 1, array( $post_id ) ); + $this->assertEquals( $caps, $result, 'Capabilities should remain unchanged for "wp_font_family" post type without associated font faces.' ); + } + + public function test_should_include_upload_fonts_cap_for_font_faces_types_with_files() { + $font_family_post_id = $this->factory->post->create( array( 'post_type' => 'wp_font_family' ) ); + $font_face_post_id = $this->factory->post->create( + array( + 'post_type' => 'wp_font_face', + 'post_parent' => $font_family_post_id, + ) + ); + add_post_meta( $font_face_post_id, '_wp_font_face_file', 'path/to/font/file' ); + + $result = gutenberg_delete_font_post_meta_caps( array(), 'delete_post', 1, array( $font_family_post_id ) ); + $this->assertContains( 'upload_fonts', $result, 'Capabilities should include "upload_fonts" for "wp_font_family" post type with at least one associated font face having files.' ); + } + + public function test_should_return_original_caps_for_non_font_post_types() { + $caps = array( 'edit_posts' ); + $post_id = $this->factory->post->create( array( 'post_type' => 'post' ) ); + $result = gutenberg_delete_font_post_meta_caps( $caps, 'delete_post', 1, array( $post_id ) ); + $this->assertEquals( $caps, $result, 'Capabilities should remain unchanged for non-font post types.' ); + } +} diff --git a/phpunit/tests/fonts/font-library/maybeGrantUploadFontCap.php b/phpunit/tests/fonts/font-library/maybeGrantUploadFontCap.php new file mode 100644 index 00000000000000..685ed2d7617403 --- /dev/null +++ b/phpunit/tests/fonts/font-library/maybeGrantUploadFontCap.php @@ -0,0 +1,96 @@ + get_temp_dir() ); + } + + /** + * Mock the wp_is_file_mod_allowed function to return true. + * + * @param string $context + * @return bool + */ + public function mock_wp_is_file_mod_allowed( $context ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + return true; + } + + /** + * Mock the wp_is_file_mod_allowed function to return false. + * + * @param string $context + * @return bool + */ + public function mock_wp_is_file_mod_allowed_return_false( $context ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + return false; + } + + /** + * Mocks the current_user_can function to conditionally modify capabilities. + * + * @param string[] $caps Current user's capabilities. + * @param string $cap Capability being checked. + * @param int $user_id ID of the user being checked. + * @param array $args Additional arguments for capability check. + * @return string[] Modified capabilities array. + */ + public function mock_current_user_can( $caps, $cap, $user_id, $args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( 'edit_theme_options' === $cap ) { + return array( 'exist' ); + } + + return $caps; + } + + public function test_should_not_grant_upload_fonts_if_not_in_caps() { + $caps = array( 'edit_posts' ); // 'upload_fonts' is not in $caps + $result = gutenberg_maybe_grant_upload_font_cap( array(), $caps ); + $this->assertArrayNotHasKey( 'upload_fonts', $result, 'upload_fonts capability should not be granted.' ); + } + + public function test_should_grant_upload_fonts_under_correct_conditions() { + add_filter( 'file_mod_allowed', array( $this, 'mock_wp_is_file_mod_allowed' ) ); + + $caps = array( 'upload_fonts' ); + $result = gutenberg_maybe_grant_upload_font_cap( array(), $caps ); + + $this->assertArrayHasKey( 'upload_fonts', $result, 'upload_fonts capability should be granted.' ); + $this->assertTrue( $result['upload_fonts'], 'upload_fonts capability should be true.' ); + } + + public function test_should_not_grant_upload_fonts_if_conditions_not_met() { + add_filter( 'file_mod_allowed', array( $this, 'mock_wp_is_file_mod_allowed_return_false' ) ); + + $result = gutenberg_maybe_grant_upload_font_cap( array(), array( 'upload_fonts' ) ); + $this->assertArrayNotHasKey( 'upload_fonts', $result, 'upload_fonts capability should not be granted under incorrect conditions.' ); + } +}