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