Current File : /home/jvzmxxx/wiki1/extensions/Wikibase/repo/includes/EditEntity.php
<?php

namespace Wikibase;

use Html;
use IContextSource;
use InvalidArgumentException;
use MWException;
use ReadOnlyError;
use RequestContext;
use Status;
use Title;
use User;
use Wikibase\DataModel\Entity\EntityDocument;
use Wikibase\DataModel\Entity\EntityId;
use Wikibase\DataModel\Services\Diff\EntityDiffer;
use Wikibase\DataModel\Services\Diff\EntityPatcher;
use Wikibase\Lib\Store\EntityRevisionLookup;
use Wikibase\Lib\Store\EntityStore;
use Wikibase\Lib\Store\EntityTitleLookup;
use Wikibase\Lib\Store\StorageException;
use Wikibase\Repo\Hooks\EditFilterHookRunner;
use Wikibase\Repo\Store\EntityPermissionChecker;

/**
 * Handler for editing activity, providing a unified interface for saving modified entities while performing
 * permission checks and handling edit conflicts.
 *
 * @since 0.1
 *
 * @license GPL-2.0+
 * @author John Erling Blad < jeblad@gmail.com >
 * @author Daniel Kinzler
 * @author Thiemo Mättig
 */
class EditEntity {

	/**
	 * @var EntityRevisionLookup
	 */
	private $entityRevisionLookup;

	/**
	 * @var EntityTitleLookup
	 */
	private $titleLookup;

	/**
	 * @var EntityStore
	 */
	private $entityStore;

	/**
	 * The modified entity we are trying to save
	 *
	 * @var EntityDocument|null
	 */
	private $newEntity = null;

	/**
	 * @var EntityRevision|null
	 */
	private $baseRev = null;

	/**
	 * @var int|bool
	 */
	private $baseRevId;

	/**
	 * @var EntityRevision|null
	 */
	private $latestRev = null;

	/**
	 * @var int
	 */
	private $latestRevId = 0;

	/**
	 * @var Status|null
	 */
	private $status = null;

	/**
	 * @var User|null
	 */
	private $user = null;

	/**
	 * @var Title|null
	 */
	private $title = null;

	/**
	 * @var IContextSource
	 */
	private $context;

	/**
	 * @var EditFilterHookRunner
	 */
	private $editFilterHookRunner;

	/**
	 * @var int Bit field for error types, using the EditEntity::XXX_ERROR constants.
	 */
	private $errorType = 0;

	/**
	 * indicates a permission error
	 */
	const PERMISSION_ERROR = 1;

	/**
	 * indicates an unresolved edit conflict
	 */
	const EDIT_CONFLICT_ERROR = 2;

	/**
	 * indicates a token or session error
	 */
	const TOKEN_ERROR = 4;

	/**
	 * indicates that an error occurred while saving
	 */
	const SAVE_ERROR = 8;

	/**
	 * Indicates that the content failed some precondition to saving,
	 * such a a global uniqueness constraint.
	 */
	const PRECONDITION_FAILED = 16;

	/**
	 * Indicates that the content triggered an edit filter that uses
	 * the EditFilterMergedContent hook to supervise edits.
	 */
	const FILTERED = 32;

	/**
	 * Indicates that the edit exceeded a rate limit.
	 */
	const RATE_LIMIT = 64;

	/**
	 * bit mask for asking for any error.
	 */
	const ANY_ERROR = 0xFFFFFFFF;

	/**
	 * @var string[]
	 */
	private $requiredPermissions = array( 'edit' );

