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