Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.12% covered (warning)
87.12%
142 / 163
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Author_Schema_Node
87.12% covered (warning)
87.12%
142 / 163
64.29% covered (warning)
64.29%
9 / 14
59.01
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
 build_person
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
11
 build_profile_page
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 render_profile_fields
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 save_profile_fields
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 get_profile
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 update_profile
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
10
 is_wpcom_profile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 save_job_title
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 save_same_as
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 sanitize_url_list
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_user
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 text
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Author Person / ProfilePage Schema.org node builder.
4 *
5 * @package automattic/jetpack-seo-package
6 */
7
8namespace Automattic\Jetpack\SEO;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Status\Host;
12use WP_Error;
13use WP_User;
14
15/**
16 * Builds author Person / ProfilePage nodes and owns the extra author profile meta.
17 */
18class Author_Schema_Node {
19
20    /**
21     * User meta key for the author's job title.
22     */
23    const META_JOB_TITLE = 'jetpack_seo_job_title';
24
25    /**
26     * User meta key for profile links emitted as Person `sameAs`.
27     */
28    const META_SAME_AS = 'jetpack_seo_same_as';
29
30    /**
31     * Wire the WP user-profile fields.
32     *
33     * @return void
34     */
35    public static function init() {
36        register_meta(
37            'user',
38            self::META_JOB_TITLE,
39            array(
40                'type'              => 'string',
41                'single'            => true,
42                'default'           => '',
43                'sanitize_callback' => 'sanitize_text_field',
44                'show_in_rest'      => true,
45            )
46        );
47        register_meta(
48            'user',
49            self::META_SAME_AS,
50            array(
51                'type'              => 'array',
52                'single'            => true,
53                'default'           => array(),
54                'sanitize_callback' => array( __CLASS__, 'sanitize_url_list' ),
55                'show_in_rest'      => array(
56                    'schema' => array(
57                        'type'  => 'array',
58                        'items' => array(
59                            'type'   => 'string',
60                            'format' => 'uri',
61                        ),
62                    ),
63                ),
64            )
65        );
66
67        add_action( 'show_user_profile', array( __CLASS__, 'render_profile_fields' ) );
68        add_action( 'edit_user_profile', array( __CLASS__, 'render_profile_fields' ) );
69        add_action( 'personal_options_update', array( __CLASS__, 'save_profile_fields' ) );
70        add_action( 'edit_user_profile_update', array( __CLASS__, 'save_profile_fields' ) );
71    }
72
73    /**
74     * Build a Person node for a WP user.
75     *
76     * @param WP_User|int|null $user User object or ID.
77     * @return array|null
78     */
79    public static function build_person( $user ) {
80        $user = self::get_user( $user );
81        if ( ! $user ) {
82            return null;
83        }
84
85        $name = self::text( $user->display_name );
86        if ( '' === $name ) {
87            return null;
88        }
89
90        $node = array(
91            '@type' => 'Person',
92            '@id'   => Schema_Node_Ids::person( $user->ID, $user->user_nicename ),
93            'name'  => $name,
94        );
95
96        $image = get_avatar_url( $user->ID, array( 'size' => 512 ) );
97        if ( is_string( $image ) && '' !== $image ) {
98            $node['image'] = $image;
99        }
100
101        $description = self::text( get_the_author_meta( 'description', $user->ID ) );
102        if ( '' !== $description ) {
103            $node['description'] = $description;
104        }
105
106        // Prefer the user's Website field; fall back to the author archive so the
107        // Person always carries a URL identifying the author.
108        $url         = self::url( $user->user_url );
109        $node['url'] = '' !== $url ? $url : get_author_posts_url( $user->ID, $user->user_nicename );
110
111        $given_name = self::text( get_the_author_meta( 'first_name', $user->ID ) );
112        if ( '' !== $given_name ) {
113            $node['givenName'] = $given_name;
114        }
115
116        $family_name = self::text( get_the_author_meta( 'last_name', $user->ID ) );
117        if ( '' !== $family_name ) {
118            $node['familyName'] = $family_name;
119        }
120
121        $job_title = self::text( get_user_meta( $user->ID, self::META_JOB_TITLE, true ) );
122        if ( '' !== $job_title ) {
123            $node['jobTitle'] = $job_title;
124        }
125
126        $same_as = self::sanitize_url_list( get_user_meta( $user->ID, self::META_SAME_AS, true ) );
127        if ( ! empty( $same_as ) ) {
128            $node['sameAs'] = $same_as;
129        }
130
131        return $node;
132    }
133
134    /**
135     * Build a ProfilePage node for an author archive.
136     *
137     * @param WP_User|int|null $user User object or ID.
138     * @return array|null
139     */
140    public static function build_profile_page( $user ) {
141        $user = self::get_user( $user );
142        if ( ! $user || '' === self::text( $user->display_name ) ) {
143            return null;
144        }
145
146        return array(
147            '@type'      => 'ProfilePage',
148            '@id'        => Schema_Node_Ids::profile_page( $user->ID, $user->user_nicename ),
149            'url'        => get_author_posts_url( $user->ID, $user->user_nicename ),
150            'mainEntity' => array( '@id' => Schema_Node_Ids::person( $user->ID, $user->user_nicename ) ),
151        );
152    }
153
154    /**
155     * Render the Jetpack SEO author profile fields.
156     *
157     * @param WP_User $user User being edited.
158     * @return void
159     */
160    public static function render_profile_fields( WP_User $user ) {
161        $job_field   = self::META_JOB_TITLE;
162        $links_field = self::META_SAME_AS;
163        $job_title   = get_user_meta( $user->ID, $job_field, true );
164        $same_as     = self::sanitize_url_list( get_user_meta( $user->ID, $links_field, true ) );
165
166        printf(
167            '<h2>%1$s</h2>
168            <table class="form-table" role="presentation">
169                <tr>
170                    <th><label for="%2$s">%3$s</label></th>
171                    <td><input type="text" class="regular-text" name="%2$s" id="%2$s" value="%4$s" /></td>
172                </tr>
173                <tr>
174                    <th><label for="%5$s">%6$s</label></th>
175                    <td>
176                        <textarea class="large-text code" rows="5" name="%5$s" id="%5$s">%7$s</textarea>
177                        <p class="description">%8$s</p>
178                    </td>
179                </tr>
180            </table>',
181            esc_html__( 'Jetpack SEO author schema', 'jetpack-seo' ),
182            esc_attr( $job_field ),
183            esc_html__( 'Job title', 'jetpack-seo' ),
184            esc_attr( $job_title ),
185            esc_attr( $links_field ),
186            esc_html__( 'Profile links', 'jetpack-seo' ),
187            esc_textarea( implode( "\n", $same_as ) ),
188            esc_html__( 'One public profile URL per line.', 'jetpack-seo' )
189        );
190    }
191
192    /**
193     * Save the Jetpack SEO author profile fields.
194     *
195     * @param int $user_id User ID.
196     * @return void
197     */
198    public static function save_profile_fields( $user_id ) {
199        if ( ! current_user_can( 'edit_user', $user_id ) ) {
200            return;
201        }
202
203        $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
204        if ( ! wp_verify_nonce( $nonce, 'update-user_' . $user_id ) ) {
205            return;
206        }
207
208        self::save_job_title(
209            $user_id,
210            isset( $_POST[ self::META_JOB_TITLE ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::META_JOB_TITLE ] ) ) : ''
211        );
212        self::save_same_as(
213            $user_id,
214            isset( $_POST[ self::META_SAME_AS ] )
215                ? preg_split( '/\r\n|\r|\n/', sanitize_textarea_field( wp_unslash( $_POST[ self::META_SAME_AS ] ) ) )
216                : array()
217        );
218    }
219
220    /**
221     * The author profile of a user, as the Settings Author profile card edits it.
222     * `accountProfileUrl` is where name, bio and website are edited when WordPress.com owns them.
223     *
224     * @param int $user_id User ID.
225     * @return array{name: string, description: string, url: string, jobTitle: string, sameAs: array<int, string>, avatarUrl: string, accountProfileUrl: string}
226     */
227    public static function get_profile( $user_id ) {
228        $user = get_userdata( $user_id );
229        return array(
230            'name'              => $user ? (string) $user->display_name : '',
231            'description'       => $user ? (string) $user->description : '',
232            'url'               => $user ? (string) $user->user_url : '',
233            'jobTitle'          => (string) get_user_meta( $user_id, self::META_JOB_TITLE, true ),
234            'sameAs'            => self::sanitize_url_list( get_user_meta( $user_id, self::META_SAME_AS, true ) ),
235            'avatarUrl'         => (string) get_avatar_url( $user_id, array( 'size' => 96 ) ),
236            'accountProfileUrl' => self::is_wpcom_profile( $user_id ) ? 'https://my.wordpress.com/me/account' : '',
237        );
238    }
239
240    /**
241     * Update a user's author profile. Omitted fields keep their stored values, and
242     * name, bio and website are left alone where WordPress.com owns them.
243     *
244     * @param int   $user_id User ID.
245     * @param array $fields  Any of `name`, `description`, `url`, `jobTitle` and `sameAs`.
246     * @return array|WP_Error The stored profile, or the error that stopped the update.
247     */
248    public static function update_profile( $user_id, array $fields ) {
249        if ( self::is_wpcom_profile( $user_id ) ) {
250            unset( $fields['name'], $fields['description'], $fields['url'] );
251        }
252
253        $userdata = array( 'ID' => $user_id );
254        if ( isset( $fields['name'] ) ) {
255            $userdata['display_name'] = sanitize_text_field( (string) $fields['name'] );
256            if ( '' === $userdata['display_name'] ) {
257                return new WP_Error(
258                    'jetpack_seo_author_name_empty',
259                    __( 'Author name cannot be empty.', 'jetpack-seo' ),
260                    array( 'status' => 400 )
261                );
262            }
263        }
264        if ( isset( $fields['description'] ) ) {
265            $userdata['description'] = (string) $fields['description'];
266        }
267        if ( isset( $fields['url'] ) ) {
268            $userdata['user_url'] = esc_url_raw( trim( (string) $fields['url'] ) );
269        }
270        if ( count( $userdata ) > 1 ) {
271            // wp_update_user() and update_user_meta() unslash their input, so slash it as core's users route does.
272            $result = wp_update_user( wp_slash( $userdata ) );
273            if ( is_wp_error( $result ) ) {
274                return $result;
275            }
276        }
277
278        if ( isset( $fields['jobTitle'] ) ) {
279            self::save_job_title( $user_id, $fields['jobTitle'] );
280        }
281        if ( isset( $fields['sameAs'] ) ) {
282            self::save_same_as( $user_id, $fields['sameAs'] );
283        }
284
285        return self::get_profile( $user_id );
286    }
287
288    /**
289     * Whether name, bio and website belong to the user's WordPress.com account: on Simple
290     * they're shared by all the user's sites, and on Atomic WordPress.com pushes them over local edits.
291     *
292     * @param int $user_id User ID.
293     * @return bool
294     */
295    private static function is_wpcom_profile( $user_id ) {
296        $host = new Host();
297        return $host->is_wpcom_simple()
298            || ( $host->is_woa_site() && ( new Connection_Manager() )->is_user_connected( $user_id ) );
299    }
300
301    /**
302     * Store a user's job title, deleting the meta when it's empty.
303     *
304     * @param int   $user_id User ID.
305     * @param mixed $value   Raw job title.
306     * @return void
307     */
308    private static function save_job_title( $user_id, $value ) {
309        $job_title = self::text( is_scalar( $value ) ? sanitize_text_field( (string) $value ) : '' );
310        if ( '' === $job_title ) {
311            delete_user_meta( $user_id, self::META_JOB_TITLE );
312        } else {
313            update_user_meta( $user_id, self::META_JOB_TITLE, wp_slash( $job_title ) );
314        }
315    }
316
317    /**
318     * Store a user's profile links, deleting the meta when none are valid.
319     *
320     * @param int   $user_id User ID.
321     * @param mixed $value   Raw list of URLs.
322     * @return void
323     */
324    private static function save_same_as( $user_id, $value ) {
325        $same_as = self::sanitize_url_list( $value );
326        if ( empty( $same_as ) ) {
327            delete_user_meta( $user_id, self::META_SAME_AS );
328        } else {
329            update_user_meta( $user_id, self::META_SAME_AS, wp_slash( $same_as ) );
330        }
331    }
332
333    /**
334     * Normalize a list of URLs: keep unique absolute http(s) URLs. Delegates to
335     * the shared {@see Schema_Settings::sanitize_url_list()} sanitizer.
336     *
337     * @param mixed $value Raw value.
338     * @return array<int, string>
339     */
340    public static function sanitize_url_list( $value ) {
341        return Schema_Settings::sanitize_url_list( $value );
342    }
343
344    /**
345     * Resolve a WP user.
346     *
347     * @param WP_User|int|null $user User object or ID.
348     * @return WP_User|null
349     */
350    private static function get_user( $user ) {
351        if ( $user instanceof WP_User ) {
352            return $user;
353        }
354
355        // Plugins such as PublishPress Authors replace the author archive's queried
356        // object with their own class; casting that to int silently yields user 1.
357        if ( ! is_numeric( $user ) ) {
358            return null;
359        }
360
361        $user = get_userdata( (int) $user );
362        return $user instanceof WP_User ? $user : null;
363    }
364
365    /**
366     * Normalize a scalar value to trimmed plain text.
367     *
368     * @param mixed $value Raw value.
369     * @return string
370     */
371    private static function text( $value ) {
372        if ( ! is_scalar( $value ) ) {
373            return '';
374        }
375        return trim( wp_strip_all_tags( (string) $value ) );
376    }
377
378    /**
379     * Normalize an absolute http(s) URL.
380     *
381     * @param mixed $value Raw value.
382     * @return string
383     */
384    private static function url( $value ) {
385        $list = Schema_Settings::sanitize_url_list( array( $value ) );
386        return $list[0] ?? '';
387    }
388}