Commit realizado el 12:13:52 08-04-2024

This commit is contained in:
Pagina Web Monito
2024-04-08 12:13:55 -04:00
commit 0c33094de9
7815 changed files with 1365694 additions and 0 deletions

View File

@@ -0,0 +1,585 @@
<?php
/**
* Class Embed_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use DOMElement;
use DOMNode;
use DOMNodeList;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
use WP_Error;
use WP_Http;
use WP_Network;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Embed controller class.
*
* API endpoint to facilitate embedding web stories.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* }
*
* @phpstan-type Schema array{
* properties: array<string, SchemaEntry>
* }
*/
class Embed_Controller extends REST_Controller implements HasRequirements {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Constructor.
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
$this->namespace = 'web-stories/v1';
$this->rest_base = 'embed';
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type' ];
}
/**
* Registers routes for links.
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_proxy_item' ],
'permission_callback' => [ $this, 'get_proxy_item_permissions_check' ],
'args' => [
'url' => [
'description' => __( 'The URL for which to fetch embed data.', 'web-stories' ),
'required' => true,
'type' => 'string',
'format' => 'uri',
],
],
],
]
);
}
/**
* Callback for the Web Stories embed endpoints.
*
* Returns information about the given story.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.0.0
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_proxy_item( $request ) {
/**
* Requested URL.
*
* @var string $url
*/
$url = $request['url'];
$url = urldecode( untrailingslashit( $url ) );
if ( empty( $url ) ) {
return new \WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
}
/**
* Filters the link data TTL value.
*
* @since 1.0.0
*
* @param int $time Time to live (in seconds). Default is 1 day.
* @param string $url The attempted URL.
*/
$cache_ttl = apply_filters( 'web_stories_embed_data_cache_ttl', DAY_IN_SECONDS, $url );
$cache_key = 'web_stories_embed_data_' . md5( $url );
$data = get_transient( $cache_key );
if ( \is_string( $data ) && ! empty( $data ) ) {
/**
* Decoded cached embed data.
*
* @var array<string,mixed>|null $embed
*/
$embed = json_decode( $data, true );
if ( $embed ) {
$response = $this->prepare_item_for_response( $embed, $request );
return rest_ensure_response( $response );
}
}
$data = $this->get_data_from_post( $url );
if ( $data ) {
$response = $this->prepare_item_for_response( $data, $request );
return rest_ensure_response( $response );
}
$args = [
'limit_response_size' => 153_600, // 150 KB.
'timeout' => 7, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
];
/**
* Filters the HTTP request args for link data retrieval.
*
* Can be used to adjust timeout and response size limit.
*
* @since 1.0.0
*
* @param array<string,mixed> $args Arguments used for the HTTP request
* @param string $url The attempted URL.
*
* @phpstan-param array{
* method?: string,
* timeout?: float,
* redirection?: int,
* httpversion?: string,
* user-agent?: string,
* reject_unsafe_urls?: bool,
* blocking?: bool,
* headers?: string|array,
* cookies?: array,
* body?: string|array,
* compress?: bool,
* decompress?: bool,
* sslverify?: bool,
* sslcertificates?: string,
* stream?: bool,
* filename?: string,
* limit_response_size?: int,
* } $args
*/
$args = apply_filters( 'web_stories_embed_data_request_args', $args, $url );
$response = wp_safe_remote_get( $url, $args );
if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) {
// Not saving the error response to cache since the error might be temporary.
return new \WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
}
$html = wp_remote_retrieve_body( $response );
if ( ! $html ) {
return new \WP_Error( 'rest_invalid_story', __( 'URL is not a story', 'web-stories' ), [ 'status' => 404 ] );
}
$data = $this->get_data_from_document( $html );
if ( ! $data ) {
return new \WP_Error( 'rest_invalid_story', __( 'URL is not a story', 'web-stories' ), [ 'status' => 404 ] );
}
$response = $this->prepare_item_for_response( $data, $request );
set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl );
return rest_ensure_response( $response );
}
/**
* Prepares a single embed output for response.
*
* @since 1.10.0
*
* @param array<string, mixed>|false $embed Embed value, default to false is not set.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response object.
*/
public function prepare_item_for_response( $embed, $request ) {
$fields = $this->get_fields_for_response( $request );
$schema = $this->get_item_schema();
$data = [];
if ( \is_array( $embed ) ) {
$check_fields = array_keys( $embed );
foreach ( $check_fields as $check_field ) {
if ( ! empty( $schema['properties'][ $check_field ] ) && rest_is_field_included( $check_field, $fields ) ) {
$data[ $check_field ] = rest_sanitize_value_from_schema( $embed[ $check_field ], $schema['properties'][ $check_field ] );
}
}
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
return rest_ensure_response( $data );
}
/**
* Retrieves the link's schema, conforming to JSON Schema.
*
* @since 1.10.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'embed',
'type' => 'object',
'properties' => [
'title' => [
'description' => __( 'Embed\'s title', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
'poster' => [
'description' => __( 'Embed\'s image', 'web-stories' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
],
],
];
$this->schema = $schema;
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Checks if current user can process links.
*
* @since 1.0.0
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function get_proxy_item_permissions_check() {
if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) {
return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to make proxied embed requests.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] );
}
return true;
}
/**
* Retrieves the story metadata for a given URL on the current site.
*
* @since 1.0.0
*
* @param string $url The URL that should be inspected for metadata.
* @return array{title: string, poster: string}|false Story metadata if the URL does belong to the current site. False otherwise.
*/
private function get_data_from_post( string $url ) {
$post = $this->url_to_post( $url );
if ( ! $post || $this->story_post_type->get_slug() !== $post->post_type ) {
return false;
}
return $this->get_data_from_document( $post->post_content );
}
/**
* Examines a URL and try to determine the post it represents.
*
* Checks are supposedly from the hosted site blog.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.2.0
*
* @see get_oembed_response_data_for_url
* @see url_to_postid
*
* @param string $url Permalink to check.
* @return WP_Post|null Post object on success, null otherwise.
*/
private function url_to_post( $url ): ?WP_Post {
$post = null;
$switched_blog = $this->maybe_switch_site( $url );
if ( \function_exists( 'wpcom_vip_url_to_postid' ) ) {
$post_id = wpcom_vip_url_to_postid( $url );
} else {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions
$post_id = url_to_postid( $url );
}
if ( $post_id ) {
$post = get_post( $post_id );
}
if ( ! $post_id ) {
// url_to_postid() does not recognize plain permalinks like https://example.com/?web-story=my-story.
// Let's check for that ourselves.
/**
* The URL's hostname.
*
* @var string|false|null $url_host
*/
$url_host = wp_parse_url( $url, PHP_URL_HOST );
if ( $url_host ) {
$url_host = str_replace( 'www.', '', $url_host );
}
/**
* The home URL's hostname.
*
* @var string|false|null $home_url_host
*/
$home_url_host = wp_parse_url( home_url(), PHP_URL_HOST );
if ( $home_url_host ) {
$home_url_host = str_replace( 'www.', '', $home_url_host );
}
if ( $url_host && $home_url_host && $url_host === $home_url_host ) {
$values = [];
if (
preg_match(
'#[?&](' . preg_quote( $this->story_post_type->get_slug(), '#' ) . ')=([^&]+)#',
$url,
$values
)
) {
$slug = $values[2];
if ( \function_exists( 'wpcom_vip_get_page_by_path' ) ) {
$post = wpcom_vip_get_page_by_path( $slug, OBJECT, $this->story_post_type->get_slug() );
} else {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions
$post = get_page_by_path( $slug, OBJECT, $this->story_post_type->get_slug() );
}
}
}
}
if ( $switched_blog ) {
restore_current_blog();
}
if ( ! $post instanceof WP_Post ) {
return null;
}
return $post;
}
/**
* Maybe switch to site.
*
* @since 1.29.0
*
* @param string $url Permalink to check.
*/
private function maybe_switch_site( $url ): bool {
if ( ! is_multisite() ) {
return false;
}
$switched_blog = false;
/**
* URL parts.
*
* @var array<string, string>|false $url_parts
*/
$url_parts = wp_parse_url( $url );
if ( ! $url_parts ) {
$url_parts = [];
}
$url_parts = wp_parse_args(
$url_parts,
[
'host' => '',
'path' => '/',
]
);
$qv = [
'domain' => $url_parts['host'],
'path' => '/',
'number' => 1,
'update_site_cache' => false,
'update_site_meta_cache' => false,
];
// In case of subdirectory configs, set the path.
if ( ! is_subdomain_install() ) {
// Get "sub-site" part of "http://example.org/sub-site/web-stories/my-story/".
// But given just "http://example.org/web-stories/my-story/", don't treat "web-stories" as site path.
// This differs from the logic in get_oembed_response_data_for_url() which does not do this.
// TODO: Investigate possible core bug in get_oembed_response_data_for_url()?
$path = explode( '/', ltrim( $url_parts['path'], '/' ) );
$path = \count( $path ) > 2 ? reset( $path ) : false;
$network = get_network();
if ( $path && $network instanceof WP_Network ) {
$qv['path'] = $network->path . $path . '/';
}
}
$sites = (array) get_sites( $qv );
$site = reset( $sites );
if ( $site && get_current_blog_id() !== (int) $site->blog_id ) {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.switch_to_blog_switch_to_blog
switch_to_blog( (int) $site->blog_id );
$switched_blog = true;
}
return $switched_blog;
}
/**
* Parses an HTML document to and returns the story's title and poster.
*
* @since 1.0.0
*
* @param string $html HTML document markup.
* @return array{title: string, poster: string}|false Response data or false if document is not a story.
*/
private function get_data_from_document( string $html ) {
try {
$doc = Document::fromHtml( $html );
} catch ( \DOMException $exception ) {
return false;
}
if ( ! $doc ) {
return false;
}
/**
* List of <amp-story> elements.
*
* @var DOMNodeList<DOMElement>|false $amp_story
*/
$amp_story = $doc->xpath->query( '//amp-story' );
if ( ! $amp_story instanceof DOMNodeList || 0 === $amp_story->length ) {
return false;
}
$title = $this->get_dom_attribute_content( $amp_story, 'title' );
$poster = $this->get_dom_attribute_content( $amp_story, 'poster-portrait-src' );
return [
'title' => $title ?: '',
'poster' => $poster ?: '',
];
}
/**
* Retrieve content of a given DOM node attribute.
*
* @since 1.0.0
*
* @param DOMNodeList<DOMElement>|false $query XPath query result.
* @param string $attribute Attribute name.
* @return string|false Attribute content on success, false otherwise.
*/
protected function get_dom_attribute_content( $query, string $attribute ) {
if ( ! $query instanceof DOMNodeList || 0 === $query->length ) {
return false;
}
/**
* DOMElement
*
* @var DOMElement|DOMNode $node
*/
$node = $query->item( 0 );
if ( ! $node instanceof DOMElement ) {
return false;
}
return $node->getAttribute( $attribute );
}
}

View File