	/**
	 * @since 0.1
	 *
	 * @param EntityTitleLookup $titleLookup
	 * @param EntityRevisionLookup $entityLookup
	 * @param EntityStore $entityStore
	 * @param EntityPermissionChecker $permissionChecker
	 * @param EntityDocument $newEntity the new entity object
	 * @param User $user the user performing the edit
	 * @param EditFilterHookRunner $editFilterHookRunner
	 * @param int|bool $baseRevId the base revision ID for conflict checking.
	 *        Defaults to false, disabling conflict checks.
	 *        `true` can be used to set the base revision to the latest revision:
	 *        This will detect "late" edit conflicts, i.e. someone squeezing in an edit
	 *        just before the actual database transaction for saving beings.
	 *        The empty string and 0 are both treated as `false`, disabling conflict checks.
	 * @param IContextSource|null $context the context to use while processing
	 *        the edit; defaults to RequestContext::getMain().
	 *
	 * @throws InvalidArgumentException
	 */
	public function __construct(
		EntityTitleLookup $titleLookup,
		EntityRevisionLookup $entityLookup,
		EntityStore $entityStore,
		EntityPermissionChecker $permissionChecker,
		EntityDocument $newEntity,
		User $user,
		EditFilterHookRunner $editFilterHookRunner,
		$baseRevId = false,
		IContextSource $context = null
	) {
		$this->newEntity = $newEntity;

		if ( is_string( $baseRevId ) ) {
			$baseRevId = (int)$baseRevId;
		}

		if ( $baseRevId === 0 ) {
			$baseRevId = false;
		}

		$this->user = $user;
		$this->baseRevId = $baseRevId;

		$this->errorType = 0;
		$this->status = Status::newGood();

		if ( $context === null ) {
			$context = RequestContext::getMain();
		}

		$this->context = $context;

		$this->titleLookup = $titleLookup;
		$this->entityRevisionLookup = $entityLookup;
		$this->entityStore = $entityStore;
		$this->permissionChecker = $permissionChecker;

		$this->editFilterHookRunner = $editFilterHookRunner;
	}

	/**
	 * Returns the new entity object to be saved. May be different from the entity supplied
	 * to the constructor in case the entity was patched to resolve edit conflicts.
	 *
	 * @return EntityDocument
	 */
	public function getNewEntity() {
		return $this->newEntity;
	}

	/**
	 * Returns the Title of the page holding the entity that is being edited.
	 *
	 * @return Title|null
	 */
	private function getTitle() {
		if ( $this->title === null ) {
			$id = $this->newEntity->getId();

			if ( $id !== null ) {
				$this->title = $this->titleLookup->getTitleForId( $id );
			}
		}

		return $this->title;
	}

	/**
	 * Returns the latest revision of the entity.
	 *
	 * @return EntityRevision|null
	 */
	public function getLatestRevision() {
		if ( $this->latestRev === null ) {
			$id = $this->newEntity->getId();

			if ( $id !== null ) {
				// NOTE: It's important to remember this, if someone calls clear() on
				// $this->getPage(), this should NOT change!
				$this->latestRev = $this->entityRevisionLookup->getEntityRevision(
					$id,
					EntityRevisionLookup::LATEST_FROM_MASTER
				);
			}
		}

		return $this->latestRev;
	}

	/**
	 * Returns the latest revision ID.
	 *
	 * @return int 0 if the entity doesn't exist
	 */
	private function getLatestRevisionId() {
		// Don't do negative caching: We call this to see whether the entity yet exists
		// before creating.
		if ( $this->latestRevId === 0 ) {
			$id = $this->newEntity->getId();

			if ( $this->latestRev !== null ) {
				$this->latestRevId = $this->latestRev->getRevisionId();
			} elseif ( $id !== null ) {
				$this->latestRevId = (int)$this->entityRevisionLookup->getLatestRevisionId(
					$id,
					EntityRevisionLookup::LATEST_FROM_MASTER
				);
			}
		}

		return $this->latestRevId;
	}

	/**
	 * Is the entity new?
	 * An entity is new in case it either doesn't have an id or the Title belonging
	 * to it doesn't (yet) exist.
	 *
	 * @return bool
	 */
	private function isNew() {
		return $this->newEntity->getId() === null || $this->getLatestRevisionId() === 0;
	}

