Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Base64-encode HTML markup over the wire #4859

Merged
merged 15 commits into from
Oct 27, 2020
55 changes: 31 additions & 24 deletions assets/src/edit-story/app/api/apiProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ import addQueryArgs from '../../utils/addQueryArgs';
import { DATA_VERSION } from '../../migration';
import { useConfig } from '../config';
import Context from './context';
import base64Encode from './base64Encode';

function APIProvider({ children }) {
const {
api: { stories, media, link, users },
encodeMarkup,
} = useConfig();

const getStoryById = useCallback(
Expand All @@ -46,28 +48,33 @@ function APIProvider({ children }) {
[stories]
);

const getStorySaveData = ({
pages,
featuredMedia,
stylePresets,
publisherLogo,
autoAdvance,
defaultPageDuration,
...rest
}) => {
return {
story_data: {
version: DATA_VERSION,
pages,
autoAdvance,
defaultPageDuration,
},
featured_media: featuredMedia,
style_presets: stylePresets,
publisher_logo: publisherLogo,
...rest,
};
};
const getStorySaveData = useCallback(
({
pages,
featuredMedia,
stylePresets,
publisherLogo,
autoAdvance,
defaultPageDuration,
content,
...rest
}) => {
return {
story_data: {
version: DATA_VERSION,
pages,
autoAdvance,
defaultPageDuration,
},
featured_media: featuredMedia,
style_presets: stylePresets,
publisher_logo: publisherLogo,
content: encodeMarkup ? base64Encode(content) : content,
...rest,
};
},
[encodeMarkup]
);

const saveStoryById = useCallback(
/**
Expand All @@ -84,7 +91,7 @@ function APIProvider({ children }) {
method: 'POST',
});
},
[stories]
[stories, getStorySaveData]
);

const autoSaveById = useCallback(
Expand All @@ -102,7 +109,7 @@ function APIProvider({ children }) {
method: 'POST',
});
},
[stories]
[stories, getStorySaveData]
);

const getMedia = useCallback(
Expand Down
49 changes: 49 additions & 0 deletions assets/src/edit-story/app/api/base64Encode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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.
*/

/**
* Convert a Unicode string to a string in which
* each 16-bit unit occupies only one byte.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa
*
* @param {string} string Input string.
* @return {string} Converted string.
*/
function toBinary(string) {
const codeUnits = new Uint16Array(string.length);
for (let i = 0; i < codeUnits.length; i++) {
codeUnits[i] = string.charCodeAt(i);
}

// Not using String.fromCharCode(...new Uint8Array(...)) to avoid RangeError due to too many arguments.
return new TextDecoder('utf-8').decode(new Uint8Array(codeUnits.buffer));
}

/**
* Base64-encodes a string with Unicode support.
*
* Prefixes the encoded result so it can be easily identified
* and treated accordingly.
*
* @param {string} string string to encode.
* @return {string} Encoded string.
*/
function base64Encode(string) {
return '__WEB_STORIES_ENCODED__' + btoa(toBinary(string));
}

export default base64Encode;
69 changes: 69 additions & 0 deletions assets/src/edit-story/app/api/test/base64Encode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* 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.
*/

/**
* External dependencies
*/
import { TextDecoder, TextEncoder } from 'util';

/**
* Internal dependencies
*/
import base64Encode from '../base64Encode';

// see https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa.
function fromBinary(binary) {
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return String.fromCharCode(...new Uint16Array(bytes.buffer));
}

// These are not yet available in jsdom environment.
// See https://github.com/facebook/jest/issues/9983.
// See https://github.com/jsdom/jsdom/issues/2524.
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;

describe('base64Encode', () => {
beforeAll(() => {
// eslint-disable-next-line jest/prefer-spy-on
global.btoa = jest.fn().mockImplementation(() => '*encoded*');
});
afterAll(() => {
global.btoa.mockClear();
});

it('prefixes encoded content', () => {
expect(base64Encode('Hello World')).toStartWith('__WEB_STORIES_ENCODED__');
});

it('converts Unicode characters', () => {
swissspidy marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line jest/prefer-spy-on
global.btoa = jest
.fn()
.mockImplementation(() => 'SABlAGwAbABvACAAPNgN3w==');

const actual = base64Encode('Hello 🌍');
expect(actual).toStrictEqual(
'__WEB_STORIES_ENCODED__SABlAGwAbABvACAAPNgN3w=='
); // Hello 🌍
expect(
fromBinary(atob(actual.replace('__WEB_STORIES_ENCODED__', '')))
).toStrictEqual('Hello 🌍');
});
});
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"require": {
"php": "^5.6 || ^7.0",
"ext-dom": "*",
"ext-iconv": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
Expand Down
13 changes: 7 additions & 6 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion includes/Dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@
namespace Google\Web_Stories;

use Google\Web_Stories\Traits\Assets;
use Google\Web_Stories\Traits\Decoder;
use WP_Screen;

/**
* Dashboard class.
*/
class Dashboard {

use Assets;
use Decoder;

/**
* Script handle.
*
Expand Down Expand Up @@ -343,6 +345,7 @@ public function get_dashboard_settings() {
'assetsURL' => trailingslashit( WEBSTORIES_ASSETS_URL ),
'cdnURL' => trailingslashit( WEBSTORIES_CDN_URL ),
'version' => WEBSTORIES_VERSION,
'encodeMarkup' => $this->supports_decoding(),
'api' => [
'stories' => sprintf( '/web-stories/v1/%s', $rest_base ),
'media' => '/web-stories/v1/media',
Expand Down
19 changes: 13 additions & 6 deletions includes/REST_API/Stories_Base_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

use Google\Web_Stories\KSES;
use Google\Web_Stories\Media;
use Google\Web_Stories\Traits\Decoder;
use stdClass;
use WP_Error;
use WP_Post;
Expand All @@ -41,6 +42,8 @@
* Override the WP_REST_Posts_Controller class to add `post_content_filtered` to REST request.
*/
class Stories_Base_Controller extends WP_REST_Posts_Controller {
use Decoder;

/**
* Constructor.
*
Expand All @@ -55,7 +58,7 @@ public function __construct( $post_type ) {
$this->namespace = 'web-stories/v1';
}
/**
* Prepares a single template for create or update. Add post_content_filtered field to save/insert.
* Prepares a single story for create or update. Add post_content_filtered field to save/insert.
*
* @since 1.0.0
*
Expand All @@ -64,10 +67,10 @@ public function __construct( $post_type ) {
* @return stdClass|WP_Error Post object or WP_Error.
*/
protected function prepare_item_for_database( $request ) {
$prepared_template = parent::prepare_item_for_database( $request );
$prepared_post = parent::prepare_item_for_database( $request );

if ( is_wp_error( $prepared_template ) ) {
return $prepared_template;
if ( is_wp_error( $prepared_post ) ) {
return $prepared_post;
}
// Ensure that content and story_data are updated together.
if (
Expand All @@ -77,12 +80,16 @@ protected function prepare_item_for_database( $request ) {
return new WP_Error( 'rest_empty_content', __( 'content and story_data should always be updated together.', 'web-stories' ), [ 'status' => 412 ] );
}

if ( isset( $request['content'] ) ) {
$prepared_post->post_content = $this->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 ( isset( $request['story_data'] ) ) {
$prepared_template->post_content_filtered = wp_json_encode( $request['story_data'] );
$prepared_post->post_content_filtered = wp_json_encode( $request['story_data'] );
}

return $prepared_template;
return $prepared_post;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions includes/Story_Post_Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Google\Web_Stories\Story_Renderer\Embed;
use Google\Web_Stories\Story_Renderer\Image;
use Google\Web_Stories\Traits\Assets;
use Google\Web_Stories\Traits\Decoder;
use Google\Web_Stories\Traits\Publisher;
use Google\Web_Stories\Traits\Types;
use WP_Post;
Expand All @@ -46,6 +47,7 @@ class Story_Post_Type {
use Publisher;
use Types;
use Assets;
use Decoder;

/**
* The slug of the stories post type.
Expand Down Expand Up @@ -715,6 +717,7 @@ public function get_editor_settings() {
'publisher' => $this->get_publisher_data(),
],
'version' => WEBSTORIES_VERSION,
'encodeMarkup' => $this->supports_decoding(),
],
'flags' => array_merge(
$this->experiments->get_experiment_statuses( 'general' ),
Expand Down
Loading