diff options
Diffstat (limited to 'includes')
-rw-r--r-- | includes/EditPage.php | 19 | ||||
-rw-r--r-- | includes/Revision/BadRevisionException.php | 12 | ||||
-rw-r--r-- | includes/Revision/RenderedRevision.php | 32 | ||||
-rw-r--r-- | includes/Revision/RevisionRecord.php | 31 | ||||
-rw-r--r-- | includes/Revision/RevisionStore.php | 10 | ||||
-rw-r--r-- | includes/Revision/SlotRecord.php | 2 | ||||
-rw-r--r-- | includes/Storage/BadBlobException.php | 11 | ||||
-rw-r--r-- | includes/Storage/SqlBlobStore.php | 61 | ||||
-rw-r--r-- | includes/api/ApiComparePages.php | 3 | ||||
-rw-r--r-- | includes/api/ApiQueryRevisionsBase.php | 3 | ||||
-rw-r--r-- | includes/diff/DifferenceEngine.php | 84 | ||||
-rw-r--r-- | includes/page/Article.php | 10 |
12 files changed, 224 insertions, 54 deletions
diff --git a/includes/EditPage.php b/includes/EditPage.php index a033994abc39..b36a65979763 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -1518,7 +1518,7 @@ class EditPage implements IEditObject { if ( $content === false ) { # Warn the user that something went wrong - $undoMsg = 'failure'; + $undoMsg = 'norev'; } } @@ -1664,13 +1664,16 @@ class EditPage implements IEditObject { $undoContent = $undoRev->getContent( SlotRecord::MAIN ); $undoAfterContent = $oldRev->getContent( SlotRecord::MAIN ); $undoIsLatest = $this->page->getRevisionRecord()->getId() === $undoRev->getId(); + if ( $currentContent === null + || $undoContent === null + || $undoAfterContent === null + ) { + return false; + } return $handler->getUndoContent( - // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Content is for public use $currentContent, - // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Content is for public use $undoContent, - // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Content is for public use $undoAfterContent, $undoIsLatest ); @@ -3074,13 +3077,16 @@ class EditPage implements IEditObject { * content. * * @param Content|null|false|string $content - * @return string|false|null The editable text form of the content. + * @return string The editable text form of the content. * * @throws MWException If $content is not an instance of TextContent and * $this->allowNonTextContent is not true. */ private function toEditText( $content ) { - if ( $content === null || $content === false || is_string( $content ) ) { + if ( $content === null || $content === false ) { + return ''; + } + if ( is_string( $content ) ) { return $content; } @@ -3876,7 +3882,6 @@ class EditPage implements IEditObject { ); $this->context->getOutput()->addHTML( - // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive, text is not null Html::textarea( $name, $builder->addNewLineAtEnd( $text ), $attribs ) ); } diff --git a/includes/Revision/BadRevisionException.php b/includes/Revision/BadRevisionException.php new file mode 100644 index 000000000000..fc99ef1a5084 --- /dev/null +++ b/includes/Revision/BadRevisionException.php @@ -0,0 +1,12 @@ +<?php + +namespace MediaWiki\Revision; + +/** + * Exception raised when the text of a revision is permanently missing or + * corrupt. This wraps BadBlobException which is thrown by the Storage layer. + * To mark a revision as permanently missing, use findBadBlobs.php. + */ +class BadRevisionException extends RevisionAccessException { + +} diff --git a/includes/Revision/RenderedRevision.php b/includes/Revision/RenderedRevision.php index ae0b84b97937..b6d87a2188ef 100644 --- a/includes/Revision/RenderedRevision.php +++ b/includes/Revision/RenderedRevision.php @@ -226,29 +226,23 @@ class RenderedRevision implements SlotRenderingProvider { if ( !isset( $this->slotsOutput[ $role ] ) || ( $withHtml && !$this->slotsOutput[ $role ]->hasText() ) ) { - $content = $this->revision->getContent( $role, $this->audience, $this->performer ); + $content = $this->revision->getContentOrThrow( $role, $this->audience, $this->performer ); - if ( !$content ) { - throw new SuppressedDataException( - 'Access to the content has been suppressed for this audience' - ); - } else { - // XXX: allow SlotRoleHandler to control the ParserOutput? - $output = $this->getSlotParserOutputUncached( $content, $withHtml ); - - if ( $withHtml && !$output->hasText() ) { - throw new LogicException( - 'HTML generation was requested, but ' - . get_class( $content ) - . ' that passed to ' - . 'ContentRenderer::getParserOutput() returns a ParserOutput with no text set.' - ); - } + // XXX: allow SlotRoleHandler to control the ParserOutput? + $output = $this->getSlotParserOutputUncached( $content, $withHtml ); - // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput. - $this->options->registerWatcher( null ); + if ( $withHtml && !$output->hasText() ) { + throw new LogicException( + 'HTML generation was requested, but ' + . get_class( $content ) + . ' that passed to ' + . 'ContentRenderer::getParserOutput() returns a ParserOutput with no text set.' + ); } + // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput. + $this->options->registerWatcher( null ); + $this->slotsOutput[ $role ] = $output; } diff --git a/includes/Revision/RevisionRecord.php b/includes/Revision/RevisionRecord.php index 0953a07a0cd5..f089f772ce09 100644 --- a/includes/Revision/RevisionRecord.php +++ b/includes/Revision/RevisionRecord.php @@ -145,21 +145,40 @@ abstract class RevisionRecord implements WikiAwareEntity { * Note that for mutable Content objects, each call to this method will return a * fresh clone. * - * MCR migration note: this replaced Revision::getContent + * Use getContentOrThrow() for more specific error information. * * @param string $role The role name of the desired slot * @param int $audience * @param Authority|null $performer user on whose behalf to check * - * @return Content|null The content of the given slot, or null if access is forbidden. + * @return Content|null The content of the given slot, or null on error */ public function getContent( $role, $audience = self::FOR_PUBLIC, Authority $performer = null ): ?Content { - // XXX: throwing an exception would be nicer, but would a further - // departure from the old signature of Revision::getContent() when it existed, - // and thus result in more complex and error prone refactoring. - if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $performer ) ) { + try { + $content = $this->getSlot( $role, $audience, $performer )->getContent(); + } catch ( BadRevisionException | SuppressedDataException $e ) { return null; } + return $content->copy(); + } + + /** + * Get the Content of the given slot of this revision. + * + * @param string $role The role name of the desired slot + * @param int $audience + * @param Authority|null $performer user on whose behalf to check + * + * @return Content + * @throws SuppressedDataException if the content is not viewable by the given audience + * @throws BadRevisionException if the content is missing or corrupted + * @throws RevisionAccessException + */ + public function getContentOrThrow( $role, $audience = self::FOR_PUBLIC, Authority $performer = null ): Content { + if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $performer ) ) { + throw new SuppressedDataException( + 'Access to the content has been suppressed for this audience' ); + } $content = $this->getSlot( $role, $audience, $performer )->getContent(); return $content->copy(); diff --git a/includes/Revision/RevisionStore.php b/includes/Revision/RevisionStore.php index 910c6f9df6e0..9d1c98a16d0b 100644 --- a/includes/Revision/RevisionStore.php +++ b/includes/Revision/RevisionStore.php @@ -45,6 +45,7 @@ use MediaWiki\Page\PageIdentity; use MediaWiki\Page\PageIdentityValue; use MediaWiki\Page\PageStore; use MediaWiki\Permissions\Authority; +use MediaWiki\Storage\BadBlobException; use MediaWiki\Storage\BlobAccessException; use MediaWiki\Storage\BlobStore; use MediaWiki\Storage\NameTableStore; @@ -1166,7 +1167,12 @@ class RevisionStore // No blob flags, so use the blob verbatim. $data = $blobData; } else { - $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $blobAddress ); + try { + $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $blobAddress ); + } catch ( BadBlobException $e ) { + throw new BadRevisionException( $e->getMessage(), [], 0, $e ); + } + if ( $data === false ) { throw new RevisionAccessException( 'Failed to expand blob data using flags {flags} (key: {cache_key})', @@ -1182,6 +1188,8 @@ class RevisionStore $address = $slot->getAddress(); try { $data = $this->blobStore->getBlob( $address, $queryFlags ); + } catch ( BadBlobException $e ) { + throw new BadRevisionException( $e->getMessage(), [], 0, $e ); } catch ( BlobAccessException $e ) { throw new RevisionAccessException( 'Failed to load data blob from {address}' diff --git a/includes/Revision/SlotRecord.php b/includes/Revision/SlotRecord.php index 2059dd3dcd1b..0435680713ec 100644 --- a/includes/Revision/SlotRecord.php +++ b/includes/Revision/SlotRecord.php @@ -310,6 +310,8 @@ class SlotRecord { * * @throws SuppressedDataException if access to the content is not allowed according * to the audience check performed by RevisionRecord::getSlot(). + * @throws BadRevisionException if the revision is permanently missing + * @throws RevisionAccessException for other storage access errors * * @return Content The slot's content. This is a direct reference to the internal instance, * copy before exposing to application logic! diff --git a/includes/Storage/BadBlobException.php b/includes/Storage/BadBlobException.php new file mode 100644 index 000000000000..e32dc33489ea --- /dev/null +++ b/includes/Storage/BadBlobException.php @@ -0,0 +1,11 @@ +<?php + +namespace MediaWiki\Storage; + +/** + * Exception thrown when a blob has the "bad" content address schema, or has + * "error" in its old_flags, meaning it is permanently missing. + */ +class BadBlobException extends BlobAccessException { + +} diff --git a/includes/Storage/SqlBlobStore.php b/includes/Storage/SqlBlobStore.php index 58e2565d235b..20e12dd861ed 100644 --- a/includes/Storage/SqlBlobStore.php +++ b/includes/Storage/SqlBlobStore.php @@ -265,13 +265,20 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { [ $result, $errors ] = $this->fetchBlobs( [ $blobAddress ], $queryFlags ); // No negative caching; negative hits on text rows may be due to corrupted replica DBs $error = $errors[$blobAddress] ?? null; + if ( $error ) { + $ttl = WANObjectCache::TTL_UNCACHEABLE; + } return $result[$blobAddress]; }, $this->getCacheOptions() ); if ( $error ) { - throw new BlobAccessException( $error ); + if ( $error[0] === 'badrevision' ) { + throw new BadBlobException( $error[1] ); + } else { + throw new BlobAccessException( $error[1] ); + } } Assert::postcondition( is_string( $blob ), 'Blob must not be null' ); @@ -301,10 +308,9 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { }, $blobsByAddress ); $result = StatusValue::newGood( $blobsByAddress ); - if ( $errors ) { - foreach ( $errors as $error ) { - $result->warning( 'internalerror', $error ); - } + foreach ( $errors as $error ) { + // @phan-suppress-next-line PhanParamTooFewUnpack + $result->warning( ...$error ); } return $result; } @@ -316,8 +322,12 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { * @param int $queryFlags * * @throws BlobAccessException - * @return array [ $result, $errors ] A map of blob addresses to successfully fetched blobs - * or false if fetch failed, plus and array of errors + * @return array [ $result, $errors ] A list with the following elements: + * - The result: a map of blob addresses to successfully fetched blobs + * or false if fetch failed + * - Errors: a map of blob addresses to error information about the blob. + * On success, the relevant key will be absent. Each error is a list of + * parameters to be passed to StatusValue::warning(). */ private function fetchBlobs( $blobAddresses, $queryFlags ) { $textIdToBlobAddress = []; @@ -336,26 +346,33 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { // TODO: MCR: also support 'ex' schema with ExternalStore URLs, plus flags encoded in the URL! if ( $schema === 'bad' ) { - // Database row was marked as "known bad", no need to trigger an error. + // Database row was marked as "known bad" wfDebug( __METHOD__ . ": loading known-bad content ($blobAddress), returning empty string" ); $result[$blobAddress] = ''; - continue; + $errors[$blobAddress] = [ + 'badrevision', + 'The content of this revision is missing or corrupted (bad schema)' + ]; } elseif ( $schema === 'tt' ) { $textId = intval( $id ); if ( $textId < 1 || $id !== (string)$textId ) { - $errors[$blobAddress] = "Bad blob address: $blobAddress." - . ' Use findBadBlobs.php to remedy.'; + $errors[$blobAddress] = [ + 'internalerror', + "Bad blob address: $blobAddress. Use findBadBlobs.php to remedy." + ]; $result[$blobAddress] = false; } $textIdToBlobAddress[$textId] = $blobAddress; } else { - $errors[$blobAddress] = "Unknown blob address schema: $schema." - . ' Use findBadBlobs.php to remedy.'; + $errors[$blobAddress] = [ + 'internalerror', + "Unknown blob address schema: $schema. Use findBadBlobs.php to remedy." + ]; $result[$blobAddress] = false; } } @@ -414,8 +431,10 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { $blob = $this->expandBlob( $row->old_text, $row->old_flags, $blobAddress ); } if ( $blob === false ) { - $errors[$blobAddress] = "Bad data in text row {$row->old_id}." - . ' Use findBadBlobs.php to remedy.'; + $errors[$blobAddress] = [ + 'internalerror', + "Bad data in text row {$row->old_id}. Use findBadBlobs.php to remedy." + ]; } $result[$blobAddress] = $blob; } @@ -424,8 +443,10 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { if ( count( $result ) !== count( $blobAddresses ) ) { foreach ( $blobAddresses as $blobAddress ) { if ( !isset( $result[$blobAddress ] ) ) { - $errors[$blobAddress] = "Unable to fetch blob at $blobAddress." - . ' Use findBadBlobs.php to remedy.'; + $errors[$blobAddress] = [ + 'internalerror', + "Unable to fetch blob at $blobAddress. Use findBadBlobs.php to remedy." + ]; $result[$blobAddress] = false; } } @@ -482,11 +503,17 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { * caching is disabled. * * @return false|string The expanded blob or false on failure + * @throws BlobAccessException */ public function expandBlob( $raw, $flags, $blobAddress = null ) { if ( is_string( $flags ) ) { $flags = explode( ',', $flags ); } + if ( in_array( 'error', $flags ) ) { + throw new BadBlobException( + "The content of this revision is missing or corrupted (error flag)" + ); + } // Use external methods for external objects, text in table is URL-only then if ( in_array( 'external', $flags ) ) { diff --git a/includes/api/ApiComparePages.php b/includes/api/ApiComparePages.php index 4275fc6242fb..7341bca2109d 100644 --- a/includes/api/ApiComparePages.php +++ b/includes/api/ApiComparePages.php @@ -216,6 +216,9 @@ class ApiComparePages extends ApiBase { $difftext[$role] = $de->getDiffBodyForRole( $role ); } } + foreach ( $de->getRevisionLoadErrors() as $msg ) { + $this->addWarning( $msg ); + } // Fill in the response $vals = []; diff --git a/includes/api/ApiQueryRevisionsBase.php b/includes/api/ApiQueryRevisionsBase.php index cb55ac07527e..5a9bd450ada9 100644 --- a/includes/api/ApiQueryRevisionsBase.php +++ b/includes/api/ApiQueryRevisionsBase.php @@ -711,6 +711,9 @@ abstract class ApiQueryRevisionsBase extends ApiQueryGeneratorBase { if ( !$engine->wasCacheHit() ) { $this->numUncachedDiffs++; } + foreach ( $engine->getRevisionLoadErrors() as $msg ) { + $this->addWarning( $msg ); + } } } else { $vals['diff']['notcached'] = true; diff --git a/includes/diff/DifferenceEngine.php b/includes/diff/DifferenceEngine.php index d316082d3112..11f898e61d9b 100644 --- a/includes/diff/DifferenceEngine.php +++ b/includes/diff/DifferenceEngine.php @@ -29,6 +29,7 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Page\WikiPageFactory; use MediaWiki\Permissions\Authority; use MediaWiki\Permissions\PermissionStatus; +use MediaWiki\Revision\BadRevisionException; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\RevisionStore; use MediaWiki\Revision\SlotRecord; @@ -235,6 +236,9 @@ class DifferenceEngine extends ContextSource { /** @var UserOptionsLookup */ private $userOptionsLookup; + /** @var Message[] */ + private $revisionLoadErrors = []; + /** #@- */ /** @@ -343,8 +347,14 @@ class DifferenceEngine extends ContextSource { $slots = []; foreach ( $roles as $role ) { $slots[$role] = [ - 'old' => isset( $oldSlots[$role] ) ? $oldSlots[$role]->getContent() : null, - 'new' => isset( $newSlots[$role] ) ? $newSlots[$role]->getContent() : null, + 'old' => $this->loadSingleSlot( + $oldSlots[$role] ?? null, + 'old' + ), + 'new' => $this->loadSingleSlot( + $newSlots[$role] ?? null, + 'new' + ) ]; } // move main slot to front @@ -354,6 +364,59 @@ class DifferenceEngine extends ContextSource { return $slots; } + /** + * Load the content of a single slot record + * + * @param SlotRecord|null $slot + * @param string $which "new" or "old" + * @return Content|null + */ + private function loadSingleSlot( ?SlotRecord $slot, string $which ) { + if ( !$slot ) { + return null; + } + try { + return $slot->getContent(); + } catch ( BadRevisionException $e ) { + $this->addRevisionLoadError( $which ); + return null; + } + } + + /** + * Set a message to show as a notice at the top of the page + * + * @param string $which "new" or "old" + */ + private function addRevisionLoadError( $which ) { + $this->revisionLoadErrors[] = $this->msg( $which === 'new' + ? 'difference-bad-new-revision' : 'difference-bad-old-revision' + ); + } + + /** + * If errors were encountered while loading the revision contents, this + * will return an array of Messages describing the errors. + * + * @return Message[] + */ + public function getRevisionLoadErrors() { + return $this->revisionLoadErrors; + } + + /** + * Determine whether there was an error loading the new revision + * @return bool + */ + private function hasNewRevisionLoadError() { + foreach ( $this->revisionLoadErrors as $error ) { + if ( $error->getKey() === 'difference-bad-new-revision' ) { + return true; + } + } + return false; + } + /** @inheritDoc */ public function getTitle() { // T202454 avoid errors when there is no title @@ -841,6 +904,13 @@ class DifferenceEngine extends ContextSource { $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view'; $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' ); } + + # Add an error if the content can't be loaded + $this->getSlotContents(); + foreach ( $this->getRevisionLoadErrors() as $msg ) { + $notice .= Html::warningBox( $msg->parse() ); + } + $this->showDiff( $oldHeader, $newHeader, $notice ); if ( !$diffOnly ) { $this->renderNewRevision(); @@ -990,6 +1060,10 @@ class DifferenceEngine extends ContextSource { // even if it's unsaved, but a lot of untangling is required to do it safely. return; } + if ( $this->hasNewRevisionLoadError() ) { + // There was an error loading the new revision + return; + } $out->setRevisionId( $this->mNewid ); $out->setRevisionIsCurrent( $this->mNewRevisionRecord->isCurrent() ); @@ -1815,6 +1889,9 @@ class DifferenceEngine extends ContextSource { // revision that's not readable to the user, but check it just in case. $this->mOldContent = $oldRevision->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); + if ( !$this->mOldContent ) { + $this->addRevisionLoadError( 'old' ); + } } else { $this->mOldPage = null; $this->mOldRevisionRecord = $this->mOldid = false; @@ -1824,6 +1901,9 @@ class DifferenceEngine extends ContextSource { $this->mNewPage = Title::newFromLinkTarget( $newRevision->getPageAsLinkTarget() ); $this->mNewContent = $newRevision->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); + if ( !$this->mNewContent ) { + $this->addRevisionLoadError( 'new' ); + } $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true; $this->mTextLoaded = $oldRevision ? 2 : 1; diff --git a/includes/page/Article.php b/includes/page/Article.php index 570a521c07c2..ddad836be070 100644 --- a/includes/page/Article.php +++ b/includes/page/Article.php @@ -26,6 +26,7 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Page\ParserOutputAccess; use MediaWiki\Permissions\Authority; use MediaWiki\Permissions\PermissionStatus; +use MediaWiki\Revision\BadRevisionException; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\RevisionStore; use MediaWiki\Revision\SlotRecord; @@ -521,8 +522,13 @@ class Article implements Page { } $poOptions += [ 'includeDebugInfo' => true ]; - $continue = - $this->generateContentOutput( $authority, $parserOptions, $oldid, $outputPage, $poOptions ); + try { + $continue = + $this->generateContentOutput( $authority, $parserOptions, $oldid, $outputPage, $poOptions ); + } catch ( BadRevisionException $e ) { + $continue = false; + $this->showViewError( wfMessage( 'badrevision' )->text() ); + } if ( !$continue ) { return; |