Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 173
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiDiscussionToolsEdit
0.00% covered (danger)
0.00%
0 / 173
0.00% covered (danger)
0.00%
0 / 6
2652
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 161
0.00% covered (danger)
0.00%
0 / 1
2162
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 needsToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isWriteMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use ApiBase;
6use ApiMain;
7use Config;
8use ConfigFactory;
9use DerivativeContext;
10use DerivativeRequest;
11use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
12use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
13use MediaWiki\Extension\VisualEditor\ApiParsoidTrait;
14use MediaWiki\Extension\VisualEditor\VisualEditorParsoidClientFactory;
15use MediaWiki\Logger\LoggerFactory;
16use MediaWiki\Revision\RevisionLookup;
17use SkinFactory;
18use Title;
19use Wikimedia\ParamValidator\ParamValidator;
20use Wikimedia\ParamValidator\TypeDef\StringDef;
21use Wikimedia\Parsoid\Utils\DOMCompat;
22use Wikimedia\Parsoid\Utils\DOMUtils;
23
24class ApiDiscussionToolsEdit extends ApiBase {
25    use ApiDiscussionToolsTrait;
26    use ApiParsoidTrait;
27
28    private CommentParser $commentParser;
29    private VisualEditorParsoidClientFactory $parsoidClientFactory;
30    private SubscriptionStore $subscriptionStore;
31    private SkinFactory $skinFactory;
32    private Config $config;
33    private RevisionLookup $revisionLookup;
34
35    public function __construct(
36        ApiMain $main,
37        string $name,
38        VisualEditorParsoidClientFactory $parsoidClientFactory,
39        CommentParser $commentParser,
40        SubscriptionStore $subscriptionStore,
41        SkinFactory $skinFactory,
42        ConfigFactory $configFactory,
43        RevisionLookup $revisionLookup
44    ) {
45        parent::__construct( $main, $name );
46        $this->parsoidClientFactory = $parsoidClientFactory;
47        $this->commentParser = $commentParser;
48        $this->subscriptionStore = $subscriptionStore;
49        $this->skinFactory = $skinFactory;
50        $this->config = $configFactory->makeConfig( 'discussiontools' );
51        $this->revisionLookup = $revisionLookup;
52        $this->setLogger( LoggerFactory::getInstance( 'DiscussionTools' ) );
53    }
54
55    /**
56     * @inheritDoc
57     */
58    public function execute() {
59        $params = $this->extractRequestParams();
60        $title = Title::newFromText( $params['page'] );
61        $result = null;
62
63        $autoSubscribe =
64            $this->config->get( 'DiscussionToolsAutoTopicSubEditor' ) === 'discussiontoolsapi' &&
65            HookUtils::shouldAddAutoSubscription( $this->getUser(), $title );
66        $subscribableHeadingName = null;
67        $subscribableSectionTitle = '';
68
69        if ( !$title ) {
70            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
71        }
72
73        $this->getErrorFormatter()->setContextTitle( $title );
74
75        $session = null;
76        $usedFormTokensKey = 'DiscussionTools:usedFormTokens';
77        $formToken = $params['formtoken'];
78        if ( $formToken ) {
79            $session = $this->getContext()->getRequest()->getSession();
80            $usedFormTokens = $session->get( $usedFormTokensKey ) ?? [];
81            if ( in_array( $formToken, $usedFormTokens ) ) {
82                $this->dieWithError( [ 'apierror-discussiontools-formtoken-used' ] );
83            }
84        }
85
86        $this->requireOnlyOneParameter( $params, 'wikitext', 'html' );
87        if ( $params['paction'] === 'addtopic' ) {
88            $this->requireAtLeastOneParameter( $params, 'sectiontitle' );
89        }
90
91        // To determine if we need to add a signature,
92        // preview the comment without adding one and check if the result is signed properly.
93        $previewResult = $this->previewMessage(
94            $params['paction'] === 'addtopic' ? 'topic' : 'reply',
95            $title,
96            [
97                'wikitext' => $params['wikitext'],
98                'html' => $params['html'],
99                'sectiontitle' => $params['sectiontitle'],
100            ],
101            $params
102        );
103        $previewResultHtml = $previewResult->getResultData( [ 'parse', 'text' ] );
104        $previewContainer = DOMCompat::getBody( DOMUtils::parseHTML( $previewResultHtml ) );
105        $previewThreadItemSet = $this->commentParser->parse( $previewContainer, $title->getTitleValue() );
106        if ( CommentUtils::isSingleCommentSignedBy(
107            $previewThreadItemSet, $this->getUser()->getName(), $previewContainer
108        ) ) {
109            $signature = null;
110        } else {
111            $signature = $this->msg( 'discussiontools-signature-prefix' )->inContentLanguage()->text() . '~~~~';
112        }
113
114        switch ( $params['paction'] ) {
115            case 'addtopic':
116                $wikitext = $params['wikitext'];
117                $html = $params['html'];
118
119                if ( $wikitext !== null ) {
120                    if ( $signature !== null ) {
121                        $wikitext = CommentModifier::appendSignatureWikitext( $wikitext, $signature );
122                    }
123                } else {
124                    $doc = DOMUtils::parseHTML( '' );
125                    $container = DOMUtils::parseHTMLToFragment( $doc, $html );
126                    if ( $signature !== null ) {
127                        CommentModifier::appendSignature( $container, $signature );
128                    }
129                    $html = DOMUtils::getFragmentInnerHTML( $container );
130                    $wikitext = $this->transformHTML( $title, $html )[ 'body' ];
131                }
132
133                $mobileFormatParams = [];
134                // Boolean parameters must be omitted completely to be treated as false.
135                // Param is added by hook in MobileFrontend, so it may be unset.
136                if ( isset( $params['mobileformat'] ) && $params['mobileformat'] ) {
137                    $mobileFormatParams['mobileformat'] = '1';
138                }
139                // As section=new this is append only so we don't need to
140                // worry about edit-conflict params such as oldid/baserevid/etag.
141                // Edit summary is also automatically generated when section=new
142                $context = new DerivativeContext( $this->getContext() );
143                $context->setRequest(
144                    new DerivativeRequest(
145                        $context->getRequest(),
146                        [
147                            'action' => 'visualeditoredit',
148                            'paction' => 'save',
149                            'page' => $params['page'],
150                            'token' => $params['token'],
151                            'wikitext' => $wikitext,
152                            // A default is provided automatically by the Edit API
153                            // for new sections when the summary is empty.
154                            'summary' => $params['summary'],
155                            'section' => 'new',
156                            'sectiontitle' => $params['sectiontitle'],
157                            'starttimestamp' => wfTimestampNow(),
158                            'useskin' => $params['useskin'],
159                            'watchlist' => $params['watchlist'],
160                            'captchaid' => $params['captchaid'],
161                            'captchaword' => $params['captchaword']
162                        ] + $mobileFormatParams,
163                        /* was posted? */ true
164                    )
165                );
166                $api = new ApiMain(
167                    $context,
168                    /* enable write? */ true
169                );
170
171                $api->execute();
172
173                $data = $api->getResult()->getResultData();
174                $result = $data['visualeditoredit'];
175
176                if ( $autoSubscribe && isset( $result['content'] ) ) {
177                    // Determining the added topic's name directly is hard (we'd have to ensure we have the
178                    // same timestamp, and replicate some CommentParser stuff). Just pull it out of the response.
179                    $doc = DOMUtils::parseHTML( $result['content'] );
180                    $container = DOMCompat::getBody( $doc );
181                    $threadItemSet = $this->commentParser->parse( $container, $title->getTitleValue() );
182                    $threads = $threadItemSet->getThreads();
183                    if ( count( $threads ) ) {
184                        $lastHeading = end( $threads );
185                        $subscribableHeadingName = $lastHeading->getName();
186                        $subscribableSectionTitle = $lastHeading->getLinkableTitle();
187                    }
188                }
189
190                break;
191
192            case 'addcomment':
193                $this->requireAtLeastOneParameter( $params, 'commentid', 'commentname' );
194
195                $commentId = $params['commentid'] ?? null;
196                $commentName = $params['commentname'] ?? null;
197
198                if ( !$title->exists() ) {
199                    // The page does not exist, so the comment we're trying to reply to can't exist either.
200                    if ( $commentId ) {
201                        $this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
202                    } else {
203                        $this->dieWithError( [ 'apierror-discussiontools-commentname-notfound', $commentName ] );
204                    }
205                }
206
207                // Fetch the latest revision
208                $requestedRevision = $this->revisionLookup->getRevisionByTitle( $title );
209                if ( !$requestedRevision ) {
210                    $this->dieWithError(
211                        [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ],
212                        'nosuchrevid'
213                    );
214                }
215
216                $response = $this->requestRestbasePageHtml( $requestedRevision );
217
218                $headers = $response['headers'];
219                $doc = DOMUtils::parseHTML( $response['body'] );
220
221                // Don't trust RESTBase to always give us the revision we requested,
222                // instead get the revision ID from the document and use that.
223                // Ported from ve.init.mw.ArticleTarget.prototype.parseMetadata
224                $docRevId = null;
225                $aboutDoc = $doc->documentElement->getAttribute( 'about' );
226
227                if ( $aboutDoc ) {
228                    preg_match( '/revision\\/([0-9]+)$/', $aboutDoc, $docRevIdMatches );
229                    if ( $docRevIdMatches ) {
230                        $docRevId = (int)$docRevIdMatches[ 1 ];
231                    }
232                }
233
234                if ( !$docRevId ) {
235                    $this->dieWithError( 'apierror-visualeditor-docserver', 'docserver' );
236                }
237
238                if ( $docRevId !== $requestedRevision->getId() ) {
239                    // TODO: If this never triggers, consider removing the check.
240                    $this->getLogger()->warning(
241                        "Requested revision {$requestedRevision->getId()} " .
242                        "but received {$docRevId}."
243                    );
244                }
245
246                $container = DOMCompat::getBody( $doc );
247
248                $threadItemSet = $this->commentParser->parse( $container, $title->getTitleValue() );
249
250                if ( $commentId ) {
251                    $comment = $threadItemSet->findCommentById( $commentId );
252
253                    if ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
254                        $this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
255                    }
256
257                } else {
258                    $comments = $threadItemSet->findCommentsByName( $commentName );
259                    $comment = $comments[ 0 ] ?? null;
260
261                    if ( count( $comments ) > 1 ) {
262                        $this->dieWithError( [ 'apierror-discussiontools-commentname-ambiguous', $commentName ] );
263                    } elseif ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
264                        $this->dieWithError( [ 'apierror-discussiontools-commentname-notfound', $commentName ] );
265                    }
266                }
267
268                if ( $comment->getTranscludedFrom() ) {
269                    // Replying to transcluded comments should not be possible. We check this client-side, but
270                    // the comment might have become transcluded in the meantime (T268069), so check again. We
271                    // didn't have this check before T313100, since usually the reply would just disappear in
272                    // Parsoid, but now it would be placed after the transclusion, which would be wrong.
273                    $this->dieWithError( 'discussiontools-error-comment-not-saved', 'comment-became-transcluded' );
274                }
275
276                if ( $params['wikitext'] !== null ) {
277                    CommentModifier::addWikitextReply( $comment, $params['wikitext'], $signature );
278                } else {
279                    CommentModifier::addHtmlReply( $comment, $params['html'], $signature );
280                }
281
282                if ( isset( $params['summary'] ) ) {
283                    $summary = $params['summary'];
284                } else {
285                    $sectionTitle = $comment->getHeading()->getLinkableTitle();
286                    $summary = ( $sectionTitle ? '/* ' . $sectionTitle . ' */ ' : '' ) .
287                        $this->msg( 'discussiontools-defaultsummary-reply' )->inContentLanguage()->text();
288                }
289
290                if ( $autoSubscribe ) {
291                    $heading = $comment->getSubscribableHeading();
292                    if ( $heading ) {
293                        $subscribableHeadingName = $heading->getName();
294                        $subscribableSectionTitle = $heading->getLinkableTitle();
295                    }
296                }
297
298                $context = new DerivativeContext( $this->getContext() );
299                $context->setRequest(
300                    new DerivativeRequest(
301                        $context->getRequest(),
302                        [
303                            'action' => 'visualeditoredit',
304                            'paction' => 'save',
305                            'page' => $params['page'],
306                            'token' => $params['token'],
307                            'oldid' => $docRevId,
308                            'html' => DOMCompat::getOuterHTML( $doc->documentElement ),
309                            'summary' => $summary,
310                            'baserevid' => $docRevId,
311                            'starttimestamp' => wfTimestampNow(),
312                            'etag' => $headers['etag'] ?? null,
313                            'useskin' => $params['useskin'],
314                            'watchlist' => $params['watchlist'],
315                            'captchaid' => $params['captchaid'],
316                            'captchaword' => $params['captchaword']
317                        ],
318                        /* was posted? */ true
319                    )
320                );
321                $api = new ApiMain(
322                    $context,
323                    /* enable write? */ true
324                );
325
326                $api->execute();
327
328                // TODO: Tags are only added by 'dttags' existing on the original request
329                // context (see Hook::onRecentChangeSave). What tags (if any) should be
330                // added in this API?
331
332                $data = $api->getResult()->getResultData();
333                $result = $data['visualeditoredit'];
334                break;
335        }
336
337        if ( !isset( $result['newrevid'] ) && isset( $result['result'] ) && $result['result'] === 'success' ) {
338            // No new revision, so no changes were made to the page (null edit).
339            // Comment was not actually saved, so for this API, that's an error.
340            // This should not be possible after T313100.
341            $this->dieWithError( 'discussiontools-error-comment-not-saved', 'comment-comment-not-saved' );
342        }
343
344        if ( $autoSubscribe && $subscribableHeadingName ) {
345            $subsTitle = $title->createFragmentTarget( $subscribableSectionTitle );
346            $this->subscriptionStore
347                ->addAutoSubscriptionForUser( $this->getUser(), $subsTitle, $subscribableHeadingName );
348        }
349
350        // Check the post was successful (could have been blocked by ConfirmEdit) before
351        // marking the form token as used.
352        if ( $formToken && isset( $result['result'] ) && $result['result'] === 'success' ) {
353            $usedFormTokens[] = $formToken;
354            // Set an arbitrary limit of the number of form tokens to
355            // store to prevent session storage from becoming full.
356            // It is unlikely that form tokens other than the few most
357            // recently used will be needed.
358            while ( count( $usedFormTokens ) > 50 ) {
359                // Discard the oldest tokens first
360                array_shift( $usedFormTokens );
361            }
362            $session->set( $usedFormTokensKey, $usedFormTokens );
363        }
364
365        $this->getResult()->addValue( null, $this->getModuleName(), $result );
366    }
367
368    /**
369     * @inheritDoc
370     */
371    public function getAllowedParams() {
372        return [
373            'paction' => [
374                ParamValidator::PARAM_REQUIRED => true,
375                ParamValidator::PARAM_TYPE => [
376                    'addcomment',
377                    'addtopic',
378                ],
379                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-paction',
380                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
381            ],
382            'page' => [
383                ParamValidator::PARAM_REQUIRED => true,
384                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page',
385            ],
386            'token' => [
387                ParamValidator::PARAM_REQUIRED => true,
388            ],
389            'formtoken' => [
390                ParamValidator::PARAM_TYPE => 'string',
391                StringDef::PARAM_MAX_CHARS => 16,
392            ],
393            'commentname' => null,
394            'commentid' => null,
395            'wikitext' => [
396                ParamValidator::PARAM_TYPE => 'text',
397                ParamValidator::PARAM_DEFAULT => null,
398            ],
399            'html' => [
400                ParamValidator::PARAM_TYPE => 'text',
401                ParamValidator::PARAM_DEFAULT => null,
402            ],
403            'summary' => [
404                ParamValidator::PARAM_TYPE => 'string',
405                ParamValidator::PARAM_DEFAULT => null,
406                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-summary',
407            ],
408            'sectiontitle' => [
409                ParamValidator::PARAM_TYPE => 'string',
410            ],
411            'useskin' => [
412                ParamValidator::PARAM_TYPE => array_keys( $this->skinFactory->getInstalledSkins() ),
413                ApiBase::PARAM_HELP_MSG => 'apihelp-parse-param-useskin',
414            ],
415            'watchlist' => [
416                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-watchlist',
417            ],
418            'captchaid' => [
419                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-captchaid',
420            ],
421            'captchaword' => [
422                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-captchaword',
423            ],
424        ];
425    }
426
427    /**
428     * @inheritDoc
429     */
430    public function needsToken() {
431        return 'csrf';
432    }
433
434    /**
435     * @inheritDoc
436     */
437    public function isInternal() {
438        return true;
439    }
440
441    /**
442     * @inheritDoc
443     */
444    public function isWriteMode() {
445        return true;
446    }
447}