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