@@ -0,0 +1,707 @@
<?php
/**
* Class Font_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use stdClass;
use WP_Error;
use WP_Post;
use WP_Query;
use WP_REST_Posts_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Font_Controller class.
*
* @phpstan-type Font array{
* family: string,
* fallbacks?: string[],
* weights?: array<int, array{0: int, 1: int}>,
* styles?: string[],
* variants?: string[],
* service?: string,
* metrics?: mixed,
* id?: string,
* url?: string
* }
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* }
* @phpstan-type Schema array{
* properties: array{
* family?: SchemaEntry,
* fallbacks?: SchemaEntry,
* weights?: SchemaEntry,
* styles?: SchemaEntry,
* variants?: SchemaEntry,
* service?: SchemaEntry,
* metrics?: SchemaEntry,
* id?: SchemaEntry,
* url?: SchemaEntry
* }
* }
*/
class Font_Controller extends WP_REST_Posts_Controller {
/**
* Registers the routes for posts.
*
* @since 1.16.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
'args' => $this->get_collection_params(),
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_item' ],
'permission_callback' => [ $this, 'create_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
[
'args' => [
'id' => [
'description' => __( 'Unique identifier for the font.', 'web-stories' ),
'type' => 'integer',
],
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_item' ],
'permission_callback' => [ $this, 'delete_item_permissions_check' ],
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
}
/**
* Retrieves a collection of fonts.
*
* @since 1.16.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
/**
* Fonts list.
*
* @phpstan-var Font[] $fonts
*/
$fonts = [];
// Retrieve the list of registered collection query parameters.
$registered = $this->get_collection_params();
if ( isset( $registered['service'], $request['service'] ) ) {
if ( 'all' === $request['service'] || 'builtin' === $request['service'] ) {
array_push( $fonts, ...$this->get_builtin_fonts() );
// For custom fonts the searching will be done in WP_Query already.
if ( isset( $registered['search'], $request['search'] ) && ! empty( $request['search'] ) ) {
/**
* Requested URL.
*
* @var string $search
*/
$search = $request['search'];
$fonts = array_values(
array_filter(
$fonts,
/**
* Font data.
*
* @param array{family: string} $font
* @return bool
*/
static fn( array $font ) => false !== stripos( $font['family'], $search )
)
);
}
}
if ( 'all' === $request['service'] || 'custom' === $request['service'] ) {
array_push( $fonts, ...$this->get_custom_fonts( $request ) );
}
// Filter before doing any sorting.
if ( isset( $registered['include'], $request['include'] ) && ! empty( $request['include'] ) ) {
/**
* Include list.
*
* @var array{string} $include_list
*/
$include_list = $request['include'];
$include_list = array_map( 'strtolower', $include_list );
$fonts = array_values(
array_filter(
$fonts,
/**
* Font data.
*
* @param array{family: string} $font
* @return bool
*/
static fn( array $font ): bool => \in_array( strtolower( $font['family'] ), $include_list, true )
)
);
}
if ( 'all' === $request['service'] ) {
// Since the built-in fonts and custom fonts both are already sorted,
// we only need to sort when including both.
usort(
$fonts,
/**
* Font A and Font B.
*
* @param Font $a
* @param Font $b
* @return int
*/
static fn( array $a, array $b ): int => strnatcasecmp( $a['family'], $b['family'] )
);
}
}
return rest_ensure_response( $fonts );
}
/**
* Checks if a given request has access to read posts.
*
* @since 1.16.0
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function get_items_permissions_check( $request ) {
$post_type = get_post_type_object( $this->post_type );
if (
! $post_type ||
! current_user_can( $post_type->cap->read_post ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined
) {
return new \WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to list fonts.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
/**
* Force-deletes a single font.
*
* @since 1.16.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function delete_item( $request ) {
$request->set_param( 'force', true );
return parent::delete_item( $request );
}
/**
* Prepares a single post output for response.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.16.0
*
* @param WP_Post $item Post object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $item, $request ): WP_REST_Response {
// Restores the more descriptive, specific name for use within this method.
$post = $item;
$GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
setup_postdata( $post );
$fields = $this->get_fields_for_response( $request );
$data = [];
if ( rest_is_field_included( 'id', $fields ) ) {
$data['id'] = $post->ID;
}
if ( rest_is_field_included( 'family', $fields ) ) {
$data['family'] = $post->post_title;
}
if ( rest_is_field_included( 'service', $fields ) ) {
$data['service'] = 'custom';
}
/**
* Font data.
*
* @var array<string, mixed>|null $font_data
*/
$font_data = json_decode( $post->post_content, true );
if ( $font_data ) {
foreach ( $font_data as $key => $value ) {
if ( rest_is_field_included( $key, $fields ) ) {
$data[ $key ] = $value;
}
}
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
/**
* Response object.
*
* @var WP_REST_Response $response
*/
$response = rest_ensure_response( $data );
if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
$links = $this->prepare_links( $post );
$response->add_links( $links );
if ( ! empty( $links['self']['href'] ) ) {
$actions = $this->get_available_actions( $post, $request );
$self = $links['self']['href'];
foreach ( $actions as $rel ) {
$response->add_link( $rel, $self );
}
}
}
return $response;
}
/**
* Retrieves the query params for the fonts collection.
*
* @since 1.16.0
*
* @return array<string, array<string, mixed>> Collection parameters.
*/
public function get_collection_params(): array {
$query_params = parent::get_collection_params();
$query_params['context']['default'] = 'view';
$query_params['search'] = [
'description' => __( 'Limit results to those matching a string.', 'web-stories' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
];
$query_params['include'] = [
'description' => __( 'Limit result set to specific fonts.', 'web-stories' ),
'type' => 'array',
'items' => [
'type' => 'string',
],
'default' => [],
];
$query_params['service'] = [
'description' => __( 'Filter fonts by service.', 'web-stories' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => 'all',
'enum' => [
'all',
'custom',
'builtin', // system + fonts.google.com.
],
];
/** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
return apply_filters( "rest_{$this->post_type}_collection_params", $query_params, $this->post_type );
}
/**
* Retrieves the font's schema, conforming to JSON Schema.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.16.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => $this->post_type,
'type' => 'object',
// Base properties for every font.
'properties' => [
'family' => [
'description' => __( 'The font family', 'web-stories' ),
'type' => [ 'string', 'null' ],
'context' => [ 'view', 'edit', 'embed' ],
'required' => true,
],
'fallbacks' => [
'description' => __( 'Fallback fonts', 'web-stories' ),
'type' => 'array',
'items' => [
'type' => 'string',
],
'context' => [ 'view', 'edit' ],
'required' => true,
],
'weights' => [
'description' => __( 'Font weights', 'web-stories' ),
'type' => 'array',
'items' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => 900,
],
'minimum' => 1,
'context' => [ 'view', 'edit' ],
'required' => true,
],
'styles' => [
'description' => __( 'Font styles', 'web-stories' ),
'type' => 'array',
'items' => [
'type' => 'string',
],
'minimum' => 1,
'context' => [ 'view', 'edit' ],
'required' => true,
],
'variants' => [
'description' => __( 'Font variants', 'web-stories' ),
'type' => 'array',
'items' => [
'type' => 'array',
'items' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => 900,
],
'minimum' => 2,
'maximum' => 2,
],
'context' => [ 'view', 'edit' ],
'required' => true,
],
'service' => [
'description' => __( 'Font service', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'metrics' => [
'description' => __( 'Font metrics', 'web-stories' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'id' => [
'description' => __( 'Unique identifier for the font.', 'web-stories' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'url' => [
'description' => __( 'Font URL.', 'web-stories' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
'required' => true,
],
],
];
$this->schema = $schema;
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Returns a list of Google fonts.
*
* @since 1.16.0
*
* @return array<int, mixed> List of Google fonts.
*
* @phpstan-return Font[]
*/
protected function get_builtin_fonts(): array {
$file = WEBSTORIES_PLUGIN_DIR_PATH . 'includes/data/fonts/fonts.json';
if ( ! is_readable( $file ) ) {
return [];
}
$content = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown
if ( ! $content ) {
return [];
}
/**
* List of Google Fonts.
*
* @var array|null $fonts
* @phpstan-var Font[]|null $fonts
*/
$fonts = json_decode( $content, true );
if ( ! $fonts ) {
return [];
}
return $fonts;
}
/**
* Returns a list of custom fonts.
*
* @since 1.16.0
*
* @param WP_REST_Request $request Full details about the request.
* @return array<int, mixed> List of custom fonts.
*
* @phpstan-return Font[]
*/
protected function get_custom_fonts( $request ): array {
// Retrieve the list of registered collection query parameters.
$registered = $this->get_collection_params();
$args = [
'orderby' => 'title',
'order' => 'ASC',
];
/*
* This array defines mappings between public API query parameters whose
* values are accepted as-passed, and their internal WP_Query parameter
* name equivalents (some are the same). Only values which are also
* present in $registered will be set.
*/
$parameter_mappings = [
'search' => 's',
];
/*
* For each known parameter which is both registered and present in the request,
* set the parameter's value on the query $args.
*/
foreach ( $parameter_mappings as $api_param => $wp_param ) {
if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
$args[ $wp_param ] = $request[ $api_param ];
}
}
// Ensure our per_page parameter overrides any provided posts_per_page filter.
if ( isset( $registered['per_page'] ) ) {
$args['posts_per_page'] = $request['per_page'];
}
// Force search to be case-insensitive.
// Force the post_type argument, since it's not a user input variable.
$args['post_type'] = $this->post_type;
$query_args = $this->prepare_items_query( $args, $request );
$posts_query = new WP_Query();
$query_result = $posts_query->query( $query_args );
/**
* List of custom fonts.
*
* @var array $posts
* @phpstan-var Font[] $posts
*/
$posts = [];
/**
* We're expecting a post object.
*
* @var WP_Post $post
*/
foreach ( $query_result as $post ) {
if ( ! $this->check_read_permission( $post ) ) {
continue;
}
$data = $this->prepare_item_for_response( $post, $request );
$posts[] = $this->prepare_response_for_collection( $data );
}
// Reset filter.
if ( 'edit' === $request['context'] ) {
remove_filter( 'post_password_required', [ $this, 'check_password_required' ] );
}
return $posts;
}
/**
* Prepares a single post for create.
*
* @since 1.16.0
*
* @param WP_REST_Request $request Request object.
* @return stdClass|WP_Error Post object or WP_Error.
*/
protected function prepare_item_for_database( $request ) {
$prepared_post = new stdClass();
$prepared_post->post_status = 'publish';
$font_data = [];
$fields = [
'family',
'fallbacks',
'weights',
'styles',
'variants',
'metrics',
'url',
];
$schema = $this->get_item_schema();
foreach ( $fields as $field ) {
if ( ! empty( $schema['properties'][ $field ] ) && ! empty( $request[ $field ] ) ) {
$font_data[ $field ] = $request[ $field ];
if ( 'family' === $field ) {
/**
* Request data.
*
* @var array{family: string} $request
*/
$font_family = trim( $request['family'] );
$prepared_post->post_title = $font_family;
if ( $this->font_exists( $font_family ) ) {
return new \WP_Error(
'rest_invalid_field',
__( 'A font with this name already exists', 'web-stories' ),
[ 'status' => 400 ]
);
}
}
}
}
$prepared_post->post_content = wp_json_encode( $font_data );
return $prepared_post;
}
/**
* Determines whether a font with the same name already exists.
*
* Performs a case-insensitive comparison.
*
* @since 1.16.0
*
* @param string $font_family Font family.
* @return bool Whether a font with this exact name already exists.
*/
private function font_exists( string $font_family ): bool {
$request = new WP_REST_Request(
WP_REST_Server::READABLE,
$this->namespace .
'/' . $this->rest_base
);
$request->set_param( 'include', [ $font_family ] );
$request->set_param( 'service', 'all' );
/**
* Response object.
*
* @var WP_REST_Response $response
*/
$response = $this->get_items( $request );
return ! empty( $response->get_data() );
}
}

View File

@@ -0,0 +1,873 @@
<?php
/**
* Class Hotlinking_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Media\Types;
use Google\Web_Stories\Story_Post_Type;
use WP_Error;
use WP_Http;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Hotlinking_Controller class.
*
* API endpoint for pinging and hotlinking media URLs.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type LinkData array{
* ext?: string,
* file_name?: string,
* file_size?: int,
* mime_type?: string,
* type?: string
* }
*
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* enum?: string[]
* }
*
* @phpstan-type Schema array{
* properties: array{
* ext?: SchemaEntry,
* file_name?: SchemaEntry,
* file_size?: SchemaEntry,
* mime_type?: SchemaEntry,
* type?: SchemaEntry
* }
* }
* @phpstan-type URLParts array{
* scheme?: string,
* user?: string,
* pass?: string,
* host?: string,
* port?: int,
* path?: string,
* query?: string
* }
*/
class Hotlinking_Controller extends REST_Controller implements HasRequirements {
public const PROXY_HEADERS_ALLOWLIST = [
'Content-Type',
'Cache-Control',
'Etag',
'Last-Modified',
'Content-Range',
];
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Types instance.
*
* @var Types Types instance.
*/
private Types $types;
/**
* File pointer resource.
*
* @var resource
*/
protected $stream_handle;
/**
* Constructor.
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Types $types Types instance.
* @return void
*/
public function __construct( Story_Post_Type $story_post_type, Types $types ) {
$this->story_post_type = $story_post_type;
$this->types = $types;
$this->namespace = 'web-stories/v1';
$this->rest_base = 'hotlink';
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type' ];
}
/**
* Registers routes for urls.
*
* @since 1.11.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/validate',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'parse_url' ],
'permission_callback' => [ $this, 'parse_url_permissions_check' ],
'args' => [
'url' => [
'description' => __( 'The URL to process.', 'web-stories' ),
'required' => true,
'type' => 'string',
'format' => 'uri',
'validate_callback' => [ $this, 'validate_callback' ],
'sanitize_callback' => 'esc_url_raw',
],
],
],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/proxy',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'proxy_url' ],
'permission_callback' => [ $this, 'parse_url_permissions_check' ],
'args' => [
'url' => [
'description' => __( 'The URL to process.', 'web-stories' ),
'required' => true,
'type' => 'string',
'format' => 'uri',
'validate_callback' => [ $this, 'validate_callback' ],
'sanitize_callback' => 'esc_url_raw',
],
],
],
]
);
}
/**
* Parses a URL to return some metadata for inserting external media.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.11.0
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function parse_url( WP_REST_Request $request ) {
/**
* Requested URL.
*
* @var string $raw_url
*/
$raw_url = $request['url'];
$raw_url = untrailingslashit( $raw_url );
$url_or_ip = $this->validate_url( $raw_url );
$host = wp_parse_url( $raw_url, PHP_URL_HOST );
if ( ! $url_or_ip || ! $host ) {
return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
}
/**
* Filters the hotlinking data TTL value.
*
* @since 1.11.0
*
* @param int $time Time to live (in seconds). Default is 1 day.
* @param string $url The attempted URL.
*/
$cache_ttl = apply_filters( 'web_stories_hotlinking_url_data_cache_ttl', DAY_IN_SECONDS, $raw_url );
$cache_key = 'web_stories_url_data_' . md5( $raw_url );
$data = get_transient( $cache_key );
if ( \is_string( $data ) && ! empty( $data ) ) {
/**
* Decoded cached link data.
*
* @var array|null $link
* @phpstan-var LinkData|null $link
*/
$link = json_decode( $data, true );
if ( $link ) {
$response = $this->prepare_item_for_response( $link, $request );
return rest_ensure_response( $response );
}
}
$callback = $this->get_curl_resolve_callback( $raw_url, $url_or_ip );
add_action( 'http_api_curl', $callback );
$response = wp_safe_remote_head(
$raw_url,
[
'redirection' => 0, // No redirects allowed.
'headers' => [
'Host' => $host,
],
]
);
remove_action( 'http_api_curl', $callback );
if ( is_wp_error( $response ) && 'http_request_failed' === $response->get_error_code() ) {
return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
}
if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) {
return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
}
$headers = wp_remote_retrieve_headers( $response );
$mime_type = $headers['content-type'];
if ( $mime_type && str_contains( $mime_type, ';' ) ) {
$pieces = explode( ';', $mime_type );
$mime_type = array_shift( $pieces );
}
$file_size = (int) $headers['content-length'];
$path = wp_parse_url( $raw_url, PHP_URL_PATH );
if ( ! \is_string( $path ) ) {
return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
}
$file_name = basename( $path );
$exts = $this->types->get_file_type_exts( [ $mime_type ] );
$ext = '';
if ( $exts ) {
$ext = end( $exts );
}
$allowed_mime_types = $this->get_allowed_mime_types();
$type = '';
foreach ( $allowed_mime_types as $key => $mime_types ) {
if ( \in_array( $mime_type, $mime_types, true ) ) {
$type = $key;
break;
}
}
$data = [
'ext' => $ext,
'file_name' => $file_name,
'file_size' => $file_size,
'mime_type' => $mime_type,
'type' => $type,
];
set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl );
$response = $this->prepare_item_for_response( $data, $request );
return rest_ensure_response( $response );
}
/**
* Parses a URL to return proxied file.
*
* @SuppressWarnings(PHPMD.ErrorControlOperator)
*
* @since 1.13.0
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_Error|void Proxied data on success, error otherwise.
*/
public function proxy_url( WP_REST_Request $request ) {
/**
* Requested URL.
*
* @var string $raw_url
*/
$raw_url = $request['url'];
$raw_url = untrailingslashit( $raw_url );
$url_or_ip = $this->validate_url( $raw_url );
$host = wp_parse_url( $raw_url, PHP_URL_HOST );
if ( ! $url_or_ip || ! $host ) {
return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
}
// Remove any relevant headers already set by WP_REST_Server::serve_request() // wp_get_nocache_headers().
if ( ! headers_sent() ) {
header_remove( 'Cache-Control' );
header_remove( 'Content-Type' );
header_remove( 'Expires' );
header_remove( 'Last Modified' );
}
header( 'Cache-Control: max-age=3600' );
header( 'Accept-Ranges: bytes' );
$args = [
'timeout' => 60, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
'blocking' => false,
'headers' => [
'Range' => $request->get_header( 'Range' ),
'Host' => $host,
],
'redirection' => 0, // No redirects allowed.
];
$callback = $this->get_curl_resolve_callback( $raw_url, $url_or_ip );
add_action( 'http_api_curl', $callback );
$http = _wp_http_get_object();
$transport = $http->_get_first_available_transport( $args, $raw_url );
// When cURL is available, we might be able to use it together with fopen().
if ( 'WP_Http_Curl' === $transport ) {
// php://temp is a read-write streams that allows temporary data to be stored in a file-like wrapper.
// Other than php://memory, php://temp will use a temporary file once the amount of data stored hits a predefined limit (the default is 2 MB).
// The location of this temporary file is determined in the same way as the {@see sys_get_temp_dir()} function.
if ( WP_DEBUG ) {
$stream_handle = fopen( 'php://memory', 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
} else {
$stream_handle = @fopen( 'php://memory', 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Forbidden
}
if ( $stream_handle ) {
$this->stream_handle = $stream_handle;
$this->proxy_url_curl( $raw_url, $args );
exit;
}
}
// If either cURL is not available or fopen() did not succeed,
// fall back to using whatever else is set up on the site,
// presumably WP_Http_Streams or still WP_Http_Curl but without streams.
unset( $args['blocking'] );
$this->proxy_url_fallback( $raw_url, $args );
exit;
}
/**
* Prepares response asset response.
*
* @since 1.11.0
*
* @param LinkData|false $link URL data value, default to false is not set.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response object.
*
* @phpstan-param LinkData $link
*/
public function prepare_item_for_response( $link, $request ) {
$fields = $this->get_fields_for_response( $request );
$schema = $this->get_item_schema();
$data = [];
$error = new WP_Error();
foreach ( $schema['properties'] as $field => $args ) {
if ( ! isset( $link[ $field ] ) || ! rest_is_field_included( $field, $fields ) ) {
continue;
}
$check = rest_validate_value_from_schema( $link[ $field ], $args, $field );
if ( is_wp_error( $check ) ) {
$error->add( 'rest_invalid_' . $field, $check->get_error_message(), [ 'status' => 400 ] );
continue;
}
$data[ $field ] = rest_sanitize_value_from_schema( $link[ $field ], $args, $field );
}
if ( $error->get_error_codes() ) {
return $error;
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
return rest_ensure_response( $data );
}
/**
* Retrieves the link's schema, conforming to JSON Schema.
*
* @since 1.11.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
$allowed_mime_types = $this->get_allowed_mime_types();
$types = array_keys( $allowed_mime_types );
$allowed_mime_types = array_merge( ...array_values( $allowed_mime_types ) );
$exts = $this->types->get_file_type_exts( $allowed_mime_types );
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'link',
'type' => 'object',
'properties' => [
'ext' => [
'description' => __( 'File extension', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'enum' => $exts,
],
'file_name' => [
'description' => __( 'File name', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
'file_size' => [
'description' => __( 'File size', 'web-stories' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
],
'mime_type' => [
'description' => __( 'Mime type', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'enum' => $allowed_mime_types,
],
'type' => [
'description' => __( 'Type', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'enum' => $types,
],
],
];
$this->schema = $schema;
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Checks if current user can process urls.
*
* @since 1.11.0
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function parse_url_permissions_check() {
if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to insert external media.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
/**
* Callback to validate urls.
*
* @since 1.11.0
*
* @param string $value Value to be validated.
* @return true|WP_Error
*/
public function validate_callback( $value ) {
$url = untrailingslashit( $value );
if ( empty( $url ) || ! $this->validate_url( $url ) ) {
return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
}
$path = wp_parse_url( $url, PHP_URL_PATH );
if ( ! $path ) {
return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
}
return true;
}
/**
* Returns a callback to modify the cURL configuration before the request is executed.
*
* @since 1.22.1
*
* @param string $url URL.
* @param string $url_or_ip URL or IP address.
*/
public function get_curl_resolve_callback( string $url, string $url_or_ip ): callable {
/**
* CURL configuration callback.
*
* @param resource $handle The cURL handle returned by curl_init() (passed by reference).
*/
return static function ( $handle ) use ( $url, $url_or_ip ): void {
// Just some safeguard in case cURL is not really available,
// despite this method being run in the context of WP_Http_Curl.
if ( ! \function_exists( '\curl_setopt' ) ) {
return;
}
if ( $url === $url_or_ip ) {
return;
}
$host = wp_parse_url( $url, PHP_URL_HOST );
$scheme = wp_parse_url( $url, PHP_URL_SCHEME ) ?? 'http';
$port = wp_parse_url( $url, PHP_URL_PORT ) ?? 'http' === $scheme ? 80 : 443;
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt(
$handle,
CURLOPT_RESOLVE,
[
"$host:$port:$url_or_ip",
]
);
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
};
}
/**
* Modifies the cURL configuration before the request is executed.
*
* @since 1.15.0
*
* @param resource $handle The cURL handle returned by {@see curl_init()} (passed by reference).
*/
public function modify_curl_configuration( $handle ): void {
// Just some safeguard in case cURL is not really available,
// despite this method being run in the context of WP_Http_Curl.
if ( ! \function_exists( '\curl_setopt' ) ) {
return;
}
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt(
$handle,
CURLOPT_FILE,
$this->stream_handle
);
curl_setopt( $handle, CURLOPT_HEADERFUNCTION, [ $this, 'stream_headers' ] );
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
}
/**
* Grabs the headers of the cURL request.
*
* Each header is sent individually to this callback,
* so we take a look at each one to see if we should "forward" it.
*
* @since 1.15.0
*
* @param resource $handle cURL handle.
* @param string $header cURL header.
* @return int Header length.
*/
public function stream_headers( $handle, $header ): int {
// Parse Status-Line, the first component in the HTTP response, e.g. HTTP/1.1 200 OK.
// Extract the status code to re-send that here.
if ( str_starts_with( $header, 'HTTP/' ) ) {
$status = explode( ' ', $header );
http_response_code( (int) $status[1] );
return \strlen( $header );
}
foreach ( self::PROXY_HEADERS_ALLOWLIST as $_header ) {
if ( str_starts_with( strtolower( $header ), strtolower( $_header ) . ': ' ) ) {
header( $header, true );
}
}
return \strlen( $header );
}
/**
* Proxy a given URL via a PHP read-write stream.
*
* @since 1.15.0
*
* @param string $url Request URL.
* @param array<string, mixed> $args Request args.
*
* @phpstan-param array{
* method?: string,
* timeout?: float,
* redirection?: int,
* httpversion?: string,
* user-agent?: string,
* reject_unsafe_urls?: bool,
* blocking?: bool,
* headers?: string|array,
* cookies?: array,
* body?: string|array,
* compress?: bool,
* decompress?: bool,
* sslverify?: bool,
* sslcertificates?: string,
* stream?: bool,
* filename?: string,
* limit_response_size?: int,
* } $args
*/
private function proxy_url_curl( string $url, array $args ): void {
add_action( 'http_api_curl', [ $this, 'modify_curl_configuration' ] );
wp_safe_remote_get( $url, $args );
remove_action( 'http_api_curl', [ $this, 'modify_curl_configuration' ] );
rewind( $this->stream_handle );
while ( ! feof( $this->stream_handle ) ) {
echo fread( $this->stream_handle, 1024 * 1024 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread
}
fclose( $this->stream_handle );
}
/**
* Proxy a given URL by storing in memory.
*
* @since 1.15.0
*
* @param string $url Request URL.
* @param array<string, mixed> $args Request args.
*
* @phpstan-param array{
* method?: string,
* timeout?: float,
* redirection?: int,
* httpversion?: string,
* user-agent?: string,
* reject_unsafe_urls?: bool,
* blocking?: bool,
* headers?: string|array,
* cookies?: array,
* body?: string|array,
* compress?: bool,
* decompress?: bool,
* sslverify?: bool,
* sslcertificates?: string,
* stream?: bool,
* filename?: string,
* limit_response_size?: int,
* } $args
*/
private function proxy_url_fallback( string $url, array $args ): void {
$response = wp_safe_remote_get( $url, $args );
$status = wp_remote_retrieve_response_code( $response );
if ( ! $status ) {
http_response_code( 404 );
return;
}
http_response_code( (int) $status );
$headers = wp_remote_retrieve_headers( $response );
foreach ( self::PROXY_HEADERS_ALLOWLIST as $_header ) {
if ( isset( $headers[ $_header ] ) ) {
header( $_header . ': ' . $headers[ $_header ] );
}
}
echo wp_remote_retrieve_body( $response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Validate a URL for safe use in the HTTP API.
*
* Like {@see wp_http_validate_url} in core, but with extra hardening
* to avoid DNS rebinding issues.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @since 1.22.0
*
* @param string $url Request URL.
* @return string|false Original URL, resolved IP address, or false on failure.
*/
private function validate_url( string $url ) { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
if ( '' === $url || is_numeric( $url ) ) {
return false;
}
$original_url = $url;
$url = wp_kses_bad_protocol( $url, [ 'http', 'https' ] );
if ( ! $url || strtolower( $url ) !== strtolower( $original_url ) ) {
return false;
}
$parsed_url = wp_parse_url( $url );
if ( ! $parsed_url || ! isset( $parsed_url['host'], $parsed_url['scheme'] ) ) {
return false;
}
if ( isset( $parsed_url['user'] ) || isset( $parsed_url['pass'] ) ) {
return false;
}
if ( false !== strpbrk( $parsed_url['host'], ':#?[]' ) ) {
return false;
}
/**
* Home URL.
*
* @var string
*/
$home_url = get_option( 'home' );
$parsed_home = wp_parse_url( $home_url );
if ( ! $parsed_home ) {
return false;
}
$same_host = isset( $parsed_home['host'] ) && strtolower( $parsed_home['host'] ) === strtolower( $parsed_url['host'] );
$host = trim( $parsed_url['host'], '.' );
$validated_url = $url;
if ( ! $same_host ) {
if ( preg_match( '#^(([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)\.){3}([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)$#', $host ) ) {
$ip = $host;
} else {
$ip = gethostbyname( $host );
if ( $ip === $host ) { // Error condition for gethostbyname().
return false;
}
}
$parts = array_map( 'intval', explode( '.', $ip ) );
if (
0 === $parts[0] // 0.0.0.0/8.
||
127 === $parts[0] // 127.0.0.0/8.
||
10 === $parts[0] // 10.0.0.0/8.
||
( 172 === $parts[0] && 16 <= $parts[1] && 31 >= $parts[1] ) // 172.16.0.0/12.
||
( 192 === $parts[0] && 168 === $parts[1] ) // 192.168.0.0/16.
||
( 169 === $parts[0] && 254 === $parts[1] ) // 169.254.0.0/16.
||
// phpcs:ignore Squiz.PHP.CommentedOutCode.Found
( 100 === $parts[0] && 64 <= $parts[1] && 127 >= $parts[1] ) // Private: 100.64.0.0/10.
) {
// If host appears local, reject.
return false;
}
// Use resolved IP address to avoid DNS rebinding issues.
$validated_url = $ip;
}
/** This filter is documented in wp-includes/http.php */
$allowed_ports = apply_filters( 'http_allowed_safe_ports', [ 80, 443, 8080 ], $host, $url );
if (
! isset( $parsed_url['port'] ) ||
( \is_array( $allowed_ports ) && \in_array( $parsed_url['port'], $allowed_ports, true ) )
) {
return $validated_url;
}
if ( $same_host && isset( $parsed_home['port'] ) && $parsed_home['port'] === $parsed_url['port'] ) {
return $validated_url;
}
return false;
}
/**
* Returns a list of allowed mime types per media type (image, audio, video).
*
* @since 1.19.0
*
* @return array<string, string[]> List of allowed mime types.
*/
protected function get_allowed_mime_types(): array {
$mime_type = $this->types->get_allowed_mime_types();
// Do not support hotlinking SVGs for security reasons.
unset( $mime_type['vector'] );
return $mime_type;
}
}

View File

@@ -0,0 +1,502 @@
<?php
/**
* Class Link_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use DOMElement;
use DOMNode;
use DOMNodeList;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
use WP_Error;
use WP_Http;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* API endpoint to allow parsing of metadata from a URL
*
* Class Link_Controller
*
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* }
* @phpstan-type Schema array{
* properties: array<string, SchemaEntry>
* }
*/
class Link_Controller extends REST_Controller implements HasRequirements {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Constructor.
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
$this->namespace = 'web-stories/v1';
$this->rest_base = 'link';
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type' ];
}
/**
* Registers routes for links.
*
* @since 1.0.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'parse_link' ],
'permission_callback' => [ $this, 'parse_link_permissions_check' ],
'args' => [
'url' => [
'description' => __( 'The URL to process.', 'web-stories' ),
'required' => true,
'type' => 'string',
'format' => 'uri',
'validate_callback' => [ $this, 'validate_url' ],
],
],
],
]
);
}
/**
* Parses a URL to return some metadata for inserting links.
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.0.0
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function parse_link( $request ) {
/**
* Requested URL.
*
* @var string $url
*/
$url = $request['url'];
$url = untrailingslashit( $url );
/**
* Filters the link data TTL value.
*
* @since 1.0.0
*
* @param int $time Time to live (in seconds). Default is 1 day.
* @param string $url The attempted URL.
*/
$cache_ttl = apply_filters( 'web_stories_link_data_cache_ttl', DAY_IN_SECONDS, $url );
$cache_key = 'web_stories_link_data_' . md5( $url );
$data = get_transient( $cache_key );
if ( \is_string( $data ) && ! empty( $data ) ) {
/**
* Decoded cached link data.
*
* @var array{title: string, image: string, description: string}|null $link
*/
$link = json_decode( $data, true );
if ( $link ) {
$response = $this->prepare_item_for_response( $link, $request );
return rest_ensure_response( $response );
}
}
$data = [
'title' => '',
'image' => '',
'description' => '',
];
// Do not request instagram.com, as it redirects to a login page.
// See https://github.com/GoogleForCreators/web-stories-wp/issues/10451.
$matches = [];
$query_string = wp_parse_url( $url, PHP_URL_QUERY );
$check_url = \is_string( $query_string ) ? str_replace( "?$query_string", '', $url ) : $url;
if ( preg_match( '~^https?://(www\.)?instagram\.com/([^/]+)/?$~', $check_url, $matches ) ) {
$data['title'] = sprintf(
/* translators: %s: Instagram username. */
__( 'Instagram - @%s', 'web-stories' ),
$matches[2]
);
set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl );
$response = $this->prepare_item_for_response( $data, $request );
return rest_ensure_response( $response );
}
$args = [
'limit_response_size' => 153_600, // 150 KB.
'timeout' => 7, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
];
/**
* Filters the HTTP request args for link data retrieval.
*
* Can be used to adjust timeout and response size limit.
*
* @since 1.0.0
*
* @param array $args Arguments used for the HTTP request
* @param string $url The attempted URL.
*/
$args = apply_filters( 'web_stories_link_data_request_args', $args, $url );
$response = wp_safe_remote_get( $url, $args );
if ( is_wp_error( $response ) && 'http_request_failed' === $response->get_error_code() ) {
return new \WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
}
if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) {
// Not saving to cache since the error might be temporary.
$response = $this->prepare_item_for_response( $data, $request );
return rest_ensure_response( $response );
}
$html = wp_remote_retrieve_body( $response );
// Strip <body>.
$html_head_end = stripos( $html, '</head>' );
if ( $html_head_end ) {
$html = substr( $html, 0, $html_head_end );
}
if ( ! $html ) {
set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl );
$response = $this->prepare_item_for_response( $data, $request );
return rest_ensure_response( $response );
}
try {
$doc = Document::fromHtml( $html );
} catch ( \DOMException $exception ) {
set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl );
$response = $this->prepare_item_for_response( $data, $request );
return rest_ensure_response( $response );
}
if ( ! $doc ) {
set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl );
$response = $this->prepare_item_for_response( $data, $request );
return rest_ensure_response( $response );
}
$xpath = $doc->xpath;
// Link title.
$title = '';
$title_query = $xpath->query( '//title' );
if ( $title_query instanceof DOMNodeList && $title_query->length > 0 ) {
$title_node = $title_query->item( 0 );
if ( $title_node instanceof DOMElement ) {
$title = $title_node->textContent;
}
}
if ( ! $title ) {
/**
* List of found elements.
*
* @var DOMNodeList<DOMElement> $og_title_query
*/
$og_title_query = $xpath->query( '//meta[@property="og:title"]' );
$title = $this->get_dom_attribute_content( $og_title_query, 'content' );
}
if ( ! $title ) {
/**
* List of found elements.
*
* @var DOMNodeList<DOMElement> $og_site_name_query
*/
$og_site_name_query = $xpath->query( '//meta[@property="og:site_name"]' );
$title = $this->get_dom_attribute_content( $og_site_name_query, 'content' );
}
// Site icon.
/**
* List of found elements.
*
* @var DOMNodeList<DOMElement> $og_image_query
*/
$og_image_query = $xpath->query( '//meta[@property="og:image"]' );
$image = $this->get_dom_attribute_content( $og_image_query, 'content' );
if ( ! $image ) {
/**
* List of found elements.
*
* @var DOMNodeList<DOMElement> $icon_query
*/
$icon_query = $xpath->query( '//link[contains(@rel, "icon")]' );
$image = $this->get_dom_attribute_content( $icon_query, 'content' );
}
if ( ! $image ) {
/**
* List of found elements.
*
* @var DOMNodeList<DOMElement> $touch_icon_query
*/
$touch_icon_query = $xpath->query( '//link[contains(@rel, "apple-touch-icon")]' );
$image = $this->get_dom_attribute_content( $touch_icon_query, 'href' );
}
// Link description.
/**
* List of found elements.
*
* @var DOMNodeList<DOMElement> $description_query
*/
$description_query = $xpath->query( '//meta[@name="description"]' );
$description = $this->get_dom_attribute_content( $description_query, 'content' );
if ( ! $description ) {
/**
* List of found elements.
*
* @var DOMNodeList<DOMElement> $og_description_query
*/
$og_description_query = $xpath->query( '//meta[@property="og:description"]' );
$description = $this->get_dom_attribute_content( $og_description_query, 'content' );
}
$data = [
'title' => $title ?: '',
'image' => $image ?: '',
'description' => $description ?: '',
];
$response = $this->prepare_item_for_response( $data, $request );
set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl );
return rest_ensure_response( $response );
}
/**
* Prepares a single lock output for response.
*
* @since 1.10.0
*
* @param array{title: string, image: string, description: string} $link Link value, default to false is not set.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response object.
*/
public function prepare_item_for_response( $link, $request ) {
$fields = $this->get_fields_for_response( $request );
$schema = $this->get_item_schema();
$data = [];
$check_fields = array_keys( $link );
foreach ( $check_fields as $check_field ) {
if ( ! empty( $schema['properties'][ $check_field ] ) && rest_is_field_included( $check_field, $fields ) ) {
$data[ $check_field ] = rest_sanitize_value_from_schema( $link[ $check_field ], $schema['properties'][ $check_field ] );
}
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
return rest_ensure_response( $data );
}
/**
* Retrieves the link's schema, conforming to JSON Schema.
*
* @since 1.10.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'link',
'type' => 'object',
'properties' => [
'title' => [
'description' => __( 'Link\'s title', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
'image' => [
'description' => __( 'Link\'s image', 'web-stories' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
],
'description' => [
'description' => __( 'Link\'s description', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
],
];
$this->schema = $schema;
/**
* Schema
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Checks if current user can process links.
*
* @since 1.0.0
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function parse_link_permissions_check() {
if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) {
return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to process links.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] );
}
return true;
}
/**
* Callback to validate urls.
*
* @since 1.11.0
*
* @param string $value Value to be validated.
* @return true|WP_Error
*/
public function validate_url( $value ) {
$url = untrailingslashit( $value );
if ( empty( $url ) || ! wp_http_validate_url( $url ) ) {
return new \WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
}
return true;
}
/**
* Retrieve content of a given DOM node attribute.
*
* @since 1.0.0
*
* @param DOMNodeList<DOMElement>|false $query XPath query result.
* @param string $attribute Attribute name.
* @return string|false Attribute content on success, false otherwise.
*/
protected function get_dom_attribute_content( $query, string $attribute ) {
if ( ! $query instanceof DOMNodeList || 0 === $query->length ) {
return false;
}
/**
* DOMElement
*
* @var DOMElement|DOMNode $node
*/
$node = $query->item( 0 );
if ( ! $node instanceof DOMElement ) {
return false;
}
return $node->getAttribute( $attribute );
}
}

