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