Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 539
0.00% covered (danger)
0.00%
0 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
PFAutoeditAPI
0.00% covered (danger)
0.00%
0 / 539
0.00% covered (danger)
0.00%
0 / 31
43890
0.00% covered (danger)
0.00%
0 / 1
 addOptionsFromString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOption
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 prepareAction
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
272
 getFormTitle
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
210
 setupEditPage
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 setResultFromOutput
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 doPreview
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 doDiff
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 doStore
0.00% covered (danger)
0.00%
0 / 117
0.00% covered (danger)
0.00%
0 / 1
2256
 finalizeResults
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 setHeaders
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 generateTargetName
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
306
 makeRandomNumber
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 doAction
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 1
870
 tokenOk
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 parseDataFromHTMLFrag
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
870
 parseDataFromQueryString
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 addToArray
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
110
 getMessageCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logMessage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isWriteMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getParamDescription
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getExamples
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getVersion
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * @author Stephan Gambke
4 * @author Yaron Koren
5 * @file
6 * @ingroup PageForms
7 */
8
9use MediaWiki\MediaWikiServices;
10use MediaWiki\Revision\RevisionRecord;
11
12/**
13 * @ingroup PageForms
14 */
15class PFAutoeditAPI extends ApiBase {
16
17    public const ACTION_FORMEDIT = 0;
18    public const ACTION_SAVE = 1;
19    public const ACTION_PREVIEW = 2;
20    public const ACTION_DIFF = 3;
21
22    /**
23     * Error level used when a non-recoverable error occurred.
24     */
25    public const ERROR = 0;
26
27    /**
28     * Error level used when a recoverable error occurred.
29     */
30    public const WARNING = 1;
31
32    /**
33     * Error level used to give information that might be of interest to the user.
34     */
35    public const NOTICE = 2;
36
37    /**
38     * Error level used for debug messages.
39     */
40    public const DEBUG = 3;
41
42    private $mOptions = [];
43
44    /**
45     * @var int|null
46     */
47    private $mAction;
48
49    /**
50     * @var int|null
51     */
52    private $mStatus;
53    private $mIsAutoEdit = false;
54
55    /**
56     * Converts an options string into an options array and stores it
57     *
58     * @param string $options
59     * @return array Options
60     */
61    function addOptionsFromString( $options ) {
62        return $this->parseDataFromQueryString( $this->mOptions, $options );
63    }
64
65    /**
66     * @return array
67     */
68    function getOptions() {
69        return $this->mOptions;
70    }
71
72    /**
73     * Returns the action performed by the module.
74     *
75     * Return value is either null or one of ACTION_SAVE, ACTION_PREVIEW,
76     * ACTION_FORMEDIT
77     *
78     * @return int|null
79     */
80    function getAction() {
81        return $this->mAction;
82    }
83
84    /**
85     * @param array $options
86     */
87    function setOptions( $options ) {
88        $this->mOptions = $options;
89    }
90
91    /**
92     * @param string $option
93     * @param mixed $value
94     */
95    function setOption( $option, $value ) {
96        $this->mOptions[$option] = $value;
97    }
98
99    /**
100     * Returns the HTTP status
101     *
102     * 200 - ok
103     * 400 - error
104     *
105     * @return int
106     */
107    function getStatus() {
108        return $this->mStatus;
109    }
110
111    /**
112     * Evaluates the parameters, performs the requested API query, and sets up
113     * the result.
114     *
115     * The execute() method will be invoked when an API call is processed.
116     *
117     * The result data is stored in the ApiResult object available through
118     * getResult().
119     */
120    function execute() {
121        $this->prepareAction();
122        $this->getOutput()->enableOOUI();
123
124        if ( PFUtils::ignoreFormName( $this->mOptions['form'] ) ) {
125            $this->logMessage( $this->msg( 'pf_autoedit_invalidform', $this->mOptions['form'] )->parse() );
126            return;
127        }
128
129        try {
130            $this->doAction();
131        } catch ( Exception $e ) {
132            // This has to be Exception, not MWException, due to
133            // DateTime errors and possibly others.
134            $this->logMessage( PFUtils::getParser()->recursiveTagParseFully( $e->getMessage() ), $e->getCode() );
135        }
136
137        $this->finalizeResults();
138        $this->setHeaders();
139    }
140
141    function prepareAction() {
142        // Get options from the request, but keep the explicitly set options.
143        $data = $this->getRequest()->getValues();
144        $this->mOptions = PFUtils::arrayMergeRecursiveDistinct( $data, $this->mOptions );
145
146        PFUtils::getParser()->startExternalParse(
147            null,
148            ParserOptions::newFromUser( $this->getUser() ),
149            Parser::OT_WIKI
150        );
151
152        // MW uses the parameter 'title' instead of 'target' when submitting
153        // data for formedit action => use that
154        if ( !array_key_exists( 'target', $this->mOptions ) && array_key_exists( 'title', $this->mOptions ) ) {
155            $this->mOptions['target'] = $this->mOptions['title'];
156            unset( $this->mOptions['title'] );
157        }
158
159        // if the 'query' parameter was used, unpack the param string
160        if ( array_key_exists( 'query', $this->mOptions ) ) {
161            $this->addOptionsFromString( $this->mOptions['query'] );
162            unset( $this->mOptions['query'] );
163        }
164
165        // if an action is explicitly set in the form data, use that
166        if ( array_key_exists( 'wpSave', $this->mOptions ) ) {
167            // set action to 'save' if requested
168            $this->mAction = self::ACTION_SAVE;
169            unset( $this->mOptions['wpSave'] );
170        } elseif ( array_key_exists( 'wpPreview', $this->mOptions ) ) {
171            // set action to 'preview' if requested
172            $this->mAction = self::ACTION_PREVIEW;
173            unset( $this->mOptions['wpPreview'] );
174        } elseif ( array_key_exists( 'wpDiff', $this->mOptions ) ) {
175            // set action to 'preview' if requested
176            $this->mAction = self::ACTION_DIFF;
177            unset( $this->mOptions['wpDiff'] );
178        } elseif ( array_key_exists( 'action', $this->mOptions ) ) {
179            switch ( $this->mOptions['action'] ) {
180                case 'pfautoedit':
181                    $this->mIsAutoEdit = true;
182                    $this->mAction = self::ACTION_SAVE;
183                    break;
184                case 'preview':
185                    $this->mAction = self::ACTION_PREVIEW;
186                    break;
187                default:
188                    $this->mAction = self::ACTION_FORMEDIT;
189            }
190        } else {
191            // set default action
192            $this->mAction = self::ACTION_FORMEDIT;
193        }
194
195        $hookQuery = null;
196
197        // ensure 'form' key exists
198        if ( array_key_exists( 'form', $this->mOptions ) ) {
199            $hookQuery = $this->mOptions['form'];
200        } else {
201            $this->mOptions['form'] = '';
202        }
203
204        // ensure 'target' key exists
205        if ( array_key_exists( 'target', $this->mOptions ) ) {
206            if ( $hookQuery !== null ) {
207                $hookQuery .= '/' . $this->mOptions['target'];
208            }
209        } else {
210            $this->mOptions['target'] = '';
211        }
212
213        // Normalize form and target names
214
215        $form = Title::newFromText( $this->mOptions['form'] );
216        if ( $form !== null ) {
217            $this->mOptions['form'] = $form->getPrefixedText();
218        }
219
220        $target = Title::newFromText( $this->mOptions['target'] );
221        if ( $target !== null ) {
222            $this->mOptions['target'] = $target->getPrefixedText();
223        }
224
225        MediaWikiServices::getInstance()->getHookContainer()->run( 'PageForms::SetTargetName', [ &$this->mOptions['target'], $hookQuery ] );
226
227        // set html return status. If all goes well, this will not be changed
228        $this->mStatus = 200;
229    }
230
231    /**
232     * Get the Title object of a form suitable for editing the target page.
233     *
234     * @return Title
235     * @throws MWException
236     */
237    protected function getFormTitle() {
238        // if no form was explicitly specified, try for explicitly set alternate forms
239        if ( $this->mOptions['form'] === '' ) {
240            $this->logMessage( 'No form specified. Will try to find the default form for the target page.', self::DEBUG );
241
242            $formNames = [];
243
244            // try explicitly set alternative forms
245            if ( array_key_exists( 'alt_form', $this->mOptions ) ) {
246                // cast to array to make sure we get an array, even if only a string was sent.
247                $formNames = (array)$this->mOptions['alt_form'];
248            }
249
250            // if no alternate forms were explicitly set, try finding a default form for the target page
251            if ( count( $formNames ) === 0 ) {
252                // if no form and and no alt forms and no target page was specified, give up
253                if ( $this->mOptions['target'] === '' ) {
254                    throw new MWException( $this->msg( 'pf_autoedit_notargetspecified' )->parse() );
255                }
256
257                $targetTitle = Title::newFromText( $this->mOptions['target'] );
258
259                // if the specified target title is invalid, give up
260                if ( !$targetTitle instanceof Title ) {
261                    throw new MWException( $this->msg( 'pf_autoedit_invalidtargetspecified', $this->mOptions['target'] )->parse() );
262                }
263
264                $formNames = PFFormLinker::getDefaultFormsForPage( $targetTitle );
265                if ( count( $formNames ) === 0 ) {
266                    throw new MWException( $this->msg( 'pf_autoedit_noformfound' )->parse() );
267                }
268
269            }
270
271            // if more than one form was found, issue a notice and give up
272            // this happens if no default form but several alternate forms are defined
273            if ( count( $formNames ) > 1 ) {
274                throw new MWException( $this->msg( 'pf_autoedit_toomanyformsfound' )->parse(), self::DEBUG );
275            }
276
277            $this->mOptions['form'] = $formNames[0];
278
279            $this->logMessage( 'Using ' . $this->mOptions['form'] . ' as default form.', self::DEBUG );
280        }
281
282        $formTitle = Title::makeTitleSafe( PF_NS_FORM, $this->mOptions['form'] );
283
284        // If the given form is not a valid title, give up.
285        if ( !( $formTitle instanceof Title ) ) {
286            throw new MWException( $this->msg( 'pf_autoedit_invalidform', $this->mOptions['form'] )->parse() );
287        }
288
289        // If the form page is a redirect, follow the redirect.
290        if ( $formTitle->isRedirect() ) {
291            $this->logMessage( 'Form ' . $this->mOptions['form'] . ' is a redirect. Finding target.', self::DEBUG );
292
293            $formWikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $formTitle );
294            $formTitle = $formWikiPage->getContent( RevisionRecord::RAW )->getUltimateRedirectTarget();
295
296            // if we exceeded $wgMaxRedirects or encountered an invalid redirect target, give up
297            if ( $formTitle->isRedirect() ) {
298                $newTitle = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $formTitle )->getRedirectTarget();
299
300                if ( $newTitle instanceof Title && $newTitle->isValidRedirectTarget() ) {
301                    throw new MWException( $this->msg( 'pf_autoedit_redirectlimitexeeded', $this->mOptions['form'] )->parse() );
302                } else {
303                    throw new MWException( $this->msg( 'pf_autoedit_invalidredirecttarget', $newTitle->getFullText(), $this->mOptions['form'] )->parse() );
304                }
305            }
306        }
307
308        // if specified or found form does not exist (e.g. is a red link), give up
309        // FIXME: Throw specialized error message, so a list of alternative forms can be shown
310        if ( !$formTitle->exists() ) {
311            throw new MWException( $this->msg( 'pf_autoedit_invalidform', $this->mOptions['form'] )->parse() );
312        }
313
314        return $formTitle;
315    }
316
317    protected function setupEditPage( $targetContent ) {
318        global $wgRequest;
319        // Find existing target article if it exists, or create a new one.
320        $targetTitle = Title::newFromText( $this->mOptions['target'] );
321
322        // If the specified target title is invalid, give up.
323        if ( !$targetTitle instanceof Title ) {
324            throw new MWException( $this->msg( 'pf_autoedit_invalidtargetspecified', $this->mOptions['target'] )->parse() );
325        }
326
327        $article = new Article( $targetTitle );
328
329        // set up a normal edit page
330        // we'll feed it our data to simulate a normal edit
331        $editor = new EditPage( $article );
332
333        // set up form data:
334        // merge data coming from the web request on top of some defaults
335        $data = array_merge(
336            [
337                'wpTextbox1' => $targetContent,
338                'wpUnicodeCheck' => 'ℳ𝒲♥𝓊𝓃𝒾𝒸ℴ𝒹ℯ',
339                'wpSummary' => '',
340                'wpStarttime' => wfTimestampNow(),
341                'wpEditToken' => isset( $this->mOptions[ 'token' ] ) ? $this->mOptions[ 'token' ] : $this->getUser()->getEditToken(),
342                'action' => 'submit',
343            ],
344            $this->mOptions
345        );
346
347        // Checks if the "Watch this page" checkbox is checked
348        if ( $wgRequest->getCheck( 'wpWatchthis' ) ) {
349            $data[ 'wpWatchthis' ] = true;
350        }
351
352        // Checks if the "Minor edit" checkbox is checked
353        if ( $wgRequest->getCheck( 'wpMinoredit' ) ) {
354            $data[ 'wpMinoredit' ] = true;
355        }
356
357        if ( array_key_exists( 'format', $data ) ) {
358            unset( $data['format'] );
359        }
360
361        // set up a faux request with the simulated data
362        $request = new FauxRequest( $data, true );
363
364        // and import it into the edit page
365        $editor->importFormData( $request );
366        $editor->pfFauxRequest = $request;
367
368        return $editor;
369    }
370
371    /**
372     * Sets the output HTML of wgOut as the module's result
373     */
374    protected function setResultFromOutput() {
375        // turn on output buffering
376        ob_start();
377
378        // generate preview document and write it to output buffer
379        $this->getOutput()->output();
380
381        // retrieve the preview document from output buffer
382        $targetHtml = ob_get_contents();
383
384        // clean output buffer, so MW can use it again
385        ob_clean();
386
387        // store the document as result
388        $this->getResult()->addValue( null, 'result', $targetHtml );
389    }
390
391    protected function doPreview( $editor ) {
392        $out = $this->getOutput();
393        $previewOutput = $editor->getPreviewText();
394
395        $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
396        $hookContainer->run( 'EditPage::showEditForm:initial', [ $editor, $out ] );
397
398        $out->setRobotPolicy( 'noindex,nofollow' );
399
400        // This hook seems slightly odd here, but makes things more
401        // consistent for extensions.
402        $hookContainer->run( 'OutputPageBeforeHTML', [ $out, $previewOutput ] );
403
404        $out->addHTML( Html::rawElement( 'div', [ 'id' => 'wikiPreview' ], $previewOutput ) );
405
406        $this->setResultFromOutput();
407    }
408
409    protected function doDiff( $editor ) {
410        $editor->showDiff();
411        $this->setResultFromOutput();
412    }
413
414    protected function doStore( EditPage $editor ) {
415        $title = $editor->getTitle();
416
417        // If they used redlink=1 and the page exists, redirect to the main article and send notice
418        if ( $this->getRequest()->getBool( 'redlink' ) && $title->exists() ) {
419            $this->logMessage( $this->msg( 'pf_autoedit_redlinkexists' )->parse(), self::WARNING );
420        }
421
422        $user = $this->getUser();
423
424        $services = MediaWikiServices::getInstance();
425        $permManager = $services->getPermissionManager();
426        $permErrors = $permManager->getPermissionErrors( 'edit', $user, $title );
427
428        // if this title needs to be created, user needs create rights
429        if ( !$title->exists() ) {
430            $permErrorsForCreate = $permManager->getPermissionErrors( 'create', $user, $title );
431            $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsForCreate, $permErrors ) );
432        }
433
434        if ( $permErrors ) {
435            // Auto-block user's IP if the account was "hard" blocked
436            $user->spreadAnyEditBlock();
437
438            foreach ( $permErrors as $error ) {
439                $this->logMessage( call_user_func_array( 'wfMessage', $error )->parse() );
440            }
441
442            return;
443        }
444
445        $resultDetails = [];
446        $isBot = $user->isAllowed( 'bot' );
447
448        $request = $editor->pfFauxRequest;
449        if ( $this->tokenOk( $request ) ) {
450            $ctx = RequestContext::getMain();
451            $tempTitle = $ctx->getTitle();
452            // We add an @ before the setTitle() calls to silence
453            // the "Unexpected clearActionName after getActionName"
454            // PHP notice that MediaWiki outputs.
455            // @todo Make a real fix for this.
456            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
457            @$ctx->setTitle( $title );
458            $status = $editor->internalAttemptSave( $resultDetails, $isBot );
459            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
460            @$ctx->setTitle( $tempTitle );
461        } else {
462            throw new MWException( $this->msg( 'session_fail_preview' )->parse() );
463        }
464
465        switch ( $status->value ) {
466            case EditPage::AS_HOOK_ERROR_EXPECTED:
467                // A hook function returned an error
468                // show normal Edit page
469
470                // remove Preview and Diff standard buttons from editor page
471                $services->getHookContainer()->register( 'EditPageBeforeEditButtons', static function ( &$editor, &$buttons, &$tabindex ) {
472                    foreach ( array_keys( $buttons ) as $key ) {
473                        if ( $key !== 'save' ) {
474                            unset( $buttons[$key] );
475                        }
476                    }
477                } );
478
479                // Context title needed for correct Cancel link
480                $editor->setContextTitle( $title );
481
482                $editor->showEditForm();
483                // success
484                return false;
485
486            case EditPage::AS_CONTENT_TOO_BIG:
487                // Content too big (> $wgMaxArticleSize)
488            case EditPage::AS_ARTICLE_WAS_DELETED:
489                // article was deleted while editing and param wpRecreate == false or form was not posted
490            case EditPage::AS_CONFLICT_DETECTED:
491                // (non-resolvable) edit conflict
492            case EditPage::AS_SUMMARY_NEEDED:
493                // no edit summary given and the user has forceeditsummary set
494                // and the user is not editting in his own userspace or
495                // talkspace and wpIgnoreBlankSummary == false
496            case EditPage::AS_TEXTBOX_EMPTY:
497                // user tried to create a new section without content
498            case EditPage::AS_MAX_ARTICLE_SIZE_EXCEEDED:
499                // article is too big (> $wgMaxArticleSize), after merging in the new section
500            case EditPage::AS_END:
501                // WikiPage::doEdit() was unsuccessful
502                throw new MWException( $this->msg( 'pf_autoedit_fail', $this->mOptions['target'] )->parse() );
503
504            case EditPage::AS_HOOK_ERROR:
505                // Article update aborted by a hook function
506                $this->logMessage( 'Article update aborted by a hook function', self::DEBUG );
507                return false;
508
509            case EditPage::AS_PARSE_ERROR:
510                // Can't parse content
511                throw new MWException( $status->getHTML() );
512
513            case EditPage::AS_SUCCESS_NEW_ARTICLE:
514                // Article successfully created
515                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
516                $query = $resultDetails['redirect'] ? 'redirect=no' : '';
517                $anchor = isset( $resultDetails['sectionanchor'] ) ? $resultDetails['sectionanchor'] : '';
518
519                // Give extensions a chance to modify URL query on create
520                $sectionanchor = null;
521                $extraQuery = null;
522                MediaWikiServices::getInstance()->getHookContainer()->run( 'ArticleUpdateBeforeRedirect', [ $editor->getArticle(), &$sectionanchor, &$extraQuery ] );
523
524                // @phan-suppress-next-line PhanImpossibleCondition
525                if ( $extraQuery ) {
526                    if ( $query ) {
527                        $query .= '&' . $extraQuery;
528                    } else {
529                        $query .= $extraQuery;
530                    }
531                }
532
533                $redirect = $title->getFullURL( $query ) . $anchor;
534
535                $returnto = Title::newFromText( $this->getRequest()->getText( 'returnto' ) );
536                $reload = $this->getRequest()->getText( 'reload' );
537                if ( $returnto !== null ) {
538                    // Purge the returnto page
539                    $returntoPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $returnto );
540                    if ( $returntoPage->exists() && $reload ) {
541                        $returntoPage->doPurge();
542                    }
543                    $redirect = $returnto->getFullURL();
544                }
545
546                $this->getOutput()->redirect( $redirect );
547                $this->getResult()->addValue( null, 'redirect', $redirect );
548                return false;
549
550            case EditPage::AS_SUCCESS_UPDATE:
551                // Article successfully updated
552                $extraQuery = '';
553                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
554                $sectionanchor = $resultDetails['sectionanchor'];
555
556                // Give extensions a chance to modify URL query on update
557                MediaWikiServices::getInstance()->getHookContainer()->run( 'ArticleUpdateBeforeRedirect', [ $editor->getArticle(), &$sectionanchor, &$extraQuery ] );
558
559                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
560                if ( $resultDetails['redirect'] ) {
561                    // @phan-suppress-next-line PhanSuspiciousValueComparison
562                    if ( $extraQuery == '' ) {
563                        $extraQuery = 'redirect=no';
564                    } else {
565                        $extraQuery = 'redirect=no&' . $extraQuery;
566                    }
567                }
568
569                $redirect = $title->getFullURL( $extraQuery ) . $sectionanchor;
570
571                $returnto = Title::newFromText( $this->getRequest()->getText( 'returnto' ) );
572                $reload = $this->getRequest()->getText( 'reload' );
573                if ( $returnto !== null ) {
574                    // Purge the returnto page
575                    $returntoPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $returnto );
576                    if ( $returntoPage->exists() && $reload ) {
577                        $returntoPage->doPurge();
578                    }
579                    $redirect = $returnto->getFullURL();
580                }
581
582                $this->getOutput()->redirect( $redirect );
583                $this->getResult()->addValue( null, 'redirect', $redirect );
584
585                return false;
586
587            case EditPage::AS_BLANK_ARTICLE:
588                // user tried to create a blank page
589                $this->logMessage( 'User tried to create a blank page', self::DEBUG );
590                try {
591                    $contextTitle = $editor->getContextTitle();
592                } catch ( Exception $e ) {
593                    // getContextTitle() throws an exception
594                    // if there's no context title - this
595                    // happens when using the one-stop process.
596                    throw new RuntimeException( 'Error: Saving this form would result in a blank page.' );
597                }
598
599                $this->getOutput()->redirect( $contextTitle->getFullURL() );
600                $this->getResult()->addValue( null, 'redirect', $contextTitle->getFullURL() );
601
602                return false;
603
604            case EditPage::AS_SPAM_ERROR:
605                // summary contained spam according to one of the regexes in $wgSummarySpamRegex
606                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
607                $match = $resultDetails['spam'];
608                if ( is_array( $match ) ) {
609                    $match = $this->getLanguage()->listToText( $match );
610                }
611
612                // FIXME: Include better error message
613                throw new MWException( $this->msg( 'spamprotectionmatch', wfEscapeWikiText( $match ) )->parse() );
614
615            case EditPage::AS_BLOCKED_PAGE_FOR_USER:
616                // User is blocked from editing editor page
617                throw new UserBlockedError( $this->getUser()->getBlock() );
618
619            case EditPage::AS_IMAGE_REDIRECT_ANON:
620                // anonymous user is not allowed to upload (User::isAllowed('upload') == false)
621            case EditPage::AS_IMAGE_REDIRECT_LOGGED:
622                // logged in user is not allowed to upload (User::isAllowed('upload') == false)
623                throw new PermissionsError( 'upload' );
624
625            case EditPage::AS_READ_ONLY_PAGE_ANON:
626                // editor anonymous user is not allowed to edit editor page
627            case EditPage::AS_READ_ONLY_PAGE_LOGGED:
628                // editor logged in user is not allowed to edit editor page
629                throw new PermissionsError( 'edit' );
630
631            case EditPage::AS_READ_ONLY_PAGE:
632                // wiki is in readonly mode
633                throw new ReadOnlyError;
634
635            case EditPage::AS_RATE_LIMITED:
636                // rate limiter for action 'edit' was tripped
637                throw new ThrottledError();
638
639            case EditPage::AS_NO_CREATE_PERMISSION:
640                // user tried to create editor page, but is not allowed to do
641                // that ( Title->usercan('create') == false )
642                $permission = $title->isTalkPage() ? 'createtalk' : 'createpage';
643                throw new PermissionsError( $permission );
644
645            default:
646                // We don't recognize $status->value. Presumably this can only
647                // happen if some other extension set the value.
648                throw new MWException( $status->getHTML() );
649        }
650    }
651
652    protected function finalizeResults() {
653        // set response text depending on the status and the requested action
654        if ( $this->mStatus === 200 ) {
655            if ( array_key_exists( 'ok text', $this->mOptions ) ) {
656                $targetTitle = Title::newFromText( $this->mOptions['target'] );
657                $responseText = $this->getMessageCache()->parse( $this->mOptions['error text'], $targetTitle )->getText();
658            } elseif ( $this->mAction === self::ACTION_SAVE ) {
659                // We turn this into a link of the form [[:A|A]]
660                // so that pages in the File: namespace won't
661                // cause the actual image to be displayed.
662                $targetText = ':' . $this->mOptions['target'] . '|' . $this->mOptions['target'];
663                $responseText = $this->msg( 'pf_autoedit_success', $targetText, $this->mOptions['form'] )->parse();
664            } else {
665                $responseText = null;
666            }
667        } else {
668            // get errortext (or use default)
669            if ( array_key_exists( 'error text', $this->mOptions ) ) {
670                $targetTitle = Title::newFromText( $this->mOptions['target'] );
671                $responseText = $this->getMessageCache()->parse( $this->mOptions['error text'], $targetTitle )->getText();
672            } elseif ( $this->mAction === self::ACTION_SAVE ) {
673                $targetText = ':' . $this->mOptions['target'] . '|' . $this->mOptions['target'];
674                $responseText = $this->msg( 'pf_autoedit_fail', $targetText )->parse();
675            } else {
676                $responseText = null;
677            }
678        }
679
680        $result = $this->getResult();
681
682        if ( $responseText !== null ) {
683            $result->addValue( null, 'responseText', $responseText );
684        }
685
686        $result->addValue( null, 'status', $this->mStatus, true );
687        $result->addValue( [ 'form' ], 'title', $this->mOptions['form'] );
688        $result->addValue( null, 'target', $this->mOptions['target'], true );
689    }
690
691    /**
692     * Set custom headers to attach to the answer
693     */
694    protected function setHeaders() {
695        if ( !headers_sent() ) {
696            header( 'X-Status: ' . $this->mStatus, true, $this->mStatus );
697            header( 'X-Form: ' . $this->mOptions['form'] );
698            header( 'X-Target: ' . $this->mOptions['target'] );
699
700            $redirect = $this->getOutput()->getRedirect();
701            if ( $redirect ) {
702                header( 'X-Location: ' . $redirect );
703            }
704        }
705    }
706
707    /**
708     * Generates a target name from the given target name formula
709     *
710     * This parses the formula and replaces &lt;unique number&gt; tags
711     *
712     * @param string $targetNameFormula
713     *
714     * @throws MWException
715     * @return string
716     */
717    protected function generateTargetName( $targetNameFormula ) {
718        $targetName = $targetNameFormula;
719
720        // Prepend a super-page, if one was specified.
721        if ( $this->getRequest()->getCheck( 'super_page' ) ) {
722            $targetName = $this->getRequest()->getVal( 'super_page' ) . '/' . $targetName;
723        }
724
725        // Prepend a namespace, if one was specified.
726        if ( $this->getRequest()->getCheck( 'namespace' ) ) {
727            $targetName = $this->getRequest()->getVal( 'namespace' ) . ':' . $targetName;
728        }
729
730        // replace "unique number" tag with one that won't get erased by the next line
731        $targetName = preg_replace( '/<unique number(.*)>/', '{num\1}', $targetName, 1 );
732
733        // If any formula stuff is still in the name after the parsing,
734        // just remove it.
735        // FIXME: This is wrong. If anything is still left, something
736        // should have been present in the form and wasn't. An error
737        // should be raised.
738        // $targetName = StringUtils::delimiterReplace( '<', '>', '', $targetName );
739
740        // Replace spaces back with underlines, in case a magic word or
741        // parser function name contains underlines - hopefully this
742        // won't cause problems of its own.
743        $targetName = str_replace( ' ', '_', $targetName );
744
745        // Now run the parser on it.
746        $parserOptions = ParserOptions::newFromUser( $this->getUser() );
747        $targetName = PFUtils::getParser()->transformMsg(
748            $targetName, $parserOptions, $this->getTitle()
749        );
750
751        $titleNumber = '';
752        $isRandom = false;
753        $randomNumHasPadding = false;
754        $randomNumDigits = 6;
755
756        if ( preg_match( '/{num.*}/', $targetName, $matches ) && strpos( $targetName, '{num' ) !== false ) {
757            // Random number
758            if ( preg_match( '/{num;random(;(0)?([1-9][0-9]*))?}/', $targetName, $matches ) ) {
759                $isRandom = true;
760                $randomNumHasPadding = array_key_exists( 2, $matches );
761                $randomNumDigits = ( array_key_exists( 3, $matches ) ? $matches[3] : $randomNumDigits );
762                $titleNumber = self::makeRandomNumber( $randomNumDigits, $randomNumHasPadding );
763            } elseif ( preg_match( '/{num.*start[_]*=[_]*([^;]*).*}/', $targetName, $matches ) ) {
764                // get unique number start value
765                // from target name; if it's not
766                // there, or it's not a positive
767                // number, start it out as blank
768                if ( count( $matches ) == 2 && is_numeric( $matches[1] ) && $matches[1] >= 0 ) {
769                    // the "start" value"
770                    $titleNumber = $matches[1];
771                }
772            } elseif ( preg_match( '/^(_?{num.*}?)*$/', $targetName, $matches ) ) {
773                // the target name contains only underscores and number fields,
774                // i.e. would result in an empty title without the number set
775                $titleNumber = '1';
776            }
777
778            // set target title
779            $targetTitle = Title::newFromText( preg_replace( '/{num.*}/', $titleNumber, $targetName ) );
780
781            // if the specified target title is invalid, give up
782            if ( !$targetTitle instanceof Title ) {
783                $targetString = trim( preg_replace( '/<unique number(.*)>/', $titleNumber, $targetNameFormula ) );
784                throw new MWException( $this->msg( 'pf_autoedit_invalidtargetspecified', $targetString )->parse() );
785            }
786
787            // If title exists already, cycle through numbers for
788            // this tag until we find one that gives a nonexistent
789            // page title.
790            // We cannot use $targetTitle->exists(); it does not use
791            // IDBAccessObject::READ_LATEST, which is needed to get
792            // correct data from cache; use
793            // $targetTitle->getArticleID() instead.
794            $numAttemptsAtTitle = 0;
795            while ( $targetTitle->getArticleID( IDBAccessObject::READ_LATEST ) !== 0 ) {
796                $numAttemptsAtTitle++;
797
798                if ( $isRandom ) {
799                    // If the set of pages is "crowded"
800                    // already, go one digit higher.
801                    if ( $numAttemptsAtTitle > 20 ) {
802                        $randomNumDigits++;
803                    }
804                    $titleNumber = self::makeRandomNumber( $randomNumDigits, $randomNumHasPadding );
805                } elseif ( $titleNumber == "" ) {
806                    // If title number is blank, change it to 2;
807                    // otherwise, increment it, and if necessary
808                    // pad it with leading 0s as well.
809                    $titleNumber = 2;
810                } else {
811                    $titleNumber = str_pad( $titleNumber + 1, strlen( $titleNumber ), '0', STR_PAD_LEFT );
812                }
813
814                $targetTitle = Title::newFromText( preg_replace( '/{num.*}/', $titleNumber, $targetName ) );
815            }
816
817            $targetName = $targetTitle->getPrefixedText();
818        }
819
820        return $targetName;
821    }
822
823    /**
824     * Returns a formatted (pseudo) random number
825     *
826     * @param int $numDigits the min width of the random number
827     * @param bool $hasPadding should the number should be padded with zeros instead of spaces?
828     * @return string
829     */
830    static function makeRandomNumber( $numDigits = 1, $hasPadding = false ) {
831        $maxValue = pow( 10, $numDigits ) - 1;
832        if ( $maxValue > getrandmax() ) {
833            $maxValue = getrandmax();
834        }
835        $value = rand( 0, $maxValue );
836        $format = '%' . ( $hasPadding ? '0' : '' ) . $numDigits . 'd';
837        // trim() is needed, when $hasPadding == false
838        return trim( sprintf( $format, $value ) );
839    }
840
841    /**
842     * Depending on the requested action this method will try to
843     * store/preview the data in mOptions or retrieve the edit form.
844     *
845     * The form and target page will be available in mOptions after
846     * execution of the method.
847     *
848     * Errors and warnings are logged in the API result under the 'errors'
849     * key. The general request status is maintained in mStatus.
850     *
851     * @throws MWException
852     */
853    public function doAction() {
854        global $wgRequest, $wgPageFormsFormPrinter;
855
856        // If the wiki is read-only, do not save.
857        if ( MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
858            if ( $this->mAction === self::ACTION_SAVE ) {
859                throw new MWException( $this->msg( 'pf_autoedit_readonly', MediaWikiServices::getInstance()->getReadOnlyMode()->getReason() )->parse() );
860            }
861
862            // even if not saving notify client anyway. Might want to display a notice
863            $this->logMessage( $this->msg( 'pf_autoedit_readonly', MediaWikiServices::getInstance()->getReadOnlyMode()->getReason() )->parse(), self::NOTICE );
864        }
865
866        // find the title of the form to be used
867        $formTitle = $this->getFormTitle();
868
869        // Get the form content - remove the <noinclude> tags from the text of the Form: page.
870        $formContent = StringUtils::delimiterReplace(
871            '<noinclude>', '</noinclude>', '',
872            PFUtils::getPageText( $formTitle, RevisionRecord::RAW )
873        );
874
875        // signals that the form was submitted
876        // always true, else we would not be here
877        $isFormSubmitted = $this->mAction === self::ACTION_SAVE || $this->mAction === self::ACTION_PREVIEW || $this->mAction === self::ACTION_DIFF;
878
879        // the article id of the form to be used
880        $formArticleId = $formTitle->getArticleID();
881
882        // the name of the target page; might be empty when using the one-step-process
883        $targetName = $this->mOptions['target'];
884
885        // if the target page was not specified, try finding the page name formula
886        // (Why is this not done in PFFormPrinter::formHTML?)
887        if ( $targetName === '' ) {
888            // Parse the form to see if it has a 'page name' value set.
889            if ( preg_match( '/{{{\s*info.*page name\s*=\s*(.*)}}}/msU', $formContent, $matches ) ) {
890                $pageNameElements = PFUtils::getFormTagComponents( trim( $matches[1] ) );
891                $targetNameFormula = $pageNameElements[0];
892            } else {
893                throw new MWException( $this->msg( 'pf_autoedit_notargetspecified' )->parse() );
894            }
895
896            $targetTitle = null;
897        } else {
898            $targetNameFormula = null;
899            $targetTitle = Title::newFromText( $targetName );
900        }
901
902        $preloadContent = '';
903
904        // save $wgRequest for later restoration
905        $oldRequest = $wgRequest;
906        $pageExists = false;
907
908        if ( $targetTitle !== null && $targetTitle->exists() ) {
909            if ( !$isFormSubmitted || $this->mIsAutoEdit ) {
910                $preloadContent = PFUtils::getPageText( $targetTitle, RevisionRecord::RAW );
911            }
912            $pageExists = true;
913        } elseif ( isset( $this->mOptions['preload'] ) && is_string( $this->mOptions['preload'] ) ) {
914            $preloadTitle = Title::newFromText( $this->mOptions['preload'] );
915
916            if ( $preloadTitle !== null && $preloadTitle->exists() ) {
917                // the content of the page that was specified to be used for preloading
918                $preloadContent = PFUtils::getPageText( $preloadTitle, RevisionRecord::RAW );
919            } else {
920                $this->logMessage( $this->msg( 'pf_autoedit_invalidpreloadspecified', $this->mOptions['preload'] )->parse(), self::WARNING );
921            }
922        }
923
924        // Allow extensions to set/change the preload text, for new
925        // pages.
926        if ( !$pageExists ) {
927            MediaWikiServices::getInstance()->getHookContainer()->run( 'PageForms::EditFormPreloadText', [ &$preloadContent, $targetTitle, $formTitle ] );
928        } else {
929            MediaWikiServices::getInstance()->getHookContainer()->run( 'PageForms::EditFormInitialText', [ &$preloadContent, $targetTitle, $formTitle ] );
930        }
931
932        // Flag to keep track of formHTML() runs.
933        $formHtmlHasRun = false;
934
935        $formContext = $this->mIsAutoEdit ? PFFormPrinter::CONTEXT_AUTOEDIT : PFFormPrinter::CONTEXT_REGULAR;
936
937        if ( $preloadContent !== '' ) {
938            // Spoof $wgRequest for PFFormPrinter::formHTML().
939            $session = RequestContext::getMain()->getRequest()->getSession();
940            $wgRequest = new FauxRequest( $this->mOptions, true, $session );
941            // Call PFFormPrinter::formHTML() to get at the form
942            // HTML of the existing page.
943            list( $formHTML, $targetContent, $form_page_title, $generatedTargetNameFormula ) =
944                $wgPageFormsFormPrinter->formHTML(
945                    // Special handling for autoedit edits -
946                    // otherwise, multi-instance templates
947                    // don't get saved, for some convoluted
948                    // reason.
949                    $formContent, ( $isFormSubmitted && !$this->mIsAutoEdit ), $pageExists,
950                    $formArticleId, $preloadContent, $targetName, $targetNameFormula,
951                    $formContext, $autocreate_query = [], $this->getUser()
952                );
953            $formHtmlHasRun = true;
954
955            // Parse the data to be preloaded from the form HTML of
956            // the existing page.
957            $data = $this->parseDataFromHTMLFrag( $formHTML );
958
959            // ...and merge/overwrite it with the new data.
960            $this->mOptions = PFUtils::arrayMergeRecursiveDistinct( $data, $this->mOptions );
961        }
962
963        // We already preloaded stuff for saving/previewing -
964        // do not do this again.
965        if ( $isFormSubmitted ) {
966            $preloadContent = '';
967            $pageExists = false;
968        } else {
969            // Source of the data is a page.
970            $pageExists = ( is_a( $targetTitle, 'Title' ) && $targetTitle->exists() );
971        }
972
973        // Get wikitext for submitted data and form - call formHTML(),
974        // if we haven't called it already.
975        if ( $preloadContent == '' ) {
976            // Spoof $wgRequest for PFFormPrinter::formHTML().
977            $session = RequestContext::getMain()->getRequest()->getSession();
978            $wgRequest = new FauxRequest( $this->mOptions, true, $session );
979            list( $formHTML, $targetContent, $generatedFormName, $generatedTargetNameFormula ) =
980                $wgPageFormsFormPrinter->formHTML(
981                    $formContent, $isFormSubmitted, $pageExists,
982                    $formArticleId, $preloadContent, $targetName, $targetNameFormula,
983                    $formContext, $autocreate_query = [], $this->getUser()
984                );
985            // Restore original request.
986            $wgRequest = $oldRequest;
987        } else {
988            $generatedFormName = $form_page_title;
989        }
990
991        if ( $generatedFormName !== '' ) {
992            $this->mOptions['formtitle'] = $generatedFormName;
993        }
994
995        $this->mOptions['formHTML'] = $formHTML;
996
997        if ( $isFormSubmitted ) {
998            // If the target page was not specified, see if
999            // something was generated from the target name formula.
1000            if ( $this->mOptions['target'] === '' ) {
1001                // If no name was generated, we cannot save => give up
1002                if ( $generatedTargetNameFormula === '' ) {
1003                    throw new MWException( $this->msg( 'pf_autoedit_notargetspecified' )->parse() );
1004                }
1005
1006                $this->mOptions['target'] = $this->generateTargetName( $generatedTargetNameFormula );
1007            }
1008
1009            $contextTitle = Title::newFromText( $this->mOptions['target'] );
1010
1011            // Lets other code process additional form-definition syntax
1012            MediaWikiServices::getInstance()->getHookContainer()->run( 'PageForms::WritePageData', [ $this->mOptions['form'], $contextTitle, &$targetContent ] );
1013
1014            $editor = $this->setupEditPage( $targetContent );
1015
1016            // Perform the requested action.
1017            if ( $this->mAction === self::ACTION_PREVIEW ) {
1018                $editor->setContextTitle( $contextTitle );
1019                $this->doPreview( $editor );
1020            } elseif ( $this->mAction === self::ACTION_DIFF ) {
1021                $this->doDiff( $editor );
1022            } else {
1023                $this->doStore( $editor );
1024            }
1025        } elseif ( $this->mAction === self::ACTION_FORMEDIT ) {
1026            $out = $this->getOutput();
1027            $parserOutput = PFUtils::getParser()->getOutput();
1028            $out->addParserOutputMetadata( $parserOutput );
1029
1030            $this->getResult()->addValue( [ 'form' ], 'HTML', $formHTML );
1031        }
1032    }
1033
1034    private function tokenOk( WebRequest $request ) {
1035        $token = $request->getVal( 'wpEditToken' );
1036        $user = $this->getUser();
1037        return $user->matchEditToken( $token );
1038    }
1039
1040    private function parseDataFromHTMLFrag( $html ) {
1041        $data = [];
1042        $doc = new DOMDocument();
1043        if ( LIBXML_VERSION < 20900 ) {
1044            // PHP < 8
1045            $oldVal = libxml_disable_entity_loader( true );
1046        }
1047
1048        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1049        @$doc->loadHTML(
1050            '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd"><html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/></head><body>'
1051            . $html
1052            . '</body></html>'
1053        );
1054
1055        if ( LIBXML_VERSION < 20900 ) {
1056            // PHP < 8
1057            libxml_disable_entity_loader( $oldVal );
1058        }
1059
1060        // Process input tags.
1061        $inputs = $doc->getElementsByTagName( 'input' );
1062
1063        for ( $i = 0; $i < $inputs->length; $i++ ) {
1064            $input = $inputs->item( $i );
1065            '@phan-var DOMElement $input';/** @var DOMElement $input */
1066            $type = $input->getAttribute( 'type' );
1067            $name = trim( $input->getAttribute( 'name' ) );
1068
1069            if ( !$name ) {
1070                continue;
1071            }
1072            if ( $input->hasAttribute( 'disabled' ) ) {
1073                // Remove fields from mOptions which are restricted or disabled
1074                // so that they do not get edited in an #autoedit call.
1075                $restrictedField = preg_split( "/[\[\]]/", $name, -1, PREG_SPLIT_NO_EMPTY );
1076                if ( $restrictedField && count( $restrictedField ) > 1 ) {
1077                    unset( $this->mOptions[$restrictedField[0]][$restrictedField[1]] );
1078                }
1079                continue;
1080            }
1081
1082            if ( $type === '' ) {
1083                $type = 'text';
1084            }
1085
1086            switch ( $type ) {
1087                case 'checkbox':
1088                case 'radio':
1089                    if ( $input->hasAttribute( 'checked' ) ) {
1090                        self::addToArray( $data, $name, $input->getAttribute( 'value' ) );
1091                    }
1092                    break;
1093
1094                // case 'button':
1095                case 'hidden':
1096                case 'image':
1097                case 'password':
1098                case 'date':
1099                case 'datetime':
1100                // case 'reset':
1101                // case 'submit':
1102                case 'text':
1103                    self::addToArray( $data, $name, $input->getAttribute( 'value' ) );
1104                    break;
1105            }
1106        }
1107
1108        // Process select tags
1109        $selects = $doc->getElementsByTagName( 'select' );
1110
1111        for ( $i = 0; $i < $selects->length; $i++ ) {
1112            $select = $selects->item( $i );
1113            $name = trim( $select->getAttribute( 'name' ) );
1114
1115            if ( !$name || $select->hasAttribute( 'disabled' ) ) {
1116                // Remove fields from mOptions which are restricted or disabled
1117                // so that they do not get edited in an #autoedit call.
1118                $restrictedField = preg_split( "/[\[\]]/", $name, -1, PREG_SPLIT_NO_EMPTY );
1119                if ( $restrictedField ) {
1120                    unset( $this->mOptions[$restrictedField[0]][$restrictedField[1]] );
1121                }
1122                continue;
1123            }
1124
1125            $options = $select->getElementsByTagName( 'option' );
1126
1127            // If the current $select is a radio button select
1128            // (i.e. not multiple) set the first option to selected
1129            // as default. This may be overwritten in the loop below.
1130            if ( $options->length > 0 && ( !$select->hasAttribute( 'multiple' ) ) ) {
1131                self::addToArray( $data, $name, $options->item( 0 )->getAttribute( 'value' ) );
1132            }
1133
1134            for ( $o = 0; $o < $options->length; $o++ ) {
1135                if ( $options->item( $o )->hasAttribute( 'selected' ) ) {
1136                    if ( $options->item( $o )->getAttribute( 'value' ) ) {
1137                        self::addToArray( $data, $name, $options->item( $o )->getAttribute( 'value' ) );
1138                    } else {
1139                        self::addToArray( $data, $name, $options->item( $o )->nodeValue );
1140                    }
1141                }
1142            }
1143        }
1144
1145        // Process textarea tags
1146        $textareas = $doc->getElementsByTagName( 'textarea' );
1147
1148        for ( $i = 0; $i < $textareas->length; $i++ ) {
1149            $textarea = $textareas->item( $i );
1150            $name = trim( $textarea->getAttribute( 'name' ) );
1151
1152            if ( !$name ) {
1153                continue;
1154            }
1155
1156            self::addToArray( $data, $name, $textarea->textContent );
1157        }
1158
1159        return $data;
1160    }
1161
1162    /**
1163     * Parses data from a query string into the $data array
1164     *
1165     * @param array &$data
1166     * @param string $queryString
1167     * @return array
1168     */
1169    private function parseDataFromQueryString( &$data, $queryString ) {
1170        $params = explode( '&', $queryString );
1171
1172        foreach ( $params as $param ) {
1173            $elements = explode( '=', $param, 2 );
1174
1175            $key = trim( urldecode( $elements[0] ) );
1176            $value = count( $elements ) > 1 ? urldecode( $elements[1] ) : null;
1177
1178            if ( $key == "query" || $key == "query string" ) {
1179                $this->parseDataFromQueryString( $data, $value );
1180            } else {
1181                self::addToArray( $data, $key, $value );
1182            }
1183        }
1184
1185        return $data;
1186    }
1187
1188    /**
1189     * This function recursively inserts the value into a tree.
1190     *
1191     * @param array &$array is root
1192     * @param string $key identifies path to position in tree.
1193     *    Format: 1stLevelName[2ndLevel][3rdLevel][...], i.e. normal array notation
1194     * @param mixed $value the value to insert
1195     * @param bool $toplevel if this is a toplevel value.
1196     */
1197    public static function addToArray( &$array, $key, $value, $toplevel = true ) {
1198        $matches = [];
1199        if ( preg_match( '/^([^\[\]]*)\[([^\[\]]*)\](.*)/', $key, $matches ) ) {
1200            // for some reason toplevel keys get their spaces encoded by MW.
1201            // We have to imitate that.
1202            if ( $toplevel ) {
1203                $key = str_replace( ' ', '_', $matches[1] );
1204            } else {
1205                if ( is_numeric( $matches[1] ) && isset( $matches[2] ) ) {
1206                    // Multiple instances are indexed like 0a,1a,2a... to differentiate
1207                    // the inputs the form starts out with from any inputs added by the Javascript.
1208                    // Append the character "a" only if the instance number is numeric.
1209                    // If the key(i.e. the instance) doesn't exists then the numerically next
1210                    // instance is created whatever be the key.
1211                    $key = $matches[1] . 'a';
1212                } else {
1213                    $key = $matches[1];
1214                }
1215            }
1216            // if subsequent element does not exist yet or is a string (we prefer arrays over strings)
1217            if ( !array_key_exists( $key, $array ) || is_string( $array[$key] ) ) {
1218                $array[$key] = [];
1219            }
1220
1221            // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
1222            self::addToArray( $array[$key], $matches[2] . $matches[3], $value, false );
1223        } else {
1224            if ( $key ) {
1225                // only add the string value if there is no child array present
1226                if ( !array_key_exists( $key, $array ) || !is_array( $array[$key] ) ) {
1227                    $array[$key] = $value;
1228                }
1229            } else {
1230                array_push( $array, $value );
1231            }
1232        }
1233    }
1234
1235    /**
1236     * Get a MessageCache depending on mediawiki version
1237     * @return MessageCache
1238     */
1239    private function getMessageCache() {
1240        return MediaWikiServices::getInstance()->getMessageCache();
1241    }
1242
1243    /**
1244     * Add error message to the ApiResult
1245     *
1246     * @param string $msg
1247     * @param int $errorLevel
1248     *
1249     * @return string
1250     */
1251    private function logMessage( $msg, $errorLevel = self::ERROR ) {
1252        if ( $errorLevel === self::ERROR ) {
1253            $this->mStatus = 400;
1254        }
1255
1256        $this->getResult()->addValue( [ 'errors' ], null, [ 'level' => $errorLevel, 'message' => $msg ] );
1257
1258        return $msg;
1259    }
1260
1261    /**
1262     * Indicates whether this module requires write mode
1263     * @return bool
1264     */
1265    public function isWriteMode() {
1266        return true;
1267    }
1268
1269    /**
1270     * Returns the array of allowed parameters (parameter name) => (default
1271     * value) or (parameter name) => (array with PARAM_* constants as keys)
1272     * Don't call this function directly: use getFinalParams() to allow
1273     * hooks to modify parameters as needed.
1274     *
1275     * @return array or false
1276     */
1277    function getAllowedParams() {
1278        return [
1279            'form' => null,
1280            'target' => null,
1281            'query' => null,
1282            'preload' => null
1283        ];
1284    }
1285
1286    /**
1287     * Returns an array of parameter descriptions.
1288     * Don't call this function directly: use getFinalParamDescription() to
1289     * allow hooks to modify descriptions as needed.
1290     *
1291     * @return array or false
1292     */
1293    function getParamDescription() {
1294        return [
1295            'form' => 'The form to use.',
1296            'target' => 'The target page.',
1297            'query' => 'The query string.',
1298            'preload' => 'The name of a page to preload'
1299        ];
1300    }
1301
1302    /**
1303     * Returns the description string for this module
1304     *
1305     * @return string|string[]
1306     */
1307    function getDescription() {
1308        return <<<END
1309This module is used to remotely create or edit pages using Page Forms.
1310
1311Add "template-name[field-name]=field-value" to the query string parameter, to set the value for a specific field.
1312To set values for more than one field use "&", or rather its URL encoded version "%26": "template-name[field-name-1]=field-value-1%26template-name[field-name-2]=field-value-2".
1313See the first example below.
1314
1315In addition to the query parameter, any parameter in the URL of the form "template-name[field-name]=field-value" will be treated as part of the query. See the second example.
1316END;
1317    }
1318
1319    /**
1320     * Returns usage examples for this module.
1321     *
1322     * @return string|string[]
1323     */
1324    protected function getExamples() {
1325        return [
1326            'With query parameter:    api.php?action=pfautoedit&form=form-name&target=page-name&query=template-name[field-name-1]=field-value-1%26template-name[field-name-2]=field-value-2',
1327            'Without query parameter: api.php?action=pfautoedit&form=form-name&target=page-name&template-name[field-name-1]=field-value-1&template-name[field-name-2]=field-value-2'
1328        ];
1329    }
1330
1331    /**
1332     * Returns a string that identifies the version of the class.
1333     * Includes the class name, the svn revision, timestamp, and
1334     * last author.
1335     *
1336     * @return string
1337     */
1338    function getVersion() {
1339        global $wgPageFormsIP;
1340        $gitSha1 = SpecialVersion::getGitHeadSha1( $wgPageFormsIP );
1341        return __CLASS__ . '-' . PF_VERSION . ( $gitSha1 !== false ) ? ' (' . substr( $gitSha1, 0, 7 ) . ')' : '';
1342    }
1343
1344}