View File

@@ -0,0 +1,206 @@
<?php
/**
* Class Page_Template_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
/**
* Page_Template_Controller class.
*
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* }
* @phpstan-type Schema array{
* properties: array{
* permalink_template?: SchemaEntry,
* generated_slug?: SchemaEntry
* }
* }
*/
class Page_Template_Controller extends Stories_Base_Controller {
/**
* Retrieves a collection of page templates.
*
* @since 1.7.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
$response = parent::get_items( $request );
if ( is_wp_error( $response ) ) {
return $response;
}
if ( $request['_web_stories_envelope'] ) {
/**
* Embed directive.
*
* @var string|string[] $embed
*/
$embed = $request['_embed'] ?? false;
$embed = $embed ? rest_parse_embed_param( $embed ) : false;
$response = rest_get_server()->envelope_response( $response, $embed );
}
return $response;
}
/**
* Retrieves the query params for the posts collection.
*
* @since 1.7.0
*
* @return array<string, array<string, mixed>> Collection parameters.
*/
public function get_collection_params(): array {
$query_params = parent::get_collection_params();
$query_params['_web_stories_envelope'] = [
'description' => __( 'Envelope request for preloading.', 'web-stories' ),
'type' => 'boolean',
'default' => false,
];
return $query_params;
}
/**
* Retrieves the attachment's schema, conforming to JSON Schema.
*
* Removes some unneeded fields to improve performance by
* avoiding some expensive database queries.
*
* @since 1.10.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = parent::get_item_schema();
unset(
$schema['properties']['permalink_template'],
$schema['properties']['generated_slug']
);
$this->schema = $schema;
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Checks if a given request has access to read posts.
*
* @since 1.14.0
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function get_items_permissions_check( $request ) {
$ret = parent::get_items_permissions_check( $request );
if ( is_wp_error( $ret ) ) {
return $ret;
}
$post_type = get_post_type_object( $this->post_type );
if (
! $post_type ||
! current_user_can( $post_type->cap->edit_posts ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined
) {
return new \WP_Error(
'rest_forbidden_context',
__( 'Sorry, you are not allowed to edit page templates.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
/**
* Checks if a given request has access to read a post.
*
* @since 1.14.0
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
*/
public function get_item_permissions_check( $request ) {
$ret = parent::get_item_permissions_check( $request );
if ( is_wp_error( $ret ) ) {
return $ret;
}
$post_type = get_post_type_object( $this->post_type );
if (
! $post_type ||
! current_user_can( $post_type->cap->edit_posts ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined
) {
return new \WP_Error(
'rest_forbidden_context',
__( 'Sorry, you are not allowed to edit page templates.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
}

View File

@@ -0,0 +1,528 @@
<?php
/**
* Class Products_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Shopping\Product;
use Google\Web_Stories\Shopping\Shopping_Vendors;
use Google\Web_Stories\Story_Post_Type;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Class to access products via the REST API.
*
* @since 1.20.0
*
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* }
*
* @phpstan-type Schema array{
* properties: array{
* productId?: SchemaEntry,
* productUrl?: SchemaEntry,
* productTitle?: SchemaEntry,
* productBrand?: SchemaEntry,
* productPrice?: SchemaEntry,
* productPriceCurrency?: SchemaEntry,
* productImages?: SchemaEntry,
* aggregateRating?: SchemaEntry,
* productDetails?: SchemaEntry
* }
* }
*/
class Products_Controller extends REST_Controller implements HasRequirements {
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Shopping_Vendors instance.
*
* @var Shopping_Vendors Shopping_Vendors instance.
*/
private Shopping_Vendors $shopping_vendors;
/**
* Constructor.
*
* @param Settings $settings Settings instance.
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Shopping_Vendors $shopping_vendors Shopping_Vendors instance.
*/
public function __construct( Settings $settings, Story_Post_Type $story_post_type, Shopping_Vendors $shopping_vendors ) {
$this->settings = $settings;
$this->story_post_type = $story_post_type;
$this->shopping_vendors = $shopping_vendors;
$this->namespace = 'web-stories/v1';
$this->rest_base = 'products';
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.20.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'settings', 'story_post_type' ];
}
/**
* Registers routes for links.
*
* @since 1.20.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
'args' => $this->get_collection_params(),
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
}
/**
* Checks if a given request has access to get and create items.
*
* @since 1.20.0
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function get_items_permissions_check( $request ) {
if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) {
return new \WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to manage products.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
/**
* Retrieves all products.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.20.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
/**
* Shopping provider.
*
* @var string $shopping_provider
*/
$shopping_provider = $this->settings->get_setting( Settings::SETTING_NAME_SHOPPING_PROVIDER );
$query = $this->shopping_vendors->get_vendor_class( $shopping_provider );
if ( 'none' === $shopping_provider ) {
return new WP_Error( 'rest_shopping_provider', __( 'No shopping provider set up.', 'web-stories' ), [ 'status' => 400 ] );
}
if ( ! $query ) {
return new WP_Error( 'rest_shopping_provider_not_found', __( 'Unable to find shopping integration.', 'web-stories' ), [ 'status' => 400 ] );
}
/**
* Request context.
*
* @var string $search_term
*/
$search_term = ! empty( $request['search'] ) ? $request['search'] : '';
/**
* Request context.
*
* @var string $orderby
*/
$orderby = ! empty( $request['orderby'] ) ? $request['orderby'] : 'date';
/**
* Request context.
*
* @var int $page
*/
$page = ! empty( $request['page'] ) ? $request['page'] : 1;
/**
* Request context.
*
* @var int $per_page
*/
$per_page = ! empty( $request['per_page'] ) ? $request['per_page'] : 100;
/**
* Request context.
*
* @var string $order
*/
$order = ! empty( $request['order'] ) ? $request['order'] : 'desc';
$query_result = $query->get_search( $search_term, $page, $per_page, $orderby, $order );
if ( is_wp_error( $query_result ) ) {
return $query_result;
}
$products = [];
foreach ( $query_result['products'] as $product ) {
$data = $this->prepare_item_for_response( $product, $request );
$products[] = $this->prepare_response_for_collection( $data );
}
/**
* Response object.
*
* @var WP_REST_Response $response
*/
$response = rest_ensure_response( $products );
$response->header( 'X-WP-HasNextPage', $query_result['has_next_page'] ? 'true' : 'false' );
if ( $request['_web_stories_envelope'] ) {
/**
* Embed directive.
*
* @var string|string[] $embed
*/
$embed = $request['_embed'] ?? false;
$embed = $embed ? rest_parse_embed_param( $embed ) : false;
$response = rest_get_server()->envelope_response( $response, $embed );
}
return $response;
}
/**
* Prepares a single post output for response.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.20.0
*
* @param Product $item Project object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $item, $request ): WP_REST_Response { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
$product = $item;
$fields = $this->get_fields_for_response( $request );
$data = [];
if ( rest_is_field_included( 'productId', $fields ) ) {
$data['productId'] = $product->get_id();
}
if ( rest_is_field_included( 'productUrl', $fields ) ) {
$data['productUrl'] = $product->get_url();
}
if ( rest_is_field_included( 'productTitle', $fields ) ) {
$data['productTitle'] = $product->get_title();
}
if ( rest_is_field_included( 'productBrand', $fields ) ) {
$data['productBrand'] = $product->get_brand();
}
if ( rest_is_field_included( 'productPrice', $fields ) ) {
$data['productPrice'] = $product->get_price();
}
if ( rest_is_field_included( 'productPriceCurrency', $fields ) ) {
$data['productPriceCurrency'] = $product->get_price_currency();
}
if ( rest_is_field_included( 'productDetails', $fields ) ) {
$data['productDetails'] = $product->get_details();
}
if ( rest_is_field_included( 'productImages', $fields ) ) {
$data['productImages'] = [];
foreach ( $product->get_images() as $image ) {
$image_data = [];
if ( rest_is_field_included( 'productImages.url', $fields ) ) {
$image_data['url'] = $image['url'];
}
if ( rest_is_field_included( 'productImages.alt', $fields ) ) {
$image_data['alt'] = $image['alt'];
}
$data['productImages'][] = $image_data;
}
}
$rating = $product->get_aggregate_rating();
if ( $rating ) {
if ( rest_is_field_included( 'aggregateRating', $fields ) ) {
$data['aggregateRating'] = [];
}
if ( rest_is_field_included( 'aggregateRating.ratingValue', $fields ) ) {
$data['aggregateRating']['ratingValue'] = (float) $rating['rating_value'];
}
if ( rest_is_field_included( 'aggregateRating.reviewCount', $fields ) ) {
$data['aggregateRating']['reviewCount'] = (int) $rating['review_count'];
}
if ( rest_is_field_included( 'aggregateRating.reviewUrl', $fields ) ) {
$data['aggregateRating']['reviewUrl'] = $rating['review_url'];
}
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
/**
* Response object.
*
* @var WP_REST_Response $response
*/
$response = rest_ensure_response( $data );
return $response;
}
/**
* Retrieves the product schema, conforming to JSON Schema.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.20.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
// This must not be an actually existing post type like "product".
// See https://github.com/GoogleForCreators/web-stories-wp/issues/12735.
'title' => 'web-story-product',
'type' => 'object',
'properties' => [
'productId' => [
'description' => __( 'Product ID.', 'web-stories' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'productUrl' => [
'description' => __( 'Product URL.', 'web-stories' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'productTitle' => [
'description' => __( 'Product title.', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'productBrand' => [
'description' => __( 'Product brand.', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'productPrice' => [
'description' => __( 'Product price.', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'productPriceCurrency' => [
'description' => __( 'Product currency.', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'productImages' => [
'description' => __( 'Product brand.', 'web-stories' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'url' => [
'description' => __( 'Product image URL', 'web-stories' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
],
'alt' => [
'description' => __( 'Product image alt text', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
],
],
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'aggregateRating' => [
'description' => __( 'Product rating.', 'web-stories' ),
'type' => 'object',
'properties' => [
'ratingValue' => [
'description' => __( 'Average rating.', 'web-stories' ),
'type' => 'number',
'context' => [ 'view', 'edit', 'embed' ],
],
'reviewCount' => [
'description' => __( 'Number of reviews.', 'web-stories' ),
'type' => 'number',
'context' => [ 'view', 'edit', 'embed' ],
],
'reviewUrl' => [
'description' => __( 'Product review URL.', 'web-stories' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
],
],
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'productDetails' => [
'description' => __( 'Product description.', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
];
$this->schema = $schema;
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Retrieves the query params for the products collection.
*
* @since 1.21.0
*
* @return array<string, array<string, mixed>> Collection parameters.
*/
public function get_collection_params(): array {
$query_params = parent::get_collection_params();
$query_params['per_page']['default'] = 100;
$query_params['orderby'] = [
'description' => __( 'Sort collection by product attribute.', 'web-stories' ),
'type' => 'string',
'default' => 'date',
'enum' => [
'date',
'price',
'title',
],
];
$query_params['order'] = [
'description' => __( 'Order sort attribute ascending or descending.', 'web-stories' ),
'type' => 'string',
'default' => 'desc',
'enum' => [ 'asc', 'desc' ],
];
$query_params['_web_stories_envelope'] = [
'description' => __( 'Envelope request for preloading.', 'web-stories' ),
'type' => 'boolean',
'default' => false,
];
return $query_params;
}
}

View File

@@ -0,0 +1,545 @@
<?php
/**
* Class Publisher_Logos_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Story_Post_Type;
use WP_Error;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Class to access publisher logos via the REST API.
*
* @since 1.12.0
*
* @phpstan-import-type Links from \Google\Web_Stories\REST_API\Stories_Base_Controller
*/
class Publisher_Logos_Controller extends REST_Controller implements HasRequirements {
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Constructor.
*
* @param Settings $settings Settings instance.
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Settings $settings, Story_Post_Type $story_post_type ) {
$this->settings = $settings;
$this->story_post_type = $story_post_type;
$this->namespace = 'web-stories/v1';
$this->rest_base = 'publisher-logos';
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'settings', 'story_post_type' ];
}
/**
* Registers routes for links.
*
* @since 1.12.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'permissions_check' ],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_item' ],
'permission_callback' => [ $this, 'permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
[
'args' => [
'id' => [
'description' => __( 'Publisher logo ID.', 'web-stories' ),
'type' => 'integer',
],
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'update_item' ],
'permission_callback' => [ $this, 'update_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_item' ],
'permission_callback' => [ $this, 'permissions_check' ],
],
]
);
}
/**
* Checks if a given request has access to get and create items.
*
* @since 1.12.0
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function permissions_check() {
if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) {
return new \WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to manage publisher logos.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
/**
* Checks if a given request has access to manage a single item.
*
* @since 1.12.0
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
*/
public function update_item_permissions_check( $request ) {
if ( ! current_user_can( 'manage_options' ) ) {
return new \WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to manage publisher logos.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
/**
* Retrieves all active publisher logos.
*
* @since 1.12.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
/**
* Publisher logo IDs.
*
* @var int[] $publisher_logos_ids
*/
$publisher_logos_ids = $this->settings->get_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] );
_prime_post_caches( $publisher_logos_ids );
$publisher_logos = $this->filter_publisher_logos( $publisher_logos_ids );
$results = [];
foreach ( $publisher_logos as $logo ) {
/**
* We're expecting a post object after the filtering above.
*
* @var WP_Post $post
*/
$post = get_post( $logo );
$data = $this->prepare_item_for_response( $post, $request );
$results[] = $this->prepare_response_for_collection( $data );
}
// Ensure the default publisher logo is first in the list.
$active = array_column( $results, 'active' );
array_multisort( $active, SORT_DESC, $results );
return rest_ensure_response( $results );
}
/**
* Adds a new publisher logo to the collection.
*
* Supports adding multiple logos at once.
*
* @since 1.12.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function create_item( $request ) {
/**
* List of publisher logos.
*
* @var int[] $publisher_logos
*/
$publisher_logos = $this->settings->get_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] );
$publisher_logos = $this->filter_publisher_logos( $publisher_logos );
/**
* Publisher logo ID(s).
*
* Could be a single attachment ID or an array of attachment IDs.
*
* @var int|int[] $logo_id
*/
$logo_id = $request['id'];
$posts = (array) $logo_id;
if ( empty( $posts ) ) {
return new \WP_Error(
'rest_invalid_id',
__( 'Invalid ID', 'web-stories' ),
[ 'status' => 400 ]
);
}
foreach ( $posts as $post_id ) {
$post = get_post( $post_id );
if ( ! $post || 'attachment' !== $post->post_type ) {
return new \WP_Error(
'rest_invalid_id',
__( 'Invalid ID', 'web-stories' ),
[ 'status' => 400 ]
);
}
if ( \in_array( $post->ID, $publisher_logos, true ) ) {
return new \WP_Error(
'rest_publisher_logo_exists',
__( 'Publisher logo already exists', 'web-stories' ),
[ 'status' => 400 ]
);
}
$publisher_logos[] = $post->ID;
}
$this->settings->update_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, $publisher_logos );
$active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ) );
if ( 1 === \count( $publisher_logos ) || ! \in_array( $active_publisher_logo_id, $publisher_logos, true ) ) {
$this->settings->update_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, $posts[0] );
}
$results = [];
foreach ( $posts as $post ) {
/**
* Post object.
*
* @var WP_Post $post
*/
$post = get_post( $post );
$data = $this->prepare_item_for_response( $post, $request );
if ( 1 === \count( $posts ) ) {
return $data;
}
$results[] = $this->prepare_response_for_collection( $data );
}
return rest_ensure_response( $results );
}
/**
* Removes a publisher logo from the collection.
*
* @since 1.12.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function delete_item( $request ) {
/**
* Publisher logo ID.
*
* @var int $logo_id
*/
$logo_id = $request['id'];
$post = $this->get_publisher_logo( $logo_id );
if ( is_wp_error( $post ) ) {
return $post;
}
$prepared = $this->prepare_item_for_response( $post, $request );
/**
* List of publisher logos.
*
* @var int[] $publisher_logos
*/
$publisher_logos = $this->settings->get_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] );
$publisher_logos = $this->filter_publisher_logos( $publisher_logos );
$publisher_logos = array_values( array_diff( $publisher_logos, [ $post->ID ] ) );
$active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ) );
if ( $post->ID === $active_publisher_logo_id || ! \in_array( $active_publisher_logo_id, $publisher_logos, true ) ) {
// Mark the first available publisher logo as the new default.
$this->settings->update_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, ! empty( $publisher_logos[0] ) ? $publisher_logos[0] : 0 );
}
$this->settings->update_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, $publisher_logos );
return new WP_REST_Response(
[
'deleted' => true,
'previous' => $prepared->get_data(),
]
);
}
/**
* Updates a publisher logo in the collection.
*
* Can only be used to make it the "active" one.
*
* @since 1.12.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function update_item( $request ) {
/**
* Publisher logo ID.
*
* @var int $logo_id
*/
$logo_id = $request['id'];
$post = $this->get_publisher_logo( $logo_id );
if ( is_wp_error( $post ) ) {
return $post;
}
if ( $request['active'] ) {
$this->settings->update_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, $post->ID );
}
return $this->prepare_item_for_response( $post, $request );
}
/**
* Prepares a single publisher logo output for response.
*
* @since 1.12.0
*
* @param WP_Post $post Post object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $post, $request ): WP_REST_Response {
$fields = $this->get_fields_for_response( $request );
// Base fields for every post.
$data = [];
if ( rest_is_field_included( 'id', $fields ) ) {
$data['id'] = $post->ID;
}
if ( rest_is_field_included( 'title', $fields ) ) {
$data['title'] = get_the_title( $post->ID );
}
if ( rest_is_field_included( 'active', $fields ) ) {
$active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ) );
$data['active'] = $post->ID === $active_publisher_logo_id;
}
if ( rest_is_field_included( 'url', $fields ) ) {
$data['url'] = wp_get_attachment_url( $post->ID );
}
/**
* Wrapped response object.
*
* @var WP_REST_Response $response Response object.
*/
$response = rest_ensure_response( $data );
if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
$response->add_links( $this->prepare_links( $post ) );
}
return $response;
}
/**
* Retrieves the publisher logo's schema, conforming to JSON Schema.
*
* @since 1.12.0
*
* @return array<string, array<string, array<string, array<int, string>|bool|string>>|string> Item schema data.
*/
public function get_item_schema(): array {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
return [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'publisher-logo',
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'Publisher logo ID.', 'web-stories' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'title' => [
'description' => __( 'Publisher logo title.', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'url' => [
'description' => __( 'Publisher logo URL.', 'web-stories' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'active' => [
'description' => __( 'Whether the publisher logo is the default one.', 'web-stories' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
];
}
/**
* Get an existing publisher logo's post object, if valid.
*
* @since 1.12.0
*
* @param int $id Supplied ID.
* @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
*/
protected function get_publisher_logo( $id ) {
/**
* List of publisher logos.
*
* @var int[] $publisher_logos
*/
$publisher_logos = $this->settings->get_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] );
$publisher_logos = $this->filter_publisher_logos( $publisher_logos );
$post = get_post( $id );
if ( ! $post || ! \in_array( $post->ID, $publisher_logos, true ) ) {
return new \WP_Error(
'rest_invalid_id',
__( 'Invalid ID', 'web-stories' ),
[ 'status' => 400 ]
);
}
return $post;
}
/**
* Filters publisher logos to remove non-existent or invalid ones.
*
* @param int[] $publisher_logos List of publisher logos.
* @return int[] Filtered list of publisher logos.
*/
protected function filter_publisher_logos( $publisher_logos ): array {
return array_filter( $publisher_logos, 'wp_attachment_is_image' );
}
/**
* Prepares links for the request.
*
* @since 1.12.0
*
* @param WP_Post $post Post object.
* @return array Links for the given post.
*
* @phpstan-return Links
*/
protected function prepare_links( $post ): array {
$base = sprintf( '%s/%s', $this->namespace, $this->rest_base );
// Entity meta.
return [
'self' => [
'href' => rest_url( trailingslashit( $base ) . $post->ID ),
],
'collection' => [
'href' => rest_url( $base ),
],
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* Class REST_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\Delayed;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use WP_REST_Controller;
/**
* Class REST_Controller
*/
abstract class REST_Controller extends WP_REST_Controller implements Service, Delayed, Registerable {
/**
* Register the service.
*
* @since 1.7.0
*/
public function register(): void {
$this->register_routes();
}
/**
* Get the action to use for registering the service.
*
* @since 1.7.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'rest_api_init';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.7.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 100;
}
}

View File

@@ -0,0 +1,222 @@
<?php
/**
* Class Status_Check_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Story_Post_Type;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* API endpoint check status.
*
* Class Status_Check_Controller
*
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* }
* @phpstan-type Schema array{
* properties: array{
* success?: SchemaEntry,
* }
* }
*/
class Status_Check_Controller extends REST_Controller implements HasRequirements {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Constructor.
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
$this->namespace = 'web-stories/v1';
$this->rest_base = 'status-check';
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type' ];
}
/**
* Registers routes for links.
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::ALLMETHODS,
'callback' => [ $this, 'status_check' ],
'permission_callback' => [ $this, 'status_check_permissions_check' ],
'args' => [
'content' => [
'description' => __( 'Test HTML content.', 'web-stories' ),
'required' => true,
'type' => 'string',
],
],
],
]
);
}
/**
* Status check, return true for now.
*
* @since 1.1.0
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function status_check( $request ) {
$data = [
'success' => true,
];
$response = $this->prepare_item_for_response( $data, $request );
return rest_ensure_response( $response );
}
/**
* Prepares a status data output for response.
*
* @since 1.10.0
*
* @param array{success: bool} $item Status array.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response object.
*/
public function prepare_item_for_response( $item, $request ) {
$fields = $this->get_fields_for_response( $request );
$schema = $this->get_item_schema();
$data = [];
if ( ! empty( $schema['properties']['success'] ) && rest_is_field_included( 'success', $fields ) ) {
$data['success'] = rest_sanitize_value_from_schema( $item['success'], $schema['properties']['success'] );
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
return rest_ensure_response( $data );
}
/**
* Retrieves the status schema, conforming to JSON Schema.
*
* @since 1.10.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'status',
'type' => 'object',
'properties' => [
'success' => [
'description' => __( 'Whether check was successful', 'web-stories' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
],
],
];
$this->schema = $schema;
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Checks if current user can process status.
*
* @since 1.1.0
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function status_check_permissions_check() {
if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) {
return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed run status check.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] );
}
return true;
}
}

View File

@@ -0,0 +1,245 @@
<?php
/**
* Class Stories_Autosaves_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\Delayed;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use Google\Web_Stories\Story_Post_Type;
use WP_Post;
use WP_REST_Autosaves_Controller;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Stories_Autosaves_Controller class.
*
* Register using register_post_type once https://core.trac.wordpress.org/ticket/56922 is committed.
*
* @phpstan-import-type Schema from \Google\Web_Stories\REST_API\Stories_Base_Controller
*/
class Stories_Autosaves_Controller extends WP_REST_Autosaves_Controller implements Service, Delayed, Registerable, HasRequirements {
/**
* Parent post controller.
*/
protected WP_REST_Controller $parent_controller;
/**
* The base of the parent controller's route.
*/
protected string $parent_base;
/**
* Constructor.
*
* @since 1.0.0
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
parent::__construct( $story_post_type->get_slug() );
$this->parent_controller = $story_post_type->get_parent_controller();
$this->parent_base = $story_post_type->get_rest_base();
$this->namespace = $story_post_type->get_rest_namespace();
}
/**
* Register the service.
*
* @since 1.7.0
*/
public function register(): void {
$this->register_routes();
}
/**
* Get the action to use for registering the service.
*
* @since 1.7.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'rest_api_init';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.7.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 100;
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type' ];
}
/**
* Registers the routes for autosaves.
*
* Used to override the create_item() callback.
*
* @since 1.0.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
parent::register_routes();
register_rest_route(
$this->namespace,
'/' . $this->parent_base . '/(?P<id>[\d]+)/' . $this->rest_base,
[
'args' => [
'parent' => [
'description' => __( 'The ID for the parent of the object.', 'web-stories' ),
'type' => 'integer',
],
],
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
'args' => $this->get_collection_params(),
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_item' ],
'permission_callback' => [ $this, 'create_item_permissions_check' ],
'args' => $this->parent_controller->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
],
'schema' => [ $this, 'get_public_item_schema' ],
],
true // required so that the existing route is overridden.
);
}
/**
* Prepares a single template output for response.
*
* Adds post_content_filtered field to output.
*
* @since 1.0.0
*
* @param WP_Post $post Post object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $post, $request ): WP_REST_Response {
$response = parent::prepare_item_for_response( $post, $request );
$fields = $this->get_fields_for_response( $request );
$schema = $this->get_item_schema();
/**
* Response data.
*
* @var array<string,mixed> $data
*/
$data = $response->get_data();
if ( ! empty( $schema['properties']['story_data'] ) && rest_is_field_included( 'story_data', $fields ) ) {
$post_story_data = json_decode( $post->post_content_filtered, true );
$data['story_data'] = rest_sanitize_value_from_schema( $post_story_data, $schema['properties']['story_data'] );
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->filter_response_by_context( $data, $context );
$links = $response->get_links();
// Wrap the data in a response object.
$response = new WP_REST_Response( $data );
foreach ( $links as $rel => $rel_links ) {
foreach ( $rel_links as $link ) {
$response->add_link( $rel, $link['href'], $link['attributes'] );
}
}
/** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php */
return apply_filters( 'rest_prepare_autosave', $response, $post, $request );
}
/**
* Retrieves the story's schema, conforming to JSON Schema.
*
* @since 1.0.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
$autosaves_schema = parent::get_item_schema();
$stories_schema = $this->parent_controller->get_item_schema();
$autosaves_schema['properties']['story_data'] = $stories_schema['properties']['story_data'];
$this->schema = $autosaves_schema;
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
}

View File

@@ -0,0 +1,420 @@
<?php
/**
* Class Stories_Base_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Decoder;
use Google\Web_Stories\Services;
use stdClass;
use WP_Error;
use WP_Post;
use WP_REST_Posts_Controller;
use WP_REST_Request;
use WP_REST_Response;
/**
* Stories_Base_Controller class.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* Override the WP_REST_Posts_Controller class to add `post_content_filtered` to REST request.
*
* @phpstan-type Link array{
* href?: string,
* embeddable?: bool,
* taxonomy?: string
* }
* @phpstan-type Links array<string, Link|Link[]>
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* }
* @phpstan-type Schema array{
* properties: array{
* content?: SchemaEntry,
* story_data?: SchemaEntry
* }
* }
* @phpstan-type RegisteredMetadata array{
* type: string,
* description: string,
* single: bool,
* sanitize_callback?: callable,
* auth_callback: callable,
* show_in_rest: bool|array{schema: array<string, mixed>},
* default?: mixed
* }
*/
class Stories_Base_Controller extends WP_REST_Posts_Controller {
/**
* Decoder instance.
*
* @var Decoder Decoder instance.
*/
private Decoder $decoder;
/**
* Constructor.
*
* Override the namespace.
*
* @since 1.0.0
*
* @param string $post_type Post type.
*/
public function __construct( $post_type ) {
parent::__construct( $post_type );
$injector = Services::get_injector();
/**
* Decoder instance.
*
* @var Decoder $decoder Decoder instance.
*/
$decoder = $injector->make( Decoder::class );
$this->decoder = $decoder;
}
/**
* Prepares a single template output for response.
*
* Adds post_content_filtered field to output.
*
* @since 1.0.0
*
* @param WP_Post $post Post object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $post, $request ): WP_REST_Response {
$response = parent::prepare_item_for_response( $post, $request );
$fields = $this->get_fields_for_response( $request );
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->get_item_schema();
/**
* Response data.
*
* @var array<string,mixed> $data
*/
$data = $response->get_data();
if ( ! empty( $schema['properties']['story_data'] ) && rest_is_field_included( 'story_data', $fields ) ) {
$post_story_data = json_decode( $post->post_content_filtered, true );
$data['story_data'] = post_password_required( $post ) ? (object) [] : rest_sanitize_value_from_schema( $post_story_data, $schema['properties']['story_data'] );
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->filter_response_by_context( $data, $context );
$links = $response->get_links();
// Wrap the data in a response object.
$response = new WP_REST_Response( $data );
foreach ( $links as $rel => $rel_links ) {
foreach ( $rel_links as $link ) {
$response->add_link( $rel, $link['href'], $link['attributes'] );
}
}
/** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
return apply_filters( "rest_prepare_{$this->post_type}", $response, $post, $request );
}
/**
* Creates a single story.
*
* Override the existing method so we can set parent id.
*
* @since 1.11.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
*/
public function create_item( $request ) {
/**
* Original post ID.
*
* @var int $original_id
*/
$original_id = ! empty( $request['original_id'] ) ? $request['original_id'] : null;
if ( ! $original_id ) {
return parent::create_item( $request );
}
$original_post = $this->get_post( $original_id );
if ( is_wp_error( $original_post ) ) {
return $original_post;
}
if ( ! $this->check_update_permission( $original_post ) ) {
return new \WP_Error(
'rest_cannot_create',
__( 'Sorry, you are not allowed to duplicate this story.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
$request->set_param( 'content', $original_post->post_content );
$request->set_param( 'excerpt', $original_post->post_excerpt );
$title = sprintf(
/* translators: %s: story title. */
__( '%s (Copy)', 'web-stories' ),
$original_post->post_title
);
$request->set_param( 'title', $title );
$story_data = json_decode( $original_post->post_content_filtered, true );
if ( $story_data ) {
$request->set_param( 'story_data', $story_data );
}
$thumbnail_id = get_post_thumbnail_id( $original_post );
if ( $thumbnail_id ) {
$request->set_param( 'featured_media', $thumbnail_id );
}
$meta = $this->get_registered_meta( $original_post );
if ( $meta ) {
$request->set_param( 'meta', $meta );
}
return parent::create_item( $request );
}
/**
* Retrieves the story's schema, conforming to JSON Schema.
*
* @since 1.0.0
*
* @return array Item schema data.
*
* @phpstan-return Schema
*/
public function get_item_schema(): array {
if ( $this->schema ) {
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
$schema = parent::get_item_schema();
$schema['properties']['story_data'] = [
'description' => __( 'Story data', 'web-stories' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'default' => [],
];
$schema['properties']['original_id'] = [
'description' => __( 'Unique identifier for original story id.', 'web-stories' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
];
$this->schema = $schema;
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->add_additional_fields_schema( $this->schema );
return $schema;
}
/**
* Prepares a single story for create or update. Add post_content_filtered field to save/insert.
*
* @since 1.0.0
*
* @param WP_REST_Request $request Request object.
* @return stdClass|WP_Error Post object or WP_Error.
*/
protected function prepare_item_for_database( $request ) {
$prepared_post = parent::prepare_item_for_database( $request );
if ( is_wp_error( $prepared_post ) ) {
return $prepared_post;
}
/**
* Schema.
*
* @phpstan-var Schema $schema
*/
$schema = $this->get_item_schema();
// Post content.
if ( ! empty( $schema['properties']['content'] ) ) {
// Ensure that content and story_data are updated together.
// Exception: new auto-draft created from a template.
if (
(
( ! empty( $request['story_data'] ) && empty( $request['content'] ) ) ||
( ! empty( $request['content'] ) && empty( $request['story_data'] ) )
) && ( 'auto-draft' !== $prepared_post->post_status )
) {
return new \WP_Error(
'rest_empty_content',
sprintf(
/* translators: 1: content, 2: story_data */
__( '%1$s and %2$s should always be updated together.', 'web-stories' ),
'content',
'story_data'
),
[ 'status' => 412 ]
);
}
if ( isset( $request['content'] ) ) {
$prepared_post->post_content = $this->decoder->base64_decode( $prepared_post->post_content );
}
}
// If the request is updating the content as well, let's make sure the JSON representation of the story is saved, too.
if ( ! empty( $schema['properties']['story_data'] ) && isset( $request['story_data'] ) ) {
$prepared_post->post_content_filtered = wp_json_encode( $request['story_data'] );
}
return $prepared_post;
}
/**
* Get registered post meta.
*
* @since 1.23.0
*
* @param WP_Post $original_post Post Object.
* @return array<string, mixed> $meta
*/
protected function get_registered_meta( WP_Post $original_post ): array {
$meta_keys = get_registered_meta_keys( 'post', $this->post_type );
$meta = [];
/**
* Meta key settings.
*
* @var array $settings
* @phpstan-var RegisteredMetadata $settings
*/
foreach ( $meta_keys as $key => $settings ) {
if ( $settings['show_in_rest'] ) {
$meta[ $key ] = get_post_meta( $original_post->ID, $key, $settings['single'] );
}
}
return $meta;
}
/**
* Prepares links for the request.
*
* Ensures that {@see Stories_Users_Controller} is used for author embeds.
*
* @since 1.10.0
*
* @param WP_Post $post Post object.
* @return array Links for the given post.
*
* @phpstan-return Links
*/
protected function prepare_links( $post ): array {
$links = parent::prepare_links( $post );
if ( ! empty( $post->post_author ) && post_type_supports( $post->post_type, 'author' ) ) {
$links['author'] = [
'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, 'users', $post->post_author ) ),
'embeddable' => true,
];
}
// If we have a featured media, add that.
$featured_media = get_post_thumbnail_id( $post->ID );
if ( $featured_media ) {
$image_url = rest_url( sprintf( '%s/%s/%s', $this->namespace, 'media', $featured_media ) );
$links['https://api.w.org/featuredmedia'] = [
'href' => $image_url,
'embeddable' => true,
];
}
if ( ! \in_array( $post->post_type, [ 'attachment', 'nav_menu_item', 'revision' ], true ) ) {
$attachments_url = rest_url( sprintf( '%s/%s', $this->namespace, 'media' ) );
$attachments_url = add_query_arg( 'parent', $post->ID, $attachments_url );
$links['https://api.w.org/attachment'] = [
'href' => $attachments_url,
];
}
return $links;
}
/**
* Get the link relations available for the post and current user.
*
* @since 1.10.0
*
* @param WP_Post $post Post object.
* @param WP_REST_Request $request Request object.
* @return string[] List of link relations.
*/
protected function get_available_actions( $post, $request ): array {
$rels = parent::get_available_actions( $post, $request );
if ( $this->check_delete_permission( $post ) ) {
$rels[] = 'https://api.w.org/action-delete';
}
if ( $this->check_update_permission( $post ) ) {
$rels[] = 'https://api.w.org/action-edit';
}
return $rels;
}
}

View File

@@ -0,0 +1,667 @@
<?php
/**
* Class Stories_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Demo_Content;
use Google\Web_Stories\Media\Image_Sizes;
use Google\Web_Stories\Story_Post_Type;
use WP_Error;
use WP_Post;
use WP_Post_Type;
use WP_Query;
use WP_REST_Request;
use WP_REST_Response;
/**
* Stories_Controller class.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type QueryArgs array{
* posts_per_page?: int,
* post_status?: string[],
* tax_query?: array<int|'relation', mixed>
* }
* @phpstan-import-type Links from \Google\Web_Stories\REST_API\Stories_Base_Controller
*/
class Stories_Controller extends Stories_Base_Controller {
/**
* Default style presets to pass if not set.
*/
public const EMPTY_STYLE_PRESETS = [
'colors' => [],
'textStyles' => [],
];
/**
* Query args.
*
* @var array<string,mixed>
* @phpstan-var QueryArgs
*/
private array $args = [];
/**
* Prepares a single story output for response. Add post_content_filtered field to output.
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.0.0
*
* @param WP_Post $post Post object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $post, $request ): WP_REST_Response { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
if ( 'auto-draft' === $post->post_status && wp_validate_boolean( $request['web_stories_demo'] ) ) {
$demo = new Demo_Content();
$demo_content = $demo->get_content();
if ( ! empty( $demo_content ) ) {
$post->post_title = $demo->get_title();
$post->post_content_filtered = $demo_content;
}
}
$response = parent::prepare_item_for_response( $post, $request );
$fields = $this->get_fields_for_response( $request );
/**
* Response data.
*
* @var array<string,mixed> $data
*/
$data = $response->get_data();
if ( rest_is_field_included( 'style_presets', $fields ) ) {
$style_presets = get_option( Story_Post_Type::STYLE_PRESETS_OPTION, self::EMPTY_STYLE_PRESETS );
$data['style_presets'] = \is_array( $style_presets ) ? $style_presets : self::EMPTY_STYLE_PRESETS;
}
if ( rest_is_field_included( 'preview_link', $fields ) ) {
// Based on https://github.com/WordPress/wordpress-develop/blob/8153c8ba020c4aec0b9d94243cd39c689a0730f7/src/wp-admin/includes/post.php#L1445-L1457.
if ( 'draft' === $post->post_status || empty( $post->post_name ) ) {
$view_link = get_preview_post_link( $post );
} elseif ( 'publish' === $post->post_status ) {
$view_link = get_permalink( $post );
} else {
if ( ! \function_exists( 'get_sample_permalink' ) ) {
require_once ABSPATH . 'wp-admin/includes/post.php';
}
[ $permalink ] = get_sample_permalink( $post->ID, $post->post_title, '' );
// Allow non-published (private, future) to be viewed at a pretty permalink, in case $post->post_name is set.
$view_link = str_replace( [ '%pagename%', '%postname%' ], $post->post_name, $permalink );
}
$data['preview_link'] = $view_link;
}
if ( rest_is_field_included( 'edit_link', $fields ) ) {
$edit_link = get_edit_post_link( $post, 'rest-api' );
if ( $edit_link ) {
$data['edit_link'] = $edit_link;
}
}
if ( rest_is_field_included( 'embed_post_link', $fields ) && current_user_can( 'edit_posts' ) ) {
$data['embed_post_link'] = add_query_arg( [ 'from-web-story' => $post->ID ], admin_url( 'post-new.php' ) );
}
if ( rest_is_field_included( 'story_poster', $fields ) ) {
$story_poster = $this->get_story_poster( $post );
if ( $story_poster ) {
$data['story_poster'] = $story_poster;
}
}
$data = $this->filter_response_by_context( $data, $context );
$links = $response->get_links();
$response = new WP_REST_Response( $data );
foreach ( $links as $rel => $rel_links ) {
foreach ( $rel_links as $link ) {
$response->add_link( $rel, $link['href'], $link['attributes'] );
}
}
/**
* Filters the post data for a response.
*
* The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug.
*
* @since 1.0.0
*
* @param WP_REST_Response $response The response object.
* @param WP_Post $post Post object.
* @param WP_REST_Request $request Request object.
*/
return apply_filters( "rest_prepare_{$this->post_type}", $response, $post, $request );
}
/**
* Updates a single post.
*
* @since 1.0.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function update_item( $request ) {
$response = parent::update_item( $request );
if ( is_wp_error( $response ) ) {
return rest_ensure_response( $response );
}
// If style presets are set.
$style_presets = $request->get_param( 'style_presets' );
if ( \is_array( $style_presets ) ) {
update_option( Story_Post_Type::STYLE_PRESETS_OPTION, $style_presets );
}
return rest_ensure_response( $response );
}
/**
* Retrieves the story's schema, conforming to JSON Schema.
*
* @since 1.0.0
*
* @return array<string, string|array<string, array<string,string|string[]>>> Item schema data.
*/
public function get_item_schema(): array {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$schema = parent::get_item_schema();
$schema['properties']['style_presets'] = [
'description' => __( 'Style presets used by all stories', 'web-stories' ),
'type' => 'object',
'context' => [ 'edit' ],
];
$schema['properties']['preview_link'] = [
'description' => __( 'Preview Link.', 'web-stories' ),
'type' => 'string',
'context' => [ 'edit' ],
'format' => 'uri',
'default' => '',
];
$schema['properties']['edit_link'] = [
'description' => _x( 'Edit Link', 'compound noun', 'web-stories' ),
'type' => 'string',
'context' => [ 'edit' ],
'format' => 'uri',
'default' => '',
];
$schema['properties']['embed_post_link'] = [
'description' => __( 'Embed Post Edit Link.', 'web-stories' ),
'type' => 'string',
'context' => [ 'edit' ],
'format' => 'uri',
'default' => '',
];
$schema['properties']['story_poster'] = [
'description' => __( 'Story poster image.', 'web-stories' ),
'type' => 'object',
'properties' => [
'id' => [
'type' => 'integer',
'description' => __( 'Poster ID', 'web-stories' ),
],
'needsProxy' => [
'description' => __( 'If poster needs to be proxied', 'web-stories' ),
'type' => 'boolean',
],
'height' => [
'type' => 'integer',
'description' => __( 'Poster height', 'web-stories' ),
],
'url' => [
'description' => __( 'Poster URL.', 'web-stories' ),
'type' => 'string',
'format' => 'uri',
],
'width' => [
'description' => __( 'Poster width.', 'web-stories' ),
'type' => 'integer',
],
],
'default' => null,
];
$schema['properties']['status']['enum'][] = 'auto-draft';
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
/**
* Filters query clauses to sort posts by the author's display name.
*
* @since 1.0.0
*
* @param string[] $clauses Associative array of the clauses for the query.
* @param WP_Query $query The WP_Query instance.
* @return string[] Filtered query clauses.
*/
public function filter_posts_clauses( $clauses, WP_Query $query ): array {
global $wpdb;
if ( $this->post_type !== $query->get( 'post_type' ) ) {
return $clauses;
}
if ( 'story_author' !== $query->get( 'orderby' ) ) {
return $clauses;
}
/**
* Order value.
*
* @var string $order
*/
$order = $query->get( 'order' );
// phpcs:disable WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users
$clauses['join'] .= " LEFT JOIN {$wpdb->users} ON {$wpdb->posts}.post_author={$wpdb->users}.ID";
$clauses['orderby'] = "{$wpdb->users}.display_name $order, " . $clauses['orderby'];
// phpcs:enable WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users
return $clauses;
}
/**
* Prime post caches for attachments and parents.
*
* @since 1.21.0
*
* @param WP_Post[] $posts Array of post objects.
* @return WP_Post[] Array of posts.
*/
public function prime_post_caches( array $posts ): array {
$post_ids = $this->get_attached_post_ids( $posts );
if ( ! empty( $post_ids ) ) {
_prime_post_caches( $post_ids );
}
return $posts;
}
/**
* Filter the query to cache the value to a class property.
*
* @param array<string, mixed> $args WP_Query arguments.
* @return array<string, mixed> Current args.
*
* @phpstan-param QueryArgs $args
*/
public function filter_query( $args ): array {
$this->args = $args;
return $args;
}
/**
* Retrieves a collection of web stories.
*
* @since 1.0.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
add_filter( "rest_{$this->post_type}_query", [ $this, 'filter_query' ], 100, 1 );
add_filter( 'posts_clauses', [ $this, 'filter_posts_clauses' ], 10, 2 );
add_filter( 'posts_results', [ $this, 'prime_post_caches' ] );
$response = parent::get_items( $request );
remove_filter( 'posts_results', [ $this, 'prime_post_caches' ] );
remove_filter( 'posts_clauses', [ $this, 'filter_posts_clauses' ], 10 );
remove_filter( "rest_{$this->post_type}_query", [ $this, 'filter_query' ], 100 );
if ( is_wp_error( $response ) ) {
return $response;
}
if ( 'edit' !== $request['context'] ) {
return $response;
}
$response = $this->add_response_headers( $response, $request );
if ( is_wp_error( $response ) ) {
return $response;
}
if ( $request['_web_stories_envelope'] ) {
/**
* Embed directive.
*
* @var string|string[] $embed
*/
$embed = $request['_embed'];
$embed = $embed ? rest_parse_embed_param( $embed ) : false;
$response = rest_get_server()->envelope_response( $response, $embed );
}
return $response;
}
/**
* Retrieves the query params for the posts collection.
*
* @since 1.0.0
*
* @return array<string, array<string, mixed>> Collection parameters.
*/
public function get_collection_params(): array {
$query_params = parent::get_collection_params();
$query_params['_web_stories_envelope'] = [
'description' => __( 'Envelope request for preloading.', 'web-stories' ),
'type' => 'boolean',
'default' => false,
];
$query_params['web_stories_demo'] = [
'description' => __( 'Load demo data.', 'web-stories' ),
'type' => 'boolean',
'default' => false,
];
if ( ! empty( $query_params['orderby'] ) ) {
$query_params['orderby']['enum'][] = 'story_author';
}
return $query_params;
}
/**
* Get an array of attached post objects.
*
* @since 1.22.0
*
* @param WP_Post[] $posts Array of post objects.
* @return int[] Array of post ids.
*/
protected function get_attached_post_ids( array $posts ): array {
return array_unique( array_filter( array_map( [ $this, 'get_publisher_logo_id' ], $posts ) ) );
}
/**
* Add response headers, with post counts.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.12.0
*
* @param WP_REST_Response $response Response object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
protected function add_response_headers( WP_REST_Response $response, WP_REST_Request $request ) {
// Add counts for other statuses.
$statuses = [
'publish' => 'publish',
];
$post_type = get_post_type_object( $this->post_type );
if ( ! ( $post_type instanceof WP_Post_Type ) ) {
return $response;
}
if ( current_user_can( $post_type->cap->edit_posts ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined
$statuses['draft'] = 'draft';
$statuses['future'] = 'future';
$statuses['pending'] = 'pending';
}
if ( current_user_can( $post_type->cap->publish_posts ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined
$statuses['private'] = 'private';
}
$edit_others_posts = current_user_can( $post_type->cap->edit_others_posts ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined
$edit_private_posts = current_user_can( $post_type->cap->edit_private_posts ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined
$statuses_count = [ 'all' => 0 ];
$total_posts = 0;
$query_args = $this->prepare_items_query( $this->args, $request );
// Strip down query for speed.
$query_args['fields'] = 'ids';
$query_args['posts_per_page'] = 1;
$query_args['paged'] = 1;
$query_args['update_post_meta_cache'] = false;
$query_args['update_post_term_cache'] = false;
foreach ( $statuses as $key => $status ) {
$posts_query = new WP_Query();
$query_args['post_status'] = $status;
if ( \in_array( $status, [ 'draft', 'future', 'pending' ], true ) && ! $edit_others_posts ) {
$query_args['author'] = get_current_user_id();
}
if ( 'private' === $status && ! $edit_private_posts ) {
$query_args['author'] = get_current_user_id();
}
$posts_query->query( $query_args );
$statuses_count[ $key ] = absint( $posts_query->found_posts );
$statuses_count['all'] += $statuses_count[ $key ];
if ( \in_array( $status, $this->args['post_status'] ?? [], true ) ) {
$total_posts += $statuses_count[ $key ];
}
}
// Encode the array as headers do not support passing an array.
$encoded_statuses = wp_json_encode( $statuses_count );
if ( $encoded_statuses ) {
$response->header( 'X-WP-TotalByStatus', $encoded_statuses );
}
$page = (int) $posts_query->query_vars['paged'];
$max_pages = ceil( $total_posts / (int) ( $this->args['posts_per_page'] ?? 10 ) );
if ( $page > $max_pages && $total_posts > 0 ) {
return new \WP_Error(
'rest_post_invalid_page_number',
__( 'The page number requested is larger than the number of pages available.', 'web-stories' ),
[ 'status' => 400 ]
);
}
$response->header( 'X-WP-Total', (string) $total_posts );
$response->header( 'X-WP-TotalPages', (string) $max_pages );
return $response;
}
/**
* Prepares links for the request.
*
* @param WP_Post $post Post object.
* @return array Links for the given post.
*
* @phpstan-return Links
*/
protected function prepare_links( $post ): array {
$links = parent::prepare_links( $post );
$links = $this->add_post_locking_link( $links, $post );
$links = $this->add_publisher_logo_link( $links, $post );
return $links;
}
/**
* Adds a REST API link if the story is locked.
*
* @since 1.12.0
*
* @param array $links Links for the given post.
* @param WP_Post $post Post object.
* @return array Modified list of links.
*
* @phpstan-param Links $links
* @phpstan-return Links
*/
private function add_post_locking_link( array $links, WP_Post $post ): array {
$base = sprintf( '%s/%s', $this->namespace, $this->rest_base );
$lock_url = rest_url( trailingslashit( $base ) . $post->ID . '/lock' );
$links['https://api.w.org/lock'] = [
'href' => $lock_url,
'embeddable' => true,
];
/**
* Lock data.
*
* @var string|false $lock
*/
$lock = get_post_meta( $post->ID, '_edit_lock', true );
if ( ! empty( $lock ) ) {
[ $time, $user ] = explode( ':', $lock );
/** This filter is documented in wp-admin/includes/ajax-actions.php */
$time_window = apply_filters( 'wp_check_post_lock_window', 150 );
if ( $time && $time > time() - $time_window ) {
$links['https://api.w.org/lockuser'] = [
'href' => rest_url( sprintf( '%s/%s', $this->namespace, 'users/' ) . $user ),
'embeddable' => true,
];
}
}
return $links;
}
/**
* Helper method to get publisher logo id.
*
* @since 1.22.0
*
* @param WP_Post $post Post Object.
* @return int ID of attachment for publisher logo.
*/
private function get_publisher_logo_id( WP_Post $post ): int {
/**
* Publisher logo ID.
*
* @var string|int $publisher_logo_id
*/
$publisher_logo_id = get_post_meta( $post->ID, Story_Post_Type::PUBLISHER_LOGO_META_KEY, true );
return (int) $publisher_logo_id;
}
/**
* Adds a REST API link for the story's publisher logo.
*
* @since 1.12.0
*
* @param array $links Links for the given post.
* @param WP_Post $post Post object.
* @return array Modified list of links.
*
* @phpstan-param Links $links
* @phpstan-return Links
*/
private function add_publisher_logo_link( array $links, WP_Post $post ): array {
$publisher_logo_id = $this->get_publisher_logo_id( $post );
if ( $publisher_logo_id ) {
$links['https://api.w.org/publisherlogo'] = [
'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, 'media', $publisher_logo_id ) ),
'embeddable' => true,
];
}
return $links;
}
/**
* Helper method to get the story poster.
*
* Checks for the regular featured image as well as a hotlinked image.
*
* @since 1.23.2
*
* @param WP_Post $post Post Object.
* @return array{url:string, width: int, height: int, needsProxy: bool, id?: int}|null Story poster data.
*/
private function get_story_poster( WP_Post $post ): ?array {
$thumbnail_id = (int) get_post_thumbnail_id( $post );
if ( 0 !== $thumbnail_id ) {
$poster_src = wp_get_attachment_image_src( $thumbnail_id, Image_Sizes::POSTER_PORTRAIT_IMAGE_DIMENSIONS );
if ( $poster_src ) {
[$url, $width, $height] = $poster_src;
return [
'id' => $thumbnail_id,
'url' => $url,
'width' => $width,
'height' => $height,
'needsProxy' => false,
];
}
} else {
/**
* Poster.
*
* @var array{url:string, width: int, height: int, needsProxy: bool}|false $poster
*/
$poster = get_post_meta( $post->ID, Story_Post_Type::POSTER_META_KEY, true );
if ( ! empty( $poster ) ) {
return $poster;
}
}
return null;
}
}

View File

@@ -0,0 +1,508 @@
<?php
/**
* Class Stories_Lock_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Story_Post_Type;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Class Stories_Lock_Controller
*/
class Stories_Lock_Controller extends REST_Controller implements HasRequirements {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Parent post controller.
*
* @var WP_REST_Controller WP_REST_Controller instance.
*/
private WP_REST_Controller $parent_controller;
/**
* Constructor.
*
* @since 1.6.0
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
$this->parent_controller = $story_post_type->get_parent_controller();
$this->rest_base = $story_post_type->get_rest_base();
$this->namespace = $story_post_type->get_rest_namespace();
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type' ];
}
/**
* Registers the routes for the objects of the controller.
*
* @since 1.6.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/lock',
[
'args' => [
'id' => [
'description' => __( 'Unique identifier for the object.', 'web-stories' ),
'type' => 'integer',
],
],
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_item' ],
'permission_callback' => [ $this, 'get_item_permissions_check' ],
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'update_item' ],
'permission_callback' => [ $this, 'update_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_item' ],
'permission_callback' => [ $this, 'delete_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::DELETABLE ),
],
]
);
}
/**
* Get post lock
*
* @since 1.6.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success.
*/
public function get_item( $request ) {
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $request['id'];
$lock = $this->get_lock( $post_id );
return $this->prepare_item_for_response( $lock, $request );
}
/**
* Update post lock
*
* @since 1.6.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success.
*/
public function update_item( $request ) {
if ( ! \function_exists( '\wp_set_post_lock' ) ) {
require_once ABSPATH . 'wp-admin/includes/post.php';
}
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $request['id'];
wp_set_post_lock( $post_id );
$lock = $this->get_lock( $post_id );
return $this->prepare_item_for_response( $lock, $request );
}
/**
* Delete post lock
*
* @since 1.6.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response Response object on success.
*/
public function delete_item( $request ): WP_REST_Response {
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $request['id'];
$lock = $this->get_lock( $post_id );
$previous = $this->prepare_item_for_response( $lock, $request );
$result = delete_post_meta( $post_id, '_edit_lock' );
$data = [];
if ( ! is_wp_error( $previous ) ) {
$data = $previous->get_data();
}
$response = new WP_REST_Response();
$response->set_data(
[
'deleted' => $result,
'previous' => $data,
]
);
return $response;
}
/**
* Checks if a given request has access to read a lock.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
*/
public function get_item_permissions_check( $request ) {
return $this->parent_controller->update_item_permissions_check( $request );
}
/**
* Checks if a given request has access to update a lock.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
*/
public function update_item_permissions_check( $request ) {
return $this->parent_controller->update_item_permissions_check( $request );
}
/**
* Checks if a given request has access to delete a lock.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
*/
public function delete_item_permissions_check( $request ) {
$result = $this->parent_controller->update_item_permissions_check( $request );
if ( is_wp_error( $result ) ) {
return $result;
}
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $request['id'];
$lock = $this->get_lock( $post_id );
if ( \is_array( $lock ) && isset( $lock['user'] ) && get_current_user_id() !== (int) $lock['user'] ) {
return new \WP_Error(
'rest_cannot_delete_others_lock',
__( 'Sorry, you are not allowed delete others lock.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
/**
* Prepares a single lock output for response.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.6.0
*
* @param array{time?: int, user?: int}|false $item Lock value, default to false is not set.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response object.
*/
public function prepare_item_for_response( $item, $request ) { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
$fields = $this->get_fields_for_response( $request );
$schema = $this->get_item_schema();
$nonce = wp_create_nonce( 'wp_rest' );
$lock_data = [
'locked' => false,
'time' => '',
'user' => [
'name' => '',
'id' => 0,
],
'nonce' => $nonce,
];
if ( get_option( 'show_avatars' ) ) {
$lock_data['user']['avatar'] = [];
}
if ( ! empty( $item ) ) {
/** This filter is documented in wp-admin/includes/ajax-actions.php */
$time_window = apply_filters( 'wp_check_post_lock_window', 150 );
if ( $item['time'] && $item['time'] > time() - $time_window ) {
$lock_data = [
'locked' => true,
'time' => $item['time'],
'user' => isset( $item['user'] ) ? (int) $item['user'] : 0,
'nonce' => $nonce,
];
if ( isset( $item['user'] ) ) {
$user = get_user_by( 'id', $item['user'] );
if ( $user ) {
$lock_data['user'] = [
'name' => $user->display_name,
'id' => $item['user'],
];
if ( get_option( 'show_avatars' ) ) {
$lock_data['user']['avatar'] = rest_get_avatar_urls( $user );
}
}
}
}
}
$data = [];
$check_fields = array_keys( $lock_data );
foreach ( $check_fields as $check_field ) {
if ( ! empty( $schema['properties'][ $check_field ] ) && rest_is_field_included( $check_field, $fields ) ) {
$data[ $check_field ] = rest_sanitize_value_from_schema( $lock_data[ $check_field ], $schema['properties'][ $check_field ] );
}
}
/**
* Request context.
*
* @var string $context
*/
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
/**
* Response object.
*
* @var WP_REST_Response $response
*/
$response = rest_ensure_response( $data );
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $request['id'];
if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
$response->add_links( $this->prepare_links( $item, $post_id ) );
}
$post_type = $this->story_post_type->get_slug();
/**
* Filters the lock data for a response.
*
* The dynamic portion of the hook name, `$post_type`, refers to the post type slug.
*
* @since 1.6.0
*
* @param WP_REST_Response $response The response object.
* @param array|false $item Lock array if available.
* @param WP_REST_Request $request Request object.
*/
return apply_filters( "rest_prepare_{$post_type}_lock", $response, $item, $request );
}
/**
* Retrieves the post's schema, conforming to JSON Schema.
*
* @since 1.6.0
*
* @return array<string, string|array<string, array<string,string|string[]>>> Item schema data.
*/
public function get_item_schema(): array {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'lock',
'type' => 'object',
'properties' => [
'time' => [
'description' => __( 'Unix time of lock', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
'nonce' => [
'description' => __( 'Nonce value', 'web-stories' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'locked' => [
'description' => __( 'If the current object is locked or not.', 'web-stories' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
],
'user' => [
'description' => __( 'User', 'web-stories' ),
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The ID for the author of the lock.', 'web-stories' ),
'type' => 'integer',
'readonly' => true,
'context' => [ 'view', 'edit', 'embed' ],
],
'name' => [
'description' => __( 'Display name for the user.', 'web-stories' ),
'type' => 'string',
'readonly' => true,
'context' => [ 'embed', 'view', 'edit' ],
],
],
],
],
];
if ( get_option( 'show_avatars' ) ) {
$avatar_properties = [];
$avatar_sizes = rest_get_avatar_sizes();
foreach ( $avatar_sizes as $size ) {
$avatar_properties[ $size ] = [
/* translators: %d: Avatar image size in pixels. */
'description' => sprintf( __( 'Avatar URL with image size of %d pixels.', 'web-stories' ), $size ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'embed', 'view', 'edit' ],
];
}
$schema['properties']['user']['properties']['avatar'] = [
'description' => __( 'Avatar URLs for the user.', 'web-stories' ),
'type' => 'object',
'context' => [ 'embed', 'view', 'edit' ],
'readonly' => true,
'properties' => $avatar_properties,
];
}
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
/**
* Get the lock, if the ID is valid.
*
* @param int $post_id Supplied ID.
* @return array{time?: int, user?: int}|false Lock data or false.
*/
protected function get_lock( int $post_id ) {
/**
* Lock data.
*
* @var string|false $lock
*/
$lock = get_post_meta( $post_id, '_edit_lock', true );
if ( ! empty( $lock ) ) {
[ $time, $user ] = explode( ':', $lock );
if ( $time && $user ) {
return [
'time' => (int) $time,
'user' => (int) $user,
];
}
}
return false;
}
/**
* Prepares links for the request.
*
* @param array{time?: int, user?: int}|false $lock Lock state.
* @param int $post_id Post object ID.
* @return array{self: array{href?: string}, author?: array{href: string, embeddable: true}} Links for the given term.
*/
protected function prepare_links( $lock, int $post_id ): array {
$base = $this->namespace . '/' . $this->rest_base;
$links = [
'self' => [
'href' => rest_url( trailingslashit( $base ) . $post_id . '/lock' ),
],
];
if ( ! empty( $lock ) ) {
/** This filter is documented in wp-admin/includes/ajax-actions.php */
$time_window = apply_filters( 'wp_check_post_lock_window', 150 );
if ( $lock['time'] && $lock['time'] > time() - $time_window && isset( $lock['user'] ) ) {
$links['author'] = [
'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, 'users', $lock['user'] ) ),
'embeddable' => true,
];
}
}
return $links;
}
}

View File

@@ -0,0 +1,409 @@
<?php
/**
* Class Stories_Media_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\Delayed;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use Google\Web_Stories\Media\Base_Color;
use Google\Web_Stories\Media\Types;
use WP_Error;
use WP_Post;
use WP_REST_Attachments_Controller;
use WP_REST_Request;
use WP_REST_Response;
/**
* Stories_Media_Controller class.
*
* @phpstan-import-type Links from \Google\Web_Stories\REST_API\Stories_Base_Controller
* @phpstan-type ResponseData array{
* media_details: array{
* width?: int,
* height?: int,
* }
* }
*/
class Stories_Media_Controller extends WP_REST_Attachments_Controller implements Service, Delayed, Registerable {
/**
* Types instance.
*
* @var Types Types instance.
*/
private Types $types;
/**
* Constructor.
*
* Override the namespace.
*
* @since 1.0.0
*
* @param Types $types Types instance.
*/
public function __construct( Types $types ) {
parent::__construct( 'attachment' );
$this->namespace = 'web-stories/v1';
$this->types = $types;
}
/**
* Register the service.
*
* @since 1.7.0
*/
public function register(): void {
$this->register_routes();
}
/**
* Get the action to use for registering the service.
*
* @since 1.7.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'rest_api_init';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.7.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 100;
}
/**
* Retrieves a collection of media.
*
* Read _web_stories_envelope param to envelope response.
*
* @since 1.0.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
$response = parent::get_items( $request );
if ( $request['_web_stories_envelope'] && ! is_wp_error( $response ) ) {
/**
* Embed directive.
*
* @var string|string[] $embed
*/
$embed = $request['_embed'] ?? false;
$embed = $embed ? rest_parse_embed_param( $embed ) : false;
$response = rest_get_server()->envelope_response( $response, $embed );
}
return $response;
}
/**
* Creates a single attachment.
*
* Override the existing method so we can set parent id.
*
* @since 1.2.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
*/
public function create_item( $request ) {
// WP_REST_Attachments_Controller doesn't allow setting an attachment as the parent post.
// Hence we are working around this here.
/**
* Parent post.
*
* @var int $parent_post
*/
$parent_post = ! empty( $request['post'] ) ? $request['post'] : null;
/**
* Original post ID.
*
* @var int $original_id
*/
$original_id = ! empty( $request['original_id'] ) ? $request['original_id'] : null;
unset( $request['post'] );
$response = parent::create_item( $request );
if ( ( ! $parent_post && ! $original_id ) || is_wp_error( $response ) ) {
return $response;
}
/**
* Response data.
*
* @var array<string,mixed> $data
*/
$data = $response->get_data();
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $data['id'];
$attachment = $this->process_post( $post_id, $parent_post, $original_id );
if ( is_wp_error( $attachment ) ) {
return $attachment;
}
$new_response = $this->prepare_item_for_response( $attachment, $request );
$data = $new_response->get_data();
$response->set_data( $data );
return $response;
}
/**
* Retrieves the query params for the posts collection.
*
* @since 1.0.0
*
* @return array<string, array<string, mixed>> Collection parameters.
*/
public function get_collection_params(): array {
$query_params = parent::get_collection_params();
$query_params['_web_stories_envelope'] = [
'description' => __( 'Envelope request for preloading.', 'web-stories' ),
'type' => 'boolean',
'default' => false,
];
return $query_params;
}
/**
* Prepares a single attachment output for response.
*
* @since 1.7.2
*
* @param WP_Post $post Attachment object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $post, $request ): WP_REST_Response {
$response = parent::prepare_item_for_response( $post, $request );
/**
* Response data.
*
* @var array<string, string|array<string, int|string>|bool> $data
* @phpstan-var ResponseData $data
*/
$data = $response->get_data();
$fields = $this->get_fields_for_response( $request );
if ( rest_is_field_included( 'media_details', $fields ) ) {
// Could also be a stdClass if empty.
$data['media_details'] = (array) $data['media_details'];
if ( empty( $data['media_details']['width'] ) ) {
$data['media_details']['width'] = 150;
}
if ( empty( $data['media_details']['height'] ) ) {
$data['media_details']['height'] = 150;
}
}
$response->set_data( $data );
/**
* Filters an attachment returned from the REST API.
*
* Allows modification of the attachment right before it is returned.
*
* Note the filter is run after rest_prepare_attachment is run. This filter is designed to only target web stories rest api requests.
*
* @since 1.7.2
*
* @param WP_REST_Response $response The response object.
* @param WP_Post $post The original attachment post.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'web_stories_rest_prepare_attachment', $response, $post, $request );
}
/**
* Retrieves the attachment's schema, conforming to JSON Schema.
*
* Removes some unneeded fields to improve performance by
* avoiding some expensive database queries.
*
* @since 1.10.0
*
* @return array<string, string|array<string, array<string,string|string[]>>> Item schema data.
*/
public function get_item_schema(): array {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$schema = parent::get_item_schema();
unset(
$schema['properties']['permalink_template'],
$schema['properties']['generated_slug'],
$schema['properties']['description']
);
$schema['properties']['original_id'] = [
'description' => __( 'Unique identifier for original attachment id.', 'web-stories' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
];
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
/**
* Process post to update attribute.
*
* @since 1.11.0
*
* @param int $post_id Post id.
* @param int|null $parent_post New post parent. Default null.
* @param int|null $original_id Original id to copy data from. Default null.
* @return WP_Post|WP_Error
*/
protected function process_post( $post_id, $parent_post, $original_id ) {
$args = [ 'ID' => $post_id ];
if ( $parent_post ) {
$args['post_parent'] = $parent_post;
}
if ( $original_id ) {
$attachment_post = $this->get_post( (int) $original_id );
if ( is_wp_error( $attachment_post ) ) {
return $attachment_post;
}
$args['post_content'] = $attachment_post->post_content;
$args['post_excerpt'] = $attachment_post->post_excerpt;
$args['post_title'] = $attachment_post->post_title;
$meta_fields = [ '_wp_attachment_image_alt', Base_Color::BASE_COLOR_POST_META_KEY ];
foreach ( $meta_fields as $meta_field ) {
/**
* Meta value.
*
* @var string $value
*/
$value = get_post_meta( $original_id, $meta_field, true );
if ( ! empty( $value ) ) {
// update_post_meta() expects slashed.
update_post_meta( $post_id, $meta_field, wp_slash( $value ) );
}
}
}
$attachment_id = wp_update_post( $args, true );
if ( is_wp_error( $attachment_id ) ) {
if ( 'db_update_error' === $attachment_id->get_error_code() ) {
$attachment_id->add_data( [ 'status' => 500 ] );
} else {
$attachment_id->add_data( [ 'status' => 400 ] );
}
return $attachment_id;
}
return $this->get_post( $attachment_id );
}
/**
* Filter request by allowed mime types.
*
* @since 1.2.0
*
* @param array<string,mixed> $prepared_args Optional. Array of prepared arguments. Default empty array.
* @param WP_REST_Request $request Optional. Request to prepare items for.
* @return array<string, mixed> Array of query arguments.
*/
protected function prepare_items_query( $prepared_args = [], $request = null ): array {
$query_args = parent::prepare_items_query( $prepared_args, $request );
if ( empty( $request['mime_type'] ) && empty( $request['media_type'] ) ) {
$media_types = $this->get_media_types();
$media_type_mimes = array_values( $media_types );
$media_type_mimes = array_filter( $media_type_mimes );
$media_type_mimes = array_merge( ...$media_type_mimes );
$query_args['post_mime_type'] = $media_type_mimes;
}
/**
* Filters WP_Query arguments when querying posts via the REST API.
*
* @since 1.10.0
*
* @see WP_Query
*
* @param array $args Array of arguments for WP_Query.
* @param WP_REST_Request|null $request The REST API request.
*/
return apply_filters( 'web_stories_rest_attachment_query', $query_args, $request );
}
/**
* Retrieves the supported media types.
*
* Media types are considered the MIME type category.
*
* @since 1.2.0
*
* @return array<string, string[]> Array of supported media types.
*/
protected function get_media_types(): array {
$mime_type = $this->types->get_allowed_mime_types();
// TODO: Update once audio elements are supported.
$mime_type['audio'] = [];
unset( $mime_type['caption'] );
return $mime_type;
}
}

View File

@@ -0,0 +1,82 @@
<?php
/**
* Class Stories_Settings_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\Delayed;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use WP_REST_Settings_Controller;
/**
* Stories_Settings_Controller class.
*/
class Stories_Settings_Controller extends WP_REST_Settings_Controller implements Service, Delayed, Registerable {
/**
* Constructor.
*
* Override the namespace.
*
* @since 1.2.0
*/
public function __construct() {
parent::__construct();
$this->namespace = 'web-stories/v1';
}
/**
* Register the service.
*
* @since 1.7.0
*/
public function register(): void {
$this->register_routes();
}
/**
* Get the action to use for registering the service.
*
* @since 1.7.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'rest_api_init';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.7.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 100;
}
}

View File

@@ -0,0 +1,176 @@
<?php
/**
* Class Stories_Taxonomies_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\Delayed;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Taxonomies_Controller;
use WP_Taxonomy;
/**
* Stories_Taxonomies_Controller class.
*
* @phpstan-import-type Links from \Google\Web_Stories\REST_API\Stories_Base_Controller
*/
class Stories_Taxonomies_Controller extends WP_REST_Taxonomies_Controller implements Service, Delayed, Registerable {
/**
* Constructor.
*
* Override the namespace.
*
* @since 1.12.0
*/
public function __construct() {
parent::__construct();
$this->namespace = 'web-stories/v1';
}
/**
* Retrieves all public taxonomies.
*
* Adds support for filtering by the hierarchical attribute.
*
* @since 1.22.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
// Retrieve the list of registered collection query parameters.
$registered = $this->get_collection_params();
if ( isset( $registered['type'] ) && ! empty( $request['type'] ) ) {
/**
* Object type.
*
* @var string Object type.
*/
$type = $request['type'];
$taxonomies = get_object_taxonomies( $type, 'objects' );
} else {
$taxonomies = get_taxonomies( [], 'objects' );
}
$filters = [ 'hierarchical', 'show_ui' ];
foreach ( $filters as $filter ) {
if ( isset( $registered[ $filter ], $request[ $filter ] ) ) {
$taxonomies = wp_filter_object_list( $taxonomies, [ $filter => (bool) $request[ $filter ] ] );
}
}
$data = [];
/**
* Taxonomy.
*
* @var WP_Taxonomy $value
*/
foreach ( $taxonomies as $tax_type => $value ) {
if (
empty( $value->show_in_rest ) ||
( 'edit' === $request['context'] && ! current_user_can( $value->cap->assign_terms ) ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined
) {
continue;
}
$tax = $this->prepare_item_for_response( $value, $request );
$tax = $this->prepare_response_for_collection( $tax );
$data[ $tax_type ] = $tax;
}
if ( empty( $data ) ) {
// Response should still be returned as a JSON object when it is empty.
$data = (object) $data;
}
return rest_ensure_response( $data );
}
/**
* Retrieves the query params for collections.
*
* Adds support for filtering by the hierarchical attribute.
*
* @since 1.22.0
*
* @return array<string, array<string, mixed>> Collection parameters.
*/
public function get_collection_params(): array {
$query_params = parent::get_collection_params();
$query_params['per_page']['default'] = 100;
$query_params['hierarchical'] = [
'description' => __( 'Whether to show only hierarchical taxonomies.', 'web-stories' ),
'type' => 'boolean',
];
$query_params['show_ui'] = [
'description' => __( 'Whether to show only show taxonomies that allow a UI in the admin.', 'web-stories' ),
'type' => 'boolean',
];
return $query_params;
}
/**
* Register the service.
*
* @since 1.12.0
*/
public function register(): void {
$this->register_routes();
}
/**
* Get the action to use for registering the service.
*
* @since 1.12.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'rest_api_init';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.12.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 100;
}
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* Class Stories_Terms_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use WP_REST_Terms_Controller;
use WP_Term;
/**
* Stories_Terms_Controller class.
*/
class Stories_Terms_Controller extends WP_REST_Terms_Controller {
/**
* Override the existing prepare_links to ensure that all links have the correct namespace.
*
* @since 1.12.0
*
* @param WP_Term $term Term object.
* @return array<string, array{href?: string, taxonomy?: string, embeddable?: bool}> Links for the given term.
*/
protected function prepare_links( $term ): array {
$links = parent::prepare_links( $term );
$links['about'] = [
'href' => rest_url( sprintf( '%s/taxonomies/%s', $this->namespace, $this->taxonomy ) ),
];
return $links;
}
}

View File

@@ -0,0 +1,264 @@
<?php
/**
* Class Stories_Users_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\Delayed;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use Google\Web_Stories\Story_Post_Type;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Users_Controller;
/**
* Stories_Users_Controller class.
*/
class Stories_Users_Controller extends WP_REST_Users_Controller implements Service, Delayed, Registerable {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Constructor.
*
* Override the namespace.
*
* @since 1.2.0
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
parent::__construct();
$this->namespace = 'web-stories/v1';
$this->story_post_type = $story_post_type;
}
/**
* Register the service.
*
* @since 1.7.0
*/
public function register(): void {
$this->register_routes();
}
/**
* Get the action to use for registering the service.
*
* @since 1.7.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'rest_api_init';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.7.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 100;
}
/**
* Permissions check for getting all users.
*
* Allows edit_posts capabilities queries for stories if the user has the same cap,
* enabling them to see the users dropdown.
*
* @since 1.28.1
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access, otherwise WP_Error object.
*/
public function get_items_permissions_check( $request ) {
/**
* The edit_posts capability.
*
* @var string $edit_posts
*/
$edit_posts = $this->story_post_type->get_cap_name( 'edit_posts' );
if (
! empty( $request['capabilities'] ) &&
[ $edit_posts ] === $request['capabilities'] &&
current_user_can( $edit_posts ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined
) {
unset( $request['capabilities'] );
}
return parent::get_items_permissions_check( $request );
}
/**
* Retrieves all users.
*
* Includes a workaround for a shortcoming in WordPress core where
* only users with published posts are returned if not an admin
* and not using a 'who' -> 'authors' query, since we're using
* the recommended capabilities queries instead.
*
* @since 1.28.1
*
* @link https://github.com/WordPress/wordpress-develop/blob/008277583be15ee1738fba51ad235af5bbc5d721/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php#L308-L312
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
/**
* The edit_posts capability.
*
* @var string $edit_posts
*/
$edit_posts = $this->story_post_type->get_cap_name( 'edit_posts' );
if (
! isset( $request['has_published_posts'] ) &&
! empty( $request['capabilities'] ) &&
[ $edit_posts ] === $request['capabilities'] &&
current_user_can( $edit_posts ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined
) {
add_filter( 'rest_user_query', [ $this, 'filter_query_args' ] );
$response = parent::get_items( $request );
remove_filter( 'rest_user_query', [ $this, 'filter_query_args' ] );
return $response;
}
return parent::get_items( $request );
}
/**
* Filters WP_User_Query arguments when querying users via the REST API.
*
* Removes 'has_published_posts' query argument.
*
* @since 1.28.1
*
* @param array<string,mixed> $prepared_args Array of arguments for WP_User_Query.
* @return array<string,mixed> Filtered query args.
*/
public function filter_query_args( array $prepared_args ): array {
unset( $prepared_args['has_published_posts'] );
return $prepared_args;
}
/**
* Checks if a given request has access to read a user.
*
* Same as the parent function but with using a cached version of {@see count_user_posts()}.
*
* @since 1.10.0
*
* @see WP_REST_Users_Controller::get_item_permissions_check()
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object.
*/
public function get_item_permissions_check( $request ) {
/**
* User ID.
*
* @var int $user_id
*/
$user_id = $request['id'];
$user = $this->get_user( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
if ( get_current_user_id() === $user->ID ) {
return true;
}
if ( 'edit' === $request['context'] && ! current_user_can( 'list_users' ) ) {
return new \WP_Error(
'rest_user_cannot_view',
__( 'Sorry, you are not allowed to list users.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
if ( ! $this->user_posts_count_public( $user->ID, $this->story_post_type->get_slug() ) && ! current_user_can( 'edit_user', $user->ID ) && ! current_user_can( 'list_users' ) ) {
return new \WP_Error(
'rest_user_cannot_view',
__( 'Sorry, you are not allowed to list users.', 'web-stories' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
/**
* Number of posts user has written.
*
* Wraps {@see count_user_posts()} results in a cache.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.10.0
*
* @link https://core.trac.wordpress.org/ticket/39242
*
* @param int $userid User ID.
* @param string $post_type Optional. Single post type or array of post types to count the number of posts for. Default 'post'.
* @return int Number of posts the user has written in this post type.
*/
protected function user_posts_count_public( int $userid, string $post_type = 'post' ): int {
$cache_key = "count_user_{$post_type}_{$userid}";
$cache_group = 'user_posts_count';
/**
* Post count.
*
* @var string|false $count
*/
$count = wp_cache_get( $cache_key, $cache_group );
if ( false === $count ) {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.count_user_posts_count_user_posts
$count = count_user_posts( $userid, $post_type, true );
wp_cache_add( $cache_key, $count, $cache_group );
}
return (int) $count;
}
}