Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.62% covered (warning)
79.62%
254 / 319
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiDiscussionToolsEdit
79.62% covered (warning)
79.62%
254 / 319
83.33% covered (warning)
83.33%
5 / 6
96.58
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 execute
70.59% covered (warning)
70.59%
156 / 221
0.00% covered (danger)
0.00%
0 / 1
131.96
 getCaptchaParams
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getAllowedParams
100.00% covered (success)
100.00%
83 / 83
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use MediaWiki\Api\ApiBase;
6use MediaWiki\Api\ApiMain;
7use MediaWiki\Api\ApiUsageException;
8use MediaWiki\Config\Config;
9use MediaWiki\Config\ConfigFactory;
10use MediaWiki\Context\DerivativeContext;
11use MediaWiki\Extension\ConfirmEdit\Hooks as ConfirmEditHooks;
12use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
13use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
14use MediaWiki\Extension\VisualEditor\ApiParsoidTrait;
15use MediaWiki\Extension\VisualEditor\VisualEditorParsoidClientFactory;
16use MediaWiki\Logger\LoggerFactory;
17use MediaWiki\Registration\ExtensionRegistry;
18use MediaWiki\Request\DerivativeRequest;
19use MediaWiki\Revision\RevisionLookup;
20use MediaWiki\Skin\SkinFactory;
21use MediaWiki\Title\Title;
22use MediaWiki\User\TempUser\TempUserCreator;
23use MediaWiki\User\UserFactory;
24use Wikimedia\Assert\Assert;
25use Wikimedia\ParamValidator\ParamValidator;
26use Wikimedia\ParamValidator\TypeDef\StringDef;
27use Wikimedia\Parsoid\Core\DOMCompat;
28use Wikimedia\Parsoid\Ext\DOMUtils;
29
30class ApiDiscussionToolsEdit extends ApiBase {
31    use ApiDiscussionToolsTrait;
32    use ApiParsoidTrait;
33
34    private readonly Config $config;
35
36    public function __construct(
37        ApiMain $main,
38        string $name,
39        private readonly VisualEditorParsoidClientFactory $parsoidClientFactory,
40        private readonly CommentParser $commentParser,
41        private readonly SubscriptionStore $subscriptionStore,
42        private readonly TempUserCreator $tempUserCreator,
43        private readonly UserFactory $userFactory,
44        private readonly SkinFactory $skinFactory,
45        ConfigFactory $configFactory,
46        private readonly RevisionLookup $revisionLookup,
47        private readonly ExtensionRegistry $extensionRegistry,
48    ) {
49        parent::__construct( $main, $name );
50        $this->config = $configFactory->makeConfig( 'discussiontools' );
51        $this->setLogger( LoggerFactory::getInstance( 'DiscussionTools' ) );
52    }
53
54    /**
55     * @inheritDoc
56     * @throws ApiUsageException
57     */
58    public function execute() {
59        $params = $this->extractRequestParams();
60        $title = Title::newFromText( $params['page'] );
61        $result = null;
62
63        if ( !$title ) {
64            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
65        }
66
67        $autoSubscribe = $params['autosubscribe'] === 'yes' ||
68            ( $this->config->get( 'DiscussionToolsAutoTopicSubEditor' ) === 'discussiontoolsapi' &&
69            HookUtils::shouldAddAutoSubscription( $this->getUser(), $title ) &&
70            $params['autosubscribe'] === 'default' );
71        $subscribableHeadingName = null;
72        $subscribableSectionTitle = '';
73
74        $this->getErrorFormatter()->setContextTitle( $title );
75
76        $session = null;
77        $usedFormTokensKey = 'DiscussionTools:usedFormTokens';
78        $formToken = $params['formtoken'];
79        if ( $formToken ) {
80            $session = $this->getContext()->getRequest()->getSession();
81            $usedFormTokens = $session->get( $usedFormTokensKey ) ?? [];
82            if ( in_array( $formToken, $usedFormTokens, true ) ) {
83                $this->dieWithError( [ 'apierror-discussiontools-formtoken-used' ] );
84            }
85        }
86
87        $this->requireOnlyOneParameter( $params, 'wikitext', 'html' );
88        if ( $params['paction'] === 'addtopic' ) {
89            $this->requireAtLeastOneParameter( $params, 'sectiontitle' );
90        }
91
92        // To determine if we need to add a signature,
93        // preview the comment without adding one and check if the result is signed properly.
94        $previewResult = $this->previewMessage(
95            $params['paction'] === 'addtopic' ? 'topic' : 'reply',
96            $title,
97            [
98                'wikitext' => $params['wikitext'],
99                'html' => $params['html'],
100                'sectiontitle' => $params['sectiontitle'],
101            ],
102            $params
103        );
104        $previewResultHtml = $previewResult->getResultData( [ 'parse', 'text' ] );
105        $previewContainer = DOMCompat::getBody( DOMUtils::parseHTML( $previewResultHtml ) );
106        $previewThreadItemSet = $this->commentParser->parse( $previewContainer, $title->getTitleValue() );
107        if ( CommentUtils::isSingleCommentSignedBy(
108            $previewThreadItemSet, $this->getUserForPreview()->getName(), $previewContainer
109        ) ) {
110            $signature = null;
111        } else {
112            $signature = $this->msg( 'discussiontools-signature-prefix' )->inContentLanguage()->text() . '~~~~';
113        }
114
115        switch ( $params['paction'] ) {
116            case 'addtopic':
117                $wikitext = $params['wikitext'];
118                $html = $params['html'];
119
120                $previewHeading = null;
121                $previewHeadings = $previewThreadItemSet->getThreads();
122                if ( count( $previewHeadings ) > 0 && !$previewHeadings[ 0 ]->isPlaceholderHeading() ) {
123                    $previewHeading = $previewHeadings[ 0 ];
124                }
125
126                if ( !$params['allownosectiontitle'] ) {
127                    // Check if the preview HTML starts with a section title. Note that even if the provided
128                    // 'sectiontitle' param is empty, a heading could been included in the message body, and
129                    // that's acceptable (T338390). Heading levels other than the default level 2 are also
130                    // acceptable (T267288).
131                    if ( !$previewHeading ) {
132                        $this->dieWithError( [ 'discussiontools-newtopic-missing-title' ] );
133                    }
134                }
135
136                if ( isset( $params['summary'] ) ) {
137                    $summary = $params['summary'];
138                } else {
139                    // Generate an edit summary from the heading in the preview HTML, rather than from the
140                    // 'sectiontitle' param like the action=edit API would. This has two benefits:
141                    // * Works when the heading is included in the message body instead of the param (T338390)
142                    // * Works better for complicated markup in the heading, e.g. templates (T335200)
143                    if ( $previewHeading ) {
144                        $sectionTitle = $previewHeading->getLinkableTitle();
145                        $summary = $this->msg( 'newsectionsummary' )->plaintextParams( $sectionTitle )
146                            ->inContentLanguage()->text();
147                    } else {
148                        // TODO: Should we generate something here? (T275702)
149                        $summary = '';
150                    }
151                }
152
153                if ( $wikitext !== null ) {
154                    if ( $signature !== null ) {
155                        $wikitext = CommentModifier::appendSignatureWikitext( $wikitext, $signature );
156                    }
157                } else {
158                    $doc = DOMUtils::parseHTML( '' );
159                    $container = DOMUtils::parseHTMLToFragment( $doc, $html );
160                    if ( $signature !== null ) {
161                        CommentModifier::appendSignature( $container, $signature );
162                    }
163                    $html = DOMUtils::getFragmentInnerHTML( $container );
164                    $wikitext = $this->transformHTML( $title, $html )[ 'body' ];
165                }
166
167                $mobileFormatParams = [];
168                // Boolean parameters must be omitted completely to be treated as false.
169                // Param is added by hook in MobileFrontend, so it may be unset.
170                if ( isset( $params['mobileformat'] ) && $params['mobileformat'] ) {
171                    $mobileFormatParams['mobileformat'] = '1';
172                }
173                // As section=new this is append only so we don't need to
174                // worry about edit-conflict params such as oldid/baserevid/etag.
175                // Edit summary is also automatically generated when section=new
176                $context = new DerivativeContext( $this->getContext() );
177                $context->setRequest(
178                    new DerivativeRequest(
179                        $context->getRequest(),
180                        [
181                            'action' => 'visualeditoredit',
182                            'paction' => 'save',
183                            'page' => $params['page'],
184                            'token' => $params['token'],
185                            'wikitext' => $wikitext,
186                            'summary' => $summary,
187                            'section' => 'new',
188                            'sectiontitle' => $params['sectiontitle'],
189                            'starttimestamp' => wfTimestampNow(),
190                            'useskin' => $params['useskin'],
191                            'watchlist' => $params['watchlist'],
192                            // Always fetch content if auto-subscribing, it's needed below (T359751)
193                            'nocontent' => $autoSubscribe ? null : $params['nocontent'],
194                            // NOTE: Must use getText() to work; PHP array from $params['tags'] is not understood
195                            // by the visualeditoredit API.
196                            'tags' => $this->getRequest()->getText( 'tags' ),
197                            'returnto' => $params['returnto'],
198                            'returntoquery' => $params['returntoquery'],
199                            'returntoanchor' => $params['returntoanchor'],
200                        ] + $mobileFormatParams + $this->getCaptchaParams( $params, $title ),
201                        /* was posted? */ true
202                    )
203                );
204                $api = new ApiMain(
205                    $context,
206                    /* enable write? */ true
207                );
208
209                $api->execute();
210
211                $data = $api->getResult()->getResultData();
212                $result = $data['visualeditoredit'];
213
214                if ( $autoSubscribe && isset( $result['content'] ) ) {
215                    // Determining the added topic's name directly is hard (we'd have to ensure we have the
216                    // same timestamp, and replicate some CommentParser stuff). Just pull it out of the response.
217                    $doc = DOMUtils::parseHTML( $result['content'] );
218                    $container = DOMCompat::getBody( $doc );
219                    $threadItemSet = $this->commentParser->parse( $container, $title->getTitleValue() );
220                    $threads = $threadItemSet->getThreads();
221                    if ( count( $threads ) ) {
222                        $lastHeading = end( $threads );
223                        $subscribableHeadingName = $lastHeading->getName();
224                        $subscribableSectionTitle = $lastHeading->getLinkableTitle();
225                    }
226                    if ( $params['nocontent'] ) {
227                        // We had to fetch content even if not requested by the caller (T359751), but pretend we didn't
228                        unset( $result['content'] );
229                        $result['nocontent'] = true;
230                    }
231                }
232
233                break;
234
235            case 'addcomment':
236                $this->requireAtLeastOneParameter( $params, 'commentid', 'commentname' );
237
238                $commentId = $params['commentid'] ?? null;
239                $commentName = $params['commentname'] ?? null;
240
241                if ( !$title->exists() ) {
242                    // The page does not exist, so the comment we're trying to reply to can't exist either.
243                    if ( $commentId ) {
244                        $this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
245                    } else {
246                        $this->dieWithError( [ 'apierror-discussiontools-commentname-notfound', $commentName ] );
247                    }
248                }
249
250                // Fetch the latest revision
251                $requestedRevision = $this->revisionLookup->getRevisionByTitle( $title );
252                if ( !$requestedRevision ) {
253                    $this->dieWithError(
254                        [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ],
255                        'nosuchrevid'
256                    );
257                }
258
259                $response = $this->requestRestbasePageHtml( $requestedRevision );
260
261                $headers = $response['headers'];
262                $doc = DOMUtils::parseHTML( $response['body'] );
263
264                // Validate that we got the revision we requested.
265                // Ported from ve.init.mw.ArticleTarget.prototype.parseMetadata
266                $docRevId = null;
267                $aboutDoc = $doc->documentElement->getAttribute( 'about' );
268                if ( $aboutDoc ) {
269                    preg_match( '/revision\\/([0-9]+)$/', $aboutDoc, $docRevIdMatches );
270                    if ( $docRevIdMatches ) {
271                        $docRevId = (int)$docRevIdMatches[ 1 ];
272                    }
273                }
274                Assert::postcondition( $docRevId !== null,
275                    'Parsoid document had no revision information' );
276                Assert::postcondition( $docRevId === $requestedRevision->getId(),
277                    'Parsoid revision did not match requested revision' );
278
279                $container = DOMCompat::getBody( $doc );
280
281                // Unwrap sections, so that transclusions overlapping section boundaries don't cause all
282                // comments in the sections to be treated as transcluded from another page.
283                CommentUtils::unwrapParsoidSections( $container );
284
285                $threadItemSet = $this->commentParser->parse( $container, $title->getTitleValue() );
286
287                if ( $commentId ) {
288                    $comment = $threadItemSet->findCommentById( $commentId );
289
290                    if ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
291                        $this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
292                    }
293
294                } else {
295                    $comments = $threadItemSet->findCommentsByName( $commentName );
296                    $comment = $comments[ 0 ] ?? null;
297
298                    if ( count( $comments ) > 1 ) {
299                        $this->dieWithError( [ 'apierror-discussiontools-commentname-ambiguous', $commentName ] );
300                    } elseif ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
301                        $this->dieWithError( [ 'apierror-discussiontools-commentname-notfound', $commentName ] );
302                    }
303                }
304
305                if ( $comment->getTranscludedFrom() ) {
306                    // Replying to transcluded comments should not be possible. We check this client-side, but
307                    // the comment might have become transcluded in the meantime (T268069), so check again. We
308                    // didn't have this check before T313100, since usually the reply would just disappear in
309                    // Parsoid, but now it would be placed after the transclusion, which would be wrong.
310                    $this->dieWithError( 'discussiontools-error-comment-not-saved', 'comment-became-transcluded' );
311                }
312
313                if ( $params['wikitext'] !== null ) {
314                    CommentModifier::addWikitextReply( $comment, $params['wikitext'], $signature );
315                } else {
316                    CommentModifier::addHtmlReply( $comment, $params['html'], $signature );
317                }
318
319                if ( isset( $params['summary'] ) ) {
320                    $summary = $params['summary'];
321                } else {
322                    $prefix = '';
323                    if ( $comment->getHeading()->isPlaceholderHeading() ) {
324                        $prefix = '/* */ ';
325                    } elseif ( $comment->getHeading()->getLinkableTitle() !== '' ) {
326                        $prefix = '/* ' . $comment->getHeading()->getLinkableTitle() . ' */ ';
327                    }
328                    $summary = $prefix .
329                        $this->msg( 'discussiontools-defaultsummary-reply' )->inContentLanguage()->text();
330                }
331
332                if ( $autoSubscribe ) {
333                    $heading = $comment->getSubscribableHeading();
334                    if ( $heading ) {
335                        $subscribableHeadingName = $heading->getName();
336                        $subscribableSectionTitle = $heading->getLinkableTitle();
337                    }
338                }
339
340                $context = new DerivativeContext( $this->getContext() );
341                $context->setRequest(
342                    new DerivativeRequest(
343                        $context->getRequest(),
344                        [
345                            'action' => 'visualeditoredit',
346                            'paction' => 'save',
347                            'page' => $params['page'],
348                            'token' => $params['token'],
349                            'oldid' => $docRevId,
350                            'html' => DOMCompat::getOuterHTML( $doc->documentElement ),
351                            'summary' => $summary,
352                            'baserevid' => $docRevId,
353                            'starttimestamp' => wfTimestampNow(),
354                            'etag' => $headers['etag'] ?? null,
355                            'useskin' => $params['useskin'],
356                            'watchlist' => $params['watchlist'],
357                            'nocontent' => $params['nocontent'],
358                            // NOTE: Must use getText() to work; PHP array from $params['tags'] is not understood
359                            // by the visualeditoredit API.
360                            'tags' => $this->getRequest()->getText( 'tags' ),
361                            'returnto' => $params['returnto'],
362                            'returntoquery' => $params['returntoquery'],
363                            'returntoanchor' => $params['returntoanchor'],
364                        ] + $this->getCaptchaParams( $params, $title ),
365                        /* was posted? */ true
366                    )
367                );
368                $api = new ApiMain(
369                    $context,
370                    /* enable write? */ true
371                );
372
373                $api->execute();
374
375                // TODO: Tags are only added by 'dttags' existing on the original request
376                // context (see Hook::onRecentChangeSave). What tags (if any) should be
377                // added in this API?
378
379                $data = $api->getResult()->getResultData();
380                $result = $data['visualeditoredit'];
381                break;
382        }
383
384        if ( !isset( $result['newrevid'] ) && isset( $result['result'] ) && $result['result'] === 'success' ) {
385            // No new revision, so no changes were made to the page (null edit).
386            // Comment was not actually saved, so for this API, that's an error.
387            // This should not be possible after T313100.
388            $this->dieWithError( 'discussiontools-error-comment-not-saved', 'comment-comment-not-saved' );
389        }
390
391        if ( $autoSubscribe && $subscribableHeadingName ) {
392            $subsTitle = $title->createFragmentTarget( $subscribableSectionTitle );
393            $this->subscriptionStore
394                ->addAutoSubscriptionForUser( $this->getUser(), $subsTitle, $subscribableHeadingName );
395        }
396
397        // Check the post was successful (could have been blocked by ConfirmEdit) before
398        // marking the form token as used.
399        if ( $formToken && isset( $result['result'] ) && $result['result'] === 'success' ) {
400            $usedFormTokens[] = $formToken;
401            // Set an arbitrary limit of the number of form tokens to
402            // store to prevent session storage from becoming full.
403            // It is unlikely that form tokens other than the few most
404            // recently used will be needed.
405            while ( count( $usedFormTokens ) > 50 ) {
406                // Discard the oldest tokens first
407                array_shift( $usedFormTokens );
408            }
409            $session->set( $usedFormTokensKey, $usedFormTokens );
410        }
411
412        $this->getResult()->addValue( null, $this->getModuleName(), $result );
413    }
414
415    /**
416     * Given the list of parsed discussiontoolsedit API parameters and relevant title,
417     * returns a CAPTCHA API data to append to the edit API call.
418     */
419    private function getCaptchaParams( array $params, Title $title ): array {
420        if ( !$this->extensionRegistry->isLoaded( 'ConfirmEdit' ) ) {
421            return [];
422        }
423
424        $captchaInstance = ConfirmEditHooks::getInstance(
425            ConfirmEditHooks::getCaptchaTriggerActionFromTitle( $title )
426        );
427        $returnArray = [];
428        foreach ( $captchaInstance->getApiParams() as $captchaApiParam ) {
429            if ( array_key_exists( $captchaApiParam, $params ) ) {
430                $returnArray[$captchaApiParam] = $params[$captchaApiParam];
431            }
432        }
433        return $returnArray;
434    }
435
436    /**
437     * @inheritDoc
438     */
439    public function getAllowedParams() {
440        return [
441            'paction' => [
442                ParamValidator::PARAM_REQUIRED => true,
443                ParamValidator::PARAM_TYPE => [
444                    'addcomment',
445                    'addtopic',
446                ],
447                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-paction',
448                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
449            ],
450            'autosubscribe' => [
451                ParamValidator::PARAM_TYPE => [
452                    'yes',
453                    'no',
454                    'default'
455                ],
456                ParamValidator::PARAM_DEFAULT => 'default',
457            ],
458            'page' => [
459                ParamValidator::PARAM_REQUIRED => true,
460                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page',
461            ],
462            'token' => [
463                ParamValidator::PARAM_REQUIRED => true,
464            ],
465            'formtoken' => [
466                ParamValidator::PARAM_TYPE => 'string',
467                StringDef::PARAM_MAX_CHARS => 16,
468            ],
469            'commentname' => null,
470            'commentid' => null,
471            'wikitext' => [
472                ParamValidator::PARAM_TYPE => 'text',
473                ParamValidator::PARAM_DEFAULT => null,
474            ],
475            'html' => [
476                ParamValidator::PARAM_TYPE => 'text',
477                ParamValidator::PARAM_DEFAULT => null,
478            ],
479            'summary' => [
480                ParamValidator::PARAM_TYPE => 'string',
481                ParamValidator::PARAM_DEFAULT => null,
482                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-summary',
483            ],
484            'sectiontitle' => [
485                ParamValidator::PARAM_TYPE => 'string',
486            ],
487            'allownosectiontitle' => false,
488            'useskin' => [
489                ParamValidator::PARAM_TYPE => array_keys( $this->skinFactory->getInstalledSkins() ),
490                ApiBase::PARAM_HELP_MSG => 'apihelp-parse-param-useskin',
491            ],
492            'watchlist' => [
493                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-watchlist',
494            ],
495            'captchaid' => [
496                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-captchaid',
497            ],
498            'captchaword' => [
499                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-captchaword',
500            ],
501            'nocontent' => [
502                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-nocontent',
503            ],
504            'tags' => [
505                ParamValidator::PARAM_ISMULTI => true,
506                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-tags',
507            ],
508            'returnto' => [
509                ParamValidator::PARAM_TYPE => 'title',
510                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returnto',
511            ],
512            'returntoquery' => [
513                ParamValidator::PARAM_TYPE => 'string',
514                ParamValidator::PARAM_DEFAULT => '',
515                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returntoquery',
516            ],
517            'returntoanchor' => [
518                ParamValidator::PARAM_TYPE => 'string',
519                ParamValidator::PARAM_DEFAULT => '',
520                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returntoanchor',
521            ],
522        ];
523    }
524
525    /**
526     * @inheritDoc
527     */
528    public function needsToken() {
529        return 'csrf';
530    }
531
532    /**
533     * @inheritDoc
534     */
535    public function isWriteMode() {
536        return true;
537    }
538}