From 2ae28c50f9e97fcf96205ddd8597ceb8892f70ed Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 5 Feb 2024 09:41:28 +0000 Subject: [PATCH 1/8] Script Loader: Remove unused `WP_Scripts::get_unaliased_deps()` method. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This private method was introduced in [56033] / #12009 but it's not actually used. It was part of the inline script implementation which was later reverted before final merge. The method can be safely removed because it’s private and cannot be used by extenders. Props joemcgill. Fixes #60438. git-svn-id: https://develop.svn.wordpress.org/trunk@57533 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-scripts.php | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index 116e98f673bca..7e4bd2732a85f 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -524,33 +524,6 @@ public function get_inline_script_data( $handle, $position = 'after' ) { return trim( implode( "\n", $data ), "\n" ); } - /** - * Gets unaliased dependencies. - * - * An alias is a dependency whose src is false. It is used as a way to bundle multiple dependencies in a single - * handle. This in effect flattens an alias dependency tree. - * - * @since 6.3.0 - * - * @param string[] $deps Dependency handles. - * @return string[] Unaliased handles. - */ - private function get_unaliased_deps( array $deps ) { - $flattened = array(); - foreach ( $deps as $dep ) { - if ( ! isset( $this->registered[ $dep ] ) ) { - continue; - } - - if ( $this->registered[ $dep ]->src ) { - $flattened[] = $dep; - } elseif ( $this->registered[ $dep ]->deps ) { - array_push( $flattened, ...$this->get_unaliased_deps( $this->registered[ $dep ]->deps ) ); - } - } - return $flattened; - } - /** * Gets tags for inline scripts registered for a specific handle. * From 4e633b50a1df2b1c381ca0f54f8a2bda37c0cd7a Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Mon, 5 Feb 2024 20:58:59 +0000 Subject: [PATCH 2/8] Build/Test Tools: Update the `codecov/codecov-action` action. This updates the `codecov/codecov-action` from version `3.1.5` to `4.0.1`. Version 4 switches to using the Codecov CLI to upload test report date, and changes the version of Node.js used for the action to 20.x. This fixes the notices currently shown for the test coverage workflow. Props: mukesh27. See #59658. git-svn-id: https://develop.svn.wordpress.org/trunk@57534 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/test-coverage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index 21142be46a0e3..a32111f52c148 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -152,7 +152,7 @@ jobs: - name: Upload single site report to Codecov if: ${{ ! matrix.multisite && github.event_name != 'pull_request' }} - uses: codecov/codecov-action@4fe8c5f003fae66aa5ebb77cfd3e7bfbbda0b6b0 # v3.1.5 + uses: codecov/codecov-action@e0b68c6749509c5f83f984dd99a76a1c1a231044 # v4.0.1 with: file: wp-code-coverage-single-clover-${{ github.sha }}.xml flags: single,php @@ -167,7 +167,7 @@ jobs: - name: Upload multisite report to Codecov if: ${{ matrix.multisite && github.event_name != 'pull_request' }} - uses: codecov/codecov-action@4fe8c5f003fae66aa5ebb77cfd3e7bfbbda0b6b0 # v3.1.5 + uses: codecov/codecov-action@e0b68c6749509c5f83f984dd99a76a1c1a231044 # v4.0.1 with: file: wp-code-coverage-multisite-clover-${{ github.sha }}.xml flags: multisite,php From 77aad3a72b103284c398aae563afec7bcbb23ee4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 5 Feb 2024 21:57:44 +0000 Subject: [PATCH 3/8] General: Add tests for `array_is_list` polyfill added in r57337. Props costdev. See #55105. git-svn-id: https://develop.svn.wordpress.org/trunk@57535 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/compat/arrayIsList.php | 115 +++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/phpunit/tests/compat/arrayIsList.php diff --git a/tests/phpunit/tests/compat/arrayIsList.php b/tests/phpunit/tests/compat/arrayIsList.php new file mode 100644 index 0000000000000..90e500103e694 --- /dev/null +++ b/tests/phpunit/tests/compat/arrayIsList.php @@ -0,0 +1,115 @@ +assertTrue( function_exists( 'array_is_list' ) ); + } + + /** + * @dataProvider data_array_is_list + * + * @ticket 55105 + * + * @param bool $expected Whether the array is a list. + * @param array $arr The array. + */ + public function test_array_is_list( $expected, $arr ) { + $this->assertSame( $expected, array_is_list( $arr ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_array_is_list() { + return array( + 'empty array' => array( + 'expected' => true, + 'arr' => array(), + ), + 'array(NAN)' => array( + 'expected' => true, + 'arr' => array( NAN ), + ), + 'array( INF )' => array( + 'expected' => true, + 'arr' => array( INF ), + ), + 'consecutive int keys from 0' => array( + 'expected' => true, + 'arr' => array( + 0 => 'one', + 1 => 'two', + ), + ), + 'consecutive float keys from 0' => array( + 'expected' => true, + 'arr' => array( + 0.0 => 'one', + 1.0 => 'two', + ), + ), + 'consecutive str keys from 0' => array( + 'expected' => true, + 'arr' => array( + '0' => 'one', + '1' => 'two', + ), + ), + 'consecutive int keys from 1' => array( + 'expected' => false, + 'arr' => array( + 1 => 'one', + 2 => 'two', + ), + ), + 'consecutive float keys from 1' => array( + 'expected' => false, + 'arr' => array( + 1.0 => 'one', + 2.0 => 'two', + ), + ), + 'consecutive str keys from 1' => array( + 'expected' => false, + 'arr' => array( + '1' => 'one', + '2' => 'two', + ), + ), + 'non-consecutive int keys' => array( + 'expected' => false, + 'arr' => array( + 1 => 'one', + 0 => 'two', + ), + ), + 'non-consecutive float keys' => array( + 'expected' => false, + 'arr' => array( + 1.0 => 'one', + 0.0 => 'two', + ), + ), + 'non-consecutive string keys' => array( + 'expected' => false, + 'arr' => array( + '1' => 'one', + '0' => 'two', + ), + ), + ); + } +} From 1950da3f17629872086c1763058f0cb59db58b10 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Mon, 5 Feb 2024 22:19:19 +0000 Subject: [PATCH 4/8] Build/Test Tools: Pass a token to the Codecov action. Version 4 of the action now requires a token to be provided in order to upload coverage results. Follow up to [57534]. Props swissspidy. See #59658. git-svn-id: https://develop.svn.wordpress.org/trunk@57536 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/test-coverage.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index a32111f52c148..b99a4625bd905 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -154,6 +154,7 @@ jobs: if: ${{ ! matrix.multisite && github.event_name != 'pull_request' }} uses: codecov/codecov-action@e0b68c6749509c5f83f984dd99a76a1c1a231044 # v4.0.1 with: + token: ${{ secrets.CODECOV_TOKEN }} file: wp-code-coverage-single-clover-${{ github.sha }}.xml flags: single,php fail_ci_if_error: true @@ -169,6 +170,7 @@ jobs: if: ${{ matrix.multisite && github.event_name != 'pull_request' }} uses: codecov/codecov-action@e0b68c6749509c5f83f984dd99a76a1c1a231044 # v4.0.1 with: + token: ${{ secrets.CODECOV_TOKEN }} file: wp-code-coverage-multisite-clover-${{ github.sha }}.xml flags: multisite,php fail_ci_if_error: true From 2ac96bcd07ca615216cedbd855d641fca65853e5 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 5 Feb 2024 22:21:35 +0000 Subject: [PATCH 5/8] Upload: Fallback to `PclZip` to validate ZIP file uploads. `ZipArchive` can fail to validate ZIP files correctly and report valid files as invalid. This introduces a fallback to `PclZip` to check validity of files if `ZipArchive` fails them. This introduces the new function `wp_zip_file_is_valid()` to validate archives. Follow up to [57388]. Props audunmb, azaozz, britner, cdevroe, colorful-tones, costdev, courane01, endymion00, feastdesignco, halounsbury, jeffpaul, johnbillion, jorbin, jsandtro, karinclimber, kevincoleman, koesper, maartenbelmans, mathewemoore, melcarthus, mujuonly, nerdpressteam, olegfuture, otto42, peterwilsoncc, room34, sayful, schutzsmith, stephencronin, svitlana41319, swissspidy, tnolte, tobiasbg, vikram6, welaunchio. Fixes #60398. git-svn-id: https://develop.svn.wordpress.org/trunk@57537 602fd350-edb4-49c9-b593-d223f7449a82 --- .../includes/class-file-upload-upgrader.php | 19 +---- src/wp-admin/includes/file.php | 31 ++++++++ tests/phpunit/data/filesystem/_source-license | 33 ++++++++ .../data/filesystem/archive-comment.zip | Bin 0 -> 468 bytes .../phpunit/data/filesystem/archive-cp866.zip | Bin 0 -> 163 bytes .../filesystem/archive-directory-entry.zip | Bin 0 -> 2480 bytes .../data/filesystem/archive-encrypted.zip | Bin 0 -> 1692 bytes .../data/filesystem/archive-flags-set.zip | Bin 0 -> 1636 bytes .../phpunit/data/filesystem/archive-gnome.zip | Bin 0 -> 1193 bytes .../data/filesystem/archive-invalid-ext.md | 7 ++ .../data/filesystem/archive-invalid.zip | Bin 0 -> 1100 bytes .../phpunit/data/filesystem/archive-large.zip | Bin 0 -> 5210464 bytes .../phpunit/data/filesystem/archive-macos.zip | Bin 0 -> 2090 bytes .../filesystem/archive-ubuntu-nautilus.zip | Bin 0 -> 798 bytes .../data/filesystem/archive-uncompressed.zip | Bin 0 -> 489 bytes tests/phpunit/data/filesystem/archive.crx | Bin 0 -> 2202 bytes .../tests/filesystem/wpZipFileIsValid.php | 75 ++++++++++++++++++ 17 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 tests/phpunit/data/filesystem/_source-license create mode 100644 tests/phpunit/data/filesystem/archive-comment.zip create mode 100644 tests/phpunit/data/filesystem/archive-cp866.zip create mode 100644 tests/phpunit/data/filesystem/archive-directory-entry.zip create mode 100644 tests/phpunit/data/filesystem/archive-encrypted.zip create mode 100644 tests/phpunit/data/filesystem/archive-flags-set.zip create mode 100644 tests/phpunit/data/filesystem/archive-gnome.zip create mode 100644 tests/phpunit/data/filesystem/archive-invalid-ext.md create mode 100644 tests/phpunit/data/filesystem/archive-invalid.zip create mode 100644 tests/phpunit/data/filesystem/archive-large.zip create mode 100644 tests/phpunit/data/filesystem/archive-macos.zip create mode 100644 tests/phpunit/data/filesystem/archive-ubuntu-nautilus.zip create mode 100644 tests/phpunit/data/filesystem/archive-uncompressed.zip create mode 100644 tests/phpunit/data/filesystem/archive.crx create mode 100644 tests/phpunit/tests/filesystem/wpZipFileIsValid.php diff --git a/src/wp-admin/includes/class-file-upload-upgrader.php b/src/wp-admin/includes/class-file-upload-upgrader.php index e62561518dcc5..1201c6d188920 100644 --- a/src/wp-admin/includes/class-file-upload-upgrader.php +++ b/src/wp-admin/includes/class-file-upload-upgrader.php @@ -70,24 +70,7 @@ public function __construct( $form, $urlholder ) { } if ( 'pluginzip' === $form || 'themezip' === $form ) { - $archive_is_valid = false; - - /** This filter is documented in wp-admin/includes/file.php */ - if ( class_exists( 'ZipArchive', false ) && apply_filters( 'unzip_file_use_ziparchive', true ) ) { - $archive = new ZipArchive(); - $archive_is_valid = $archive->open( $file['file'], ZIPARCHIVE::CHECKCONS ); - - if ( true === $archive_is_valid ) { - $archive->close(); - } - } else { - require_once ABSPATH . 'wp-admin/includes/class-pclzip.php'; - - $archive = new PclZip( $file['file'] ); - $archive_is_valid = is_array( $archive->properties() ); - } - - if ( true !== $archive_is_valid ) { + if ( ! wp_zip_file_is_valid( $file['file'] ) ) { wp_delete_file( $file['file'] ); wp_die( __( 'Incompatible Archive.' ) ); } diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index c3863ba2ea5ba..583256955e250 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -1563,6 +1563,37 @@ function wp_trusted_keys() { return apply_filters( 'wp_trusted_keys', $trusted_keys ); } +/** + * Determines whether the given file is a valid ZIP file. + * + * This function does not test to ensure that a file exists. Non-existent files + * are not valid ZIPs, so those will also return false. + * + * @since 6.4.4 + * + * @param string $file Full path to the ZIP file. + * @return bool Whether the file is a valid ZIP file. + */ +function wp_zip_file_is_valid( $file ) { + /** This filter is documented in wp-admin/includes/file.php */ + if ( class_exists( 'ZipArchive', false ) && apply_filters( 'unzip_file_use_ziparchive', true ) ) { + $archive = new ZipArchive(); + $archive_is_valid = $archive->open( $file, ZipArchive::CHECKCONS ); + if ( true === $archive_is_valid ) { + $archive->close(); + return true; + } + } + + // Fall through to PclZip if ZipArchive is not available, or encountered an error opening the file. + require_once ABSPATH . 'wp-admin/includes/class-pclzip.php'; + + $archive = new PclZip( $file ); + $archive_is_valid = is_array( $archive->properties() ); + + return $archive_is_valid; +} + /** * Unzips a specified ZIP file to a location on the filesystem via the WordPress * Filesystem Abstraction. diff --git a/tests/phpunit/data/filesystem/_source-license b/tests/phpunit/data/filesystem/_source-license new file mode 100644 index 0000000000000..580a1492a4e3f --- /dev/null +++ b/tests/phpunit/data/filesystem/_source-license @@ -0,0 +1,33 @@ +The following files were sourced from https://github.com/ZJONSSON/node-unzipper a fork of https://github.com/EvanOxfeld/node-unzip + +* archive-comment.zip +* archive-cp866.zip +* archive-directory-entry.zip +* archive-encrypted.zip +* archive-flags-set.zip +* archive-invalid.zip +* archive-large.zip +* archive-uncompressed.zip +* archive.crx + +Copyright (c) 2012 - 2013 Near Infinity Corporation +Copyright (c) 2016 - 2024 Ziggy Jonsson (ziggy.jonsson.nyc@gmail.com) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/phpunit/data/filesystem/archive-comment.zip b/tests/phpunit/data/filesystem/archive-comment.zip new file mode 100644 index 0000000000000000000000000000000000000000..14a862f1f1400ff0274d798e73bdde3e36876378 GIT binary patch literal 468 zcmWIWW@Zs#U|`^2U|F*(KtkEvR}jeK2a7N;q-Ex$>XlTKoIdUEdG)kbn8qppb9&F7 zGBE@IwQ&IzF@Uv!8Bl@+NT*~L>4Q`t40acrslx_R4aA~Q6(GYs^NKT5Qe85OK(-hg zJ^~xf$Ry8z(2D9N6`*DiPymw%7ji*G7#SoOBs7g9rWjviM5hD1K_=sJHq2xJpvmSy z4DtjvQyq+sf=&PU-v~@YOhvdC)vGX5LGF!&nu_6ZY}Q~po)r{;49q|%&d9(p3CI>@ Yh{`MgnWm7DSgeq!ker{Jo0?Yw0QDzYuK)l5 literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive-cp866.zip b/tests/phpunit/data/filesystem/archive-cp866.zip new file mode 100644 index 0000000000000000000000000000000000000000..04bd3c9372aa7fea2a053e034aa9ca79a4088687 GIT binary patch literal 163 zcmWIWW@h1H0D*{DWxpVWs_(o&HVAV7@uZ~>AL*4;lw5lH@zc9Uzh5eRdGPXefHxzP xJp(RHDnM-@pa3QjMsPtz7#SoOj`<$(by$3b5uFb3W@Q6uV+6t|AZ-jb0{}7tDPRBq literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive-directory-entry.zip b/tests/phpunit/data/filesystem/archive-directory-entry.zip new file mode 100644 index 0000000000000000000000000000000000000000..e81a6aa7e068c4726c5f589fdd3d87c89f1efb0e GIT binary patch literal 2480 zcmZ{m2|Qcb9>)_2Qd(PMEuQ6}grGD^(ZyD~s%MC_ zx~mksa7?i#@C4ck;1vju!(&M?L@b6#48!?jNVsruHb{nsO+^F+jVH<9YohUw4Ih&^wxTALaJ5DjQFmr-TP4d- z0%DtdtFksniBIF9=Zl-eEz$en#h&;O#FdW<*LpIabakEkp_{EV{$d@8VN0*(dRlek z_(X#v2VT1?LFj20B|^QFGm%BCc=pKMIUBlAvRlk5(g$AJDa;gv^W@%oSla(?b#3!~r$@$PyiX;hj$NagedDqYHdvsT zrCyCQJEWcY9;x;Ut~JdFyf>%SOHgc?BQJ_iZnixxgKrP(XVMPC0&}VbwnW<~zX`@T zYHLh78)l_xtKDwXqE6AuN>by+--yVxlc`cv=bH%;wR42|;7=+|noBxK=MtamuZnjX z>N)whijUDO5y-7Vek4D(Gv$baMD1 zvwtgw1XH%7&P|Zj-aJF<^X?IWgd_tA5dac`BLrZ{$Y2sa zEXmpaPM?kld&T{BtU633y3X%`Vw;Uq%77qjRxajW)_$oiO-%jShM+-BXUSv4ibeee z(-~>WivednjAj;YG`IVEtVv;{S9Jm{9t1v#);Rloldv;M?~>5m6x8JYaDMJb^{*aK%|EKSa< zd&>5PMxx^*lyx7$&kd#2d)ww2JU+cu`bzIY_dod=tO7c;?8>@f%2AlMDYK_D;10|; zOVzD*Ek-J=Bh^@Z^@bI#qvJ=5{;V$==L@RdnX|2{IKr9j)jrQU(CT*)OWue0=^wh^ z%G=IiUaJaE{PFwEFXl&{eUhU)tjD{cGb&}o*<-yI1-PZl z@kVMRA7|eB@oHS&b_03~9XK~RrAJ5eZ%JCNEcYsx*VDHOu{j$016d17=~>?1s0Ljk zJ>DYZ$>RMQnc+%_eBs~%D%j;8ToW?s$4~eIhvW+s1QG-$w4-WR0tp#T47}@Netb%~#|4Bu8`npXGgf}QVFH0aZLY)(Xa?k!K18Q%(hWwWPYjrN zPv0^&dxr~Mrd^xk1uK`n8n5+TEW3@OB@7cduowv|g+$a4lgS93KlaQUX;+*dP-D`n zJ~2IjQc+>|Hc%2-JR_u{2Sa_&I8)XLBkSJu2+u*$s^X@7Dh$}*6Zl?vijJOEi5QEIx_RRdj{f8!?A_;IkukL73@p!vg* zt<()!Xq_P)CiI4x$yzG+*~kc$XiT}^7*(Rgw1{1z&oj#BHR~s8KRoL;%fGSqDbdY< zW56go6npaUD?dmIEDt3bU@aYIlv^D9{O5PDnzwENZ5icj?Sh@@)pjbKmA^jGXe4*C z?)`#0^|aM_NMV8*1Jmg&l;YaaaWl2x$Z^s@WPsKXKDu0p5%bWBd7 z=vB6^Z>$gGy~mK#kpP-1ksMPconyzj(^gLEtk_R}e8fp`aXon>PW#Fa3Lr+$*}kl)0Rh8|HS*T{sbd|ME|6 zh+EZnA!*3>8qdvftIjT036%X_4dQ0G1!9*y&AUe~oNRgdc0R&@s|Q#^`_7L5=s(9p B-P8a8 literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive-encrypted.zip b/tests/phpunit/data/filesystem/archive-encrypted.zip new file mode 100644 index 0000000000000000000000000000000000000000..fd58fb4f1d7e7b8f04a7b25299a7673ec15a4618 GIT binary patch literal 1692 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 wSc+YIMvE{o0n1m+5*OL%2|%L}b06*@S+5i9m literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive-gnome.zip b/tests/phpunit/data/filesystem/archive-gnome.zip new file mode 100644 index 0000000000000000000000000000000000000000..0f1fc181eab78a05f4f37a3191e773fcf7487cce GIT binary patch literal 1193 zcmWIWW@Zs#0DCrIln`Q2U`Q=X%_}L^P0Os%EyyWN&&<;g4dG>A{~zC&3d5xp z+zgB?iab22LlI+dCM3X7__mPhhc+WK}LaIQerkCi)#CQy_pRK+Rn3ml7FU< z6TKwbHc#{;bBM+^k*`XdQZCG7lr)M@8t9+@yljrg##?HSZSJh% z)864R(fN6t;Iy2H4?gekxugE?+`aYrS{u&PhEC(Pe41;Jw#qr&tFSY0^6Q(d^9#c& zn+1)y4;^Tj=EPq9ZMuT%b6$-M8Rrbgc3!W-X*bwbJ*ZjU@cPY!M~s?$FAl%VvHE@L z&s~qM*2^!F|J}ZtRj6`QrQF8uZ7zS%mXohz12d93wtDvLb2Qo-)}uDzaK!d^saw{c zIAXZRbECP9e0Rp-;QbL0Dq0PMT8vq@(XF0yV&! zkx7IZcQ%4$tG|vQ7DkdMB2z)khh;0|s0L*#82H;5fYp2q8;Q+iFpt43M)ou)CBeYo i#%sV}z_3OiLj+_op>!4C&B_Kcm>CHDfX2EqgLnY2L!_Mm literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive-invalid-ext.md b/tests/phpunit/data/filesystem/archive-invalid-ext.md new file mode 100644 index 0000000000000..1531b03435ca8 --- /dev/null +++ b/tests/phpunit/data/filesystem/archive-invalid-ext.md @@ -0,0 +1,7 @@ +# Shucked + +Shucked is a 2022 musical with music and lyrics by Brandy Clark and Shane McAnally, and a book by Robert Horn. The Broadway production began previews at the Nederlander Theatre on March 8, 2023, before opening on April 4. The show received positive reviews and went on to receive nine nominations at the 76th Tony Awards, including Best Musical. Cast member Alex Newell became one of the first two openly non-binary performers to be nominated for and win a Tony Award, with their win for Best Featured Actor in a Musical. + +A U.S. tour, West End production, and feature film adaptation are currently planned. + +From Wikipedia (https://en.wikipedia.org/wiki/Shucked) under Creative Commons Attribution CC-BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/legalcode) diff --git a/tests/phpunit/data/filesystem/archive-invalid.zip b/tests/phpunit/data/filesystem/archive-invalid.zip new file mode 100644 index 0000000000000000000000000000000000000000..d13ba6b7ff5c56fb89b1ea6f8acef1d8d81426fa GIT binary patch literal 1100 zcmWIWW@h1H00Gx14o5HpO0Y14%1JGB6u9D+T-j;?fFk21b@|j0_AcAY}ny z-Aq6hNVlP8+eKy|8-zuny3;aqQa$sEGgDGsGK=&|DoWtSt;00V#E1)Iq6h;M0|&!W zpovv)mrw5m+B5^`L=K>dAk*N6JOvs6!f0-a4U1lP+k)rsd957`hpRP~Nh~kv_ptR#JwzrUH2Q6?=r^TXpz`f^!!JCeT*&J#y3W?4YLXlNNZQ{vZdD^ z?Y_gjV)nJaUiH^=7!CKwFnRB;WO05e$(%PK@ny%(T`V0*3z{B&EM0L=y2|(6wcBso zZ%6%JG<`?mk>vlJ9fvFX{q-K7X|fW1ApGxiJY&hpo_$dh*PUR0kkP=X?{k6w-TbA87aD$E^>4+|DGLuy zO$oTC*48!M%uw{6YVqz`=kxp-OZ_iD;izOeH}9UX&XG$>A;&Ccwj{(@d5D~K6+5$6 zd|`+Bw95%E9v-sh5Z}O1%k@Ee)=Lcr!x;u2{%W>##NXGmnXuM#gJphI`O1&1`%NrF z4Qs<+x*M4MTCTBt@$x%9%U4^j+k81*&Z11YB**2izTPp;yDv^CrF1D6d!1r%|Ehni z;?k_mdNUTvf0Vc)XU<$OOLKXv%l6Z)?y~A?r>94+Gy2N0tIEpLFU~mM;A*QuMfFZ~ z=fiDUAH@3}YW7to}+(gTlF+rXDWQXA_go&P$F2c#w+J>o@G$t%+b4;~q>5IR3z39M=b$ZG!S9Z*kKTuL)?!wn>yTfDF z!t?K57}q80y;GWOEwW^1^OsW2O>9q>@cVwru+BQP-?#KeZpF04sgcUdTplvdn6>C) k@1{p}cNr=!s;qIJCc4pGG5?@;K;xS;M`o{ZKC!YM0P?}-)c^nh literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive-large.zip b/tests/phpunit/data/filesystem/archive-large.zip new file mode 100644 index 0000000000000000000000000000000000000000..57c1635fdfa8ef17c4eba90e5876c080291bb511 GIT binary patch literal 5210464 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 literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive-ubuntu-nautilus.zip b/tests/phpunit/data/filesystem/archive-ubuntu-nautilus.zip new file mode 100644 index 0000000000000000000000000000000000000000..a1d65d1771aa633347e55a5ba83355cfb6846ae7 GIT binary patch literal 798 zcmWIWW@Zs#0D*@&P7z=Rl#pOhU`Q-V&d4lF)y+#REy>I&E!Isc%Fj;C(+>^dWnd4x zvL`hbh(UB|1vdjD%L`@(1~3r-HkE^c1JzU}1_on1rsA+ouOOoUZXw7rEEdL2yzR$i zDA4x)yXG-%K2F^?LW@>+HfCIDk!V`}`jYI8wq0xAPI2vhpC_KgP`L zQsaDICj8=%Py5tW4+8fj^Chc$Z#`JSy`hh{L7OXt;QF(<&gTe(3o2Ldzn%=YTg?O&1iFtlEhikS-(i*Xr zcPq+Y1ztCe4owta_nmp(`Ze#C8uo1yag?*~Is7(EaD^&|=Hi??{(`b9Fa4(-$@;zK zz|vJ4GPWO^y!_^mpZl)9JW$T`F+t__+|y6gJ?j_)yxBQYx){8?7=h^k7-0e4j7%cT zxRVGh$^3N$v2a8o5eWuj3oOYXy8x7AVBl?I7#>@2*iK9W3h-uS16jfZgwKJrBGBs$ F3;>DiBq;y@ literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive-uncompressed.zip b/tests/phpunit/data/filesystem/archive-uncompressed.zip new file mode 100644 index 0000000000000000000000000000000000000000..2d3626d6b6ff0c66cc7d57625270cd85bdb96293 GIT binary patch literal 489 zcmWIWW@h1H00Gx14o5HpO0Y14%1JGB6u9D+Sa7acKoN10%~fMg|5Jkg@<2 z-G-WN7ny;gAS?>iotBxC>X}!ZnUd;~S)^A|Q35v(WF(q#CPrKc6TN}%dB4<0gb!#M z2y*~U1epdm#1?1(2%{O2m!FcVmsPA#l%Jek3^b3CNsbwp4<&%+f`9E6; J=?5SV0|5AfQn~;D literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/filesystem/archive.crx b/tests/phpunit/data/filesystem/archive.crx new file mode 100644 index 0000000000000000000000000000000000000000..c07b21c6c11446a4f845d834c9600d122d3b624f GIT binary patch literal 2202 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 $expected, wp_zip_file_is_valid( $zip_file ), "Expected archive to be {$expected_message}." ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_zip_file_validity() { + return array( + 'standard zip' => array( 'archive.zip', true ), + 'large zip' => array( 'archive-large.zip', true ), + 'commented zip' => array( 'archive-comment.zip', true ), + 'cp866 zip' => array( 'archive-cp866.zip', true ), + 'directory entry zip' => array( 'archive-directory-entry.zip', true ), + 'encrypted zip' => array( 'archive-encrypted.zip', true ), + 'flags-set zip' => array( 'archive-flags-set.zip', true ), + 'uncompressed zip' => array( 'archive-uncompressed.zip', true ), + 'crx zip' => array( 'archive.crx', true ), + 'macos generated zip' => array( 'archive-macos.zip', true ), + 'gnome generated zip' => array( 'archive-gnome.zip', true ), + 'ubuntu nautilus zip' => array( 'archive-ubuntu-nautilus.zip', true ), + + 'invalid zip file' => array( 'archive-invalid.zip', false ), + 'invalid file extension' => array( 'archive-invalid-ext.md', false ), + 'non-existent file' => array( 'archive-non-existent.zip', false ), + ); + } +} From 5810f227471f4ff08c1d2b89362ebc44e28a8e31 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Tue, 6 Feb 2024 01:23:54 +0000 Subject: [PATCH 6/8] Coding Standards: Rename the `$oSelf` variable in `WP_MatchesMapRegex::apply()`. This resolves a WPCS warning: {{{ Variable "$oSelf" is not in valid snake_case format, try "$o_self" }}} Follow-up to [11853], [38376]. See #59650. git-svn-id: https://develop.svn.wordpress.org/trunk@57538 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-matchesmapregex.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-matchesmapregex.php b/src/wp-includes/class-wp-matchesmapregex.php index 558bd9866781c..ddca4f2cb2e02 100644 --- a/src/wp-includes/class-wp-matchesmapregex.php +++ b/src/wp-includes/class-wp-matchesmapregex.php @@ -63,8 +63,8 @@ public function __construct( $subject, $matches ) { * @return string */ public static function apply( $subject, $matches ) { - $oSelf = new WP_MatchesMapRegex( $subject, $matches ); - return $oSelf->output; + $result = new WP_MatchesMapRegex( $subject, $matches ); + return $result->output; } /** From 0cab832eefe9fb484670b06424b317a5d66e58c5 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 6 Feb 2024 08:40:38 +0000 Subject: [PATCH 7/8] Editor: Introduce the Font Library post types and low level APIs. This is the first step towards adding the font library to WordPress. This commit includes the font library and font face CPTs. It also adds the necessary APIs and classes to register and manipulate font collections. This PR backports the font library post types and low level APIs to Core. This is the first step to include the font library entirely into Core. Once this merged, we'll open a PR with the necessary REST API controllers. Props youknowriad, get_dave, grantmkin, swissspidy, hellofromtonya, mukesh27, mcsf. See #59166. git-svn-id: https://develop.svn.wordpress.org/trunk@57539 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/default-filters.php | 2 + src/wp-includes/fonts.php | 137 +++++++ .../fonts/class-wp-font-collection.php | 260 +++++++++++++ .../fonts/class-wp-font-library.php | 144 +++++++ src/wp-includes/fonts/class-wp-font-utils.php | 238 ++++++++++++ src/wp-includes/post.php | 58 +++ src/wp-settings.php | 3 + .../fonts/font-library/fontLibraryHooks.php | 87 +++++ .../wpFontCollection/__construct.php | 26 ++ .../font-library/wpFontCollection/getData.php | 358 ++++++++++++++++++ .../fonts/font-library/wpFontLibrary/base.php | 25 ++ .../wpFontLibrary/getFontCollection.php | 30 ++ .../wpFontLibrary/getFontCollections.php | 34 ++ .../wpFontLibrary/registerFontCollection.php | 40 ++ .../unregisterFontCollection.php | 46 +++ .../wpFontUtils/getFontFaceSlug.php | 92 +++++ .../wpFontUtils/sanitizeFontFamily.php | 63 +++ .../wpFontUtils/sanitizeFromSchema.php | 310 +++++++++++++++ .../tests/fonts/font-library/wpFontsDir.php | 72 ++++ 19 files changed, 2025 insertions(+) create mode 100644 src/wp-includes/fonts/class-wp-font-collection.php create mode 100644 src/wp-includes/fonts/class-wp-font-library.php create mode 100644 src/wp-includes/fonts/class-wp-font-utils.php create mode 100644 tests/phpunit/tests/fonts/font-library/fontLibraryHooks.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontLibrary/base.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFromSchema.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpFontsDir.php diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 7ebc3a1d3bfe1..19a9285ed2fec 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -748,5 +748,7 @@ // Font management. add_action( 'wp_head', 'wp_print_font_faces', 50 ); +add_action( 'deleted_post', '_wp_after_delete_font_family', 10, 2 ); +add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 ); unset( $filter, $action ); diff --git a/src/wp-includes/fonts.php b/src/wp-includes/fonts.php index 87503c275f390..dae5283d65ce5 100644 --- a/src/wp-includes/fonts.php +++ b/src/wp-includes/fonts.php @@ -51,3 +51,140 @@ function wp_print_font_faces( $fonts = array() ) { $wp_font_face = new WP_Font_Face(); $wp_font_face->generate_and_print( $fonts ); } + +/** + * Registers a new Font Collection in the Font Library. + * + * @since 6.5.0 + * + * @param string $slug Font collection slug. May only contain alphanumeric characters, dashes, + * and underscores. See sanitize_title(). + * @param array|string $data_or_file { + * Font collection data array or a path/URL to a JSON file containing the font collection. + * + * @link https://schemas.wp.org/trunk/font-collection.json + * + * @type string $name Required. Name of the font collection shown in the Font Library. + * @type string $description Optional. A short descriptive summary of the font collection. Default empty. + * @type array $font_families Required. Array of font family definitions that are in the collection. + * @type array $categories Optional. Array of categories, each with a name and slug, that are used by the + * fonts in the collection. Default empty. + * } + * @return WP_Font_Collection|WP_Error A font collection if it was registered + * successfully, or WP_Error object on failure. + */ +function wp_register_font_collection( $slug, $data_or_file ) { + return WP_Font_Library::get_instance()->register_font_collection( $slug, $data_or_file ); +} + +/** + * Unregisters a font collection from the Font Library. + * + * @since 6.5.0 + * + * @param string $slug Font collection slug. + * @return bool True if the font collection was unregistered successfully, else false. + */ +function wp_unregister_font_collection( $slug ) { + return WP_Font_Library::get_instance()->unregister_font_collection( $slug ); +} + +/** + * Returns an array containing the current fonts upload directory's path and URL. + * + * @since 6.5.0 + * + * @param array $defaults { + * Array of information about the upload directory. + * + * @type string $path Base directory and subdirectory or full path to the fonts upload directory. + * @type string $url Base URL and subdirectory or absolute URL to the fonts upload directory. + * @type string $subdir Subdirectory + * @type string $basedir Path without subdir. + * @type string $baseurl URL path without subdir. + * @type string|false $error False or error message. + * } + * @return array $defaults { + * Array of information about the upload directory. + * + * @type string $path Base directory and subdirectory or full path to the fonts upload directory. + * @type string $url Base URL and subdirectory or absolute URL to the fonts upload directory. + * @type string $subdir Subdirectory + * @type string $basedir Path without subdir. + * @type string $baseurl URL path without subdir. + * @type string|false $error False or error message. + * } + */ +function wp_get_font_dir( $defaults = array() ) { + $site_path = ''; + if ( is_multisite() && ! ( is_main_network() && is_main_site() ) ) { + $site_path = '/sites/' . get_current_blog_id(); + } + + // Sets the defaults. + $defaults['path'] = path_join( WP_CONTENT_DIR, 'fonts' ) . $site_path; + $defaults['url'] = untrailingslashit( content_url( 'fonts' ) ) . $site_path; + $defaults['subdir'] = ''; + $defaults['basedir'] = path_join( WP_CONTENT_DIR, 'fonts' ) . $site_path; + $defaults['baseurl'] = untrailingslashit( content_url( 'fonts' ) ) . $site_path; + $defaults['error'] = false; + + /** + * Filters the fonts directory data. + * + * This filter allows developers to modify the fonts directory data. + * + * @since 6.5.0 + * + * @param array $defaults The original fonts directory data. + */ + return apply_filters( 'font_dir', $defaults ); +} + +/** + * Deletes child font faces when a font family is deleted. + * + * @access private + * @since 6.5.0 + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + */ +function _wp_after_delete_font_family( $post_id, $post ) { + if ( 'wp_font_family' !== $post->post_type ) { + return; + } + + $font_faces = get_children( + array( + 'post_parent' => $post_id, + 'post_type' => 'wp_font_face', + ) + ); + + foreach ( $font_faces as $font_face ) { + wp_delete_post( $font_face->ID, true ); + } +} + +/** + * Deletes associated font files when a font face is deleted. + * + * @access private + * @since 6.5.0 + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + */ +function _wp_before_delete_font_face( $post_id, $post ) { + if ( 'wp_font_face' !== $post->post_type ) { + return; + } + + $font_files = get_post_meta( $post_id, '_wp_font_face_file', false ); + $font_dir = wp_get_font_dir()['path']; + + foreach ( $font_files as $font_file ) { + wp_delete_file( $font_dir . '/' . $font_file ); + } +} diff --git a/src/wp-includes/fonts/class-wp-font-collection.php b/src/wp-includes/fonts/class-wp-font-collection.php new file mode 100644 index 0000000000000..240bba35e94f8 --- /dev/null +++ b/src/wp-includes/fonts/class-wp-font-collection.php @@ -0,0 +1,260 @@ +slug = sanitize_title( $slug ); + if ( $this->slug !== $slug ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Font collection slug. */ + sprintf( __( 'Font collection slug "%s" is not valid. Slugs must use only alphanumeric characters, dashes, and underscores.' ), $slug ), + '6.5.0' + ); + } + + if ( is_array( $data_or_file ) ) { + $this->data = $this->sanitize_and_validate_data( $data_or_file ); + } else { + // JSON data is lazy loaded by ::get_data(). + $this->src = $data_or_file; + } + } + + /** + * Retrieves the font collection data. + * + * @since 6.5.0 + * + * @return array|WP_Error An array containing the font collection data, or a WP_Error on failure. + */ + public function get_data() { + // If the collection uses JSON data, load it and cache the data/error. + if ( $this->src && empty( $this->data ) ) { + $this->data = $this->load_from_json( $this->src ); + } + + if ( is_wp_error( $this->data ) ) { + return $this->data; + } + + // Set defaults for optional properties. + $defaults = array( + 'description' => '', + 'categories' => array(), + ); + + return wp_parse_args( $this->data, $defaults ); + } + + /** + * Loads font collection data from a JSON file or URL. + * + * @since 6.5.0 + * + * @param string $file_or_url File path or URL to a JSON file containing the font collection data. + * @return array|WP_Error An array containing the font collection data on success, + * else an instance of WP_Error on failure. + */ + private function load_from_json( $file_or_url ) { + $url = wp_http_validate_url( $file_or_url ); + $file = file_exists( $file_or_url ) ? wp_normalize_path( realpath( $file_or_url ) ) : false; + + if ( ! $url && ! $file ) { + // translators: %s: File path or URL to font collection JSON file. + $message = __( 'Font collection JSON file is invalid or does not exist.' ); + _doing_it_wrong( __METHOD__, $message, '6.5.0' ); + return new WP_Error( 'font_collection_json_missing', $message ); + } + + return $url ? $this->load_from_url( $url ) : $this->load_from_file( $file ); + } + + /** + * Loads the font collection data from a JSON file path. + * + * @since 6.5.0 + * + * @param string $file File path to a JSON file containing the font collection data. + * @return array|WP_Error An array containing the font collection data on success, + * else an instance of WP_Error on failure. + */ + private function load_from_file( $file ) { + $data = wp_json_file_decode( $file, array( 'associative' => true ) ); + if ( empty( $data ) ) { + return new WP_Error( 'font_collection_decode_error', __( 'Error decoding the font collection JSON file contents.' ) ); + } + + return $this->sanitize_and_validate_data( $data ); + } + + /** + * Loads the font collection data from a JSON file URL. + * + * @since 6.5.0 + * + * @param string $url URL to a JSON file containing the font collection data. + * @return array|WP_Error An array containing the font collection data on success, + * else an instance of WP_Error on failure. + */ + private function load_from_url( $url ) { + // Limit key to 167 characters to avoid failure in the case of a long URL. + $transient_key = substr( 'wp_font_collection_url_' . $url, 0, 167 ); + $data = get_site_transient( $transient_key ); + + if ( false === $data ) { + $response = wp_safe_remote_get( $url ); + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + // translators: %s: Font collection URL. + return new WP_Error( 'font_collection_request_error', sprintf( __( 'Error fetching the font collection data from "%s".' ), $url ) ); + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( empty( $data ) ) { + return new WP_Error( 'font_collection_decode_error', __( 'Error decoding the font collection data from the HTTP response JSON.' ) ); + } + + // Make sure the data is valid before storing it in a transient. + $data = $this->sanitize_and_validate_data( $data ); + if ( is_wp_error( $data ) ) { + return $data; + } + + set_site_transient( $transient_key, $data, DAY_IN_SECONDS ); + } + + return $data; + } + + /** + * Sanitizes and validates the font collection data. + * + * @since 6.5.0 + * + * @param array $data Font collection data to sanitize and validate. + * @return array|WP_Error Sanitized data if valid, otherwise a WP_Error instance. + */ + private function sanitize_and_validate_data( $data ) { + $schema = self::get_sanitization_schema(); + $data = WP_Font_Utils::sanitize_from_schema( $data, $schema ); + + $required_properties = array( 'name', 'font_families' ); + foreach ( $required_properties as $property ) { + if ( empty( $data[ $property ] ) ) { + $message = sprintf( + // translators: 1: Font collection slug, 2: Missing property name, e.g. "font_families". + __( 'Font collection "%1$s" has missing or empty property: "%2$s".' ), + $this->slug, + $property + ); + _doing_it_wrong( __METHOD__, $message, '6.5.0' ); + return new WP_Error( 'font_collection_missing_property', $message ); + } + } + + return $data; + } + + /** + * Retrieves the font collection sanitization schema. + * + * @since 6.5.0 + * + * @return array Font collection sanitization schema. + */ + private static function get_sanitization_schema() { + return array( + 'name' => 'sanitize_text_field', + 'description' => 'sanitize_text_field', + 'font_families' => array( + array( + 'font_family_settings' => array( + 'name' => 'sanitize_text_field', + 'slug' => 'sanitize_title', + 'fontFamily' => 'sanitize_text_field', + 'preview' => 'sanitize_url', + 'fontFace' => array( + array( + 'fontFamily' => 'sanitize_text_field', + 'fontStyle' => 'sanitize_text_field', + 'fontWeight' => 'sanitize_text_field', + 'src' => static function ( $value ) { + return is_array( $value ) + ? array_map( 'sanitize_text_field', $value ) + : sanitize_text_field( $value ); + }, + 'preview' => 'sanitize_url', + 'fontDisplay' => 'sanitize_text_field', + 'fontStretch' => 'sanitize_text_field', + 'ascentOverride' => 'sanitize_text_field', + 'descentOverride' => 'sanitize_text_field', + 'fontVariant' => 'sanitize_text_field', + 'fontFeatureSettings' => 'sanitize_text_field', + 'fontVariationSettings' => 'sanitize_text_field', + 'lineGapOverride' => 'sanitize_text_field', + 'sizeAdjust' => 'sanitize_text_field', + 'unicodeRange' => 'sanitize_text_field', + ), + ), + ), + 'categories' => array( 'sanitize_title' ), + ), + ), + 'categories' => array( + array( + 'name' => 'sanitize_text_field', + 'slug' => 'sanitize_title', + ), + ), + ); + } +} diff --git a/src/wp-includes/fonts/class-wp-font-library.php b/src/wp-includes/fonts/class-wp-font-library.php new file mode 100644 index 0000000000000..a0c07eeffcd8f --- /dev/null +++ b/src/wp-includes/fonts/class-wp-font-library.php @@ -0,0 +1,144 @@ +is_collection_registered( $new_collection->slug ) ) { + $error_message = sprintf( + /* translators: %s: Font collection slug. */ + __( 'Font collection with slug: "%s" is already registered.' ), + $new_collection->slug + ); + _doing_it_wrong( + __METHOD__, + $error_message, + '6.5.0' + ); + return new WP_Error( 'font_collection_registration_error', $error_message ); + } + $this->collections[ $new_collection->slug ] = $new_collection; + return $new_collection; + } + + /** + * Unregisters a previously registered font collection. + * + * @since 6.5.0 + * + * @param string $slug Font collection slug. + * @return bool True if the font collection was unregistered successfully and false otherwise. + */ + public function unregister_font_collection( $slug ) { + if ( ! $this->is_collection_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Font collection slug. */ + sprintf( __( 'Font collection "%s" not found.' ), $slug ), + '6.5.0' + ); + return false; + } + unset( $this->collections[ $slug ] ); + return true; + } + + /** + * Checks if a font collection is registered. + * + * @since 6.5.0 + * + * @param string $slug Font collection slug. + * @return bool True if the font collection is registered and false otherwise. + */ + private function is_collection_registered( $slug ) { + return array_key_exists( $slug, $this->collections ); + } + + /** + * Gets all the font collections available. + * + * @since 6.5.0 + * + * @return array List of font collections. + */ + public function get_font_collections() { + return $this->collections; + } + + /** + * Gets a font collection. + * + * @since 6.5.0 + * + * @param string $slug Font collection slug. + * @return WP_Font_Collection|WP_Error Font collection object, + * or WP_Error object if the font collection doesn't exist. + */ + public function get_font_collection( $slug ) { + if ( $this->is_collection_registered( $slug ) ) { + return $this->collections[ $slug ]; + } + return new WP_Error( 'font_collection_not_found', __( 'Font collection not found.' ) ); + } + + /** + * Utility method to retrieve the main instance of the class. + * + * The instance will be created if it does not exist yet. + * + * @since 6.5.0 + * + * @return WP_Font_Library The main instance. + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } +} diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php new file mode 100644 index 0000000000000..8efa7978a8256 --- /dev/null +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -0,0 +1,238 @@ + '', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'fontStretch' => '100%', + 'unicodeRange' => 'U+0-10FFFF', + ); + $settings = wp_parse_args( $settings, $defaults ); + + $font_family = mb_strtolower( $settings['fontFamily'] ); + $font_style = strtolower( $settings['fontStyle'] ); + $font_weight = strtolower( $settings['fontWeight'] ); + $font_stretch = strtolower( $settings['fontStretch'] ); + $unicode_range = strtoupper( $settings['unicodeRange'] ); + + // Convert weight keywords to numeric strings. + $font_weight = str_replace( array( 'normal', 'bold' ), array( '400', '700' ), $font_weight ); + + // Convert stretch keywords to numeric strings. + $font_stretch_map = array( + 'ultra-condensed' => '50%', + 'extra-condensed' => '62.5%', + 'condensed' => '75%', + 'semi-condensed' => '87.5%', + 'normal' => '100%', + 'semi-expanded' => '112.5%', + 'expanded' => '125%', + 'extra-expanded' => '150%', + 'ultra-expanded' => '200%', + ); + $font_stretch = str_replace( array_keys( $font_stretch_map ), array_values( $font_stretch_map ), $font_stretch ); + + $slug_elements = array( $font_family, $font_style, $font_weight, $font_stretch, $unicode_range ); + + $slug_elements = array_map( + function ( $elem ) { + // Remove quotes to normalize font-family names, and ';' to use as a separator. + $elem = trim( str_replace( array( '"', "'", ';' ), '', $elem ) ); + + // Normalize comma separated lists by removing whitespace in between items, + // but keep whitespace within items (e.g. "Open Sans" and "OpenSans" are different fonts). + // CSS spec for whitespace includes: U+000A LINE FEED, U+0009 CHARACTER TABULATION, or U+0020 SPACE, + // which by default are all matched by \s in PHP. + return preg_replace( '/,\s+/', ',', $elem ); + }, + $slug_elements + ); + + return sanitize_text_field( join( ';', $slug_elements ) ); + } + + /** + * Sanitizes a tree of data using a schema. + * + * The schema structure should mirror the data tree. Each value provided in the + * schema should be a callable that will be applied to sanitize the corresponding + * value in the data tree. Keys that are in the data tree, but not present in the + * schema, will be removed in the santized data. Nested arrays are traversed recursively. + * + * @since 6.5.0 + * + * @access private + * + * @param array $tree The data to sanitize. + * @param array $schema The schema used for sanitization. + * @return array The sanitized data. + */ + public static function sanitize_from_schema( $tree, $schema ) { + if ( ! is_array( $tree ) || ! is_array( $schema ) ) { + return array(); + } + + foreach ( $tree as $key => $value ) { + // Remove keys not in the schema or with null/empty values. + if ( ! array_key_exists( $key, $schema ) ) { + unset( $tree[ $key ] ); + continue; + } + + $is_value_array = is_array( $value ); + $is_schema_array = is_array( $schema[ $key ] ) && ! is_callable( $schema[ $key ] ); + + if ( $is_value_array && $is_schema_array ) { + if ( wp_is_numeric_array( $value ) ) { + // If indexed, process each item in the array. + foreach ( $value as $item_key => $item_value ) { + $tree[ $key ][ $item_key ] = isset( $schema[ $key ][0] ) && is_array( $schema[ $key ][0] ) + ? self::sanitize_from_schema( $item_value, $schema[ $key ][0] ) + : self::apply_sanitizer( $item_value, $schema[ $key ][0] ); + } + } else { + // If it is an associative or indexed array, process as a single object. + $tree[ $key ] = self::sanitize_from_schema( $value, $schema[ $key ] ); + } + } elseif ( ! $is_value_array && $is_schema_array ) { + // If the value is not an array but the schema is, remove the key. + unset( $tree[ $key ] ); + } elseif ( ! $is_schema_array ) { + // If the schema is not an array, apply the sanitizer to the value. + $tree[ $key ] = self::apply_sanitizer( $value, $schema[ $key ] ); + } + + // Remove keys with null/empty values. + if ( empty( $tree[ $key ] ) ) { + unset( $tree[ $key ] ); + } + } + + return $tree; + } + + /** + * Applies a sanitizer function to a value. + * + * @since 6.5.0 + * + * @param mixed $value The value to sanitize. + * @param mixed $sanitizer The sanitizer function to apply. + * @return mixed The sanitized value. + */ + private static function apply_sanitizer( $value, $sanitizer ) { + if ( null === $sanitizer ) { + return $value; + + } + return call_user_func( $sanitizer, $value ); + } + + /** + * Returns the expected mime-type values for font files, depending on PHP version. + * + * This is needed because font mime types vary by PHP version, so checking the PHP version + * is necessary until a list of valid mime-types for each file extension can be provided to + * the 'upload_mimes' filter. + * + * @since 6.5.0 + * + * @access private + * + * @return array A collection of mime types keyed by file extension. + */ + public static function get_allowed_font_mime_types() { + $php_7_ttf_mime_type = PHP_VERSION_ID >= 70300 ? 'application/font-sfnt' : 'application/x-font-ttf'; + + return array( + 'otf' => 'application/vnd.ms-opentype', + 'ttf' => PHP_VERSION_ID >= 70400 ? 'font/sfnt' : $php_7_ttf_mime_type, + 'woff' => PHP_VERSION_ID >= 80100 ? 'font/woff' : 'application/font-woff', + 'woff2' => PHP_VERSION_ID >= 80100 ? 'font/woff2' : 'application/font-woff2', + ); + } +} diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 001ccaf7d0465..eb0d8c1926190 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -564,6 +564,64 @@ function create_initial_post_types() { ) ); + register_post_type( + 'wp_font_family', + array( + 'labels' => array( + 'name' => __( 'Font Families' ), + 'singular_name' => __( 'Font Family' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + 'capabilities' => array( + 'read' => 'edit_theme_options', + 'read_private_posts' => 'edit_theme_options', + 'create_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + 'edit_others_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + ), + 'map_meta_cap' => true, + 'query_var' => false, + 'show_in_rest' => false, + 'rewrite' => false, + ) + ); + + register_post_type( + 'wp_font_face', + array( + 'labels' => array( + 'name' => __( 'Font Faces' ), + 'singular_name' => __( 'Font Face' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + 'capabilities' => array( + 'read' => 'edit_theme_options', + 'read_private_posts' => 'edit_theme_options', + 'create_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + 'edit_others_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + ), + 'map_meta_cap' => true, + 'query_var' => false, + 'show_in_rest' => false, + 'rewrite' => false, + ) + ); + register_post_status( 'publish', array( diff --git a/src/wp-settings.php b/src/wp-settings.php index a80c661d52a65..22683b37d1f5d 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -374,7 +374,10 @@ require ABSPATH . WPINC . '/style-engine/class-wp-style-engine-css-rules-store.php'; require ABSPATH . WPINC . '/style-engine/class-wp-style-engine-processor.php'; require ABSPATH . WPINC . '/fonts/class-wp-font-face-resolver.php'; +require ABSPATH . WPINC . '/fonts/class-wp-font-collection.php'; require ABSPATH . WPINC . '/fonts/class-wp-font-face.php'; +require ABSPATH . WPINC . '/fonts/class-wp-font-library.php'; +require ABSPATH . WPINC . '/fonts/class-wp-font-utils.php'; require ABSPATH . WPINC . '/fonts.php'; require ABSPATH . WPINC . '/class-wp-script-modules.php'; require ABSPATH . WPINC . '/script-modules.php'; diff --git a/tests/phpunit/tests/fonts/font-library/fontLibraryHooks.php b/tests/phpunit/tests/fonts/font-library/fontLibraryHooks.php new file mode 100644 index 0000000000000..083c12202aa34 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/fontLibraryHooks.php @@ -0,0 +1,87 @@ +post->create( + array( + 'post_type' => 'wp_font_family', + ) + ); + $font_face_id = self::factory()->post->create( + array( + 'post_type' => 'wp_font_face', + 'post_parent' => $font_family_id, + ) + ); + $other_font_family_id = self::factory()->post->create( + array( + 'post_type' => 'wp_font_family', + ) + ); + $other_font_face_id = self::factory()->post->create( + array( + 'post_type' => 'wp_font_face', + 'post_parent' => $other_font_family_id, + ) + ); + + wp_delete_post( $font_family_id, true ); + + $this->assertNull( get_post( $font_face_id ), 'Font face post should also have been deleted.' ); + $this->assertNotNull( get_post( $other_font_face_id ), 'The other post should exist.' ); + } + + public function test_deleting_font_faces_deletes_associated_font_files() { + list( $font_face_id, $font_path ) = $this->create_font_face_with_file( 'OpenSans-Regular.woff2' ); + list( , $other_font_path ) = $this->create_font_face_with_file( 'OpenSans-Regular.ttf' ); + + wp_delete_post( $font_face_id, true ); + + $this->assertFalse( file_exists( $font_path ), 'The font file should have been deleted when the post was deleted.' ); + $this->assertTrue( file_exists( $other_font_path ), 'The other font file should exist.' ); + } + + protected function create_font_face_with_file( $filename ) { + $font_face_id = self::factory()->post->create( + array( + 'post_type' => 'wp_font_face', + ) + ); + + $font_file = $this->upload_font_file( $filename ); + + // Make sure the font file uploaded successfully. + $this->assertFalse( $font_file['error'] ); + + $font_path = $font_file['file']; + $font_filename = basename( $font_path ); + add_post_meta( $font_face_id, '_wp_font_face_file', $font_filename ); + + return array( $font_face_id, $font_path ); + } + + protected function upload_font_file( $font_filename ) { + $font_file_path = DIR_TESTDATA . '/fonts/' . $font_filename; + + add_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); + add_filter( 'upload_dir', 'wp_get_font_dir' ); + $font_file = wp_upload_bits( + $font_filename, + null, + file_get_contents( $font_file_path ) + ); + remove_filter( 'upload_dir', 'wp_get_font_dir' ); + remove_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); + + return $font_file; + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php b/tests/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php new file mode 100644 index 0000000000000..a0693ce341456 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php @@ -0,0 +1,26 @@ +setExpectedIncorrectUsage( 'WP_Font_Collection::__construct' ); + $mock_collection_data = array( + 'name' => 'Test Collection', + 'font_families' => array( 'mock ' ), + ); + + $collection = new WP_Font_Collection( 'slug with spaces', $mock_collection_data ); + + $this->assertSame( 'slug-with-spaces', $collection->slug, 'Slug is not sanitized.' ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php b/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php new file mode 100644 index 0000000000000..8a0af0c97d967 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php @@ -0,0 +1,358 @@ +get_data(); + + $this->assertSame( $slug, $collection->slug, 'The slug should match.' ); + $this->assertSame( $expected_data, $data, 'The collection data should match.' ); + } + + /** + * @dataProvider data_create_font_collection + * + * @param string $slug Font collection slug. + * @param array $config Font collection config. + * @param array $expected_data Expected collection data. + */ + public function test_should_get_data_from_json_file( $slug, $config, $expected_data ) { + $mock_file = wp_tempnam( 'my-collection-data-' ); + file_put_contents( $mock_file, wp_json_encode( $config ) ); + + $collection = new WP_Font_Collection( $slug, $mock_file ); + $data = $collection->get_data(); + + $this->assertSame( $slug, $collection->slug, 'The slug should match.' ); + $this->assertSame( $expected_data, $data, 'The collection data should match.' ); + } + + /** + * @dataProvider data_create_font_collection + * + * @param string $slug Font collection slug. + * @param array $config Font collection config. + * @param array $expected_data Expected collection data. + */ + public function test_should_get_data_from_json_url( $slug, $config, $expected_data ) { + add_filter( 'pre_http_request', array( $this, 'mock_request' ), 10, 3 ); + + self::$mock_collection_data = $config; + $collection = new WP_Font_Collection( $slug, 'https://example.com/fonts/mock-font-collection.json' ); + $data = $collection->get_data(); + + remove_filter( 'pre_http_request', array( $this, 'mock_request' ) ); + + $this->assertSame( $slug, $collection->slug, 'The slug should match.' ); + $this->assertSame( $expected_data, $data, 'The collection data should match.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_font_collection() { + return array( + + 'font collection with required data' => array( + 'slug' => 'my-collection', + 'config' => array( + 'name' => 'My Collection', + 'font_families' => array( array() ), + ), + 'expected_data' => array( + 'description' => '', + 'categories' => array(), + 'name' => 'My Collection', + 'font_families' => array( array() ), + ), + ), + + 'font collection with all data' => array( + 'slug' => 'my-collection', + 'config' => array( + 'name' => 'My Collection', + 'description' => 'My collection description', + 'font_families' => array( array() ), + 'categories' => array(), + ), + 'expected_data' => array( + 'description' => 'My collection description', + 'categories' => array(), + 'name' => 'My Collection', + 'font_families' => array( array() ), + ), + ), + + 'font collection with risky data' => array( + 'slug' => 'my-collection', + 'config' => array( + 'name' => 'My Collection', + 'description' => 'My collection description', + 'font_families' => array( + array( + 'font_family_settings' => array( + 'fontFamily' => 'Open Sans, sans-serif', + 'slug' => 'open-sans', + 'name' => 'Open Sans', + 'fontFace' => array( + array( + 'fontFamily' => 'Open Sans', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => 'https://example.com/src-as-string.ttf?a=', + ), + array( + 'fontFamily' => 'Open Sans', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => array( + 'https://example.com/src-as-array.woff2?a=', + 'https://example.com/src-as-array.ttf', + ), + ), + ), + 'unwanted_property' => 'potentially evil value', + ), + 'categories' => array( 'sans-serif' ), + ), + ), + 'categories' => array( + array( + 'name' => 'Mock col', + 'slug' => 'mock-col', + 'unwanted_property' => 'potentially evil value', + ), + ), + 'unwanted_property' => 'potentially evil value', + ), + 'expected_data' => array( + 'description' => 'My collection description', + 'categories' => array( + array( + 'name' => 'Mock col', + 'slug' => 'mock-colalertxss', + ), + ), + 'name' => 'My Collection', + 'font_families' => array( + array( + 'font_family_settings' => array( + 'fontFamily' => 'Open Sans, sans-serif', + 'slug' => 'open-sans', + 'name' => 'Open Sans', + 'fontFace' => array( + array( + 'fontFamily' => 'Open Sans', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => 'https://example.com/src-as-string.ttf?a=', + ), + array( + 'fontFamily' => 'Open Sans', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => array( + 'https://example.com/src-as-array.woff2?a=', + 'https://example.com/src-as-array.ttf', + ), + ), + ), + ), + 'categories' => array( 'sans-serifalertxss' ), + ), + ), + ), + ), + + ); + } + + /** + * @dataProvider data_should_error_when_missing_properties + * + * @param array $config Font collection config. + */ + public function test_should_error_when_missing_properties( $config ) { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::sanitize_and_validate_data' ); + + $collection = new WP_Font_Collection( 'my-collection', $config ); + $data = $collection->get_data(); + + $this->assertWPError( $data, 'Error is not returned when property is missing or invalid.' ); + $this->assertSame( + $data->get_error_code(), + 'font_collection_missing_property', + 'Incorrect error code when property is missing or invalid.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_error_when_missing_properties() { + return array( + 'missing name' => array( + 'config' => array( + 'font_families' => array( 'mock' ), + ), + ), + 'empty name' => array( + 'config' => array( + 'name' => '', + 'font_families' => array( 'mock' ), + ), + ), + 'missing font_families' => array( + 'config' => array( + 'name' => 'My Collection', + ), + ), + 'empty font_families' => array( + 'config' => array( + 'name' => 'My Collection', + 'font_families' => array(), + ), + ), + ); + } + + public function test_should_error_with_invalid_json_file_path() { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::load_from_json' ); + + $collection = new WP_Font_Collection( 'my-collection', 'non-existing.json' ); + $data = $collection->get_data(); + + $this->assertWPError( $data, 'Error is not returned when invalid file path is provided.' ); + $this->assertSame( + $data->get_error_code(), + 'font_collection_json_missing', + 'Incorrect error code when invalid file path is provided.' + ); + } + + public function test_should_error_with_invalid_json_from_file() { + $mock_file = wp_tempnam( 'my-collection-data-' ); + file_put_contents( $mock_file, 'invalid-json' ); + + $collection = new WP_Font_Collection( 'my-collection', $mock_file ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Testing error response returned by `load_from_json`, not the underlying error from `wp_json_file_decode`. + $data = @$collection->get_data(); + + $this->assertWPError( $data, 'Error is not returned with invalid json file contents.' ); + $this->assertSame( + $data->get_error_code(), + 'font_collection_decode_error', + 'Incorrect error code with invalid json file contents.' + ); + } + + public function test_should_error_with_invalid_url() { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::load_from_json' ); + + $collection = new WP_Font_Collection( 'my-collection', 'not-a-url' ); + $data = $collection->get_data(); + + $this->assertWPError( $data, 'Error is not returned when invalid url is provided.' ); + $this->assertSame( + $data->get_error_code(), + 'font_collection_json_missing', + 'Incorrect error code when invalid url is provided.' + ); + } + + public function test_should_error_with_unsuccessful_response_status() { + add_filter( 'pre_http_request', array( $this, 'mock_request_unsuccessful_response' ), 10, 3 ); + + $collection = new WP_Font_Collection( 'my-collection', 'https://example.com/fonts/missing-collection.json' ); + $data = $collection->get_data(); + + remove_filter( 'pre_http_request', array( $this, 'mock_request_unsuccessful_response' ) ); + + $this->assertWPError( $data, 'Error is not returned when response is unsuccessful.' ); + $this->assertSame( + $data->get_error_code(), + 'font_collection_request_error', + 'Incorrect error code when response is unsuccussful.' + ); + } + + public function test_should_error_with_invalid_json_from_url() { + add_filter( 'pre_http_request', array( $this, 'mock_request_invalid_json' ), 10, 3 ); + + $collection = new WP_Font_Collection( 'my-collection', 'https://example.com/fonts/invalid-collection.json' ); + $data = $collection->get_data(); + + remove_filter( 'pre_http_request', array( $this, 'mock_request_invalid_json' ) ); + + $this->assertWPError( $data, 'Error is not returned when response is invalid json.' ); + $this->assertSame( + $data->get_error_code(), + 'font_collection_decode_error', + 'Incorrect error code when response is invalid json.' + ); + } + + public function mock_request( $preempt, $args, $url ) { + if ( 'https://example.com/fonts/mock-font-collection.json' !== $url ) { + return false; + } + + return array( + 'body' => wp_json_encode( self::$mock_collection_data ), + 'response' => array( + 'code' => 200, + ), + ); + } + + public function mock_request_unsuccessful_response( $preempt, $args, $url ) { + if ( 'https://example.com/fonts/missing-collection.json' !== $url ) { + return false; + } + + return array( + 'body' => '', + 'response' => array( + 'code' => 404, + ), + ); + } + + public function mock_request_invalid_json( $preempt, $args, $url ) { + if ( 'https://example.com/fonts/invalid-collection.json' !== $url ) { + return false; + } + + return array( + 'body' => 'invalid', + 'response' => array( + 'code' => 200, + ), + ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontLibrary/base.php b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/base.php new file mode 100644 index 0000000000000..135329e5add73 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/base.php @@ -0,0 +1,25 @@ +get_font_collections(); + foreach ( $collections as $slug => $collection ) { + WP_Font_Library::get_instance()->unregister_font_collection( $slug ); + } + } + + public function set_up() { + parent::set_up(); + $this->reset_font_collections(); + } + + public function tear_down() { + parent::tear_down(); + $this->reset_font_collections(); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php new file mode 100644 index 0000000000000..675efe81aec59 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php @@ -0,0 +1,30 @@ + 'Test Collection', + 'font_families' => array( 'mock' ), + ); + + wp_register_font_collection( 'my-font-collection', $mock_collection_data ); + $font_collection = WP_Font_Library::get_instance()->get_font_collection( 'my-font-collection' ); + $this->assertInstanceOf( 'WP_Font_Collection', $font_collection ); + } + + public function test_should_get_no_font_collection_if_the_slug_is_not_registered() { + $font_collection = WP_Font_Library::get_instance()->get_font_collection( 'not-registered-font-collection' ); + $this->assertWPError( $font_collection ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php new file mode 100644 index 0000000000000..f5ca6389b8ff5 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php @@ -0,0 +1,34 @@ +get_font_collections(); + $this->assertEmpty( $font_collections, 'Should return an empty array.' ); + } + + public function test_should_get_mock_font_collection() { + $my_font_collection_config = array( + 'name' => 'My Font Collection', + 'description' => 'Demo about how to a font collection to your WordPress Font Library.', + 'font_families' => array( 'mock' ), + ); + + WP_Font_Library::get_instance()->register_font_collection( 'my-font-collection', $my_font_collection_config ); + + $font_collections = WP_Font_Library::get_instance()->get_font_collections(); + $this->assertNotEmpty( $font_collections, 'Should return an array of font collections.' ); + $this->assertCount( 1, $font_collections, 'Should return an array with one font collection.' ); + $this->assertArrayHasKey( 'my-font-collection', $font_collections, 'The array should have the key of the registered font collection id.' ); + $this->assertInstanceOf( 'WP_Font_Collection', $font_collections['my-font-collection'], 'The value of the array $font_collections[id] should be an instance of WP_Font_Collection class.' ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php new file mode 100644 index 0000000000000..d3b0f126e2e7e --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php @@ -0,0 +1,40 @@ + 'My Collection', + 'font_families' => array( 'mock' ), + ); + + $collection = WP_Font_Library::get_instance()->register_font_collection( 'my-collection', $config ); + $this->assertInstanceOf( 'WP_Font_Collection', $collection ); + } + + public function test_should_return_error_if_slug_is_repeated() { + $mock_collection_data = array( + 'name' => 'Test Collection', + 'font_families' => array( 'mock' ), + ); + + // Register first collection. + $collection1 = WP_Font_Library::get_instance()->register_font_collection( 'my-collection-1', $mock_collection_data ); + $this->assertInstanceOf( 'WP_Font_Collection', $collection1, 'A collection should be registered.' ); + + // Expects a _doing_it_wrong notice. + $this->setExpectedIncorrectUsage( 'WP_Font_Library::register_font_collection' ); + + // Try to register a second collection with same slug. + WP_Font_Library::get_instance()->register_font_collection( 'my-collection-1', $mock_collection_data ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php new file mode 100644 index 0000000000000..ddb0fa91c1d60 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php @@ -0,0 +1,46 @@ + 'Test Collection', + 'font_families' => array( 'mock' ), + ); + + // Registers two mock font collections. + WP_Font_Library::get_instance()->register_font_collection( 'mock-font-collection-1', $mock_collection_data ); + WP_Font_Library::get_instance()->register_font_collection( 'mock-font-collection-2', $mock_collection_data ); + + // Unregister mock font collection. + WP_Font_Library::get_instance()->unregister_font_collection( 'mock-font-collection-1' ); + $collections = WP_Font_Library::get_instance()->get_font_collections(); + $this->assertArrayNotHasKey( 'mock-font-collection-1', $collections, 'Font collection was not unregistered.' ); + $this->assertArrayHasKey( 'mock-font-collection-2', $collections, 'Font collection was unregistered by mistake.' ); + + // Unregisters remaining mock font collection. + WP_Font_Library::get_instance()->unregister_font_collection( 'mock-font-collection-2' ); + $collections = WP_Font_Library::get_instance()->get_font_collections(); + $this->assertArrayNotHasKey( 'mock-font-collection-2', $collections, 'Mock font collection was not unregistered.' ); + + // Checks that all font collections were unregistered. + $this->assertEmpty( $collections, 'Font collections were not unregistered.' ); + } + + public function unregister_non_existing_collection() { + // Unregisters non-existing font collection. + WP_Font_Library::get_instance()->unregister_font_collection( 'non-existing-collection' ); + $collections = WP_Font_Library::get_instance()->get_font_collections(); + $this->assertEmpty( $collections, 'No collections should be registered.' ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php new file mode 100644 index 0000000000000..de0b02e63185e --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php @@ -0,0 +1,92 @@ +assertSame( $expected_slug, $slug ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_font_face_slug_normalizes_values() { + return array( + 'Sets defaults' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans', + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Converts normal weight to 400' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans', + 'fontWeight' => 'normal', + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Converts bold weight to 700' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans', + 'fontWeight' => 'bold', + ), + 'expected_slug' => 'open sans;normal;700;100%;U+0-10FFFF', + ), + 'Converts normal font-stretch to 100%' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans', + 'fontStretch' => 'normal', + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Removes double quotes from fontFamilies' => array( + 'settings' => array( + 'fontFamily' => '"Open Sans"', + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Removes single quotes from fontFamilies' => array( + 'settings' => array( + 'fontFamily' => "'Open Sans'", + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Removes spaces between comma separated font families' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans, serif', + ), + 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', + ), + 'Removes tabs between comma separated font families' => array( + 'settings' => array( + 'fontFamily' => "Open Sans,\tserif", + ), + 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', + ), + 'Removes new lines between comma separated font families' => array( + 'settings' => array( + 'fontFamily' => "Open Sans,\nserif", + ), + 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', + ), + ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php new file mode 100644 index 0000000000000..71511331c65dc --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php @@ -0,0 +1,63 @@ +assertSame( + $expected, + WP_Font_Utils::sanitize_font_family( + $font_family + ) + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_sanitize_font_family() { + return array( + 'data_families_with_spaces_and_numbers' => array( + 'font_family' => 'Rock 3D , Open Sans,serif', + 'expected' => '"Rock 3D", "Open Sans", serif', + ), + 'data_single_font_family' => array( + 'font_family' => 'Rock 3D', + 'expected' => '"Rock 3D"', + ), + 'data_no_spaces' => array( + 'font_family' => 'Rock3D', + 'expected' => 'Rock3D', + ), + 'data_many_spaces_and_existing_quotes' => array( + 'font_family' => 'Rock 3D serif, serif,sans-serif, "Open Sans"', + 'expected' => '"Rock 3D serif", serif, sans-serif, "Open Sans"', + ), + 'data_empty_family' => array( + 'font_family' => ' ', + 'expected' => '', + ), + 'data_font_family_with_whitespace_tags_new_lines' => array( + 'font_family' => " Rock 3D\n ", + 'expected' => '"Rock 3D"', + ), + ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFromSchema.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFromSchema.php new file mode 100644 index 0000000000000..88983fe15a14e --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFromSchema.php @@ -0,0 +1,310 @@ +assertSame( $result, $expected ); + } + + public function data_sanitize_from_schema() { + return array( + 'One level associative array' => array( + 'data' => array( + 'slug' => 'open - sans', + 'fontFamily' => 'Open Sans, sans-serif', + 'src' => 'https://wordpress.org/example.json', + ), + 'schema' => array( + 'slug' => 'sanitize_title', + 'fontFamily' => 'sanitize_text_field', + 'src' => 'sanitize_url', + ), + 'expected' => array( + 'slug' => 'open-sansalertxss', + 'fontFamily' => 'Open Sans, sans-serif', + 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', + ), + ), + + 'Nested associative arrays' => array( + 'data' => array( + 'slug' => 'open - sans', + 'fontFamily' => 'Open Sans, sans-serif', + 'src' => 'https://wordpress.org/example.json', + 'nested' => array( + 'key1' => 'value1', + 'key2' => 'value2', + 'nested2' => array( + 'key3' => 'value3', + 'key4' => 'value4', + ), + ), + ), + 'schema' => array( + 'slug' => 'sanitize_title', + 'fontFamily' => 'sanitize_text_field', + 'src' => 'sanitize_url', + 'nested' => array( + 'key1' => 'sanitize_text_field', + 'key2' => 'sanitize_text_field', + 'nested2' => array( + 'key3' => 'sanitize_text_field', + 'key4' => 'sanitize_text_field', + ), + ), + ), + 'expected' => array( + 'slug' => 'open-sansalertxss', + 'fontFamily' => 'Open Sans, sans-serif', + 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', + 'nested' => array( + 'key1' => 'value1', + 'key2' => 'value2', + 'nested2' => array( + 'key3' => 'value3', + 'key4' => 'value4', + ), + ), + ), + ), + + 'Indexed arrays' => array( + 'data' => array( + 'slug' => 'oPeN SaNs', + 'enum' => array( + 'value1', + 'value2', + 'value3', + ), + ), + 'schema' => array( + 'slug' => 'sanitize_title', + 'enum' => array( 'sanitize_text_field' ), + ), + 'expected' => array( + 'slug' => 'open-sans', + 'enum' => array( 'value1', 'value2', 'value3' ), + ), + ), + + 'Nested indexed arrays' => array( + 'data' => array( + 'slug' => 'OPEN-SANS', + 'name' => 'Open Sans', + 'fontFace' => array( + array( + 'fontFamily' => 'Open Sans, sans-serif', + 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', + ), + array( + 'fontFamily' => 'Open Sans, sans-serif', + 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', + ), + ), + ), + 'schema' => array( + 'slug' => 'sanitize_title', + 'name' => 'sanitize_text_field', + 'fontFace' => array( + array( + 'fontFamily' => 'sanitize_text_field', + 'src' => 'sanitize_url', + ), + ), + ), + 'expected' => array( + 'slug' => 'open-sans', + 'name' => 'Open Sans', + 'fontFace' => array( + array( + 'fontFamily' => 'Open Sans, sans-serif', + 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', + ), + array( + 'fontFamily' => 'Open Sans, sans-serif', + 'src' => 'https://wordpress.org/example.json/stylescriptalert(xss)/script', + ), + ), + ), + ), + + 'Custom sanitization function' => array( + 'data' => array( + 'key1' => 'abc123edf456ghi789', + 'key2' => 'value2', + ), + 'schema' => array( + 'key1' => function ( $value ) { + // Remove the six first character. + return substr( $value, 6 ); + }, + 'key2' => function ( $value ) { + // Capitalize the value. + return strtoupper( $value ); + }, + ), + 'expected' => array( + 'key1' => 'edf456ghi789', + 'key2' => 'VALUE2', + ), + ), + + 'Null as schema value' => array( + 'data' => array( + 'key1' => 'value1', + 'key2' => 'value2', + 'nested' => array( + 'key3' => 'value3', + 'key4' => 'value4', + ), + ), + 'schema' => array( + 'key1' => null, + 'key2' => 'sanitize_text_field', + 'nested' => null, + ), + 'expected' => array( + 'key1' => 'value1', + 'key2' => 'value2', + 'nested' => array( + 'key3' => 'value3', + 'key4' => 'value4', + ), + ), + ), + + 'Keys to remove' => array( + 'data' => array( + 'key1' => 'value1', + 'key2' => 'value2', + 'unwanted1' => 'value', + 'unwanted2' => 'value', + 'nestedAssociative' => array( + 'key5' => 'value5', + 'unwanted3' => 'value', + ), + 'nestedIndexed' => array( + array( + 'key6' => 'value7', + 'unwanted4' => 'value', + ), + array( + 'key6' => 'value7', + 'unwanted5' => 'value', + ), + ), + + ), + 'schema' => array( + 'key1' => 'sanitize_text_field', + 'key2' => 'sanitize_text_field', + 'nestedAssociative' => array( + 'key5' => 'sanitize_text_field', + ), + 'nestedIndexed' => array( + array( + 'key6' => 'sanitize_text_field', + ), + ), + ), + 'expected' => array( + 'key1' => 'value1', + 'key2' => 'value2', + 'nestedAssociative' => array( + 'key5' => 'value5', + ), + 'nestedIndexed' => array( + array( + 'key6' => 'value7', + ), + array( + 'key6' => 'value7', + ), + ), + ), + ), + + 'With empty structure' => array( + 'data' => array( + 'slug' => 'open-sans', + 'nested' => array( + 'key1' => 'value', + 'nested2' => array( + 'key2' => 'value', + 'nested3' => array( + 'nested4' => array(), + ), + ), + ), + ), + 'schema' => array( + 'slug' => 'sanitize_title', + 'nested' => array( + 'key1' => 'sanitize_text_field', + 'nested2' => array( + 'key2' => 'sanitize_text_field', + 'nested3' => array( + 'key3' => 'sanitize_text_field', + 'nested4' => array( + 'key4' => 'sanitize_text_field', + ), + ), + ), + ), + ), + 'expected' => array( + 'slug' => 'open-sans', + 'nested' => array( + 'key1' => 'value', + 'nested2' => array( + 'key2' => 'value', + ), + ), + ), + ), + ); + } + + public function test_sanitize_from_schema_with_invalid_data() { + $data = 'invalid data'; + $schema = array( + 'key1' => 'sanitize_text_field', + 'key2' => 'sanitize_text_field', + ); + + $result = WP_Font_Utils::sanitize_from_schema( $data, $schema ); + + $this->assertSame( $result, array() ); + } + + + public function test_sanitize_from_schema_with_invalid_schema() { + $data = array( + 'key1' => 'value1', + 'key2' => 'value2', + ); + $schema = 'invalid schema'; + + $result = WP_Font_Utils::sanitize_from_schema( $data, $schema ); + + $this->assertSame( $result, array() ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontsDir.php b/tests/phpunit/tests/fonts/font-library/wpFontsDir.php new file mode 100644 index 0000000000000..a8f79888315bd --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontsDir.php @@ -0,0 +1,72 @@ + path_join( WP_CONTENT_DIR, 'fonts' ), + 'url' => content_url( 'fonts' ), + 'subdir' => '', + 'basedir' => path_join( WP_CONTENT_DIR, 'fonts' ), + 'baseurl' => content_url( 'fonts' ), + 'error' => false, + ); + } + + public function test_fonts_dir() { + $font_dir = wp_get_font_dir(); + + $this->assertSame( $font_dir, static::$dir_defaults ); + } + + public function test_fonts_dir_with_filter() { + // Define a callback function to pass to the filter. + function set_new_values( $defaults ) { + $defaults['path'] = '/custom-path/fonts/my-custom-subdir'; + $defaults['url'] = 'http://example.com/custom-path/fonts/my-custom-subdir'; + $defaults['subdir'] = 'my-custom-subdir'; + $defaults['basedir'] = '/custom-path/fonts'; + $defaults['baseurl'] = 'http://example.com/custom-path/fonts'; + $defaults['error'] = false; + return $defaults; + } + + // Add the filter. + add_filter( 'font_dir', 'set_new_values' ); + + // Gets the fonts dir. + $font_dir = wp_get_font_dir(); + + $expected = array( + 'path' => '/custom-path/fonts/my-custom-subdir', + 'url' => 'http://example.com/custom-path/fonts/my-custom-subdir', + 'subdir' => 'my-custom-subdir', + 'basedir' => '/custom-path/fonts', + 'baseurl' => 'http://example.com/custom-path/fonts', + 'error' => false, + ); + + // Remove the filter. + remove_filter( 'font_dir', 'set_new_values' ); + + $this->assertSame( $expected, $font_dir, 'The wp_get_font_dir() method should return the expected values.' ); + + // Gets the fonts dir. + $font_dir = wp_get_font_dir(); + + $this->assertSame( static::$dir_defaults, $font_dir, 'The wp_get_font_dir() method should return the default values.' ); + } +} From b48285e08809df9101d585cf5794700793b179f8 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Mon, 5 Feb 2024 14:21:51 +0000 Subject: [PATCH 8/8] Font Library: Add all REST API endpoints. --- src/wp-includes/post.php | 12 +- src/wp-includes/rest-api.php | 4 + ...ss-wp-rest-font-collections-controller.php | 326 +++++ .../class-wp-rest-font-faces-controller.php | 941 +++++++++++++++ ...class-wp-rest-font-families-controller.php | 554 +++++++++ src/wp-settings.php | 3 + .../wpRestFontCollectionsController.php | 203 ++++ .../wpRestFontFacesController.php | 1067 +++++++++++++++++ .../wpRestFontFamiliesController.php | 1050 ++++++++++++++++ 9 files changed, 4158 insertions(+), 2 deletions(-) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php create mode 100644 tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index eb0d8c1926190..5fa058363af9b 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -588,8 +588,12 @@ function create_initial_post_types() { ), 'map_meta_cap' => true, 'query_var' => false, - 'show_in_rest' => false, 'rewrite' => false, + 'show_in_rest' => true, + 'rest_base' => 'font-families', + 'rest_controller_class' => 'WP_REST_Font_Families_Controller', + // Disable autosave endpoints for font families. + 'autosave_rest_controller_class' => 'stdClass', ) ); @@ -617,8 +621,12 @@ function create_initial_post_types() { ), 'map_meta_cap' => true, 'query_var' => false, - 'show_in_rest' => false, 'rewrite' => false, + 'show_in_rest' => true, + 'rest_base' => 'font-families/(?P[\d]+)/font-faces', + 'rest_controller_class' => 'WP_REST_Font_Faces_Controller', + // Disable autosave endpoints for font faces. + 'autosave_rest_controller_class' => 'stdClass', ) ); diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 2631a6663fe92..b44b205afb6ef 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -391,6 +391,10 @@ function create_initial_rest_routes() { // Navigation Fallback. $controller = new WP_REST_Navigation_Fallback_Controller(); $controller->register_routes(); + + // Font Collections. + $font_collections_controller = new WP_REST_Font_Collections_Controller(); + $font_collections_controller->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php new file mode 100644 index 0000000000000..c6576cc77c435 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php @@ -0,0 +1,326 @@ +rest_base = 'font-collections'; + $this->namespace = 'wp/v2'; + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 6.5.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\/\w-]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Gets the font collections available. + * + * @since 6.5.0 + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $collections_all = WP_Font_Library::get_instance()->get_font_collections(); + + $page = $request['page']; + $per_page = $request['per_page']; + $total_items = count( $collections_all ); + $max_pages = ceil( $total_items / $per_page ); + + if ( $page > $max_pages && $total_items > 0 ) { + return new WP_Error( + 'rest_post_invalid_page_number', + __( 'The page number requested is larger than the number of pages available.' ), + array( 'status' => 400 ) + ); + } + + $collections_page = array_slice( $collections_all, ( $page - 1 ) * $per_page, $per_page ); + + $items = array(); + foreach ( $collections_page as $collection ) { + $item = $this->prepare_item_for_response( $collection, $request ); + + // If there's an error loading a collection, skip it and continue loading valid collections. + if ( is_wp_error( $item ) ) { + continue; + } + $item = $this->prepare_response_for_collection( $item ); + $items[] = $item; + } + + $response = rest_ensure_response( $items ); + + $response->header( 'X-WP-Total', (int) $total_items ); + $response->header( 'X-WP-TotalPages', (int) $max_pages ); + + $request_params = $request->get_query_params(); + $collection_url = rest_url( $this->namespace . '/' . $this->rest_base ); + $base = add_query_arg( urlencode_deep( $request_params ), $collection_url ); + + if ( $page > 1 ) { + $prev_page = $page - 1; + + if ( $prev_page > $max_pages ) { + $prev_page = $max_pages; + } + + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->link_header( 'prev', $prev_link ); + } + if ( $max_pages > $page ) { + $next_page = $page + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + + $response->link_header( 'next', $next_link ); + } + + return $response; + } + + /** + * Gets a font collection. + * + * @since 6.5.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_item( $request ) { + $slug = $request->get_param( 'slug' ); + $collection = WP_Font_Library::get_instance()->get_font_collection( $slug ); + + // If the collection doesn't exist returns a 404. + if ( is_wp_error( $collection ) ) { + $collection->add_data( array( 'status' => 404 ) ); + return $collection; + } + + return $this->prepare_item_for_response( $collection, $request ); + } + + /** + * Prepare a single collection output for response. + * + * @since 6.5.0 + * + * @param WP_Font_Collection $collection Collection object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $collection, $request ) { + $fields = $this->get_fields_for_response( $request ); + $item = array(); + + if ( rest_is_field_included( 'slug', $fields ) ) { + $item['slug'] = $collection->slug; + } + + // If any data fields are requested, get the collection data. + $data_fields = array( 'name', 'description', 'font_families', 'categories' ); + if ( ! empty( array_intersect( $fields, $data_fields ) ) ) { + $collection_data = $collection->get_data(); + if ( is_wp_error( $collection_data ) ) { + $collection_data->add_data( array( 'status' => 500 ) ); + return $collection_data; + } + + foreach ( $data_fields as $field ) { + if ( rest_is_field_included( $field, $fields ) ) { + $item[ $field ] = $collection_data[ $field ]; + } + } + } + + $response = rest_ensure_response( $item ); + + if ( rest_is_field_included( '_links', $fields ) ) { + $links = $this->prepare_links( $collection ); + $response->add_links( $links ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $response->data = $this->add_additional_fields_to_object( $response->data, $request ); + $response->data = $this->filter_response_by_context( $response->data, $context ); + + /** + * Filters a font collection returned from the REST API. + * + * Allows modification of the font collection right before it is returned. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Font_Collection $collection The Font Collection object. + * @param WP_REST_Request $request Request used to generate the response. + */ + return apply_filters( 'rest_prepare_font_collection', $response, $collection, $request ); + } + + /** + * Retrieves the font collection's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'font-collection', + 'type' => 'object', + 'properties' => array( + 'slug' => array( + 'description' => __( 'Unique identifier for the font collection.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'The name for the font collection.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'description' => array( + 'description' => __( 'The description for the font collection.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'font_families' => array( + 'description' => __( 'The font families for the font collection.' ), + 'type' => 'array', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'categories' => array( + 'description' => __( 'The categories for the font collection.' ), + 'type' => 'array', + 'context' => array( 'view', 'edit', 'embed' ), + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Prepares links for the request. + * + * @since 6.5.0 + * + * @param WP_Font_Collection $collection Font collection data + * @return array Links for the given font collection. + */ + protected function prepare_links( $collection ) { + return array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $collection->slug ) ), + ), + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + ); + } + + /** + * Retrieves the search params for the font collections. + * + * @since 6.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); + + unset( $query_params['search'] ); + + /** + * Filters REST API collection parameters for the font collections controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_font_collections_collection_params', $query_params ); + } + + /** + * Checks whether the user has permissions to use the Fonts Collections. + * + * @since 6.5.0 + * + * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( current_user_can( 'edit_theme_options' ) ) { + return true; + } + + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font collections.' ), + array( + 'status' => rest_authorization_required_code(), + ) + ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php new file mode 100644 index 0000000000000..6a3ae958f4f6d --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php @@ -0,0 +1,941 @@ +namespace, + '/' . $this->rest_base, + array( + 'args' => array( + 'font_family_id' => array( + 'description' => __( 'The ID for the parent font family of the font face.' ), + 'type' => 'integer', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_create_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + 'args' => array( + 'font_family_id' => array( + 'description' => __( 'The ID for the parent font family of the font face.' ), + 'type' => 'integer', + 'required' => true, + ), + 'id' => array( + 'description' => __( 'Unique identifier for the font face.' ), + 'type' => 'integer', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Whether to bypass Trash and force deletion.', 'default' ), + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to font faces. + * + * @since 6.5.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 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- required by parent class + $post_type = get_post_type_object( $this->post_type ); + + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font faces.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to a font face. + * + * @since 6.5.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_item_permissions_check( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access this font face.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Validates settings when creating a font face. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font face settings. + * @param WP_REST_Request $request Request object. + * @return true|WP_Error True if the settings are valid, otherwise a WP_Error object. + */ + public function validate_create_font_face_settings( $value, $request ) { + $settings = json_decode( $value, true ); + + // Check settings string is valid JSON. + if ( null === $settings ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_face_settings parameter must be a valid JSON string.' ), + array( 'status' => 400 ) + ); + } + + // Check that the font face settings match the theme.json schema. + $schema = $this->get_item_schema()['properties']['font_face_settings']; + $has_valid_settings = rest_validate_value_from_schema( $settings, $schema, 'font_face_settings' ); + + if ( is_wp_error( $has_valid_settings ) ) { + $has_valid_settings->add_data( array( 'status' => 400 ) ); + return $has_valid_settings; + } + + // Check that none of the required settings are empty values. + $required = $schema['required']; + foreach ( $required as $key ) { + if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Name of the missing font face settings parameter, e.g. "font_face_settings[src]". */ + sprintf( __( '%s cannot be empty.' ), "font_face_setting[ $key ]" ), + array( 'status' => 400 ) + ); + } + } + + $srcs = is_array( $settings['src'] ) ? $settings['src'] : array( $settings['src'] ); + $files = $request->get_file_params(); + + foreach ( $srcs as $src ) { + // Check that each src is a non-empty string. + $src = ltrim( $src ); + if ( empty( $src ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Font face source parameter name: "font_face_settings[src]". */ + sprintf( __( '%s values must be non-empty strings.' ), 'font_face_settings[src]' ), + array( 'status' => 400 ) + ); + } + + // Check that srcs are valid URLs or file references. + if ( false === wp_http_validate_url( $src ) && ! isset( $files[ $src ] ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: 1: Font face source parameter name: "font_face_settings[src]", 2: The invalid src value. */ + sprintf( __( '%1$s value "%2$s" must be a valid URL or file reference.' ), 'font_face_settings[src]', $src ), + array( 'status' => 400 ) + ); + } + } + + // Check that each file in the request references a src in the settings. + foreach ( array_keys( $files ) as $file ) { + if ( ! in_array( $file, $srcs, true ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: 1: File key (e.g. "file-0") in the request data, 2: Font face source parameter name: "font_face_settings[src]". */ + sprintf( __( 'File %1$s must be used in %2$s.' ), $file, 'font_face_settings[src]' ), + array( 'status' => 400 ) + ); + } + } + + return true; + } + + /** + * Sanitizes the font face settings when creating a font face. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font face settings. + * @param WP_REST_Request $request Request object. + * @return array Decoded array of font face settings. + */ + public function sanitize_font_face_settings( $value ) { + // Settings arrive as stringified JSON, since this is a multipart/form-data request. + $settings = json_decode( $value, true ); + $schema = $this->get_item_schema()['properties']['font_face_settings']['properties']; + + // Sanitize settings based on callbacks in the schema. + foreach ( $settings as $key => $value ) { + $sanitize_callback = $schema[ $key ]['arg_options']['sanitize_callback']; + $settings[ $key ] = call_user_func( $sanitize_callback, $value ); + } + + return $settings; + } + + /** + * Retrieves a collection of font faces within the parent font family. + * + * @since 6.5.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 ) { + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + return parent::get_items( $request ); + } + + /** + * Retrieves a single font face within the parent font family. + * + * @since 6.5.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_item( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + // Check that the font face has a valid parent font family. + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + if ( (int) $font_family->ID !== (int) $post->post_parent ) { + return new WP_Error( + 'rest_font_face_parent_id_mismatch', + /* translators: %d: A post id. */ + sprintf( __( 'The font face does not belong to the specified font family with id of "%d".' ), $font_family->ID ), + array( 'status' => 404 ) + ); + } + + return parent::get_item( $request ); + } + + /** + * Creates a font face for the parent font family. + * + * @since 6.5.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 ) { + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + // Settings have already been decoded by ::sanitize_font_face_settings(). + $settings = $request->get_param( 'font_face_settings' ); + $file_params = $request->get_file_params(); + + // Check that the necessary font face properties are unique. + $query = new WP_Query( + array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'title' => WP_Font_Utils::get_font_face_slug( $settings ), + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + if ( ! empty( $query->get_posts() ) ) { + return new WP_Error( + 'rest_duplicate_font_face', + __( 'A font face matching those settings already exists.' ), + array( 'status' => 400 ) + ); + } + + // Move the uploaded font asset from the temp folder to the fonts directory. + if ( ! function_exists( 'wp_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $srcs = is_string( $settings['src'] ) ? array( $settings['src'] ) : $settings['src']; + $processed_srcs = array(); + $font_file_meta = array(); + + foreach ( $srcs as $src ) { + // If src not a file reference, use it as is. + if ( ! isset( $file_params[ $src ] ) ) { + $processed_srcs[] = $src; + continue; + } + + $file = $file_params[ $src ]; + $font_file = $this->handle_font_file_upload( $file ); + if ( is_wp_error( $font_file ) ) { + return $font_file; + } + + $processed_srcs[] = $font_file['url']; + $font_file_meta[] = $this->relative_fonts_path( $font_file['file'] ); + } + + // Store the updated settings for prepare_item_for_database to use. + $settings['src'] = count( $processed_srcs ) === 1 ? $processed_srcs[0] : $processed_srcs; + $request->set_param( 'font_face_settings', $settings ); + + // Ensure that $settings data is slashed, so values with quotes are escaped. + // WP_REST_Posts_Controller::create_item uses wp_slash() on the post_content. + $font_face_post = parent::create_item( $request ); + + if ( is_wp_error( $font_face_post ) ) { + return $font_face_post; + } + + $font_face_id = $font_face_post->data['id']; + + foreach ( $font_file_meta as $font_file_path ) { + add_post_meta( $font_face_id, '_wp_font_face_file', $font_file_path ); + } + + return $font_face_post; + } + + /** + * Deletes a single font face. + * + * @since 6.5.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 ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + if ( (int) $font_family->ID !== (int) $post->post_parent ) { + return new WP_Error( + 'rest_font_face_parent_id_mismatch', + /* translators: %d: A post id. */ + sprintf( __( 'The font face does not belong to the specified font family with id of "%d".' ), $font_family->ID ), + array( 'status' => 404 ) + ); + } + + $force = isset( $request['force'] ) ? (bool) $request['force'] : false; + + // We don't support trashing for font faces. + if ( ! $force ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Font faces do not support trashing. Set '%s' to delete." ), 'force=true' ), + array( 'status' => 501 ) + ); + } + + return parent::delete_item( $request ); + } + + /** + * Prepares a single font face output for response. + * + * @since 6.5.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 ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = $item->ID; + } + if ( rest_is_field_included( 'theme_json_version', $fields ) ) { + $data['theme_json_version'] = 2; + } + + if ( rest_is_field_included( 'parent', $fields ) ) { + $data['parent'] = $item->post_parent; + } + + if ( rest_is_field_included( 'font_face_settings', $fields ) ) { + $data['font_face_settings'] = $this->get_settings_from_post( $item ); + } + + $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 = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + /** + * Filters the font face data for a REST API response. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Font face post object. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_prepare_wp_font_face', $response, $item, $request ); + } + + /** + * Retrieves the post's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + // Base properties for every Post. + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the post.', 'default' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'theme_json_version' => array( + 'description' => __( 'Version of the theme.json schema used for the typography settings.' ), + 'type' => 'integer', + 'default' => 2, + 'minimum' => 2, + 'maximum' => 2, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'parent' => array( + 'description' => __( 'The ID for the parent font family of the font face.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + ), + // Font face settings come directly from theme.json schema + // See https://schemas.wp.org/trunk/theme.json + 'font_face_settings' => array( + 'description' => __( 'font-face declaration in theme.json format.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit', 'embed' ), + 'properties' => array( + 'fontFamily' => array( + 'description' => __( 'CSS font-family value.' ), + 'type' => 'string', + 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => array( 'WP_Font_Utils', 'sanitize_font_family' ), + ), + ), + 'fontStyle' => array( + 'description' => __( 'CSS font-style value.' ), + 'type' => 'string', + 'default' => 'normal', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontWeight' => array( + 'description' => __( 'List of available font weights, separated by a space.' ), + 'default' => '400', + // Changed from `oneOf` to avoid errors from loose type checking. + // e.g. a fontWeight of "400" validates as both a string and an integer due to is_numeric check. + 'type' => array( 'string', 'integer' ), + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontDisplay' => array( + 'description' => __( 'CSS font-display value.' ), + 'type' => 'string', + 'default' => 'fallback', + 'enum' => array( + 'auto', + 'block', + 'fallback', + 'swap', + 'optional', + ), + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'src' => array( + 'description' => __( 'Paths or URLs to the font files.' ), + // Changed from `oneOf` to `anyOf` due to rest_sanitize_array converting a string into an array, + // and causing a "matches more than one of the expected formats" error. + 'anyOf' => array( + array( + 'type' => 'string', + ), + array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + 'default' => array(), + 'arg_options' => array( + 'sanitize_callback' => function ( $value ) { + return is_array( $value ) ? array_map( array( $this, 'sanitize_src' ), $value ) : $this->sanitize_src( $value ); + }, + ), + ), + 'fontStretch' => array( + 'description' => __( 'CSS font-stretch value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'ascentOverride' => array( + 'description' => __( 'CSS ascent-override value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'descentOverride' => array( + 'description' => __( 'CSS descent-override value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontVariant' => array( + 'description' => __( 'CSS font-variant value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontFeatureSettings' => array( + 'description' => __( 'CSS font-feature-settings value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontVariationSettings' => array( + 'description' => __( 'CSS font-variation-settings value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'lineGapOverride' => array( + 'description' => __( 'CSS line-gap-override value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'sizeAdjust' => array( + 'description' => __( 'CSS size-adjust value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'unicodeRange' => array( + 'description' => __( 'CSS unicode-range value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'preview' => array( + 'description' => __( 'URL to a preview image of the font face.' ), + 'type' => 'string', + 'format' => 'uri', + 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_url', + ), + ), + ), + 'required' => array( 'fontFamily', 'src' ), + 'additionalProperties' => false, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the item's schema for display / public consumption purposes. + * + * @since 6.5.0 + * + * @return array Public item schema data. + */ + public function get_public_item_schema() { + + $schema = parent::get_public_item_schema(); + + // Also remove `arg_options' from child font_family_settings properties, since the parent + // controller only handles the top level properties. + foreach ( $schema['properties']['font_face_settings']['properties'] as &$property ) { + unset( $property['arg_options'] ); + } + + return $schema; + } + + /** + * Retrieves the query params for the font face collection. + * + * @since 6.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + // Remove unneeded params. + unset( $query_params['after'] ); + unset( $query_params['modified_after'] ); + unset( $query_params['before'] ); + unset( $query_params['modified_before'] ); + unset( $query_params['search'] ); + unset( $query_params['search_columns'] ); + unset( $query_params['slug'] ); + unset( $query_params['status'] ); + + $query_params['orderby']['default'] = 'id'; + $query_params['orderby']['enum'] = array( 'id', 'include' ); + + /** + * Filters collection parameters for the font face controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_wp_font_face_collection_params', $query_params ); + } + + /** + * Get the params used when creating a new font face. + * + * @since 6.5.0 + * + * @return array Font face create arguments. + */ + public function get_create_params() { + $properties = $this->get_item_schema()['properties']; + return array( + 'theme_json_version' => $properties['theme_json_version'], + // When creating, font_face_settings is stringified JSON, to work with multipart/form-data used + // when uploading font files. + 'font_face_settings' => array( + 'description' => __( 'font-face declaration in theme.json format, encoded as a string.' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_create_font_face_settings' ), + 'sanitize_callback' => array( $this, 'sanitize_font_face_settings' ), + ), + ); + } + + /** + * Get the parent font family, if the ID is valid. + * + * @since 6.5.0 + * + * @param int $font_family_id Supplied ID. + * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. + */ + protected function get_parent_font_family_post( $font_family_id ) { + $error = new WP_Error( + 'rest_post_invalid_parent', + __( 'Invalid post parent ID.', 'default' ), + array( 'status' => 404 ) + ); + + if ( (int) $font_family_id <= 0 ) { + return $error; + } + + $font_family_post = get_post( (int) $font_family_id ); + + if ( empty( $font_family_post ) || empty( $font_family_post->ID ) + || 'wp_font_family' !== $font_family_post->post_type + ) { + return $error; + } + + return $font_family_post; + } + + /** + * Prepares links for the request. + * + * @since 6.5.0 + * + * @param WP_Post $post Post object. + * @return array Links for the given post. + */ + protected function prepare_links( $post ) { + // Entity meta. + return array( + 'self' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent . '/font-faces/' . $post->ID ), + ), + 'collection' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent . '/font-faces' ), + ), + 'parent' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent ), + ), + ); + } + + /** + * Prepares a single font face post for creation. + * + * @since 6.5.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(); + + // Settings have already been decoded by ::sanitize_font_face_settings(). + $settings = $request->get_param( 'font_face_settings' ); + + // Store this "slug" as the post_title rather than post_name, since it uses the fontFamily setting, + // which may contain multibyte characters. + $title = WP_Font_Utils::get_font_face_slug( $settings ); + + $prepared_post->post_type = $this->post_type; + $prepared_post->post_parent = $request['font_family_id']; + $prepared_post->post_status = 'publish'; + $prepared_post->post_title = $title; + $prepared_post->post_name = sanitize_title( $title ); + $prepared_post->post_content = wp_json_encode( $settings ); + + return $prepared_post; + } + + /** + * Sanitizes a single src value for a font face. + * + * @since 6.5.0 + * + * @param string $value Font face src that is a URL or the key for a $_FILES array item. + * + * @return string Sanitized value. + */ + protected function sanitize_src( $value ) { + $value = ltrim( $value ); + return false === wp_http_validate_url( $value ) ? (string) $value : sanitize_url( $value ); + } + + /** + * Handles the upload of a font file using wp_handle_upload(). + * + * @since 6.5.0 + * + * @param array $file Single file item from $_FILES. + * @return array Array containing uploaded file attributes on success, or error on failure. + */ + protected function handle_font_file_upload( $file ) { + add_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); + add_filter( 'upload_dir', 'wp_get_font_dir' ); + + $overrides = array( + 'upload_error_handler' => array( $this, 'handle_font_file_upload_error' ), + // Arbitrary string to avoid the is_uploaded_file() check applied + // when using 'wp_handle_upload'. + 'action' => 'wp_handle_font_upload', + // Not testing a form submission. + 'test_form' => false, + // Seems mime type for files that are not images cannot be tested. + // See wp_check_filetype_and_ext(). + 'test_type' => true, + // Only allow uploading font files for this request. + 'mimes' => WP_Font_Utils::get_allowed_font_mime_types(), + ); + + $uploaded_file = wp_handle_upload( $file, $overrides ); + + remove_filter( 'upload_dir', 'wp_get_font_dir' ); + remove_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); + + return $uploaded_file; + } + + /** + * Handles file upload error. + * + * @since 6.5.0 + * + * @param array $file File upload data. + * @param string $message Error message from wp_handle_upload(). + * @return WP_Error WP_Error object. + */ + public function handle_font_file_upload_error( $file, $message ) { + $status = 500; + $code = 'rest_font_upload_unknown_error'; + + if ( __( 'Sorry, you are not allowed to upload this file type.' ) === $message ) { + $status = 400; + $code = 'rest_font_upload_invalid_file_type'; + } + + return new WP_Error( $code, $message, array( 'status' => $status ) ); + } + + /** + * Returns relative path to an uploaded font file. + * + * The path is relative to the current fonts directory. + * + * @since 6.5.0 + * @access private + * + * @param string $path Full path to the file. + * @return string Relative path on success, unchanged path on failure. + */ + protected function relative_fonts_path( $path ) { + $new_path = $path; + + $fonts_dir = wp_get_font_dir(); + if ( str_starts_with( $new_path, $fonts_dir['path'] ) ) { + $new_path = str_replace( $fonts_dir, '', $new_path ); + $new_path = ltrim( $new_path, '/' ); + } + + return $new_path; + } + + /** + * Gets the font face's settings from the post. + * + * @since 6.5.0 + * + * @param WP_Post $post Font face post object. + * @return array Font face settings array. + */ + protected function get_settings_from_post( $post ) { + $settings = json_decode( $post->post_content, true ); + $properties = $this->get_item_schema()['properties']['font_face_settings']['properties']; + + // Provide required, empty settings if needed. + if ( null === $settings ) { + $settings = array( + 'fontFamily' => '', + 'src' => array(), + ); + } + + // Only return the properties defined in the schema. + return array_intersect_key( $settings, $properties ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php new file mode 100644 index 0000000000000..a04723d31669a --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php @@ -0,0 +1,554 @@ +post_type ); + + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font families.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to a font family. + * + * @since 6.5.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_item_permissions_check( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access this font family.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Validates settings when creating or updating a font family. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font family settings. + * @param WP_REST_Request $request Request object. + * @return true|WP_Error True if the settings are valid, otherwise a WP_Error object. + */ + public function validate_font_family_settings( $value, $request ) { + $settings = json_decode( $value, true ); + + // Check settings string is valid JSON. + if ( null === $settings ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Parameter name: "font_family_settings". */ + sprintf( __( '%s parameter must be a valid JSON string.' ), 'font_family_settings' ), + array( 'status' => 400 ) + ); + } + + $schema = $this->get_item_schema()['properties']['font_family_settings']; + $required = $schema['required']; + + if ( isset( $request['id'] ) ) { + // Allow sending individual properties if we are updating an existing font family. + unset( $schema['required'] ); + + // But don't allow updating the slug, since it is used as a unique identifier. + if ( isset( $settings['slug'] ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Name of parameter being updated: font_family_settings[slug]". */ + sprintf( __( '%s cannot be updated.' ), 'font_family_settings[slug]' ), + array( 'status' => 400 ) + ); + } + } + + // Check that the font face settings match the theme.json schema. + $has_valid_settings = rest_validate_value_from_schema( $settings, $schema, 'font_family_settings' ); + + if ( is_wp_error( $has_valid_settings ) ) { + $has_valid_settings->add_data( array( 'status' => 400 ) ); + return $has_valid_settings; + } + + // Check that none of the required settings are empty values. + foreach ( $required as $key ) { + if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Name of the empty font family setting parameter, e.g. "font_family_settings[slug]". */ + sprintf( __( '%s cannot be empty.' ), "font_family_settings[ $key ]" ), + array( 'status' => 400 ) + ); + } + } + + return true; + } + + /** + * Sanitizes the font family settings when creating or updating a font family. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font family settings. + * @param WP_REST_Request $request Request object. + * @return array Decoded array font family settings. + */ + public function sanitize_font_family_settings( $value ) { + // Settings arrive as stringified JSON, since this is a multipart/form-data request. + $settings = json_decode( $value, true ); + $schema = $this->get_item_schema()['properties']['font_family_settings']['properties']; + + // Sanitize settings based on callbacks in the schema. + foreach ( $settings as $key => $value ) { + $sanitize_callback = $schema[ $key ]['arg_options']['sanitize_callback']; + $settings[ $key ] = call_user_func( $sanitize_callback, $value ); + } + + return $settings; + } + + /** + * Creates a single font family. + * + * @since 6.5.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 ) { + $settings = $request->get_param( 'font_family_settings' ); + + // Check that the font family slug is unique. + $query = new WP_Query( + array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'name' => $settings['slug'], + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + if ( ! empty( $query->get_posts() ) ) { + return new WP_Error( + 'rest_duplicate_font_family', + /* translators: %s: Font family slug. */ + sprintf( __( 'A font family with slug "%s" already exists.' ), $settings['slug'] ), + array( 'status' => 400 ) + ); + } + + return parent::create_item( $request ); + } + + /** + * Deletes a single font family. + * + * @since 6.5.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 ) { + $force = isset( $request['force'] ) ? (bool) $request['force'] : false; + + // We don't support trashing for font families. + if ( ! $force ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Font faces do not support trashing. Set '%s' to delete." ), 'force=true' ), + array( 'status' => 501 ) + ); + } + + return parent::delete_item( $request ); + } + + /** + * Prepares a single font family output for response. + * + * @since 6.5.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 ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = $item->ID; + } + + if ( rest_is_field_included( 'theme_json_version', $fields ) ) { + $data['theme_json_version'] = 2; + } + + if ( rest_is_field_included( 'font_faces', $fields ) ) { + $data['font_faces'] = $this->get_font_face_ids( $item->ID ); + } + + if ( rest_is_field_included( 'font_family_settings', $fields ) ) { + $data['font_family_settings'] = $this->get_settings_from_post( $item ); + } + + $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 = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + /** + * Filters the font family data for a REST API response. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Font family post object. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_prepare_wp_font_family', $response, $item, $request ); + } + + /** + * Retrieves the post's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + // Base properties for every Post. + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the post.', 'default' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'theme_json_version' => array( + 'description' => __( 'Version of the theme.json schema used for the typography settings.' ), + 'type' => 'integer', + 'default' => 2, + 'minimum' => 2, + 'maximum' => 2, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'font_faces' => array( + 'description' => __( 'The IDs of the child font faces in the font family.' ), + 'type' => 'array', + 'context' => array( 'view', 'edit', 'embed' ), + 'items' => array( + 'type' => 'integer', + ), + ), + // Font family settings come directly from theme.json schema + // See https://schemas.wp.org/trunk/theme.json + 'font_family_settings' => array( + 'description' => __( 'font-face definition in theme.json format.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit', 'embed' ), + 'properties' => array( + 'name' => array( + 'description' => __( 'Name of the font family preset, translatable.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'slug' => array( + 'description' => __( 'Kebab-case unique identifier for the font family preset.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_title', + ), + ), + 'fontFamily' => array( + 'description' => __( 'CSS font-family value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => array( 'WP_Font_Utils', 'sanitize_font_family' ), + ), + ), + 'preview' => array( + 'description' => __( 'URL to a preview image of the font family.' ), + 'type' => 'string', + 'format' => 'uri', + 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_url', + ), + ), + ), + 'required' => array( 'name', 'slug', 'fontFamily' ), + 'additionalProperties' => false, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the item's schema for display / public consumption purposes. + * + * @since 6.5.0 + * + * @return array Public item schema data. + */ + public function get_public_item_schema() { + + $schema = parent::get_public_item_schema(); + + // Also remove `arg_options' from child font_family_settings properties, since the parent + // controller only handles the top level properties. + foreach ( $schema['properties']['font_family_settings']['properties'] as &$property ) { + unset( $property['arg_options'] ); + } + + return $schema; + } + + /** + * Retrieves the query params for the font family collection. + * + * @since 6.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + // Remove unneeded params. + unset( $query_params['after'] ); + unset( $query_params['modified_after'] ); + unset( $query_params['before'] ); + unset( $query_params['modified_before'] ); + unset( $query_params['search'] ); + unset( $query_params['search_columns'] ); + unset( $query_params['status'] ); + + $query_params['orderby']['default'] = 'id'; + $query_params['orderby']['enum'] = array( 'id', 'include' ); + + /** + * Filters collection parameters for the font family controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_wp_font_family_collection_params', $query_params ); + } + + /** + * Get the arguments used when creating or updating a font family. + * + * @since 6.5.0 + * + * @return array Font family create/edit arguments. + */ + public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { + if ( WP_REST_Server::CREATABLE === $method || WP_REST_Server::EDITABLE === $method ) { + $properties = $this->get_item_schema()['properties']; + return array( + 'theme_json_version' => $properties['theme_json_version'], + // When creating or updating, font_family_settings is stringified JSON, to work with multipart/form-data. + // Font families don't currently support file uploads, but may accept preview files in the future. + 'font_family_settings' => array( + 'description' => __( 'font-family declaration in theme.json format, encoded as a string.' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_font_family_settings' ), + 'sanitize_callback' => array( $this, 'sanitize_font_family_settings' ), + ), + ); + } + + return parent::get_endpoint_args_for_item_schema( $method ); + } + + /** + * Get the child font face post IDs. + * + * @since 6.5.0 + * + * @param int $font_family_id Font family post ID. + * @return int[] Array of child font face post IDs. + */ + protected function get_font_face_ids( $font_family_id ) { + $query = new WP_Query( + array( + 'fields' => 'ids', + 'post_parent' => $font_family_id, + 'post_type' => 'wp_font_face', + 'posts_per_page' => 99, + 'order' => 'ASC', + 'orderby' => 'id', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + return $query->get_posts(); + } + + /** + * Prepares font family links for the request. + * + * @since 6.5.0 + * + * @param WP_Post $post Post object. + * @return array Links for the given post. + */ + protected function prepare_links( $post ) { + // Entity meta. + $links = parent::prepare_links( $post ); + + return array( + 'self' => $links['self'], + 'collection' => $links['collection'], + 'font_faces' => $this->prepare_font_face_links( $post->ID ), + ); + } + + /** + * Prepares child font face links for the request. + * + * @param int $font_family_id Font family post ID. + * @return array Links for the child font face posts. + */ + protected function prepare_font_face_links( $font_family_id ) { + $font_face_ids = $this->get_font_face_ids( $font_family_id ); + $links = array(); + foreach ( $font_face_ids as $font_face_id ) { + $links[] = array( + 'embeddable' => true, + 'href' => rest_url( $this->namespace . '/' . $this->rest_base . '/' . $font_family_id . '/font-faces/' . $font_face_id ), + ); + } + return $links; + } + + /** + * Prepares a single font family post for create or update. + * + * @since 6.5.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(); + // Settings have already been decoded by ::sanitize_font_family_settings(). + $settings = $request->get_param( 'font_family_settings' ); + + // This is an update and we merge with the existing font family. + if ( isset( $request['id'] ) ) { + $existing_post = $this->get_post( $request['id'] ); + if ( is_wp_error( $existing_post ) ) { + return $existing_post; + } + + $prepared_post->ID = $existing_post->ID; + $existing_settings = $this->get_settings_from_post( $existing_post ); + $settings = array_merge( $existing_settings, $settings ); + } + + $prepared_post->post_type = $this->post_type; + $prepared_post->post_status = 'publish'; + $prepared_post->post_title = $settings['name']; + $prepared_post->post_name = sanitize_title( $settings['slug'] ); + + // Remove duplicate information from settings. + unset( $settings['name'] ); + unset( $settings['slug'] ); + + $prepared_post->post_content = wp_json_encode( $settings ); + + return $prepared_post; + } + + /** + * Gets the font family's settings from the post. + * + * @since 6.5.0 + * + * @param WP_Post $post Font family post object. + * @return array Font family settings array. + */ + protected function get_settings_from_post( $post ) { + $settings_json = json_decode( $post->post_content, true ); + + // Default to empty strings if the settings are missing. + return array( + 'name' => isset( $post->post_title ) && $post->post_title ? $post->post_title : '', + 'slug' => isset( $post->post_name ) && $post->post_name ? $post->post_name : '', + 'fontFamily' => isset( $settings_json['fontFamily'] ) && $settings_json['fontFamily'] ? $settings_json['fontFamily'] : '', + 'preview' => isset( $settings_json['preview'] ) && $settings_json['preview'] ? $settings_json['preview'] : '', + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 22683b37d1f5d..bdb27aae007d3 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -310,6 +310,9 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-templates-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-url-details-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php new file mode 100644 index 0000000000000..60f50e503fdbe --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php @@ -0,0 +1,203 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + $mock_file = wp_tempnam( 'my-collection-data-' ); + file_put_contents( $mock_file, '{"name": "Mock Collection", "font_families": [ "mock" ], "categories": [ "mock" ] }' ); + + wp_register_font_collection( 'mock-col-slug', $mock_file ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + wp_unregister_font_collection( 'mock-col-slug' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertCount( 1, $routes['/wp/v2/font-collections'], 'Rest server has not the collections path initialized.' ); + $this->assertCount( 1, $routes['/wp/v2/font-collections/(?P[\/\w-]+)'], 'Rest server has not the collection path initialized.' ); + + $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections'][0]['methods'], 'Rest server has not the GET method for collections initialized.' ); + $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections/(?P[\/\w-]+)'][0]['methods'], 'Rest server has not the GET method for collection initialized.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections' ); + $response = rest_get_server()->dispatch( $request ); + $content = $response->get_data(); + $this->assertIsArray( $content ); + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_items + */ + public function test_get_items_should_only_return_valid_collections() { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::load_from_json' ); + + wp_set_current_user( self::$admin_id ); + wp_register_font_collection( 'invalid-collection', 'invalid-collection-file' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections' ); + $response = rest_get_server()->dispatch( $request ); + $content = $response->get_data(); + + wp_unregister_font_collection( 'invalid-collection' ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertCount( 1, $content, 'The response should only contain valid collections.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/mock-col-slug' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + + $response_data = $response->get_data(); + $this->assertArrayHasKey( 'name', $response_data, 'Response data does not have the name key.' ); + $this->assertArrayHasKey( 'slug', $response_data, 'Response data does not have the slug key.' ); + $this->assertArrayHasKey( 'description', $response_data, 'Response data does not have the description key.' ); + $this->assertArrayHasKey( 'font_families', $response_data, 'Response data does not have the font_families key.' ); + $this->assertArrayHasKey( 'categories', $response_data, 'Response data does not have the categories key.' ); + + $this->assertIsString( $response_data['name'], 'name is not a string.' ); + $this->assertIsString( $response_data['slug'], 'slug is not a string.' ); + $this->assertIsString( $response_data['description'], 'description is not a string.' ); + + $this->assertIsArray( $response_data['font_families'], 'font_families is not an array.' ); + $this->assertIsArray( $response_data['categories'], 'categories is not an array.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item_invalid_slug() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/non-existing-collection' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'font_collection_not_found', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item_invalid_collection() { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::load_from_json' ); + + wp_set_current_user( self::$admin_id ); + $slug = 'invalid-collection'; + wp_register_font_collection( $slug, 'invalid-collection-file' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/' . $slug ); + $response = rest_get_server()->dispatch( $request ); + + wp_unregister_font_collection( $slug ); + + $this->assertErrorResponse( 'font_collection_json_missing', $response, 500, 'When the collection json file is invalid, the response should return an error for "font_collection_json_missing" with 500 status.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item_invalid_id_permission() { + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/mock-col-slug' ); + + wp_set_current_user( 0 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response status should be 401 for non-authenticated users.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response status should be 403 for users without the right permissions.' ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Controller does not use get_context_param(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not use test_create_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Controller does not use test_update_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not use test_delete_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Controller does not use test_prepare_item(). + } + + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-collections' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $properties = $data['schema']['properties']; + $this->assertCount( 5, $properties, 'There should be 5 properties in the response data schema.' ); + $this->assertArrayHasKey( 'slug', $properties, 'The slug property should exist in the response data schema.' ); + $this->assertArrayHasKey( 'name', $properties, 'The name property should exist in the response data schema.' ); + $this->assertArrayHasKey( 'description', $properties, 'The description property should exist in the response data schema.' ); + $this->assertArrayHasKey( 'font_families', $properties, 'The slug font_families should exist in the response data schema.' ); + $this->assertArrayHasKey( 'categories', $properties, 'The categories property should exist in the response data schema.' ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php new file mode 100644 index 0000000000000..bbfeaca30707a --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php @@ -0,0 +1,1067 @@ + '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ); + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$font_family_id = Tests_REST_WpRestFontFamiliesController::create_font_family_post(); + self::$other_font_family_id = Tests_REST_WpRestFontFamiliesController::create_font_family_post(); + + self::$font_face_id1 = self::create_font_face_post( + self::$font_family_id, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-medium.ttf' ), + ) + ); + self::$font_face_id2 = self::create_font_face_post( + self::$font_family_id, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '900', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-bold.ttf' ), + ) + ); + + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + + self::$post_ids_for_cleanup = array(); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + + wp_delete_post( self::$font_family_id, true ); + wp_delete_post( self::$other_font_family_id, true ); + wp_delete_post( self::$font_face_id1, true ); + wp_delete_post( self::$font_face_id2, true ); + } + + public function tear_down() { + foreach ( self::$post_ids_for_cleanup as $post_id ) { + wp_delete_post( $post_id, true ); + } + self::$post_ids_for_cleanup = array(); + parent::tear_down(); + } + + public static function create_font_face_post( $parent_id, $settings = array() ) { + $settings = array_merge( self::$default_settings, $settings ); + $title = WP_Font_Utils::get_font_face_slug( $settings ); + $post_id = self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_face', + 'post_status' => 'publish', + 'post_title' => $title, + 'post_name' => sanitize_title( $title ), + 'post_content' => wp_json_encode( $settings ), + 'post_parent' => $parent_id, + ) + ) + ); + + self::$post_ids_for_cleanup[] = $post_id; + + return $post_id; + } + + /** + * @covers WP_REST_Font_Faces_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces', + $routes, + 'Font faces collection for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families/(?P[\d]+)/font-faces'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', + $routes, + 'Single font face route for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + } + + public function test_font_faces_no_autosave_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)/autosaves', + $routes, + 'Font faces autosaves route exists.' + ); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)/autosaves/(?P[\d]+)', + $routes, + 'Font faces autosaves by id route exists.' + ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // See test_get_context_param(). + } + + /** + * @dataProvider data_get_context_param + * + * @covers WP_REST_Font_Faces_Controller::get_context_param + * + * @param bool $single_route Whether to test a single route. + */ + public function test_get_context_param( $single_route ) { + $route = '/wp/v2/font-families/' . self::$font_family_id . '/font-faces'; + if ( $single_route ) { + $route .= '/' . self::$font_face_id1; + } + + $request = new WP_REST_Request( 'OPTIONS', $route ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $endpoint_data = $data['endpoints'][0]; + $this->assertArrayNotHasKey( 'allow_batch', $endpoint_data, 'The allow_batch property should not exist in the endpoint data.' ); + $this->assertSame( 'view', $endpoint_data['args']['context']['default'], 'The endpoint\'s args::context::default should be set to view.' ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $endpoint_data['args']['context']['enum'], 'The endpoint\'s args::context::enum should be set to [ view, embed, edit ].' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_context_param() { + return array( + 'Collection' => array( false ), + 'Single' => array( true ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200' ); + $this->assertCount( 2, $data, 'There should be 2 properties in the response data.' ); + $this->assertArrayHasKey( '_links', $data[0], 'The _links property should exist in the response data 0.' ); + $this->check_font_face_data( $data[0], self::$font_face_id2, $data[0]['_links'] ); + $this->assertArrayHasKey( '_links', $data[1], 'The _links property should exist in the response data 1.' ); + $this->check_font_face_data( $data[1], self::$font_face_id1, $data[1]['_links'] ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response should return an error with a "rest_cannot_read" code and 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response should return an error with a "rest_cannot_read" code and 403 status.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_face_data( $data, self::$font_face_id1, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_get_item_removes_extra_settings() { + $font_face_id = self::create_font_face_post( self::$font_family_id, array( 'extra' => array() ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in the response data.' ); + $this->assertArrayNotHasKey( 'extra', $data['font_face_settings'], 'The extra property should exist in the font_face_settings data.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_get_item_malformed_post_content_returns_empty_settings() { + $font_face_id = wp_insert_post( + array( + 'post_type' => 'wp_font_face', + 'post_parent' => self::$font_family_id, + 'post_status' => 'publish', + 'post_content' => 'invalid', + ) + ); + + self::$post_ids_for_cleanup[] = $font_face_id; + + $empty_settings = array( + 'fontFamily' => '', + 'src' => array(), + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in the response data.' ); + $this->assertSame( $empty_settings, $data['font_face_settings'], 'The empty settings should exist in the font_face_settings data.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_invalid_font_face_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response should return an error with a "rest_cannot_read" code and 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response should return an error with a "rest_cannot_read" code and 403 status.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_valid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( self::$font_family_id, $data['parent'], 'The returned parent id should match the font family id.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_invalid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$other_font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_font_face_parent_id_mismatch', $response, 404 ); + + $expected_message = 'The font face does not belong to the specified font family with id of "' . self::$other_font_family_id . '"'; + $this->assertSame( $expected_message, $response->as_error()->get_error_messages()[0], 'The message must contain the correct parent ID.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + $files = $this->setup_font_file_upload( array( 'woff2' ) ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => array_keys( $files )[0], + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + $this->check_file_meta( $data['id'], array( $data['font_face_settings']['src'] ) ); + + $settings = $data['font_face_settings']; + unset( $settings['src'] ); + $this->assertSame( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + ), + $settings, + 'The font_face_settings data should match the expected data.' + ); + + $this->assertSame( self::$font_family_id, $data['parent'], 'The returned parent id should match the font family id.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_multiple_font_files() { + wp_set_current_user( self::$admin_id ); + $files = $this->setup_font_file_upload( array( 'ttf', 'otf', 'woff', 'woff2' ) ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => array_keys( $files ), + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + $this->check_file_meta( $data['id'], $data['font_face_settings']['src'] ); + + $settings = $data['font_face_settings']; + $this->assertCount( 4, $settings['src'], 'There should be 4 items in the font_face_settings::src data.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_invalid_file_type() { + $image_file = DIR_TESTDATA . '/images/canola.jpg'; + $image_path = wp_tempnam( 'canola.jpg' ); + copy( $image_file, $image_path ); + + $files = array( + 'file-0' => array( + 'name' => 'canola.jpg', + 'full_path' => 'canola.jpg', + 'type' => 'font/woff2', + 'tmp_name' => $image_path, + 'error' => 0, + 'size' => filesize( $image_path ), + ), + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array_merge( + self::$default_settings, + array( + 'fontWeight' => '200', + 'src' => array_keys( $files )[0], + ) + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_font_upload_invalid_file_type', $response, 400 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_url_src() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_all_properties() { + wp_set_current_user( self::$admin_id ); + + $properties = array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '300 500', + 'fontStyle' => 'oblique 30deg 50deg', + 'fontDisplay' => 'swap', + 'fontStretch' => 'expanded', + 'ascentOverride' => '70%', + 'descentOverride' => '30%', + 'fontVariant' => 'normal', + 'fontFeatureSettings' => '"swsh" 2', + 'fontVariationSettings' => '"xhgt" 0.7', + 'lineGapOverride' => '10%', + 'sizeAdjust' => '90%', + 'unicodeRange' => 'U+0025-00FF, U+4??', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', wp_json_encode( $properties ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + wp_delete_post( $data['id'], true ); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in the response data.' ); + $this->assertSame( $properties, $data['font_face_settings'], 'The font_face_settings should match the expected properties.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces' ); + $request->set_param( + 'font_face_settings', + wp_json_encode( array_merge( self::$default_settings, array( 'fontWeight' => '100' ) ) ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_duplicate_properties() { + $settings = array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'italic', + 'src' => home_url( '/wp-content/fonts/open-sans-italic-light.ttf' ), + ); + self::create_font_face_post( self::$font_family_id, $settings ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_duplicate_font_face', $response, 400, 'The response should return an error for "rest_duplicate_font_face" with 400 status.' ); + $expected_message = 'A font face matching those settings already exists.'; + $message = $response->as_error()->get_error_messages()[0]; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_request + */ + public function test_create_item_default_theme_json_version() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + wp_delete_post( $data['id'], true ); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in the response data.' ); + $this->assertSame( 2, $data['theme_json_version'], 'The default theme.json version should be 2.' ); + } + + /** + * @dataProvider data_create_item_invalid_theme_json_version + * + * @covers WP_REST_Font_Faces_Controller::create_item + * + * @param int $theme_json_version Version input to test. + */ + public function test_create_item_invalid_theme_json_version( $theme_json_version ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', $theme_json_version ); + $request->set_param( 'font_face_settings', '' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_invalid_theme_json_version() { + return array( + array( 1 ), + array( 3 ), + ); + } + + /** + * @dataProvider data_create_item_invalid_settings + * + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + * + * @param mixed $settings Settings to test. + */ + public function test_create_item_invalid_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_invalid_settings() { + return array( + 'Missing fontFamily' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Empty fontFamily' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Wrong fontFamily type' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => 1234 ) ), + ), + 'Invalid fontDisplay' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontDisplay' => 'invalid' ) ), + ), + 'Missing src' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'src' => '' ) ), + ), + 'Empty src string' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => '' ) ), + ), + 'Empty src array' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => array() ) ), + ), + 'Empty src array values' => array( + 'settings' => array_merge( self::$default_settings, array( '', '' ) ), + ), + 'Wrong src type' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => 1234 ) ), + ), + 'Wrong src array types' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => array( 1234, 5678 ) ) ), + ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_invalid_settings_json() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', 'invalid' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'font_face_settings parameter must be a valid JSON string.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_face_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_invalid_file_src() { + $files = $this->setup_font_file_upload( array( 'woff2' ) ); + + wp_set_current_user( self::$admin_id ); + $src = 'invalid'; + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array_merge( self::$default_settings, array( 'src' => $src ) ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'font_face_settings[src] value "' . $src . '" must be a valid URL or file reference.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_face_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_missing_file_src() { + $files = $this->setup_font_file_upload( array( 'woff2', 'woff' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array_merge( self::$default_settings, array( 'src' => array( array_keys( $files )[0] ) ) ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'File ' . array_keys( $files )[1] . ' must be used in font_face_settings[src].'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_face_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @dataProvider data_sanitize_font_face_settings + * + * @covers WP_REST_Font_Face_Controller::sanitize_font_face_settings + * + * @param string $settings Settings to test. + * @param string $expected Expected settings result. + */ + public function test_create_item_sanitize_font_face_settings( $settings, $expected ) { + $settings = array_merge( self::$default_settings, $settings ); + $expected = array_merge( self::$default_settings, $expected ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + wp_delete_post( $data['id'], true ); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertSame( $expected, $data['font_face_settings'], 'The response font_face_settings should match.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_sanitize_font_face_settings() { + return array( + 'settings with tags, extra whitespace, new lines' => array( + 'settings' => array( + 'fontFamily' => " Open Sans\n ", + 'fontStyle' => " oblique 20deg 50deg\n ", + 'fontWeight' => " 200\n ", + 'src' => " https://example.com/ ", + 'fontStretch' => " expanded\n ", + 'ascentOverride' => " 70%\n ", + 'descentOverride' => " 30%\n ", + 'fontVariant' => " normal\n ", + 'fontFeatureSettings' => " \"swsh\" 2\n ", + 'fontVariationSettings' => " \"xhgt\" 0.7\n ", + 'lineGapOverride' => " 10%\n ", + 'sizeAdjust' => " 90%\n ", + 'unicodeRange' => " U+0025-00FF, U+4??\n ", + 'preview' => " https://example.com/ ", + ), + 'expected' => array( + 'fontFamily' => '"Open Sans"', + 'fontStyle' => 'oblique 20deg 50deg', + 'fontWeight' => '200', + 'src' => 'https://example.com//stylescriptalert(\'XSS\');/script%20%20%20%20%20%20', + 'fontStretch' => 'expanded', + 'ascentOverride' => '70%', + 'descentOverride' => '30%', + 'fontVariant' => 'normal', + 'fontFeatureSettings' => '"swsh" 2', + 'fontVariationSettings' => '"xhgt" 0.7', + 'lineGapOverride' => '10%', + 'sizeAdjust' => '90%', + 'unicodeRange' => 'U+0025-00FF, U+4??', + 'preview' => 'https://example.com//stylescriptalert(\'XSS\');/script%20%20%20%20%20%20', + ), + ), + 'multiword font family name with integer' => array( + 'settings' => array( + 'fontFamily' => 'Libre Barcode 128 Text', + ), + 'expected' => array( + 'fontFamily' => '"Libre Barcode 128 Text"', + ), + ), + 'multiword font family name' => array( + 'settings' => array( + 'fontFamily' => 'B612 Mono', + ), + 'expected' => array( + 'fontFamily' => '"B612 Mono"', + ), + ), + 'comma-separated font family names' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans, Noto Sans, sans-serif', + ), + 'expected' => array( + 'fontFamily' => '"Open Sans", "Noto Sans", sans-serif', + ), + ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + // public function test_create_item_no_permission() {} + + /** + * @covers WP_REST_Font_Faces_Controller::update_item + */ + public function test_update_item() { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_no_route', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + $font_face_id = self::create_font_face_post( self::$font_family_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 201.' ); + $this->assertNull( get_post( $font_face_id ), 'The deleted post should not exist.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_no_trash() { + wp_set_current_user( self::$admin_id ); + $font_face_id = self::create_font_face_post( self::$font_family_id ); + + // Attempt trashing. + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501, 'The response should return an error for "rest_trash_not_supported" with 501 status.' ); + + $request->set_param( 'force', 'false' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501, 'When "force" is false, the response should return an error for "rest_trash_not_supported" with 501 status.' ); + + // Ensure the post still exists. + $post = get_post( $font_face_id ); + $this->assertNotEmpty( $post, 'The post should still exists.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_invalid_font_face_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete + */ + public function test_delete_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces/' . self::$font_face_id1 ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_delete_item_invalid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$other_font_family_id . '/font-faces/' . self::$font_face_id1 ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_font_face_parent_id_mismatch', $response, 404, 'The response should return an error for "rest_font_face_parent_id_mismatch" with 404 status.' ); + + $expected_message = 'The font face does not belong to the specified font family with id of "' . self::$other_font_family_id . '"'; + $this->assertSame( $expected_message, $response->as_error()->get_error_messages()[0], 'The message must contain the correct parent ID.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_no_permissions() { + $font_face_id = $this->create_font_face_post( self::$font_family_id ); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 401, 'The response should return an error for "rest_cannot_delete" with 401 status for an invalid user.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403, 'The response should return an error for "rest_cannot_delete" with 403 status for a user without permission.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id2 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_face_data( $data, self::$font_face_id2, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $properties = $data['schema']['properties']; + $this->assertCount( 4, $properties, 'There should be 4 properties in the schema::properties data.' ); + $this->assertArrayHasKey( 'id', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'theme_json_version', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'parent', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'font_face_settings', $properties, 'The id property should exist in the schema::properties data.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item_schema + */ + public function test_get_item_schema_font_face_settings_should_all_have_sanitize_callbacks() { + $schema = ( new WP_REST_Font_Faces_Controller( 'wp_font_face' ) )->get_item_schema(); + $font_face_settings_schema = $schema['properties']['font_face_settings']; + + $this->assertArrayHasKey( 'properties', $font_face_settings_schema, 'font_face_settings schema is missing properties.' ); + $this->assertIsArray( $font_face_settings_schema['properties'], 'font_face_settings properties should be an array.' ); + + // arg_options should be removed for each setting property. + foreach ( $font_face_settings_schema['properties'] as $property ) { + $this->assertArrayHasKey( 'arg_options', $property, 'Setting schema should have arg_options.' ); + $this->assertArrayHasKey( 'sanitize_callback', $property['arg_options'], 'Setting schema should have a sanitize_callback.' ); + $this->assertIsCallable( $property['arg_options']['sanitize_callback'], 'The sanitize_callback value should be callable.' ); + } + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_public_item_schema + */ + public function test_get_public_item_schema_should_not_have_arg_options() { + $schema = ( new WP_REST_Font_Faces_Controller( 'wp_font_face' ) )->get_public_item_schema(); + $font_face_settings_schema = $schema['properties']['font_face_settings']; + + $this->assertArrayHasKey( 'properties', $font_face_settings_schema, 'font_face_settings schema is missing properties.' ); + $this->assertIsArray( $font_face_settings_schema['properties'], 'font_face_settings properties should be an array.' ); + + // arg_options should be removed for each setting property. + foreach ( $font_face_settings_schema['properties'] as $property ) { + $this->assertArrayNotHasKey( 'arg_options', $property, 'arg_options should be removed from the schema for each setting.' ); + } + } + + protected function check_font_face_data( $data, $post_id, $links ) { + self::$post_ids_for_cleanup[] = $post_id; + $post = get_post( $post_id ); + + $this->assertArrayHasKey( 'id', $data, 'The id property should exist in response data.' ); + $this->assertSame( $post->ID, $data['id'], 'The "id" from the response data should match the post ID.' ); + + $this->assertArrayHasKey( 'parent', $data, 'The parent property should exist in response data.' ); + $this->assertSame( $post->post_parent, $data['parent'], 'The "parent" from the response data should match the post parent.' ); + + $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in response data.' ); + $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, $data['theme_json_version'], 'The "theme_json_version" from the response data should match WP_Theme_JSON::LATEST_SCHEMA.' ); + + $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in response data.' ); + $this->assertSame( $post->post_content, wp_json_encode( $data['font_face_settings'] ), 'The encoded "font_face_settings" from the response data should match the post content.' ); + + $this->assertNotEmpty( $links, 'The links should not be empty in the response data.' ); + $expected = rest_url( 'wp/v2/font-families/' . $post->post_parent . '/font-faces/' . $post->ID ); + $this->assertSame( $expected, $links['self'][0]['href'], 'The links URL from the response data should match the post\'s REST endpoint.' ); + $expected = rest_url( 'wp/v2/font-families/' . $post->post_parent . '/font-faces' ); + $this->assertSame( $expected, $links['collection'][0]['href'], 'The links collection URL from the response data should match the REST endpoint.' ); + $expected = rest_url( 'wp/v2/font-families/' . $post->post_parent ); + $this->assertSame( $expected, $links['parent'][0]['href'], 'The links for a parent URL from the response data should match the parent\'s REST endpoint.' ); + } + + protected function check_file_meta( $font_face_id, $src_attributes ) { + $file_meta = get_post_meta( $font_face_id, '_wp_font_face_file' ); + + foreach ( $src_attributes as $src_attribute ) { + $file_name = basename( $src_attribute ); + $this->assertContains( $file_name, $file_meta, 'The uploaded font file path should be saved in the post meta.' ); + } + } + + protected function setup_font_file_upload( $formats ) { + $files = array(); + foreach ( $formats as $format ) { + $font_file = DIR_TESTDATA . 'fonts/OpenSans-Regular.' . $format; + $font_path = wp_tempnam( 'OpenSans-Regular.' . $format ); + copy( $font_file, $font_path ); + + $files[ 'file-' . count( $files ) ] = array( + 'name' => 'OpenSans-Regular.' . $format, + 'full_path' => 'OpenSans-Regular.' . $format, + 'type' => 'font/' . $format, + 'tmp_name' => $font_path, + 'error' => 0, + 'size' => filesize( $font_path ), + ); + } + + return $files; + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php new file mode 100644 index 0000000000000..94ad5eccd7e57 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php @@ -0,0 +1,1050 @@ + 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ); + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + + self::$font_family_id1 = self::create_font_family_post( + array( + 'name' => 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ) + ); + self::$font_family_id2 = self::create_font_family_post( + array( + 'name' => 'Helvetica', + 'slug' => 'helvetica', + 'fontFamily' => 'Helvetica, Arial, sans-serif', + ) + ); + self::$font_face_id1 = Tests_REST_WpRestFontFacesController::create_font_face_post( + self::$font_family_id1, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-medium.ttf' ), + ) + ); + self::$font_face_id2 = Tests_REST_WpRestFontFacesController::create_font_face_post( + self::$font_family_id1, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '900', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-bold.ttf' ), + ) + ); + + static::$post_ids_to_cleanup = array(); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + + wp_delete_post( self::$font_family_id1 ); + wp_delete_post( self::$font_family_id2 ); + wp_delete_post( self::$font_face_id1 ); + wp_delete_post( self::$font_face_id2 ); + } + + public function tear_down() { + foreach ( static::$post_ids_to_cleanup as $post_id ) { + wp_delete_post( $post_id, true ); + } + static::$post_ids_to_cleanup = array(); + + parent::tear_down(); + } + + public static function create_font_family_post( $settings = array() ) { + $settings = array_merge( self::$default_settings, $settings ); + $post_id = self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_family', + 'post_status' => 'publish', + 'post_title' => $settings['name'], + 'post_name' => $settings['slug'], + 'post_content' => wp_json_encode( + array( + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ) + ), + ) + ) + ); + + static::$post_ids_to_cleanup[] = $post_id; + + return $post_id; + } + + /** + * @covers WP_REST_Font_Families_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/font-families', + $routes, + 'Font faces collection for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)', + $routes, + 'Single font face route for the given font family does not exist' + ); + $this->assertCount( + 3, + $routes['/wp/v2/font-families/(?P[\d]+)'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + } + + public function test_font_families_no_autosave_routes() { + // @core-merge: Enable this test. + $this->markTestSkipped( 'This test only works with WP 6.4 and above. Enable it once 6.5 is released.' ); + $routes = rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/autosaves', + $routes, + 'Font families autosaves route exists.' + ); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/autosaves/(?P[\d]+)', + $routes, + 'Font families autosaves by id route exists.' + ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // See test_get_context_param(). + } + + /** + * @dataProvider data_get_context_param + * + * @covers WP_REST_Font_Families_Controller::get_context_param + * + * @param bool $single_route Whether to test a single route. + */ + public function test_get_context_param( $single_route ) { + $route = '/wp/v2/font-families'; + if ( $single_route ) { + $route .= '/' . self::$font_family_id1; + } + + $request = new WP_REST_Request( 'OPTIONS', $route ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $endpoint_data = $data['endpoints'][0]; + $this->assertArrayNotHasKey( 'allow_batch', $endpoint_data, 'The allow_batch property should not exist in the endpoint data.' ); + $this->assertSame( 'view', $endpoint_data['args']['context']['default'], 'The endpoint\'s args::context::default should be set to view.' ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $endpoint_data['args']['context']['enum'], 'The endpoint\'s args::context::enum should be set to [ view, embed, edit ].' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_context_param() { + return array( + 'Collection' => array( false ), + 'Single' => array( true ), + ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertCount( 2, $data, 'There should be 2 properties in the response data.' ); + $this->assertArrayHasKey( '_links', $data[0], 'The _links property should exist in the response data 0.' ); + $this->check_font_family_data( $data[0], self::$font_family_id2, $data[0]['_links'] ); + $this->assertArrayHasKey( '_links', $data[1], 'The _links property should exist in the response data 1.' ); + $this->check_font_family_data( $data[1], self::$font_family_id1, $data[1]['_links'] ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_items + */ + public function test_get_items_by_slug() { + $font_family = get_post( self::$font_family_id2 ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $request->set_param( 'slug', $font_family->post_name ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertCount( 1, $data, 'There should be 2 properties in the response data.' ); + $this->assertArrayHasKey( 'id', $data[0], 'The id property should exist in the response data.' ); + $this->assertSame( $font_family->ID, $data[0]['id'], 'The id should match the expected ID in the response data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_items + */ + public function test_get_items_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response should return an error with a "rest_cannot_read" code and 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response should return an error with a "rest_cannot_read" code and 403 status.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_family_data( $data, self::$font_family_id1, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Families_Controller::prepare_item_for_response + */ + public function test_get_item_embedded_font_faces() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( '_embed', true ); + $response = rest_get_server()->dispatch( $request ); + $data = rest_get_server()->response_to_data( $response, true ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertArrayHasKey( '_embedded', $data, 'The _embedded property should exist in the response data.' ); + $this->assertArrayHasKey( 'font_faces', $data['_embedded'], 'The font_faces property should exist in _embedded data.' ); + $this->assertCount( 2, $data['_embedded']['font_faces'], 'There should be 2 font_faces in the _embedded data.' ); + + foreach ( $data['_embedded']['font_faces'] as $font_face ) { + $this->assertArrayHasKey( 'id', $font_face, 'The id property should exist in the _embedded font_face data.' ); + + $font_face_request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 . '/font-faces/' . $font_face['id'] ); + $font_face_response = rest_get_server()->dispatch( $font_face_request ); + $font_face_data = rest_get_server()->response_to_data( $font_face_response, true ); + + $this->assertSame( $font_face_data, $font_face, 'The embedded font_face data should match when the data from a single request.' ); + } + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item_removes_extra_settings() { + $font_family_id = self::create_font_family_post( array( 'fontFace' => array() ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertArrayNotHasKey( 'fontFace', $data['font_family_settings'], 'The fontFace property should not exist in the font_family_settings data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::prepare_item_for_response + */ + public function test_get_item_malformed_post_content_returns_empty_settings() { + $font_family_id = wp_insert_post( + array( + 'post_type' => 'wp_font_family', + 'post_status' => 'publish', + 'post_content' => 'invalid', + ) + ); + + static::$post_ids_to_cleanup[] = $font_family_id; + + $empty_settings = array( + 'name' => '', + // Slug will default to the post id. + 'slug' => (string) $font_family_id, + 'fontFamily' => '', + 'preview' => '', + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( $empty_settings, $data['font_family_settings'], 'The empty settings should exist in the font_family_settings data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item_invalid_font_family_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response should return an error with a "rest_cannot_read" code and 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response should return an error with a "rest_cannot_read" code and 403 status.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::create_item + */ + public function test_create_item() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->check_font_family_data( $data, $data['id'], $response->get_links() ); + + $reponse_settings = $data['font_family_settings']; + $this->assertSame( $settings, $reponse_settings, 'The expected settings should exist in the font_family_settings data.' ); + $this->assertEmpty( $data['font_faces'], 'The font_faces should be empty or not exist in the response data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::validate_create_font_face_request + */ + public function test_create_item_default_theme_json_version() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + static::$post_ids_to_cleanup[] = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in the response data.' ); + $this->assertSame( 2, $data['theme_json_version'], 'The default theme.json version should be 2.' ); + } + + /** + * @dataProvider data_create_item_invalid_theme_json_version + * + * @covers WP_REST_Font_Families_Controller::create_item + * + * @param int $theme_json_version Version to test. + */ + public function test_create_item_invalid_theme_json_version( $theme_json_version ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', $theme_json_version ); + $request->set_param( 'font_family_settings', wp_json_encode( self::$default_settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_invalid_theme_json_version() { + return array( + array( 1 ), + array( 3 ), + ); + } + + /** + * @dataProvider data_create_item_with_default_preview + * + * @covers WP_REST_Font_Families_Controller::sanitize_font_family_settings + * + * @param array $settings Settings to test. + */ + public function test_create_item_with_default_preview( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + static::$post_ids_to_cleanup[] = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $response_settings = $data['font_family_settings']; + $this->assertArrayHasKey( 'preview', $response_settings, 'The preview property should exist in the font_family_settings data.' ); + $this->assertSame( '', $response_settings['preview'], 'The preview data should be an empty string.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_with_default_preview() { + $default_settings = array( + 'name' => 'Open Sans', + 'slug' => 'open-sans-2', + 'fontFamily' => '"Open Sans", sans-serif', + ); + return array( + 'No preview param' => array( + 'settings' => $default_settings, + ), + 'Empty preview' => array( + 'settings' => array_merge( $default_settings, array( 'preview' => '' ) ), + ), + ); + } + + /** + * @dataProvider data_sanitize_font_family_settings + * + * @covers WP_REST_Font_Families_Controller::sanitize_font_family_settings + * + * @param string $settings Font family settings to test. + * @param string $expected Expected settings result. + */ + public function test_create_item_santize_font_family_settings( $settings, $expected ) { + $settings = array_merge( self::$default_settings, $settings ); + $expected = array_merge( self::$default_settings, $expected ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + static::$post_ids_to_cleanup[] = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertSame( $expected, $data['font_family_settings'], 'The response font_family_settings should match.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_sanitize_font_family_settings() { + return array( + 'settings with tags, extra whitespace, new lines' => array( + 'settings' => array( + 'name' => " Opening Sans\n ", + 'slug' => " OPENing SanS \n ", + 'fontFamily' => " Opening Sans\n ", + 'preview' => " https://example.com/ ", + ), + 'expected' => array( + 'name' => 'Opening Sans', + 'slug' => 'opening-sans-alertxss', + 'fontFamily' => '"Opening Sans"', + 'preview' => "https://example.com//stylescriptalert('XSS');/script%20%20%20%20%20%20", + ), + ), + 'multiword font family name with integer' => array( + 'settings' => array( + 'slug' => 'libre-barcode-128-text', + 'fontFamily' => 'Libre Barcode 128 Text', + ), + 'expected' => array( + 'slug' => 'libre-barcode-128-text', + 'fontFamily' => '"Libre Barcode 128 Text"', + ), + ), + 'multiword font family name' => array( + 'settings' => array( + 'slug' => 'b612-mono', + 'fontFamily' => 'B612 Mono', + ), + 'expected' => array( + 'slug' => 'b612-mono', + 'fontFamily' => '"B612 Mono"', + ), + ), + 'comma-separated font family names' => array( + 'settings' => array( + 'slug' => 'open-sans-noto-sans', + 'fontFamily' => 'Open Sans, Noto Sans, sans-serif', + ), + 'expected' => array( + 'slug' => 'open-sans-noto-sans', + 'fontFamily' => '"Open Sans", "Noto Sans", sans-serif', + ), + ), + ); + } + + /** + * @dataProvider data_create_item_invalid_settings + * + * @covers WP_REST_Font_Families_Controller::validate_create_font_face_settings + * + * @param array $settings Settings to test. + */ + public function test_create_item_invalid_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_invalid_settings() { + return array( + 'Missing name' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'name' => '' ) ), + ), + 'Empty name' => array( + 'settings' => array_merge( self::$default_settings, array( 'name' => '' ) ), + ), + 'Wrong name type' => array( + 'settings' => array_merge( self::$default_settings, array( 'name' => 1234 ) ), + ), + 'Missing slug' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'slug' => '' ) ), + ), + 'Empty slug' => array( + 'settings' => array_merge( self::$default_settings, array( 'slug' => '' ) ), + ), + 'Wrong slug type' => array( + 'settings' => array_merge( self::$default_settings, array( 'slug' => 1234 ) ), + ), + 'Missing fontFamily' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Empty fontFamily' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Wrong fontFamily type' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => 1234 ) ), + ), + ); + } + + /** + * @covers WP_REST_Font_Family_Controller::validate_font_family_settings + */ + public function test_create_item_invalid_settings_json() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', 'invalid' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'font_family_settings parameter must be a valid JSON string.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_family_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Family_Controller::create_item + */ + public function test_create_item_with_duplicate_slug() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( array_merge( self::$default_settings, array( 'slug' => 'helvetica' ) ) ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_duplicate_font_family', $response, 400, 'The response should return an error for "rest_duplicate_font_family" with 400 status.' ); + $expected_message = 'A font family with slug "helvetica" already exists.'; + $message = $response->as_error()->get_error_messages()[0]; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::create_item + */ + public function test_create_item_no_permission() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_create', $response, 401, 'The response should return an error for "rest_cannot_create" with 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_create', $response, 403, 'The response should return an error for "rest_cannot_create" with 403 status.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + + $settings = array( + 'name' => 'Open Sans', + 'fontFamily' => '"Open Sans, "Noto Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.9/previews/open-sans/open-sans-400-normal.svg', + ); + + $font_family_id = self::create_font_family_post( array( 'slug' => 'open-sans-2' ) ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( + 'font_family_settings', + wp_json_encode( $settings ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_family_data( $data, $font_family_id, $response->get_links() ); + + $expected_settings = array( + 'name' => $settings['name'], + 'slug' => 'open-sans-2', + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ); + $this->assertSame( $expected_settings, $data['font_family_settings'], 'The response font_family_settings should match expected settings.' ); + } + + /** + * @dataProvider data_update_item_individual_settings + * + * @covers WP_REST_Font_Families_Controller::update_item + * + * @param array $settings Settings to test. + */ + public function test_update_item_individual_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + + $font_family_id = self::create_font_family_post(); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $key = key( $settings ); + $value = current( $settings ); + $this->assertArrayHasKey( $key, $data['font_family_settings'], 'The expected key should exist in the font_family_settings data.' ); + $this->assertSame( $value, $data['font_family_settings'][ $key ], 'The font_family_settings data should match.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_update_item_individual_settings() { + return array( + array( array( 'name' => 'Opened Sans' ) ), + array( array( 'fontFamily' => '"Opened Sans", sans-serif' ) ), + array( array( 'preview' => 'https://s.w.org/images/fonts/16.7/previews/opened-sans/opened-sans-400-normal.svg' ) ), + // Empty preview is allowed. + array( array( 'preview' => '' ) ), + ); + } + + /** + * @dataProvider data_sanitize_font_family_settings + * + * @covers WP_REST_Font_Families_Controller::sanitize_font_family_settings + * + * @param string $settings Font family settings to test. + * @param string $expected Expected settings result. + */ + public function test_update_item_santize_font_family_settings( $settings, $expected ) { + // Unset/modify slug from the data provider, since we're updating rather than creating. + unset( $settings['slug'] ); + $initial_settings = array( 'slug' => 'open-sans-update' ); + $expected = array_merge( self::$default_settings, $expected, $initial_settings ); + + wp_set_current_user( self::$admin_id ); + $font_family_id = self::create_font_family_post( $initial_settings ); + static::$post_ids_to_cleanup[] = $font_family_id; + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( $expected, $data['font_family_settings'], 'The response font_family_settings should match.' ); + } + + /** + * @dataProvider data_update_item_invalid_settings + * + * @covers WP_REST_Font_Families_Controller::update_item + * + * @param array $settings Settings to test. + */ + public function test_update_item_empty_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( + 'font_family_settings', + wp_json_encode( $settings ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_update_item_invalid_settings() { + return array( + 'Empty name' => array( + array( 'name' => '' ), + ), + 'Wrong name type' => array( + array( 'name' => 1234 ), + ), + 'Empty fontFamily' => array( + array( 'fontFamily' => '' ), + ), + 'Wrong fontFamily type' => array( + array( 'fontFamily' => 1234 ), + ), + ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item_update_slug_not_allowed() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( + 'font_family_settings', + wp_json_encode( array( 'slug' => 'new-slug' ) ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'font_family_settings[slug] cannot be updated.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_family_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item_invalid_font_family_id() { + $settings = array_diff_key( self::$default_settings, array( 'slug' => '' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404, 'The response should return an error for "rest_post_invalid_id" with 404 status.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item_no_permission() { + $settings = array_diff_key( self::$default_settings, array( 'slug' => '' ) ); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401, 'The response should return an error for "rest_cannot_edit" with 401 status for an invalid user.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403, 'The response should return an error for "rest_cannot_edit" with 403 status for a user without permission.' ); + } + + + /** + * @covers WP_REST_Font_Families_Controller::delete_item + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + $font_family_id = self::create_font_family_post(); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $request['force'] = true; + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertNull( get_post( $font_family_id ), 'The post should not exist after deleting.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::delete_item + */ + public function test_delete_item_no_trash() { + wp_set_current_user( self::$admin_id ); + $font_family_id = self::create_font_family_post(); + + // Attempt trashing. + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501, 'The response should return an error for "rest_trash_not_supported" with 501 status.' ); + + $request->set_param( 'force', 'false' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501, 'When "force" is false, the response should return an error for "rest_trash_not_supported" with 501 status.' ); + + // Ensure the post still exists. + $post = get_post( $font_family_id ); + $this->assertNotEmpty( $post, 'The post should still exist.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::delete_item + */ + public function test_delete_item_invalid_font_family_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Families_Controller::delete_item + */ + public function test_delete_item_no_permissions() { + $font_family_id = self::create_font_family_post(); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 401, 'The response should return an error for "rest_cannot_delete" with 401 status for an invalid user.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403, 'The response should return an error for "rest_cannot_delete" with 403 status for a user without permission.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id2 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_family_data( $data, self::$font_family_id2, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $properties = $data['schema']['properties']; + $this->assertCount( 4, $properties, 'There should be 4 properties in the schema::properties data.' ); + $this->assertArrayHasKey( 'id', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'theme_json_version', $properties, 'The theme_json_version property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'font_faces', $properties, 'The font_faces property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'font_family_settings', $properties, 'The font_family_settings property should exist in the schema::properties data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item_schema + */ + public function test_get_item_schema_font_family_settings_should_all_have_sanitize_callbacks() { + $schema = ( new WP_REST_Font_Families_Controller( 'wp_font_family' ) )->get_item_schema(); + $font_family_settings_schema = $schema['properties']['font_family_settings']; + + $this->assertArrayHasKey( 'properties', $font_family_settings_schema, 'font_family_settings schema is missing properties.' ); + $this->assertIsArray( $font_family_settings_schema['properties'], 'font_family_settings properties should be an array.' ); + + // arg_options should be removed for each setting property. + foreach ( $font_family_settings_schema['properties'] as $property ) { + $this->assertArrayHasKey( 'arg_options', $property, 'Setting schema should have arg_options.' ); + $this->assertArrayHasKey( 'sanitize_callback', $property['arg_options'], 'Setting schema should have a sanitize_callback.' ); + $this->assertIsCallable( $property['arg_options']['sanitize_callback'], 'That sanitize_callback value should be callable.' ); + } + } + + /** + * @covers WP_REST_Font_Families_Controller::get_public_item_schema + */ + public function test_get_public_item_schema_should_not_have_arg_options() { + $schema = ( new WP_REST_Font_Families_Controller( 'wp_font_family' ) )->get_public_item_schema(); + $font_family_settings_schema = $schema['properties']['font_family_settings']; + + $this->assertArrayHasKey( 'properties', $font_family_settings_schema, 'font_family_settings schema is missing properties.' ); + $this->assertIsArray( $font_family_settings_schema['properties'], 'font_family_settings properties should be an array.' ); + + // arg_options should be removed for each setting property. + foreach ( $font_family_settings_schema['properties'] as $property ) { + $this->assertArrayNotHasKey( 'arg_options', $property, 'arg_options should be removed from the schema for each setting.' ); + } + } + + protected function check_font_family_data( $data, $post_id, $links ) { + static::$post_ids_to_cleanup[] = $post_id; + $post = get_post( $post_id ); + + $this->assertArrayHasKey( 'id', $data, 'The id property should exist in response data.' ); + $this->assertSame( $post->ID, $data['id'], 'The "id" from the response data should match the post ID.' ); + + $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in response data.' ); + $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, $data['theme_json_version'], 'The "theme_json_version" from the response data should match WP_Theme_JSON::LATEST_SCHEMA.' ); + + $font_face_ids = get_children( + array( + 'fields' => 'ids', + 'post_parent' => $post_id, + 'post_type' => 'wp_font_face', + 'order' => 'ASC', + 'orderby' => 'ID', + ) + ); + $this->assertArrayHasKey( 'font_faces', $data, 'The font_faces property should exist in the response data.' ); + + foreach ( $font_face_ids as $font_face_id ) { + $this->assertContains( $font_face_id, $data['font_faces'], 'The ID is in the font_faces data.' ); + } + + $this->assertArrayHasKey( 'font_family_settings', $data, 'The font_family_settings property should exist in the response data.' ); + $settings = $data['font_family_settings']; + $expected_settings = array( + 'name' => $post->post_title, + 'slug' => $post->post_name, + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ); + $this->assertSame( $expected_settings, $settings, 'The font_family_settings should match.' ); + + $this->assertNotEmpty( $links, 'The links should not be empty in the response data.' ); + $expected = rest_url( 'wp/v2/font-families/' . $post->ID ); + $this->assertSame( $expected, $links['self'][0]['href'], 'The links URL from the response data should match the post\'s REST endpoint.' ); + $expected = rest_url( 'wp/v2/font-families' ); + $this->assertSame( $expected, $links['collection'][0]['href'], 'The links collection URL from the response data should match the REST endpoint.' ); + + if ( ! $font_face_ids ) { + return; + } + + // Check font_face links, if present. + $this->assertArrayHasKey( 'font_faces', $links ); + foreach ( $links['font_faces'] as $index => $link ) { + $expected = rest_url( 'wp/v2/font-families/' . $post->ID . '/font-faces/' . $font_face_ids[ $index ] ); + $this->assertSame( $expected, $link['href'], 'The links for a font faces URL from the response data should match the REST endpoint.' ); + + $embeddable = isset( $link['attributes']['embeddable'] ) + ? $link['attributes']['embeddable'] + : $link['embeddable']; + $this->assertTrue( $embeddable, 'The embeddable should be true.' ); + } + } +}