Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 551 |
|
0.00% |
0 / 30 |
CRAP | |
0.00% |
0 / 1 |
| PFAutoeditAPI | |
0.00% |
0 / 551 |
|
0.00% |
0 / 30 |
44310 | |
0.00% |
0 / 1 |
| addOptionsFromString | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getOptions | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getAction | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setOptions | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setOption | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getStatus | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| execute | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
| prepareAction | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
272 | |||
| getFormTitle | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
156 | |||
| setupEditPage | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
42 | |||
| setResultFromOutput | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| doPreview | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| doDiff | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| doStore | |
0.00% |
0 / 131 |
|
0.00% |
0 / 1 |
2756 | |||
| finalizeResults | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
42 | |||
| setHeaders | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| generateTargetName | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
306 | |||
| makeRandomNumber | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| doAction | |
0.00% |
0 / 87 |
|
0.00% |
0 / 1 |
870 | |||
| tokenOk | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| parseDataFromHTMLFrag | |
0.00% |
0 / 64 |
|
0.00% |
0 / 1 |
870 | |||
| parseDataFromQueryString | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
| addToArray | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
110 | |||
| logMessage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| isWriteMode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getAllowedParams | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| getParamDescription | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| getDescription | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getExamples | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| getVersion | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @author Stephan Gambke |
| 4 | * @author Yaron Koren |
| 5 | * @file |
| 6 | * @ingroup PageForms |
| 7 | */ |
| 8 | |
| 9 | use MediaWiki\Context\DerivativeContext; |
| 10 | use MediaWiki\EditPage\EditPage; |
| 11 | use MediaWiki\Html\Html; |
| 12 | use MediaWiki\MediaWikiServices; |
| 13 | use MediaWiki\Request\FauxRequest; |
| 14 | use MediaWiki\Revision\RevisionRecord; |
| 15 | use MediaWiki\Title\Title; |
| 16 | |
| 17 | /** |
| 18 | * @ingroup PageForms |
| 19 | */ |
| 20 | class 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 <unique number> 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 |
| 1334 | This module is used to remotely create or edit pages using Page Forms. |
| 1335 | |
| 1336 | Add "template-name[field-name]=field-value" to the query string parameter, to set the value for a specific field. |
| 1337 | To 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". |
| 1338 | See the first example below. |
| 1339 | |
| 1340 | In 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. |
| 1341 | END; |
| 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 | } |