	/**
	 * Returns the base revision ID.
	 * If no base revision was supplied to the constructor, this will return false.
	 * In the trivial non-conflicting case, this will be the same as $this->getLatestRevisionId().
	 *
	 * @return int|bool
	 */
	private function getBaseRevisionId() {
		if ( $this->baseRevId === null || $this->baseRevId === true ) {
			$this->baseRevId = $this->getLatestRevisionId();
		}

		return $this->baseRevId;
	}

	/**
	 * Returns the edits base revision.
	 * If no base revision was supplied to the constructor, this will return null.
	 * In the trivial non-conflicting case, this will be the same as $this->getLatestRevision().
	 *
	 * @return EntityRevision|null
	 * @throws MWException
	 */
	private function getBaseRevision() {
		if ( $this->baseRev === null ) {
			$baseRevId = $this->getBaseRevisionId();

			if ( $baseRevId === false ) {
				return null;
			} elseif ( $baseRevId === $this->getLatestRevisionId() ) {
				$this->baseRev = $this->getLatestRevision();
			} else {
				if ( $baseRevId === 0 ) {
					$baseRevId = EntityRevisionLookup::LATEST_FROM_MASTER;
				}

				$id = $this->newEntity->getId();
				$this->baseRev = $this->entityRevisionLookup->getEntityRevision( $id, $baseRevId );

				if ( $this->baseRev === null ) {
					throw new MWException( 'Base revision ID not found: rev ' . $baseRevId
						. ' of ' . $id->getSerialization() );
				}
			}
		}

		return $this->baseRev;
	}

	/**
	 * Get the status object. Only defined after attemptSave() was called.
	 *
	 * After a successful save, the Status object's value field will contain an array,
	 * just like the status returned by WikiPage::doEditContent(). Well known fields
	 * in the status value are:
	 *
	 *  - new: bool whether the edit created a new page
	 *  - revision: Revision the new revision object
	 *  - errorFlags: bit field indicating errors, see the XXX_ERROR constants.
	 *
	 * @since 0.1
	 *
	 * @return Status|null
	 */
	public function getStatus() {
		return $this->status;
	}

	/**
	 * Determines whether the last call to attemptSave was successful.
	 *
	 * @since 0.1
	 *
	 * @return bool false if attemptSave() failed, true otherwise
	 */
	public function isSuccess() {
		return $this->errorType === 0 && $this->status->isOK();
	}

	/**
	 * Checks whether this EditEntity encountered any of the given error types while executing attemptSave().
	 *
	 * @since 0.1
	 *
	 * @param int $errorType bit field using the EditEntity::XXX_ERROR constants.
	 *            Defaults to EditEntity::ANY_ERROR.
	 *
	 * @return bool true if this EditEntity encountered any of the error types in $errorType, false otherwise.
	 */
	public function hasError( $errorType = self::ANY_ERROR ) {
		return ( $this->errorType & $errorType ) !== 0;
	}

	/**
	 * Determines whether an edit conflict exists, that is, whether another user has edited the same item
	 * after the base revision was created.
	 *
	 * @return bool
	 */
	public function hasEditConflict() {
		return $this->doesCheckForEditConflicts()
			&& !$this->isNew()
			&& $this->getBaseRevisionId() !== $this->getLatestRevisionId();
	}

