Commit realizado el 12:13:52 08-04-2024
This commit is contained in:
@@ -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 );
|
||||
}
|
||||
}
|
@@ -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() );
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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 );
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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 ),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user