From bcf55ff095fb9cbfee37aef24c422fbbf9661562 Mon Sep 17 00:00:00 2001 From: imath Date: Thu, 15 Nov 2018 10:17:56 +0100 Subject: [PATCH] Verify 'upload_files' capability when displaying upload UI in media blocks (#4155) * Use OPTIONS Rest API Media Request to get user `upload_file` capability Edit the preloaded path and adapt the way apiFetch is preloading the requests using the OPTIONS method. Add a hasUploadPermissions property to core/data * Introduce the MediaUploadCheck component Start using it into the MediaPlaceHolder to inform the contributor role he cannot upload media. If the block supports selecting an URL, the instruction will inform the contributor role he can only add a link to a media. * Display a message to the contributor about the featured image As it is not possible to fetch the Featured image for this role, no matter the context of the REST request, display a message to inform him managing the featured image needs the upload files cap. * Enforce strict format of the preloaded path when an array is used This is to bring into Gutenberg the improvement @danielbachhuber committed in WordPress 5.0 branch See https://core.trac.wordpress.org/changeset/43833 * Make sure the Inline Image is only available to users with the upload_files cap * Update generated data-core docs * Apply @gziolo recommandations and fix a failing unit test --- docs/data/data-core.md | 22 +++- lib/client-assets.php | 30 ++++- .../api-fetch/src/middlewares/preloading.js | 6 +- packages/core-data/src/actions.js | 14 +++ packages/core-data/src/reducer.js | 18 +++ packages/core-data/src/resolvers.js | 23 +++- packages/core-data/src/selectors.js | 11 ++ packages/editor/src/components/index.js | 1 + .../src/components/media-placeholder/index.js | 111 ++++++++++++------ .../src/components/media-upload/check.js | 19 +++ .../components/post-featured-image/index.js | 88 ++++++++------ packages/format-library/src/image/index.js | 8 +- 12 files changed, 262 insertions(+), 89 deletions(-) create mode 100644 packages/editor/src/components/media-upload/check.js diff --git a/docs/data/data-core.md b/docs/data/data-core.md index 5310fe2489c81e..2a98c3bb5d3974 100644 --- a/docs/data/data-core.md +++ b/docs/data/data-core.md @@ -136,6 +136,18 @@ get back from the oEmbed preview API. Is the preview for the URL an oEmbed link fallback. +### hasUploadPermissions + +Return Upload Permissions. + +*Parameters* + + * state: State tree. + +*Returns* + +Upload Permissions. + ## Actions ### receiveUserQuery @@ -193,4 +205,12 @@ Action triggered to save an entity record. * kind: Kind of the received entity. * name: Name of the received entity. - * record: Record to be saved. \ No newline at end of file + * record: Record to be saved. + +### receiveUploadPermissions + +Returns an action object used in signalling that Upload permissions have been received. + +*Parameters* + + * hasUploadPermissions: Does the user have permission to upload files? \ No newline at end of file diff --git a/lib/client-assets.php b/lib/client-assets.php index cbb6b077a00d68..f29baebee8d609 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -908,12 +908,22 @@ function gutenberg_preload_api_request( $memo, $path ) { return $memo; } + $method = 'GET'; + if ( is_array( $path ) && 2 === count( $path ) ) { + $method = end( $path ); + $path = reset( $path ); + + if ( ! in_array( $method, array( 'GET', 'OPTIONS' ), true ) ) { + $method = 'GET'; + } + } + $path_parts = parse_url( $path ); if ( false === $path_parts ) { return $memo; } - $request = new WP_REST_Request( 'GET', $path_parts['path'] ); + $request = new WP_REST_Request( $method, $path_parts['path'] ); if ( ! empty( $path_parts['query'] ) ) { parse_str( $path_parts['query'], $query_params ); $request->set_query_params( $query_params ); @@ -928,10 +938,19 @@ function gutenberg_preload_api_request( $memo, $path ) { $data['_links'] = $links; } - $memo[ $path ] = array( - 'body' => $data, - 'headers' => $response->headers, - ); + if ( 'OPTIONS' === $method ) { + $response = rest_send_allow_header( $response, $server, $request ); + + $memo[ $method ][ $path ] = array( + 'body' => $data, + 'headers' => $response->headers, + ); + } else { + $memo[ $path ] = array( + 'body' => $data, + 'headers' => $response->headers, + ); + } } return $memo; @@ -1431,6 +1450,7 @@ function gutenberg_editor_scripts_and_styles( $hook ) { sprintf( '/wp/v2/%s/%s?context=edit', $rest_base, $post->ID ), sprintf( '/wp/v2/types/%s?context=edit', $post_type ), sprintf( '/wp/v2/users/me?post_type=%s&context=edit', $post_type ), + array( '/wp/v2/media', 'OPTIONS' ), ); /** diff --git a/packages/api-fetch/src/middlewares/preloading.js b/packages/api-fetch/src/middlewares/preloading.js index 78f431a984d989..020cb913fced15 100644 --- a/packages/api-fetch/src/middlewares/preloading.js +++ b/packages/api-fetch/src/middlewares/preloading.js @@ -28,12 +28,14 @@ const createPreloadingMiddleware = ( preloadedData ) => ( options, next ) => { } const { parse = true } = options; - if ( typeof options.path === 'string' && parse ) { + if ( typeof options.path === 'string' ) { const method = options.method || 'GET'; const path = getStablePath( options.path ); - if ( 'GET' === method && preloadedData[ path ] ) { + if ( parse && 'GET' === method && preloadedData[ path ] ) { return Promise.resolve( preloadedData[ path ].body ); + } else if ( 'OPTIONS' === method && preloadedData[ method ][ path ] ) { + return Promise.resolve( preloadedData[ method ][ path ] ); } } diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 079ee7ad7eadc3..ebbcfb7e761500 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -127,3 +127,17 @@ export function* saveEntityRecord( kind, name, record ) { return updatedRecord; } + +/** + * Returns an action object used in signalling that Upload permissions have been received. + * + * @param {boolean} hasUploadPermissions Does the user have permission to upload files? + * + * @return {Object} Action object. + */ +export function receiveUploadPermissions( hasUploadPermissions ) { + return { + type: 'RECEIVE_UPLOAD_PERMISSIONS', + hasUploadPermissions, + }; +} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 6d286514a10f67..d37969cbdae145 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -217,6 +217,23 @@ export function embedPreviews( state = {}, action ) { return state; } +/** + * Reducer managing Upload permissions. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function hasUploadPermissions( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_UPLOAD_PERMISSIONS': + return action.hasUploadPermissions; + } + + return state; +} + export default combineReducers( { terms, users, @@ -224,4 +241,5 @@ export default combineReducers( { themeSupports, entities, embedPreviews, + hasUploadPermissions, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 6f6cd0e1d0a8c0..b8bd4ed2382854 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { find } from 'lodash'; +import { find, includes, get, hasIn } from 'lodash'; /** * WordPress dependencies @@ -16,6 +16,7 @@ import { receiveEntityRecords, receiveThemeSupports, receiveEmbedPreview, + receiveUploadPermissions, } from './actions'; import { getKindEntities } from './entities'; import { apiFetch } from './controls'; @@ -97,3 +98,23 @@ export function* getEmbedPreview( url ) { yield receiveEmbedPreview( url, false ); } } + +/** + * Requests Upload Permissions from the REST API. + */ +export function* hasUploadPermissions() { + const response = yield apiFetch( { path: '/wp/v2/media', method: 'OPTIONS', parse: false } ); + + let allowHeader; + if ( hasIn( response, [ 'headers', 'get' ] ) ) { + // If the request is fetched using the fetch api, the header can be + // retrieved using the 'get' method. + allowHeader = response.headers.get( 'allow' ); + } else { + // If the request was preloaded server-side and is returned by the + // preloading middleware, the header will be a simple property. + allowHeader = get( response, [ 'headers', 'Allow' ], '' ); + } + + yield receiveUploadPermissions( includes( allowHeader, 'POST' ) ); +} diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 57e8a758ca3929..95e9f867aa3c02 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -169,3 +169,14 @@ export function isPreviewEmbedFallback( state, url ) { } return preview.html === oEmbedLinkCheck; } + +/** + * Return Upload Permissions. + * + * @param {Object} state State tree. + * + * @return {boolean} Upload Permissions. + */ +export function hasUploadPermissions( state ) { + return state.hasUploadPermissions; +} diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index 55b798561a6022..e5f5aaf4478d01 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -27,6 +27,7 @@ export { export { default as ServerSideRender } from './server-side-render'; export { default as MediaPlaceholder } from './media-placeholder'; export { default as MediaUpload } from './media-upload'; +export { default as MediaUploadCheck } from './media-upload/check'; export { default as URLInput } from './url-input'; export { default as URLInputButton } from './url-input/button'; export { default as URLPopover } from './url-popover'; diff --git a/packages/editor/src/components/media-placeholder/index.js b/packages/editor/src/components/media-placeholder/index.js index 5ac485063b4777..4334aa3b5a2d2f 100644 --- a/packages/editor/src/components/media-placeholder/index.js +++ b/packages/editor/src/components/media-placeholder/index.js @@ -17,11 +17,14 @@ import { } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; /** * Internal dependencies */ import MediaUpload from '../media-upload'; +import MediaUploadCheck from '../media-upload/check'; import URLPopover from '../url-popover'; import { mediaUpload } from '../../utils/'; @@ -132,6 +135,7 @@ class MediaPlaceholder extends Component { multiple = false, notices, allowedTypes = [], + hasUploadPermissions, } = this.props; const { @@ -141,6 +145,11 @@ class MediaPlaceholder extends Component { let instructions = labels.instructions || ''; let title = labels.title || ''; + + if ( ! hasUploadPermissions && ! onSelectURL ) { + instructions = __( 'To edit this block, you need permission to upload media.' ); + } + if ( ! instructions || ! title ) { const isOneType = 1 === allowedTypes.length; const isAudio = isOneType && 'audio' === allowedTypes[ 0 ]; @@ -148,14 +157,26 @@ class MediaPlaceholder extends Component { const isVideo = isOneType && 'video' === allowedTypes[ 0 ]; if ( ! instructions ) { - instructions = __( 'Drag a media file, upload a new one or select a file from your library.' ); + if ( hasUploadPermissions ) { + instructions = __( 'Drag a media file, upload a new one or select a file from your library.' ); - if ( isAudio ) { - instructions = __( 'Drag an audio, upload a new one or select a file from your library.' ); - } else if ( isImage ) { - instructions = __( 'Drag an image, upload a new one or select a file from your library.' ); - } else if ( isVideo ) { - instructions = __( 'Drag a video, upload a new one or select a file from your library.' ); + if ( isAudio ) { + instructions = __( 'Drag an audio, upload a new one or select a file from your library.' ); + } else if ( isImage ) { + instructions = __( 'Drag an image, upload a new one or select a file from your library.' ); + } else if ( isVideo ) { + instructions = __( 'Drag a video, upload a new one or select a file from your library.' ); + } + } else if ( ! hasUploadPermissions && onSelectURL ) { + instructions = __( 'Given your current role, you can only link a media file, you cannot upload.' ); + + if ( isAudio ) { + instructions = __( 'Given your current role, you can only link an audio, you cannot upload.' ); + } else if ( isImage ) { + instructions = __( 'Given your current role, you can only link an image, you cannot upload.' ); + } else if ( isVideo ) { + instructions = __( 'Given your current role, you can only link a video, you cannot upload.' ); + } } } @@ -180,35 +201,37 @@ class MediaPlaceholder extends Component { className={ classnames( 'editor-media-placeholder', className ) } notices={ notices } > - - - { __( 'Upload' ) } - - ( - - ) } - /> + + + + { __( 'Upload' ) } + + ( + + ) } + /> + { onSelectURL && (
- ) } - value={ featuredImageId } - /> + + ( + + ) } + value={ featuredImageId } + /> + } { !! featuredImageId && media && ! media.isLoading && - ( - - ) } - /> - } - { ! featuredImageId && -
+ ( - ) } /> + + } + { ! featuredImageId && +
+ + ( + + ) } + /> +
} { !! featuredImageId && - + + + }
diff --git a/packages/format-library/src/image/index.js b/packages/format-library/src/image/index.js index 7deae883d3b3b4..c26bcca626a5dd 100644 --- a/packages/format-library/src/image/index.js +++ b/packages/format-library/src/image/index.js @@ -3,9 +3,9 @@ */ import { Path, SVG } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { Fragment, Component } from '@wordpress/element'; +import { Component } from '@wordpress/element'; import { insertObject } from '@wordpress/rich-text'; -import { MediaUpload, RichTextInserterItem } from '@wordpress/editor'; +import { MediaUpload, RichTextInserterItem, MediaUploadCheck } from '@wordpress/editor'; const ALLOWED_MEDIA_TYPES = [ 'image' ]; @@ -46,7 +46,7 @@ export const image = { const { value, onChange } = this.props; return ( - + } @@ -73,7 +73,7 @@ export const image = { return null; } } /> } - + ); } },