	/**
	 * Attempts to fix an edit conflict by patching the intended change into the latest revision after
	 * checking for conflicts. This modifies $this->newEntity but does not write anything to the
	 * database. Saving of the new content may still fail.
	 *
	 * @return bool True if the conflict could be resolved, false otherwise
	 */
	public function fixEditConflict() {
		$baseRev = $this->getBaseRevision();
		$latestRev = $this->getLatestRevision();

		if ( !$latestRev ) {
			wfLogWarning( 'Failed to load latest revision of entity ' . $this->newEntity->getId() . '! '
				. 'This may indicate entries missing from thw wb_entities_per_page table.' );
			return false;
		}

		$entityDiffer = new EntityDiffer();
		$entityPatcher = new EntityPatcher();

		// calculate patch against base revision
		// NOTE: will fail if $baseRev or $base are null, which they may be if
		// this gets called at an inappropriate time. The data flow in this class
		// should be improved.
		$patch = $entityDiffer->diffEntities( $baseRev->getEntity(), $this->newEntity );

		if ( $patch->isEmpty() ) {
			// we didn't technically fix anything, but if there is nothing to change,
			// so just keep the current content as it is.
			$this->newEntity = $latestRev->getEntity()->copy();
			return true;
		}

		// apply the patch( base -> new ) to the latest revision.
		$patchedLatest = $latestRev->getEntity()->copy();
		$entityPatcher->patchEntity( $patchedLatest, $patch );

		// detect conflicts against latest revision
		$cleanPatch = $entityDiffer->diffEntities( $latestRev->getEntity(), $patchedLatest );

		$conflicts = $patch->count() - $cleanPatch->count();

		if ( $conflicts > 0 ) {
			// patch doesn't apply cleanly
			if ( $this->userWasLastToEdit( $this->user, $this->newEntity->getId(), $this->getBaseRevisionId() ) ) {
				// it's a self-conflict
				if ( $cleanPatch->count() === 0 ) {
					// patch collapsed, possibly because of diff operation change from base to latest
					return false;
				} else {
					// we still have a working patch, try to apply
					$this->status->warning( 'wikibase-self-conflict-patched' );
				}
			} else {
				// there are unresolvable conflicts.
				return false;
			}
		} else {
			// can apply cleanly
			$this->status->warning( 'wikibase-conflict-patched' );
		}

		// remember the patched entity as the actual new entity to save
		$this->newEntity = $patchedLatest;

		return true;
	}

	/**
	 * Check if no edits were made by other users since the given revision.
	 * This makes the assumption that revision ids are monotonically increasing.
	 *
	 * @param User|null $user
	 * @param EntityId|null $entityId
	 * @param int|bool $lastRevId
	 *
	 * @return bool
	 */
	private function userWasLastToEdit( User $user = null, EntityId $entityId = null, $lastRevId = false ) {
		if ( $user === null || $entityId === null || $lastRevId === false ) {
			return false;
		}

		return $this->entityStore->userWasLastToEdit( $user, $entityId, $lastRevId );
	}

	/**
	 * Adds another permission (action) to be checked by checkEditPermissions().
	 * Per default, the 'edit' permission is checked.
	 *
	 * @param string $permission
	 */
	public function addRequiredPermission( $permission ) {
		$this->requiredPermissions[] = $permission;
	}

	/**
	 * Checks the necessary permissions to perform this edit.
	 * Per default, the 'edit' permission is checked.
	 * Use addRequiredPermission() to check more permissions.
	 */
	public function checkEditPermissions() {
		foreach ( $this->requiredPermissions as $action ) {
			$permissionStatus = $this->permissionChecker->getPermissionStatusForEntity(
				$this->user,
				$action,
				$this->newEntity );

			$this->status->merge( $permissionStatus );

			if ( !$this->status->isOK() ) {
				$this->errorType |= self::PERMISSION_ERROR;
				$this->status->fatal( 'no-permission' );
			}
		}
	}

	/**
	 * Checks if rate limits have been exceeded.
	 */
	private function checkRateLimits() {
		if ( $this->user->pingLimiter( 'edit' )
			|| ( $this->isNew() && $this->user->pingLimiter( 'create' ) )
		) {
			$this->errorType |= self::RATE_LIMIT;
			$this->status->fatal( 'actionthrottledtext' );
		}
	}

