Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.33% covered (warning)
78.33%
94 / 120
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Post_Handler
78.33% covered (warning)
78.33%
94 / 120
50.00% covered (danger)
50.00%
8 / 16
83.66
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_handle
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
11.39
 switch_sharing_to_block
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 switch_likes_to_block
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 remove_all_sharing_services
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
3.37
 save_settings
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 save_global_options
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 save_sharing_options
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 save_likes
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 save_comment_likes
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 save_placement
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 posted_choice
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 redirect_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 activate_module
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 render_action_field
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 render_action_form
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Handles form submissions from Settings > Sharing.
4 *
5 * @package automattic/jetpack-sharing-likes
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Sharing_Likes\Settings;
11
12use Automattic\Jetpack\Modules;
13
14/**
15 * Processes the screen's form submissions.
16 *
17 * Each section posts its own action with its own nonce, so saving one section
18 * never runs another section's handlers.
19 */
20final class Post_Handler {
21
22    /**
23     * Field naming the requested action.
24     */
25    private const ACTION_FIELD = 'jetpack_sharing_action';
26
27    /**
28     * Hook the handler up.
29     */
30    public static function init(): void {
31        add_action( 'admin_init', array( __CLASS__, 'maybe_handle' ) );
32    }
33
34    /**
35     * Dispatch a submission, if this request is one.
36     */
37    public static function maybe_handle(): void {
38        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- identifying the screen; the nonce is verified below.
39        if ( ! isset( $_GET['page'] ) || Settings_Page::SLUG !== $_GET['page'] ) {
40            return;
41        }
42
43        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified per action below.
44        if ( ! isset( $_POST[ self::ACTION_FIELD ] ) ) {
45            return;
46        }
47
48        if ( ! current_user_can( 'manage_options' ) ) {
49            return;
50        }
51
52        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified per action below.
53        $action = sanitize_key( wp_unslash( $_POST[ self::ACTION_FIELD ] ) );
54
55        $redirect = null;
56
57        switch ( $action ) {
58            case 'activate-likes':
59                $redirect = self::activate_module( 'likes', Likes_Section::NONCE_ACTION );
60                break;
61            case 'activate-sharing':
62                $redirect = self::activate_module( 'sharedaddy', Sharing_Section::NONCE_ACTION );
63                break;
64            case 'switch-to-block-likes':
65                $redirect = self::switch_likes_to_block();
66                break;
67            case 'switch-to-block-sharing':
68                $redirect = self::switch_sharing_to_block();
69                break;
70            case 'save-settings':
71                $redirect = self::save_settings();
72                break;
73        }
74
75        if ( null === $redirect ) {
76            return;
77        }
78
79        wp_safe_redirect( $redirect );
80        exit;
81    }
82
83    /**
84     * Stop producing legacy sharing buttons, so the block can take over.
85     *
86     * This is a migration, not the section's off switch: it is what the Jetpack
87     * dashboard's "Switch to the â€¦ block" button does, and it leaves the block
88     * itself untouched. Simple has no module to deactivate, so it removes every
89     * service instead, which the services list can undo.
90     *
91     * @return string URL to send the browser back to.
92     */
93    private static function switch_sharing_to_block(): string {
94        check_admin_referer( Sharing_Section::NONCE_ACTION );
95
96        if ( Environment::is_simple_site() ) {
97            self::remove_all_sharing_services();
98        } else {
99            ( new Modules() )->deactivate( 'sharedaddy' );
100        }
101
102        return self::redirect_url( true );
103    }
104
105    /**
106     * The Like buttons counterpart of `switch_sharing_to_block()`.
107     *
108     * On Simple it turns off Likes and Reblogs for every post, since the legacy
109     * widget renders for either. Posts that opted in individually keep their
110     * buttons, and Comment Likes has no block to move to, so it is left alone.
111     *
112     * @return string URL to send the browser back to.
113     */
114    private static function switch_likes_to_block(): string {
115        check_admin_referer( Likes_Section::NONCE_ACTION );
116
117        if ( Environment::is_simple_site() ) {
118            update_option( 'disabled_likes', 1 );
119            update_option( 'disabled_reblogs', 1 );
120        } else {
121            ( new Modules() )->deactivate( 'likes' );
122        }
123
124        return self::redirect_url( true );
125    }
126
127    /**
128     * Leave sharedaddy no services to render.
129     */
130    private static function remove_all_sharing_services(): void {
131        // Preferred over writing the option, because wpcom hooks the state change it announces.
132        if ( class_exists( 'Sharing_Service' ) ) {
133            ( new \Sharing_Service() )->set_blog_services( array(), array() );
134            return;
135        }
136
137        update_option(
138            'sharing-services',
139            array(
140                'visible' => array(),
141                'hidden'  => array(),
142            )
143        );
144    }
145
146    /**
147     * Save every section that put fields on the form.
148     *
149     * Only those: the others' fields were not on the screen, and reading their
150     * absence as "off" would switch them off.
151     *
152     * @return string URL to send the browser back to.
153     */
154    private static function save_settings(): string {
155        check_admin_referer( Settings_Form::NONCE_ACTION );
156
157        $sections           = Settings_Form::posted_sections();
158        $comment_likes_held = true;
159
160        // Before placement, because the services save rebuilds the global options it lives in.
161        if ( in_array( Settings_Form::SECTION_SHARING, $sections, true ) ) {
162            self::save_sharing_options();
163        }
164
165        if ( in_array( Settings_Form::SECTION_PLACEMENT, $sections, true ) ) {
166            self::save_placement();
167        }
168
169        if ( in_array( Settings_Form::SECTION_LIKES, $sections, true ) ) {
170            self::save_likes();
171        }
172
173        if ( in_array( Settings_Form::SECTION_COMMENT_LIKES, $sections, true ) && Environment::likes_supported() ) {
174            $comment_likes_held = self::save_comment_likes();
175        }
176
177        // Once, from whichever section rendered `Services_Config::global_options()`; never both.
178        if ( array_intersect( array( Settings_Form::SECTION_SHARING, Settings_Form::SECTION_EXTRAS ), $sections ) ) {
179            self::save_global_options( $sections );
180        }
181
182        return $comment_likes_held
183            ? self::redirect_url( true )
184            : add_query_arg( Settings_Page::COMMENT_LIKES_UNCHANGED, '1', self::redirect_url( true ) );
185    }
186
187    /**
188     * Save the rows that close the settings table, ours and then third parties'.
189     *
190     * @param string[] $sections Sections the submitted form carried fields for.
191     */
192    private static function save_global_options( array $sections ): void {
193        // Only the services section renders it, and `is_available()` can have turned true
194        // since the form was built, so the claim decides rather than the environment.
195        if ( in_array( Settings_Form::SECTION_SHARING, $sections, true ) ) {
196            Sharing_Resources::save();
197        }
198
199        Twitter_Site_Tag::save();
200
201        /** This action is documented in projects/packages/sharing-likes/src/settings/class-services-config.php */
202        do_action( 'sharing_admin_update' );
203    }
204
205    /**
206     * Save the services list's own settings: button style and label.
207     */
208    private static function save_sharing_options(): void {
209        // The section renders only when this class is loaded, but the request can claim it regardless.
210        if ( ! class_exists( 'Sharing_Service' ) ) {
211            return;
212        }
213
214        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- verified by the caller; set_global_options() validates each field.
215        $data = $_POST;
216
217        // set_global_options() rebuilds the global array from defaults, so a payload with no `show` would clear placement.
218        if ( ! isset( $data['show'] ) ) {
219            $data['show'] = Placement_Section::selected_post_types();
220        }
221
222        ( new \Sharing_Service() )->set_global_options( $data );
223    }
224
225    /**
226     * Save the Like buttons settings.
227     */
228    private static function save_likes(): void {
229        if ( 'off' === self::posted_choice( 'wpl_default' ) ) {
230            update_option( 'disabled_likes', 1 );
231        } else {
232            delete_option( 'disabled_likes' );
233        }
234
235        if ( Environment::is_simple_site() ) {
236            if ( 'off' === self::posted_choice( 'jetpack_reblogs_enabled' ) ) {
237                update_option( 'disabled_reblogs', 1 );
238            } else {
239                delete_option( 'disabled_reblogs' );
240            }
241        }
242    }
243
244    /**
245     * Save the Comment Likes checkbox: the option on Simple, the module on Atomic and Jetpack sites.
246     *
247     * @return bool Whether Comment Likes now match the checkbox.
248     */
249    private static function save_comment_likes(): bool {
250        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by the caller.
251        $enabled = ! empty( $_POST['jetpack_comment_likes_enabled'] );
252
253        if ( Environment::is_simple_site() ) {
254            update_option( 'jetpack_comment_likes_enabled', $enabled ? 1 : 0 );
255            return true;
256        }
257
258        // `deactivate()` fires its hooks even when the module was already off.
259        if ( Environment::comment_likes_enabled() === $enabled ) {
260            return true;
261        }
262
263        if ( $enabled ) {
264            ( new Modules() )->activate( 'comment-likes', false, false );
265        } else {
266            ( new Modules() )->deactivate( 'comment-likes' );
267        }
268
269        // A host can force the module either way, and activation needs a connected owner.
270        return Environment::comment_likes_enabled() === $enabled;
271    }
272
273    /**
274     * Save where the buttons appear.
275     */
276    private static function save_placement(): void {
277        $options = get_option( 'sharing-options' );
278        if ( ! is_array( $options ) ) {
279            $options = array();
280        }
281
282        // Sites carry a malformed `global` (see #6121), and writing into it in place
283        // would fatal where the services save, which rebuilds it wholesale, does not.
284        if ( ! isset( $options['global'] ) || ! is_array( $options['global'] ) ) {
285            $options['global'] = array();
286        }
287
288        $allowed   = array_values( get_post_types( array( 'public' => true ) ) );
289        $allowed[] = 'index';
290
291        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- verified by the caller; the values are checked against an allowlist below.
292        $posted = isset( $_POST['show'] ) && is_array( $_POST['show'] ) ? wp_unslash( $_POST['show'] ) : array();
293        $posted = array_filter( $posted, 'is_scalar' );
294
295        $options['global']['show'] = array_values( array_intersect( $posted, $allowed ) );
296
297        update_option( 'sharing-options', $options );
298    }
299
300    /**
301     * One of a radio group's values, defaulting to "on" when nothing was posted.
302     *
303     * @param string $field Field name.
304     */
305    private static function posted_choice( string $field ): string {
306        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- callers verify before reading.
307        if ( empty( $_POST[ $field ] ) ) {
308            return 'on';
309        }
310
311        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- callers verify before reading.
312        return sanitize_text_field( wp_unslash( $_POST[ $field ] ) );
313    }
314
315    /**
316     * Where to send the browser once a submission is handled.
317     *
318     * @param bool $show_saved_notice Whether the screen should confirm a save.
319     */
320    private static function redirect_url( bool $show_saved_notice ): string {
321        $url = admin_url( 'options-general.php?page=' . Settings_Page::SLUG );
322
323        return $show_saved_notice ? $url . '&update=saved' : $url;
324    }
325
326    /**
327     * Turn a module back on, then reload the screen.
328     *
329     * Reached only from the OFF variant, where no block route exists and nothing
330     * else on the site will bring the feature back. Sites that can use the block
331     * are not offered it, matching the Jetpack dashboard.
332     *
333     * @param string $module       Module slug.
334     * @param string $nonce_action Nonce action the submitting section uses.
335     * @return string URL to send the browser back to.
336     */
337    private static function activate_module( string $module, string $nonce_action ): string {
338        check_admin_referer( $nonce_action );
339
340        ( new Modules() )->activate( $module, false, false );
341
342        return self::redirect_url( false );
343    }
344
345    /**
346     * Hidden field naming the action a form is submitting.
347     *
348     * @param string $action Action name, matching a case above.
349     */
350    public static function render_action_field( string $action ): void {
351        printf(
352            '<input type="hidden" name="%1$s" value="%2$s" />',
353            esc_attr( self::ACTION_FIELD ),
354            esc_attr( $action )
355        );
356    }
357
358    /**
359     * Markup for a single-button form submitting one of the actions above.
360     *
361     * @param string $action       Action name, matching a case above.
362     * @param string $nonce_action Nonce action for the submitting section.
363     * @param string $label        Button label.
364     * @param bool   $primary      Whether this is the only action in its state.
365     */
366    public static function render_action_form( string $action, string $nonce_action, string $label, bool $primary = true ): void {
367        ?>
368        <form method="post" action="">
369            <input type="hidden" name="<?php echo esc_attr( self::ACTION_FIELD ); ?>" value="<?php echo esc_attr( $action ); ?>" />
370            <?php wp_nonce_field( $nonce_action ); ?>
371            <p><button type="submit" class="<?php echo esc_attr( $primary ? 'button button-primary' : 'button' ); ?>"><?php echo esc_html( $label ); ?></button></p>
372        </form>
373        <?php
374    }
375}