Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 555 |
|
0.00% |
0 / 30 |
CRAP | |
0.00% |
0 / 1 |
| PFAutoeditAPI | |
0.00% |
0 / 555 |
|
0.00% |
0 / 30 |
45582 | |
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 / 27 |
|
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 |
2862 | |||
| finalizeResults | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
72 | |||
| 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\EditPage\EditPage; |
| 10 | use MediaWiki\Html\Html; |
| 11 | use MediaWiki\MediaWikiServices; |
| 12 | use MediaWiki\Request\FauxRequest; |
| 13 | use MediaWiki\Revision\RevisionRecord; |
| 14 | use MediaWiki\Title\Title; |
| 15 | |
| 16 | /** |
| 17 | * @ingroup PageForms |
| 18 | */ |
| 19 | class 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 <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( $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 |
| 1337 | This module is used to remotely create or edit pages using Page Forms. |
| 1338 | |
| 1339 | Add "template-name[field-name]=field-value" to the query string parameter, to set the value for a specific field. |
| 1340 | 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". |
| 1341 | See the first example below. |
| 1342 | |
| 1343 | 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. |
| 1344 | END; |
| 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 | } |