	/**
	 * Make sure the given WebRequest contains a valid edit token.
	 *
	 * @param string $token The token to check.
	 *
	 * @return bool true if the token is valid
	 */
	public function isTokenOK( $token ) {
		$tokenOk = $this->user->matchEditToken( $token );
		$tokenOkExceptSuffix = $this->user->matchEditTokenNoSuffix( $token );

		if ( !$tokenOk ) {
			if ( $tokenOkExceptSuffix ) {
				$this->status->fatal( 'token_suffix_mismatch' );
			} else {
				$this->status->fatal( 'session_fail_preview' );
			}

			$this->errorType |= self::TOKEN_ERROR;
			return false;
		}

		return true;
	}

	/**
	 * Attempts to save the new entity content, chile first checking for permissions, edit conflicts, etc.
	 *
	 * @param string $summary The edit summary.
	 * @param int         $flags      The EDIT_XXX flags as used by WikiPage::doEditContent().
	 *        Additionally, the EntityContent::EDIT_XXX constants can be used.
	 * @param string|bool $token Edit token to check, or false to disable the token check.
	 *                                Null will fail the token text, as will the empty string.
	 * @param bool|null $watch        Whether the user wants to watch the entity.
	 *                                Set to null to apply default according to getWatchDefault().
	 *
	 * @throws ReadOnlyError
	 * @return Status Indicates success and provides detailed warnings or error messages. See
	 *         getStatus() for more details.
	 * @see    WikiPage::doEditContent
	 */
	public function attemptSave( $summary, $flags, $token, $watch = null ) {
		if ( wfReadOnly() ) {
			throw new ReadOnlyError();
		}

		if ( $watch === null ) {
			$watch = $this->getWatchDefault();
		}

		$this->status = Status::newGood();
		$this->errorType = 0;

		if ( $token !== false && !$this->isTokenOK( $token ) ) {
			//@todo: This is redundant to the error code set in isTokenOK().
			//       We should figure out which error codes the callers expect,
			//       and only set the correct error code, in one place, probably here.
			$this->errorType |= self::TOKEN_ERROR;
			$this->status->fatal( 'sessionfailure' );
			$this->status->setResult( false, array( 'errorFlags' => $this->errorType ) );
			return $this->status;
		}

		$this->checkEditPermissions();

		$this->checkRateLimits(); // modifies $this->status

		if ( !$this->status->isOK() ) {
			$this->status->setResult( false, array( 'errorFlags' => $this->errorType ) );
			return $this->status;
		}

		//NOTE: Make sure the latest revision is loaded and cached.
		//      Would happen on demand anyway, but we want a well-defined point at which "latest" is frozen
		//      to a specific revision, just before the first check for edit conflicts.
		$this->getLatestRevision();
		$this->getLatestRevisionId();

		$this->applyPreSaveChecks(); // modifies $this->status

		if ( !$this->status->isOK() ) {
			$this->errorType |= self::PRECONDITION_FAILED;
		}

		if ( !$this->status->isOK() ) {
			$this->status->setResult( false, array( 'errorFlags' => $this->errorType ) );
			return $this->status;
		}

		$hookStatus = $this->editFilterHookRunner->run( $this->newEntity, $this->user, $summary );
		if ( !$hookStatus->isOK() ) {
			$this->errorType |= self::FILTERED;
		}
		$this->status->merge( $hookStatus );

		if ( !$this->status->isOK() ) {
			$this->status->setResult( false, array( 'errorFlags' => $this->errorType ) );
			return $this->status;
		}

		try {
			$entityRevision = $this->entityStore->saveEntity(
				$this->newEntity,
				$summary,
				$this->user,
				$flags | EDIT_AUTOSUMMARY,
				$this->doesCheckForEditConflicts() ? $this->getLatestRevisionId() : false
			);

			$editStatus = Status::newGood( array( 'revision' => $entityRevision ) );
		} catch ( StorageException $ex ) {
			$editStatus = $ex->getStatus();

			if ( $editStatus === null ) {
				// XXX: perhaps internalerror_info isn't the best, but we need some generic error message.
				$editStatus = Status::newFatal( 'internalerror_info', $ex->getMessage() );
			}

			$this->errorType |= self::SAVE_ERROR;
		}

		$this->status->setResult( $editStatus->isOK(), $editStatus->getValue() );
		$this->status->merge( $editStatus );

		if ( $this->status->isOK() ) {
			$this->updateWatchlist( $watch );
		} else {
			$value = $this->status->getValue();
			$value['errorFlags'] = $this->errorType;
			$this->status->setResult( false, $value );
		}

		return $this->status;
	}

