Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
VisualEditorHooks
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 5
702
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onVisualEditorApiVisualEditorEditPreSave
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 onVisualEditorApiVisualEditorEditPostSave
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 onAPIGetAllowedParams
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 getDataFromApiRequest
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace GrowthExperiments;
4
5use ApiBase;
6use GrowthExperiments\HomepageModules\SuggestedEdits;
7use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
8use GrowthExperiments\NewcomerTasks\TaskType\StructuredTaskTypeHandler;
9use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
10use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandler;
11use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandlerRegistry;
12use MediaWiki\Api\Hook\APIGetAllowedParamsHook;
13use MediaWiki\Extension\VisualEditor\ApiVisualEditorEdit;
14use MediaWiki\Extension\VisualEditor\VisualEditorApiVisualEditorEditPostSaveHook;
15use MediaWiki\Extension\VisualEditor\VisualEditorApiVisualEditorEditPreSaveHook;
16use MediaWiki\Page\ProperPageIdentity;
17use MediaWiki\Status\Status;
18use MediaWiki\Title\TitleFactory;
19use MediaWiki\User\UserIdentity;
20use MediaWiki\User\UserIdentityUtils;
21use OutOfBoundsException;
22use PrefixingStatsdDataFactoryProxy;
23use UnexpectedValueException;
24
25/**
26 * Hook handlers for hooks defined in VisualEditor.
27 */
28class VisualEditorHooks implements
29    APIGetAllowedParamsHook,
30    VisualEditorApiVisualEditorEditPreSaveHook,
31    VisualEditorApiVisualEditorEditPostSaveHook
32{
33
34    /** Prefix used for the VisualEditor API's plugin parameter. */
35    public const PLUGIN_PREFIX = 'ge-task-';
36
37    /** @var TitleFactory */
38    private $titleFactory;
39    /** @var ConfigurationLoader */
40    private $configurationLoader;
41    /** @var TaskTypeHandlerRegistry */
42    private $taskTypeHandlerRegistry;
43    /** @var PrefixingStatsdDataFactoryProxy */
44    private $perDbNameStatsdDataFactory;
45    /** @var UserIdentityUtils */
46    private $userIdentityUtils;
47
48    /**
49     * @param TitleFactory $titleFactory
50     * @param ConfigurationLoader $configurationLoader
51     * @param TaskTypeHandlerRegistry $taskTypeHandlerRegistry
52     * @param PrefixingStatsdDataFactoryProxy $perDbNameStatsdDataFactory
53     * @param UserIdentityUtils $userIdentityUtils
54     */
55    public function __construct(
56        TitleFactory $titleFactory,
57        ConfigurationLoader $configurationLoader,
58        TaskTypeHandlerRegistry $taskTypeHandlerRegistry,
59        PrefixingStatsdDataFactoryProxy $perDbNameStatsdDataFactory,
60        UserIdentityUtils $userIdentityUtils
61    ) {
62        $this->titleFactory = $titleFactory;
63        $this->configurationLoader = $configurationLoader;
64        $this->taskTypeHandlerRegistry = $taskTypeHandlerRegistry;
65        $this->perDbNameStatsdDataFactory = $perDbNameStatsdDataFactory;
66        $this->userIdentityUtils = $userIdentityUtils;
67    }
68
69    /** @inheritDoc */
70    public function onVisualEditorApiVisualEditorEditPreSave(
71        ProperPageIdentity $page,
72        UserIdentity $user,
73        string $wikitext,
74        array &$params,
75        array $pluginData,
76        array &$apiResponse
77    ) {
78        // This is going to run on every edit and not in a deferred update, so at least filter
79        // by authenticated users to make this slightly faster for anons.
80        if ( !$this->userIdentityUtils->isNamed( $user ) ) {
81            return;
82        }
83        /** @var ?TaskTypeHandler $taskTypeHandler */
84        [ $data, $taskTypeHandler, $taskType ] = $this->getDataFromApiRequest( $pluginData );
85        if ( !$data || !$taskType ) {
86            // Not an edit we are interested in looking at.
87            return;
88        }
89        $title = $this->titleFactory->castFromPageIdentity( $page );
90        if ( !$title ) {
91            // Something weird has happened; let the save attempt go through because
92            // presumably later an exception will be thrown and that can be dealt
93            // with by VisualEditor.
94            return;
95        }
96        $status = $taskTypeHandler->getSubmissionHandler()->validate(
97            $taskType,
98            $title->toPageIdentity(),
99            $user,
100            $params['oldid'],
101            $data
102        );
103        if ( !$status->isGood() ) {
104            $message = Status::wrap( $status )->getMessage();
105            $apiResponse['message'] = array_merge( [ $message->getKey() ], $message->getParams() );
106            Util::logStatus( $status );
107            return false;
108        }
109    }
110
111    /** @inheritDoc */
112    public function onVisualEditorApiVisualEditorEditPostSave(
113        ProperPageIdentity $page,
114        UserIdentity $user,
115        string $wikitext,
116        array $params,
117        array $pluginData,
118        array $saveResult,
119        array &$apiResponse
120    ): void {
121        if ( $apiResponse['result'] !== 'success' ) {
122            return;
123        }
124        // This is going to run on every edit and not in a deferred update, so at least filter
125        // by authenticated users to make this slightly faster for anons.
126        if ( !$this->userIdentityUtils->isNamed( $user ) ) {
127            return;
128        }
129        /** @var ?TaskTypeHandler $taskTypeHandler */
130        /** @var ?TaskType $taskType */
131        [ $data, $taskTypeHandler, $taskType ] = $this->getDataFromApiRequest( $pluginData );
132        if ( !$data || !$taskType ) {
133            return;
134        }
135        $title = $this->titleFactory->castFromPageIdentity( $page );
136        if ( !$title ) {
137            throw new UnexpectedValueException( 'Unable to get Title from PageIdentity' );
138        }
139
140        $newRevId = $saveResult['edit']['newrevid'] ?? null;
141
142        $status = $taskTypeHandler->getSubmissionHandler()->handle(
143            $taskType,
144            $title->toPageIdentity(),
145            $user,
146            $params['oldid'],
147            $newRevId,
148            $data
149        );
150        if ( $status->isGood() ) {
151            $apiResponse['gelogid'] = $status->getValue()['logId'] ?? null;
152            $apiResponse['gewarnings'][] = $status->getValue()['warnings'] ?? '';
153            if ( $newRevId ) {
154                $this->perDbNameStatsdDataFactory->increment(
155                    'GrowthExperiments.NewcomerTask.' . $taskType->getId() . '.Save'
156                );
157            }
158        } else {
159            // FIXME expose error formatter to hook so this can be handled better
160            $errorMessage = Status::wrap( $status )->getWikiText();
161            $apiResponse['errors'][] = $errorMessage;
162            Util::logStatus( $status );
163        }
164    }
165
166    /** @inheritDoc */
167    public function onAPIGetAllowedParams( $module, &$params, $flags ) {
168        if ( $module instanceof ApiVisualEditorEdit
169            && ( $flags & ApiBase::GET_VALUES_FOR_HELP )
170            && SuggestedEdits::isEnabled( $module->getContext()->getConfig() )
171        ) {
172            $taskTypes = $this->configurationLoader->getTaskTypes();
173            foreach ( $taskTypes as $taskTypeId => $taskType ) {
174                $taskTypeHandler = $this->taskTypeHandlerRegistry->getByTaskType( $taskType );
175                if ( $taskTypeHandler instanceof StructuredTaskTypeHandler ) {
176                    $paramValue = self::PLUGIN_PREFIX . $taskTypeId;
177                    $params['plugins'][ApiBase::PARAM_HELP_MSG_PER_VALUE][$paramValue] = [
178                            "apihelp-visualeditoredit-paramvalue-plugins-$paramValue",
179                    ];
180                    $params['data-{plugin}'][ApiBase::PARAM_HELP_MSG_APPEND][$paramValue] = [
181                        "apihelp-visualeditoredit-append-data-plugin-$paramValue",
182                        $taskTypeHandler->getSubmitDataFormatMessage( $taskType, $module->getContext() ),
183                    ];
184                }
185            }
186        }
187    }
188
189    /**
190     * Extract the data sent by the frontend structured task logic from the API request.
191     * @param array $pluginData
192     * @return array [ JSON data from frontend, TaskTypeHandler, task type ID ] or [ null, null, null ]
193     * @phan-return array{0:?array,1:?TaskTypeHandler,2:?TaskType}
194     */
195    private function getDataFromApiRequest( array $pluginData ): array {
196        // Fast-track the common case of a non-Growth-related save - getTaskTypes() is not free.
197        if ( !$pluginData ) {
198            return [ null, null, null ];
199        }
200
201        $taskTypes = $this->configurationLoader->getTaskTypes();
202        foreach ( $taskTypes as $taskTypeId => $taskType ) {
203            $data = $pluginData[ self::PLUGIN_PREFIX . $taskTypeId ] ?? null;
204            if ( $data ) {
205                $data = json_decode( $data, true ) ?? [];
206                try {
207                    $taskTypeHandler = $this->taskTypeHandlerRegistry->getByTaskType( $taskType );
208                } catch ( OutOfBoundsException $e ) {
209                    // Probably some sort of hand-crafted fake API request. Ignore it.
210                    continue;
211                }
212
213                return [ $data, $taskTypeHandler, $taskType ];
214            }
215        }
216        return [ null, null, null ];
217    }
218
219}