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