	private function applyPreSaveChecks() {
		if ( $this->hasEditConflict() ) {
			if ( !$this->fixEditConflict() ) {
				$this->status->fatal( 'edit-conflict' );
				$this->errorType |= self::EDIT_CONFLICT_ERROR;

				return $this->status;
			}
		}

		// FIXME: Why is this dummy call here?
		$this->getBaseRevision();

		return $this->status;
	}

	/**
	 * Whether this EditEntity will check for edit conflicts
	 *
	 * @return bool
	 */
	public function doesCheckForEditConflicts() {
		return $this->getBaseRevisionId() !== false;
	}

	/**
	 * Shows an error page showing the errors that occurred during attemptSave(), if any.
	 *
	 * If $titleMessage is set it is made an assumption that the page is still the original
	 * one, and there should be no link back from a special error page.
	 *
	 * @param string|null $titleMessage Message key for the page title.
	 *
	 * @return bool true if an error page was shown, false if there were no errors to show.
	 */
	public function showErrorPage( $titleMessage = null ) {
		$out = $this->context->getOutput();

		if ( $this->status === null || $this->status->isOK() ) {
			return false;
		}

		if ( $titleMessage === null ) {
			$out->prepareErrorPage( wfMessage( 'errorpagetitle' ) );
		} else {
			$out->prepareErrorPage( wfMessage( $titleMessage ), wfMessage( 'errorpagetitle' ) );
		}

		$this->showStatus();

		if ( !isset( $titleMessage ) ) {
			$out->returnToMain( '', $this->getTitle() );
		}

		return true;
	}

	/**
	 * Shows any errors or warnings from attemptSave().
	 *
	 * @return bool true if any message was shown, false if there were no errors to show.
	 */
	private function showStatus() {
		if ( $this->status === null || $this->status->isGood() ) {
			return false;
		}

		$out = $this->context->getOutput();
		$text = $this->status->getHTML();

		$out->addHTML( Html::rawElement( 'div', array( 'class' => 'error' ), $text ) );

		return true;
	}

	/**
	 * Returns whether the present edit would, per default,
	 * lead to the user watching the page.
	 *
	 * This uses the user's watchdefault and watchcreations settings
	 * and considers whether the entity is already watched by the user.
	 *
	 * @note Keep in sync with logic in EditPage!
	 *
	 * @return bool
	 */
	private function getWatchDefault() {
		// User wants to watch all edits or all creations.
		if ( $this->user->getOption( 'watchdefault' )
			|| ( $this->user->getOption( 'watchcreations' ) && $this->isNew() )
		) {
			return true;
		}

		// keep current state
		return !$this->isNew() && $this->entityStore->isWatching( $this->user, $this->newEntity->getId() );
	}

	/**
	 * Watches or unwatches the entity.
	 *
	 * @note Keep in sync with logic in EditPage!
	 * @todo: move to separate service
	 *
	 * @param bool $watch whether to watch or unwatch the page.
	 *
	 * @throws MWException
	 */
	private function updateWatchlist( $watch ) {
		if ( $this->getTitle() === null ) {
			throw new MWException( 'Title not yet known!' );
		}

		$this->entityStore->updateWatchlist( $this->user, $this->newEntity->getId(), $watch );
	}

}