Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
45.64% covered (danger)
45.64%
110 / 241
13.33% covered (danger)
13.33%
4 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
QuestionPoster
45.64% covered (danger)
45.64%
110 / 241
13.33% covered (danger)
13.33%
4 / 30
765.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 setPostOnTop
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRelevantTitle
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getWikitextLinkTarget
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 loadExistingQuestions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 submit
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
6.20
 getTargetContentModel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 submitWikitext
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
30
 submitStructuredDiscussions
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 getNumberedSectionHeaderIfDuplicatesExist
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 checkPermissions
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 getTag
n/a
0 / 0
n/a
0 / 0
0
 makeWikitextContent
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 getPageUpdater
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addSignature
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 validateRelevantTitle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getResultUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevisionId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFirstEdit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSectionHeaderTemplate
n/a
0 / 0
n/a
0 / 0
0
 setResultUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSectionHeader
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getSectionHeader
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPostedOnTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFormattedPostedOnTimestamp
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTargetTitle
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getDirectTargetTitle
n/a
0 / 0
n/a
0 / 0
0
 getContext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQuestionStoragePref
n/a
0 / 0
n/a
0 / 0
0
 checkUserPermissions
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 runEditFilterMergedContentHook
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
3.01
 getBody
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkContent
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 saveNewQuestion
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\HelpPanel\QuestionPoster;
4
5use Flow\Container;
6use GrowthExperiments\HelpPanel\QuestionRecord;
7use GrowthExperiments\HelpPanel\QuestionStoreFactory;
8use GrowthExperiments\Hooks\HookRunner;
9use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
10use MediaWiki\CommentStore\CommentStoreComment;
11use MediaWiki\Config\Config;
12use MediaWiki\Content\Content;
13use MediaWiki\Content\WikitextContent;
14use MediaWiki\Context\DerivativeContext;
15use MediaWiki\Context\IContextSource;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Page\WikiPageFactory;
18use MediaWiki\Permissions\PermissionManager;
19use MediaWiki\Revision\SlotRecord;
20use MediaWiki\Status\Status;
21use MediaWiki\Storage\PageUpdater;
22use MediaWiki\Title\Title;
23use MediaWiki\Title\TitleFactory;
24use RecentChange;
25use RuntimeException;
26use UserNotLoggedIn;
27use Wikimedia\Stats\PrefixingStatsdDataFactoryProxy;
28
29/**
30 * Base class for sending messages containing user questions to some target page.
31 */
32abstract class QuestionPoster {
33
34    /**
35     * @var WikiPageFactory
36     */
37    private $wikiPageFactory;
38
39    /**
40     * @var TitleFactory
41     */
42    private $titleFactory;
43
44    /**
45     * @var PermissionManager
46     */
47    private $permissionManager;
48
49    /**
50     * @var bool
51     */
52    private $postOnTop = false;
53
54    /**
55     * @var IContextSource
56     */
57    private $context;
58
59    /**
60     * @var bool
61     */
62    private $isFirstEdit;
63
64    /**
65     * @var Config
66     */
67    private $config;
68
69    /**
70     * @var Title
71     */
72    private $targetTitle;
73
74    /**
75     * @var string
76     */
77    private $resultUrl;
78
79    /**
80     * @var PageUpdater
81     */
82    protected $pageUpdater;
83
84    /**
85     * @var mixed
86     */
87    private $revisionId;
88
89    /**
90     * @var string
91     */
92    private $relevantTitleRaw;
93
94    /**
95     * @var Title|null
96     */
97    private $relevantTitle;
98
99    /**
100     * @var string
101     */
102    private $postedOnTimestamp;
103
104    /**
105     * @var QuestionRecord[]
106     */
107    private $existingQuestionsByUser;
108
109    /**
110     * @var string
111     */
112    private $body;
113
114    /**
115     * @var string
116     */
117    private $sectionHeader;
118
119    /**
120     * @var PrefixingStatsdDataFactoryProxy
121     */
122    private $perDbNameStatsdDataFactory;
123
124    private bool $confirmEditInstalled = false;
125    private bool $flowInstalled = false;
126
127    /**
128     * @param WikiPageFactory $wikiPageFactory
129     * @param TitleFactory $titleFactory
130     * @param PermissionManager $permissionManager
131     * @param StatsdDataFactoryInterface $perDbNameStatsdDataFactory
132     * @param bool $confirmEditInstalled
133     * @param bool $flowInstalled
134     * @param IContextSource $context
135     * @param string $body
136     * @param string $relevantTitleRaw
137     * @throws UserNotLoggedIn
138     */
139    public function __construct(
140        WikiPageFactory $wikiPageFactory,
141        TitleFactory $titleFactory,
142        PermissionManager $permissionManager,
143        StatsdDataFactoryInterface $perDbNameStatsdDataFactory,
144        bool $confirmEditInstalled,
145        bool $flowInstalled,
146        IContextSource $context,
147        $body,
148        $relevantTitleRaw = ''
149    ) {
150        $this->wikiPageFactory = $wikiPageFactory;
151        $this->titleFactory = $titleFactory;
152        $this->permissionManager = $permissionManager;
153        $this->context = $context;
154        $this->relevantTitleRaw = $relevantTitleRaw;
155        if ( !$this->getContext()->getUser()->isNamed() ) {
156            throw new UserNotLoggedIn();
157        }
158        $this->config = $this->getContext()->getConfig();
159        $this->isFirstEdit = ( $this->getContext()->getUser()->getEditCount() === 0 );
160        $this->targetTitle = $this->getTargetTitle();
161        $page = $wikiPageFactory->newFromTitle( $this->targetTitle );
162        $this->pageUpdater = $page->newPageUpdater( $this->getContext()->getUser() );
163        $this->body = trim( $body );
164        $this->perDbNameStatsdDataFactory = $perDbNameStatsdDataFactory;
165        $this->confirmEditInstalled = $confirmEditInstalled;
166        $this->flowInstalled = $flowInstalled;
167    }
168
169    /**
170     * Whether to post on top of the help desk (as opposed to the bottom). Defaults to false.
171     * Only affects wikitext pages.
172     * @param bool $postOnTop
173     */
174    public function setPostOnTop( bool $postOnTop ): void {
175        $this->postOnTop = $postOnTop;
176    }
177
178    /**
179     * Return relevant title, if it exists.
180     * @return Title|null
181     */
182    public function getRelevantTitle(): ?Title {
183        if ( !$this->relevantTitleRaw ) {
184            return null;
185        }
186
187        if ( $this->relevantTitle === null ) {
188            $this->relevantTitle = $this->titleFactory->newFromText(
189                $this->relevantTitleRaw
190            );
191        }
192        return $this->relevantTitle;
193    }
194
195    /**
196     * Return wikitext link target suitable for usage in [[internal linking]]
197     *
198     * This returns [[$title->getPrefixedText()]] for most pages, and
199     * [[:$title->getPrefixedText()]] for files and categories.
200     *
201     * @return string
202     */
203    public function getWikitextLinkTarget(): string {
204        $title = $this->getRelevantTitle();
205        if ( !$title ) {
206            return '';
207        }
208
209        if ( in_array( $title->getNamespace(), [ NS_FILE, NS_CATEGORY ], true ) ) {
210            return ':' . $title->getPrefixedText();
211        } else {
212            return $title->getPrefixedText();
213        }
214    }
215
216    /**
217     * Load the current user's existing questions.
218     */
219    protected function loadExistingQuestions() {
220        $questionStore = QuestionStoreFactory::newFromContextAndStorage(
221            $this->getContext(),
222            $this->getQuestionStoragePref()
223        );
224        $this->existingQuestionsByUser = $questionStore->loadQuestions();
225    }
226
227    /**
228     * @return Status
229     */
230    public function submit() {
231        $this->loadExistingQuestions();
232
233        // Do not let captcha to stop us
234        if ( $this->confirmEditInstalled ) {
235            $scope = $this->permissionManager->addTemporaryUserRights(
236                $this->getContext()->getUser(),
237                'skipcaptcha'
238            );
239        }
240
241        $this->postedOnTimestamp = wfTimestamp();
242        $this->setSectionHeader();
243
244        $contentModel = $this->getTargetContentModel();
245        if ( $contentModel === CONTENT_MODEL_WIKITEXT ) {
246            $status = $this->submitWikitext();
247        } elseif ( $this->flowInstalled && $contentModel === CONTENT_MODEL_FLOW_BOARD ) {
248            $status = $this->submitStructuredDiscussions();
249        } else {
250            throw new RuntimeException( "Content model $contentModel is not supported." );
251        }
252
253        if ( $status->isGood() ) {
254            $this->saveNewQuestion();
255        }
256
257        return $status;
258    }
259
260    /**
261     * @return string Content model of the target page. One of the CONTENT_MODEL_* constants.
262     */
263    protected function getTargetContentModel() {
264        return $this->targetTitle->getContentModel();
265    }
266
267    /**
268     * @return Status
269     */
270    private function submitWikitext() {
271        $content = $this->makeWikitextContent();
272
273        $contentStatus = $this->checkContent( $content );
274        if ( !$contentStatus->isGood() ) {
275            return $contentStatus;
276        }
277        $permissionStatus = $this->checkPermissions( $content );
278        if ( !$permissionStatus->isGood() ) {
279            return $permissionStatus;
280        }
281
282        $tag = $this->getTag();
283        $this->getPageUpdater()->addTag( $tag );
284        $this->getPageUpdater()->setContent( SlotRecord::MAIN, $content );
285        if ( $this->getContext()->getAuthority()->authorizeWrite( 'autopatrol', $this->targetTitle ) ) {
286            $this->getPageUpdater()->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
287        }
288        $newRev = $this->getPageUpdater()->saveRevision(
289            CommentStoreComment::newUnsavedComment(
290                $this->getContext()
291                ->msg( 'newsectionsummary' )
292                ->params(
293                    MediaWikiServices::getInstance()
294                    ->getParserFactory()
295                    ->create()
296                    ->stripSectionName( $this->getSectionHeader() )
297                )
298                ->text()
299            )
300        );
301        if ( !$this->getPageUpdater()->getStatus()->isGood() ) {
302            return $this->getPageUpdater()->getStatus();
303        }
304
305        $this->revisionId = $newRev->getId();
306        $fragment = MediaWikiServices::getInstance()
307                ->getParser()
308                ->guessSectionNameFromWikiText( $this->getSectionHeader() );
309
310        // NOTE: Don't call setFragment() on the original Title, that may corrupt the internal
311        //       cache of Title objects.
312        $target = Title::makeTitle(
313            $this->targetTitle->getNamespace(),
314            $this->targetTitle->getDBkey(),
315            $fragment
316        );
317        $this->setResultUrl( $target->getLinkURL() );
318        $tagId = str_replace( ' ', '_', $tag );
319        $this->perDbNameStatsdDataFactory->increment( 'GrowthExperiments.QuestionPoster.' . $tagId );
320
321        return Status::newGood();
322    }
323
324    /**
325     * @return Status
326     */
327    private function submitStructuredDiscussions() {
328        $workflowLoaderFactory = Container::get( 'factory.loader.workflow' );
329        // TODO: Add statsd instrumentation after T297709 is done.
330        $loader = $workflowLoaderFactory->createWorkflowLoader( $this->targetTitle );
331        $blocksToCommit = $loader->handleSubmit(
332            $this->getContext(),
333            'new-topic',
334            [
335                'topiclist' => [
336                    'topic' => $this->getSectionHeader(),
337                    'content' => $this->getBody(),
338                    'format' => 'wikitext',
339                ],
340            ]
341        );
342
343        $status = Status::newGood();
344        foreach ( $loader->getBlocks() as $block ) {
345            if ( $block->hasErrors() ) {
346                $errors = $block->getErrors();
347                foreach ( $errors as $errorKey ) {
348                    $status->fatal( $block->getErrorMessage( $errorKey ) );
349                }
350            }
351        }
352        if ( !$status->isOK() ) {
353            return $status;
354        }
355
356        $commitMetadata = $loader->commit( $blocksToCommit );
357
358        $topicTitle = Title::newFromText( $commitMetadata['topiclist']['topic-page'] );
359        $this->setResultUrl( $topicTitle->getLinkURL() );
360        $this->revisionId = $commitMetadata['topiclist']['topic-id']->getAlphadecimal();
361
362        return Status::newGood();
363    }
364
365    private function getNumberedSectionHeaderIfDuplicatesExist( $sectionHeader ) {
366        $sectionHeaders = array_map(
367            static function ( QuestionRecord $questionRecord ) {
368                return $questionRecord->getSectionHeader();
369            },
370            $this->existingQuestionsByUser
371        );
372        $counter = 1;
373        while ( in_array( $counter === 1 ? $sectionHeader : "$sectionHeader ($counter)",
374            $sectionHeaders ) ) {
375            $counter++;
376        }
377        return $counter === 1 ? $sectionHeader : $sectionHeader . ' (' . $counter . ')';
378    }
379
380    /**
381     * @param Content $content
382     * @return Status
383     */
384    protected function checkPermissions( $content ) {
385        $userPermissionStatus = $this->checkUserPermissions();
386        if ( !$userPermissionStatus->isGood() ) {
387            return $userPermissionStatus;
388        }
389        $editFilterMergedContentHookStatus = $this->runEditFilterMergedContentHook(
390            $content,
391            $this->getSectionHeaderTemplate()
392        );
393        if ( !$editFilterMergedContentHookStatus->isGood() ) {
394            return $editFilterMergedContentHookStatus;
395        }
396        return Status::newGood();
397    }
398
399    /**
400     * The tag to add to the edit.
401     */
402    abstract protected function getTag();
403
404    /**
405     * Create a Content object with the header and question text provided by the user.
406     *
407     * @return Content|null
408     */
409    protected function makeWikitextContent() {
410        $wikitextContent = new WikitextContent(
411            $this->addSignature( $this->getBody() )
412        );
413        $header = $this->getSectionHeader();
414        $parent = $this->getPageUpdater()->grabParentRevision();
415        if ( !$parent ) {
416            return $wikitextContent->addSectionHeader( $header );
417        }
418        $existingContent = $parent->getContent( SlotRecord::MAIN );
419        if ( !$existingContent ) {
420            return null;
421        }
422
423        if ( $this->postOnTop ) {
424            $section1 = $existingContent->getSection( 1 );
425            if ( $section1 ) {
426                // Prepend to section 1 to post on top without disturbing top-of-the-page templates
427                return $existingContent->replaceSection( 1,
428                    $wikitextContent->replaceSection( 'new', $section1 )->addSectionHeader( $header ) );
429            }
430            // No sections on the page - just post on bottom.
431        }
432        return $existingContent->replaceSection(
433            'new',
434            $wikitextContent,
435            $header
436        );
437    }
438
439    /**
440     * @return PageUpdater
441     */
442    protected function getPageUpdater() {
443        return $this->pageUpdater;
444    }
445
446    /**
447     * Add signature unless already set.
448     *
449     * @param string $body
450     * @return string
451     */
452    private function addSignature( $body ) {
453        if ( strpos( $body, '~~~~' ) === false ) {
454            $body .= " --~~~~";
455        }
456        return $body;
457    }
458
459    /**
460     * @return Status
461     */
462    public function validateRelevantTitle() {
463        $title = $this->getRelevantTitle();
464        return $title && $title->isValid() ?
465            Status::newGood() :
466            Status::newFatal( 'growthexperiments-help-panel-questionposter-invalid-title' );
467    }
468
469    /**
470     * @return string
471     */
472    public function getResultUrl() {
473        return $this->resultUrl;
474    }
475
476    /**
477     * @return int
478     */
479    public function getRevisionId() {
480        return $this->revisionId;
481    }
482
483    /**
484     * @return bool
485     */
486    public function isFirstEdit() {
487        return $this->isFirstEdit;
488    }
489
490    /**
491     * Get the section header template for the question posted by the user.
492     *
493     * This method is used for generating the comment summary as well as the
494     * section header in the edit.
495     *
496     * @return string
497     */
498    abstract protected function getSectionHeaderTemplate();
499
500    /**
501     * Set the result URL to go directly to the newly created question.
502     *
503     * @param string $resultUrl
504     */
505    private function setResultUrl( $resultUrl ) {
506        $this->resultUrl = $resultUrl;
507    }
508
509    /**
510     * Set the section header with a timestamp (wikitext only) and number.
511     *
512     * THe number is appended for flow posts. For wikitext posts, a number is appended
513     * only if duplicate headers exist, which can happen when questions
514     * are posted within the same minute.
515     */
516    protected function setSectionHeader() {
517        $this->sectionHeader = $this->getSectionHeaderTemplate();
518        // If wikitext, override the section header to include the timestamp.
519        if ( $this->getTargetContentModel() === CONTENT_MODEL_WIKITEXT ) {
520            $this->sectionHeader .= ' ' . $this->getContext()
521                    ->msg( 'parentheses' )
522                    ->plaintextParams( $this->getFormattedPostedOnTimestamp() )
523                    ->inContentLanguage()
524                    ->escaped();
525        }
526        $this->sectionHeader = $this->getNumberedSectionHeaderIfDuplicatesExist(
527            $this->sectionHeader
528        );
529    }
530
531    /**
532     * @return string
533     */
534    private function getSectionHeader() {
535        return $this->sectionHeader;
536    }
537
538    /**
539     * @return string
540     */
541    private function getPostedOnTimestamp() {
542        return $this->postedOnTimestamp;
543    }
544
545    /**
546     * Timezone adjustment, site default format, and site default time zone are used for formatting.
547     * @return string
548     */
549    private function getFormattedPostedOnTimestamp() {
550        return MediaWikiServices::getInstance()->getContentLanguage()
551            ->timeanddate( $this->getPostedOnTimestamp(), true, false, '' );
552    }
553
554    /**
555     * @return Title The page where the question should be posted.
556     */
557    protected function getTargetTitle(): Title {
558        $title = $this->getDirectTargetTitle();
559        if ( $title->isRedirect() ) {
560            $page = $this->wikiPageFactory->newFromTitle( $title );
561            return $page->getRedirectTarget();
562        }
563        return $title;
564    }
565
566    /**
567     * @return Title The page where the question should be posted (barring redirects).
568     */
569    abstract protected function getDirectTargetTitle();
570
571    /**
572     * @return IContextSource
573     */
574    final protected function getContext() {
575        return $this->context;
576    }
577
578    /**
579     * The preference name where the posted question will be stored.
580     *
581     * @return string
582     */
583    abstract protected function getQuestionStoragePref();
584
585    /**
586     * @return Status
587     * @throws \Exception
588     */
589    protected function checkUserPermissions() {
590        $errors = $this->permissionManager->getPermissionErrors(
591            'edit',
592            $this->getContext()->getUser(),
593            $this->targetTitle
594        );
595
596        if ( count( $errors ) ) {
597            $key = array_shift( $errors[0] );
598            $message = $this->getContext()->msg( $key )
599                ->params( $errors[0] )
600                ->parse();
601            return Status::newFatal( $message );
602        }
603        return Status::newGood();
604    }
605
606    /**
607     * @param Content $content
608     * @param string $summary
609     * @return Status
610     */
611    protected function runEditFilterMergedContentHook( Content $content, $summary ) {
612        $derivativeContext = new DerivativeContext( $this->getContext() );
613        $services = MediaWikiServices::getInstance();
614        $derivativeContext->setConfig( $services->getMainConfig() );
615        $derivativeContext->setTitle( $this->targetTitle );
616        $status = new Status();
617        $hookRunner = new HookRunner( $services->getHookContainer() );
618        if ( !$hookRunner->onEditFilterMergedContent(
619            $derivativeContext,
620            $content,
621            $status,
622            $summary,
623            $derivativeContext->getUser(),
624            false
625        ) ) {
626            if ( $status->isGood() ) {
627                $status->fatal( 'hookaborted' );
628            }
629            return $status;
630        }
631        return $status;
632    }
633
634    private function getBody() {
635        return $this->body;
636    }
637
638    /**
639     * Validate that $content is an instance of Content
640     *
641     * @param Content|null $content
642     * @return Status
643     */
644    protected function checkContent( $content ) {
645        return $content instanceof Content ?
646            Status::newGood() :
647            Status::newFatal(
648                'apierror-missingcontent-revid',
649                $this->getPageUpdater()->grabParentRevision()->getId()
650            );
651    }
652
653    private function saveNewQuestion() {
654        $question = new QuestionRecord(
655            $this->getBody(),
656            $this->getSectionHeader(),
657            $this->revisionId,
658            (int)$this->getPostedOnTimestamp(),
659            $this->getResultUrl(),
660            $this->getTargetContentModel()
661        );
662        QuestionStoreFactory::newFromContextAndStorage(
663            $this->getContext(),
664            $this->getQuestionStoragePref()
665        )->add( $question );
666    }
667
668}